diff --git a/backend/internal/handler/openai_gateway_cn_dispatch_test.go b/backend/internal/handler/openai_gateway_cn_dispatch_test.go index b4bc132058..4d0b7e4c30 100644 --- a/backend/internal/handler/openai_gateway_cn_dispatch_test.go +++ b/backend/internal/handler/openai_gateway_cn_dispatch_test.go @@ -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 映射") } diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go index 3d844c220d..617e3fd465 100644 --- a/backend/internal/handler/openai_gateway_handler.go +++ b/backend/internal/handler/openai_gateway_handler.go @@ -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 "" } } diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index dc14a8c493..cd043db85d 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -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 { diff --git a/backend/internal/service/openai_gateway_cn_fixes_test.go b/backend/internal/service/openai_gateway_cn_fixes_test.go index 370ccce8ab..0dfb7c35b6 100644 --- a/backend/internal/service/openai_gateway_cn_fixes_test.go +++ b/backend/internal/service/openai_gateway_cn_fixes_test.go @@ -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) { diff --git a/backend/internal/service/openai_gateway_count_tokens.go b/backend/internal/service/openai_gateway_count_tokens.go index 82270b3167..9ece2430c8 100644 --- a/backend/internal/service/openai_gateway_count_tokens.go +++ b/backend/internal/service/openai_gateway_count_tokens.go @@ -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") diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 92dcbc140e..4d44d31ef6 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -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: diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 7aa0b7fe47..cbc5de9d28 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -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 端点时, diff --git a/backend/internal/service/openai_gateway_messages_anthropic_native.go b/backend/internal/service/openai_gateway_messages_anthropic_native.go index e0041961ad..263eceba04 100644 --- a/backend/internal/service/openai_gateway_messages_anthropic_native.go +++ b/backend/internal/service/openai_gateway_messages_anthropic_native.go @@ -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, diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go index 4570ed1631..0998dedbf0 100644 --- a/backend/internal/service/openai_gateway_usage.go +++ b/backend/internal/service/openai_gateway_usage.go @@ -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)) diff --git a/backend/internal/service/openai_messages_dispatch.go b/backend/internal/service/openai_messages_dispatch.go index 3495bc9e09..b17d498d0a 100644 --- a/backend/internal/service/openai_messages_dispatch.go +++ b/backend/internal/service/openai_messages_dispatch.go @@ -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 "" } diff --git a/backend/internal/service/openai_opencode_session.go b/backend/internal/service/openai_opencode_session.go index 00f078c63f..f5aaca641f 100644 --- a/backend/internal/service/openai_opencode_session.go +++ b/backend/internal/service/openai_opencode_session.go @@ -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 { diff --git a/backend/internal/service/openai_opencode_session_test.go b/backend/internal/service/openai_opencode_session_test.go index 93404d0e1f..b4e455df7c 100644 --- a/backend/internal/service/openai_opencode_session_test.go +++ b/backend/internal/service/openai_opencode_session_test.go @@ -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"}`) diff --git a/backend/internal/service/ratelimit_service.go b/backend/internal/service/ratelimit_service.go index e615535f8d..4158d0559c 100644 --- a/backend/internal/service/ratelimit_service.go +++ b/backend/internal/service/ratelimit_service.go @@ -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