mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
fix(opencode): address review on dispatch, billing, session, and 402
Skip OpenAI messages-dispatch gpt-5.x defaults for OpenCode groups and composite OpenCode targets. Filter unpriced claude-* billing candidates, estimate count_tokens locally, sanitize body session IDs before setting X-OpenCode-Session, and treat Zen 402 as recoverable payg cooldown. Stop rewriting the inbound body before protocol routing so account model_mapping applies once and usage_logs.model stays the client name.
This commit is contained in:
@@ -74,4 +74,11 @@ func TestResolveOpenAIMessagesDispatchMappedModel_CompositeCNTargetsSkipGroupMap
|
||||
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"), "model=%s", model)
|
||||
}
|
||||
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformOpenCodeGo))
|
||||
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
|
||||
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"),
|
||||
"composite → opencode_go 不得注入 openai 默认 gpt-5.x 映射")
|
||||
}
|
||||
|
||||
@@ -198,11 +198,11 @@ func resolveOpenAIMessagesDispatchMappedModel(c *gin.Context, apiKey *service.AP
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return ""
|
||||
}
|
||||
// composite 解析到 grok/CN 目标时调度级映射不适用(Group 级映射的 gpt-5.x
|
||||
// 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。
|
||||
// composite 解析到 grok/CN/OpenCode 目标时调度级映射不适用(Group 级映射的
|
||||
// gpt-5.x 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。
|
||||
if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil {
|
||||
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok &&
|
||||
(platform == service.PlatformGrok || service.IsCNProvider(platform)) {
|
||||
(platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform)) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,11 +117,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions(
|
||||
// OpenCode Go:按模型原生协议分流(与 inbound 协议正交)。
|
||||
// 规则未命中一律兜底 Chat Completions,只有显式 Responses 才走下方转换链。
|
||||
if account.IsOpenCodeGo() {
|
||||
mapped, rewritten, rewriteErr := rewriteOpenCodeGoRequestModel(account, body, defaultMappedModel)
|
||||
if rewriteErr != nil {
|
||||
return nil, rewriteErr
|
||||
}
|
||||
body = rewritten
|
||||
mapped := resolveOpenCodeGoMappedModel(account, body, defaultMappedModel)
|
||||
proto := openCodeGoNativeProtocol(account, mapped)
|
||||
if proto != APIProtocolResponses {
|
||||
if isResponsesShape {
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
)
|
||||
|
||||
func TestResolveMessagesDispatchModel_CNProvidersNoDispatchMapping(t *testing.T) {
|
||||
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax} {
|
||||
for _, platform := range []string{PlatformKimi, PlatformZhipu, PlatformDeepseek, PlatformMiniMax, PlatformOpenCodeGo} {
|
||||
g := &Group{Platform: platform}
|
||||
require.Empty(t, g.ResolveMessagesDispatchModel("claude-sonnet-4-5"),
|
||||
"CN 分组(%s)不得返回调度级映射模型(openai 默认值会发给 CN 上游)", platform)
|
||||
@@ -57,6 +57,12 @@ func TestFilterCNProviderBillingModelCandidates(t *testing.T) {
|
||||
require.Equal(t, []string{"claude-sonnet-4-5", "gpt-5.4"}, passthrough)
|
||||
|
||||
require.Nil(t, svc.filterCNProviderBillingModelCandidates(context.Background(), nil, apiKey, nil))
|
||||
|
||||
openCodeAccount := &Account{ID: 3, Platform: PlatformOpenCodeGo}
|
||||
openCodeFiltered := svc.filterCNProviderBillingModelCandidates(context.Background(), openCodeAccount, apiKey,
|
||||
[]string{"claude-sonnet-4-5", "muse-spark-1.3-contributor-free"})
|
||||
require.Equal(t, []string{"muse-spark-1.3-contributor-free"}, openCodeFiltered,
|
||||
"OpenCode 无显式定价时不得按 Claude 原价计费 claude-*")
|
||||
}
|
||||
|
||||
func TestCalculateOpenAIRecordUsageCost_EmptyCandidatesIsPricingUnavailable(t *testing.T) {
|
||||
|
||||
@@ -264,14 +264,14 @@ func (s *OpenAIGatewayService) ForwardCountTokensAsAnthropic(
|
||||
return fmt.Errorf("count_tokens: missing account")
|
||||
}
|
||||
|
||||
// 国产供应商(全部协议,含 anthropic):一律本地估算,不发上游请求。
|
||||
// 国产供应商与 OpenCode(全部协议,含 anthropic):一律本地估算,不发上游请求。
|
||||
// 依据(2026-08 核实):三家的 Anthropic 兼容层均未提供
|
||||
// /v1/messages/count_tokens——DeepSeek 官方 anthropic_api 文档无此端点
|
||||
// (且注明 anthropic-version 头被忽略),聚合网关 OpenModel 明确标注
|
||||
// count_tokens 为 "Anthropic only",Kimi/智谱亦无任何文档承诺。转发上游
|
||||
// 只会常态 404,且错误还会流入账号处置逻辑误伤整账号调度;Claude Code
|
||||
// 高频调用此端点,本地 tiktoken 估算是与 Grok 一致的既有方案。
|
||||
if account.IsCNProvider() {
|
||||
if account.IsCNProvider() || account.IsOpenCodeGo() {
|
||||
estimated, err := estimateAnthropicCountTokensLocally(body)
|
||||
if err != nil {
|
||||
writeAnthropicCountTokensError(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
|
||||
|
||||
@@ -156,20 +156,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
|
||||
if account.IsOpenCodeGo() {
|
||||
mapped, rewritten, rewriteErr := rewriteOpenCodeGoRequestModel(account, body, "")
|
||||
if rewriteErr != nil {
|
||||
return nil, rewriteErr
|
||||
}
|
||||
if mapped != originalModel {
|
||||
body = rewritten
|
||||
originalBody = rewritten
|
||||
requestView = newOpenAIRequestView(body)
|
||||
reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey
|
||||
originalModel = reqModel
|
||||
}
|
||||
mapped := resolveOpenCodeGoMappedModel(account, body, "")
|
||||
switch openCodeGoNativeProtocol(account, mapped) {
|
||||
case APIProtocolAnthropic:
|
||||
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, mapped)
|
||||
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, "")
|
||||
case APIProtocolResponses:
|
||||
break
|
||||
default:
|
||||
|
||||
@@ -46,18 +46,14 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
|
||||
|
||||
// OpenCode Go:按模型原生协议分流。规则未命中兜底 Chat Completions。
|
||||
if account.IsOpenCodeGo() {
|
||||
mapped, rewritten, rewriteErr := rewriteOpenCodeGoRequestModel(account, body, defaultMappedModel)
|
||||
if rewriteErr != nil {
|
||||
return nil, rewriteErr
|
||||
}
|
||||
body = rewritten
|
||||
mapped := resolveOpenCodeGoMappedModel(account, body, defaultMappedModel)
|
||||
switch openCodeGoNativeProtocol(account, mapped) {
|
||||
case APIProtocolAnthropic:
|
||||
return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, mapped)
|
||||
return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel)
|
||||
case APIProtocolResponses:
|
||||
break
|
||||
default:
|
||||
return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, mapped)
|
||||
return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel)
|
||||
}
|
||||
} else if account.IsAnthropicProtocol() || account.IsAdaptiveAPIProtocol() {
|
||||
// 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点时,
|
||||
|
||||
@@ -146,25 +146,6 @@ func resolveOpenCodeGoMappedModel(account *Account, body []byte, defaultMappedMo
|
||||
return normalizeOpenAIModelForUpstream(account, billing)
|
||||
}
|
||||
|
||||
// rewriteOpenCodeGoRequestModel applies account model_mapping to the request
|
||||
// body so every OpenCode protocol path (Responses / Chat Completions /
|
||||
// Anthropic Messages) sends the mapped ID as the real upstream model.
|
||||
func rewriteOpenCodeGoRequestModel(account *Account, body []byte, defaultMappedModel string) (string, []byte, error) {
|
||||
mapped := resolveOpenCodeGoMappedModel(account, body, defaultMappedModel)
|
||||
original := strings.TrimSpace(gjson.GetBytes(body, "model").String())
|
||||
if mapped == "" {
|
||||
mapped = original
|
||||
}
|
||||
if mapped == "" || mapped == original || len(body) == 0 {
|
||||
return mapped, body, nil
|
||||
}
|
||||
rewritten, err := sjson.SetBytes(body, "model", mapped)
|
||||
if err != nil {
|
||||
return mapped, body, fmt.Errorf("rewrite OpenCode mapped model: %w", err)
|
||||
}
|
||||
return mapped, rewritten, nil
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) buildNativeAnthropicUpstreamRequest(
|
||||
ctx context.Context,
|
||||
c *gin.Context,
|
||||
|
||||
@@ -918,7 +918,7 @@ func groupMediaPricingLooksIncomplete(group *Group) bool {
|
||||
// 运营者的修复手段是配置账号级 model_mapping(映射到已定价的 CN 模型)或
|
||||
// 分组/渠道显式定价。
|
||||
func (s *OpenAIGatewayService) filterCNProviderBillingModelCandidates(ctx context.Context, account *Account, apiKey *APIKey, candidates []string) []string {
|
||||
if account == nil || !account.IsCNProvider() {
|
||||
if account == nil || !(account.IsCNProvider() || account.IsOpenCodeGo()) {
|
||||
return candidates
|
||||
}
|
||||
out := make([]string, 0, len(candidates))
|
||||
|
||||
@@ -79,10 +79,10 @@ func (g *Group) ResolveMessagesDispatchModel(requestedModel string) string {
|
||||
return xai.ModelMappingWithOptions(opts)["claude-*"]
|
||||
}
|
||||
|
||||
// 国产供应商分组:调度级模型映射不适用(其配置被 sanitize 置空,且下方的
|
||||
// gpt-5.x 默认值是 openai 专属,发给 CN 上游必错)。模型改写完全交给账号级
|
||||
// model_mapping;anthropic 协议上游本身接受 claude-* 模型名。
|
||||
if IsCNProvider(g.Platform) {
|
||||
// 国产供应商 / OpenCode 分组:调度级模型映射不适用(其配置被 sanitize 置空,
|
||||
// 且下方的 gpt-5.x 默认值是 openai 专属,发给这些上游必错)。模型改写完全
|
||||
// 交给账号级 model_mapping;Anthropic 协议上游本身接受 claude-* 模型名。
|
||||
if IsMultiProtocolAPIKeyProvider(g.Platform) {
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -99,22 +99,22 @@ func isOfficialOpenCodeHost(targetURL string) bool {
|
||||
|
||||
func resolveOpenCodeSessionID(c *gin.Context, headers http.Header, generate bool, bodies ...[]byte) string {
|
||||
if c != nil && c.Request != nil {
|
||||
if sessionID := strings.TrimSpace(c.GetHeader(openCodeSessionHeader)); sessionID != "" {
|
||||
if sessionID := sanitizeSessionID(c.GetHeader(openCodeSessionHeader)); sessionID != "" {
|
||||
return sessionID
|
||||
}
|
||||
if sessionID := explicitOpenAIHeaderSessionID(c); sessionID != "" {
|
||||
if sessionID := sanitizeSessionID(explicitOpenAIHeaderSessionID(c)); sessionID != "" {
|
||||
return sessionID
|
||||
}
|
||||
if sessionID := ClaudeCodeSessionIDFromHeader(c); sessionID != "" {
|
||||
if sessionID := sanitizeSessionID(ClaudeCodeSessionIDFromHeader(c)); sessionID != "" {
|
||||
return sessionID
|
||||
}
|
||||
}
|
||||
for _, body := range bodies {
|
||||
if sessionID := openCodeSessionIDFromPayload(body); sessionID != "" {
|
||||
if sessionID := sanitizeSessionID(openCodeSessionIDFromPayload(body)); sessionID != "" {
|
||||
return sessionID
|
||||
}
|
||||
}
|
||||
if sessionID := strings.TrimSpace(existingOpenCodeSessionHeader(headers)); sessionID != "" {
|
||||
if sessionID := sanitizeSessionID(existingOpenCodeSessionHeader(headers)); sessionID != "" {
|
||||
return sessionID
|
||||
}
|
||||
if generate {
|
||||
|
||||
@@ -147,6 +147,17 @@ func TestApplyOpenCodeSessionHeaderMapsCallerSessionID(t *testing.T) {
|
||||
require.Equal(t, "conv-from-client", headers.Get(openCodeSessionHeader))
|
||||
}
|
||||
|
||||
func TestApplyOpenCodeSessionHeaderRejectsControlCharsInPromptCacheKey(t *testing.T) {
|
||||
account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}
|
||||
body := []byte("{\"model\":\"glm-5.3\",\"prompt_cache_key\":\"a\\nb\",\"input\":\"hello\"}")
|
||||
headers := make(http.Header)
|
||||
applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, ""), account, "https://opencode.ai/zen/go/v1/responses", headers, body)
|
||||
got := headers.Get(openCodeSessionHeader)
|
||||
require.NotEmpty(t, got)
|
||||
require.NotContains(t, got, "\n")
|
||||
require.NotEqual(t, "a\nb", got)
|
||||
}
|
||||
|
||||
func TestApplyOpenCodeSessionHeaderUsesPromptCacheKeyInsteadOfRandomUUID(t *testing.T) {
|
||||
account := &Account{Platform: PlatformOpenCodeGo, Type: AccountTypeAPIKey}
|
||||
body := []byte(`{"model":"grok-4.6","prompt_cache_key":"kimi-session-42","input":"hello"}`)
|
||||
|
||||
@@ -512,7 +512,7 @@ func (s *RateLimitService) HandleUpstreamError(ctx context.Context, account *Acc
|
||||
case 402:
|
||||
// 国产供应商:余额不足是可恢复状态(充值/检测恢复后由周期任务自动解除),
|
||||
// 不能走 handleAuthError 永久置 status=error。改为可恢复的临时停调。
|
||||
if account.IsCNProvider() {
|
||||
if account.IsCNProvider() || account.IsOpenCodeZen() {
|
||||
s.handleCNProviderInsufficientBalance(ctx, account, upstreamMsg)
|
||||
shouldDisable = true
|
||||
break
|
||||
|
||||
Reference in New Issue
Block a user