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:
刘佳林
2026-09-11 15:39:13 +08:00
parent 981279c996
commit efcc2252e5
13 changed files with 47 additions and 60 deletions
@@ -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