From 867563ae76f3ff99ed20dfed93ee216a7939ce3f Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 14 Oct 2026 14:58:26 +0800 Subject: [PATCH 01/10] fix(gateway): honor Anthropic protocol base URLs --- .../gateway_anthropic_base_url_test.go | 106 ++++++++++++++++++ .../service/gateway_anthropic_passthrough.go | 8 +- .../internal/service/gateway_count_tokens.go | 9 +- .../service/gateway_upstream_request.go | 19 +++- 4 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 backend/internal/service/gateway_anthropic_base_url_test.go diff --git a/backend/internal/service/gateway_anthropic_base_url_test.go b/backend/internal/service/gateway_anthropic_base_url_test.go new file mode 100644 index 0000000000..490c01cc82 --- /dev/null +++ b/backend/internal/service/gateway_anthropic_base_url_test.go @@ -0,0 +1,106 @@ +//go:build unit + +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func gatewayAnthropicBaseURLAccount(apiBaseURLs map[string]any) *Account { + return &Account{ + ID: 8074, + Platform: PlatformDeepseek, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "base_url": "https://api.deepseek.com", + "api_protocol": APIProtocolAdaptive, + "api_base_urls": apiBaseURLs, + }, + } +} + +func gatewayAnthropicBaseURLContext(t *testing.T) *gin.Context { + t.Helper() + gin.SetMode(gin.TestMode) + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil) + return c +} + +func TestGatewayService_AdaptiveAnthropicBaseURLRouting(t *testing.T) { + svc := &GatewayService{cfg: &config.Config{}} + body := []byte(`{"model":"deepseek-chat","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`) + + t.Run("base present is used by messages and count_tokens", func(t *testing.T) { + account := gatewayAnthropicBaseURLAccount(map[string]any{ + APIProtocolAnthropic: "https://anthropic.example.com", + APIProtocolChatCompletions: "https://chat.example.com", + }) + c := gatewayAnthropicBaseURLContext(t) + + req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "test-key", "apikey", "deepseek-chat", false, false) + require.NoError(t, err) + require.Equal(t, "https://anthropic.example.com/v1/messages?beta=true", req.URL.String()) + + countReq, _, err := svc.buildCountTokensRequest(context.Background(), c, account, body, "test-key", "apikey", "deepseek-chat", false) + require.NoError(t, err) + require.Equal(t, "https://anthropic.example.com/v1/messages/count_tokens?beta=true", countReq.URL.String()) + }) + + t.Run("missing anthropic base uses provider fallback", func(t *testing.T) { + account := gatewayAnthropicBaseURLAccount(map[string]any{ + APIProtocolChatCompletions: "https://chat.example.com", + }) + req, _, err := svc.buildUpstreamRequest(context.Background(), gatewayAnthropicBaseURLContext(t), account, body, "test-key", "apikey", "deepseek-chat", false, false) + require.NoError(t, err) + require.Equal(t, "https://api.deepseek.com/anthropic/v1/messages?beta=true", req.URL.String()) + }) + + t.Run("ordinary anthropic api key keeps base_url fallback", func(t *testing.T) { + account := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Credentials: map[string]any{ + "api_key": "test-key", + "base_url": "https://custom-anthropic.example.com", + }, + } + req, _, err := svc.buildUpstreamRequest(context.Background(), gatewayAnthropicBaseURLContext(t), account, body, "test-key", "apikey", "deepseek-chat", false, false) + require.NoError(t, err) + require.Equal(t, "https://custom-anthropic.example.com/v1/messages?beta=true", req.URL.String()) + }) +} + +func TestGatewayService_AnthropicPassthroughAndCountTokensUseSelectedBase(t *testing.T) { + svc := &GatewayService{cfg: &config.Config{}} + account := gatewayAnthropicBaseURLAccount(map[string]any{ + APIProtocolAnthropic: "https://ollama.com", + }) + body := []byte(`{"model":"deepseek-v4-flash","max_tokens":256000,"messages":[{"role":"user","content":"hi"}]}`) + c := gatewayAnthropicBaseURLContext(t) + + passthroughReq, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "test-key") + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages?beta=true", passthroughReq.URL.String()) + require.Equal(t, "Bearer test-key", passthroughReq.Header.Get("Authorization")) + require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int()) + + countPassthroughReq, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "test-key") + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages/count_tokens?beta=true", countPassthroughReq.URL.String()) + require.Equal(t, "Bearer test-key", countPassthroughReq.Header.Get("Authorization")) + + countReq, _, err := svc.buildCountTokensRequest(context.Background(), c, account, body, "test-key", "apikey", "deepseek-v4-flash", false) + require.NoError(t, err) + require.Equal(t, "https://ollama.com/v1/messages/count_tokens?beta=true", countReq.URL.String()) + require.Equal(t, "Bearer test-key", countReq.Header.Get("Authorization")) +} diff --git a/backend/internal/service/gateway_anthropic_passthrough.go b/backend/internal/service/gateway_anthropic_passthrough.go index b998f69eeb..889e2faa36 100644 --- a/backend/internal/service/gateway_anthropic_passthrough.go +++ b/backend/internal/service/gateway_anthropic_passthrough.go @@ -301,7 +301,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( ) (*http.Request, []byte, error) { body = stripDeferredToolCacheControl(body) targetURL := claudeAPIURL - baseURL := account.GetBaseURL() + baseURL := getAnthropicGatewayBaseURL(account) if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { @@ -326,8 +326,8 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( } // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的 - // base 取值同源(GetBaseURL),详见 helper 注释。 - body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body) + // base 取值同源,详见 helper 注释。 + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, baseURL, body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) if err != nil { @@ -354,7 +354,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough( req.Header.Del("cookie") // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 // targetURL 的 base 取值),其余保持 extra/default 行为。 - setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) + setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL) if getHeaderRaw(req.Header, "content-type") == "" { setHeaderRaw(req.Header, "content-type", "application/json") diff --git a/backend/internal/service/gateway_count_tokens.go b/backend/internal/service/gateway_count_tokens.go index ec3c2bad0e..b07feb7071 100644 --- a/backend/internal/service/gateway_count_tokens.go +++ b/backend/internal/service/gateway_count_tokens.go @@ -399,7 +399,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( ) (*http.Request, error) { body = stripDeferredToolCacheControl(body) targetURL := claudeAPICountTokensURL - baseURL := account.GetBaseURL() + baseURL := getAnthropicGatewayBaseURL(account) if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { @@ -446,7 +446,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough( req.Header.Del("cookie") // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 // targetURL 的 base 取值),其余保持 extra/default 行为。 - setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) + setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL) if req.Header.Get("content-type") == "" { req.Header.Set("content-type", "application/json") @@ -467,8 +467,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con body = stripDeferredToolCacheControl(body) // 确定目标 URL targetURL := claudeAPICountTokensURL + baseURL := "" if account.Type == AccountTypeAPIKey { - baseURL := account.GetBaseURL() + baseURL = getAnthropicGatewayBaseURL(account) if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { @@ -558,7 +559,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con } else { // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 // targetURL 的 base 取值),其余保持 extra/default 行为。 - setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) + setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL) } // 白名单透传 headers(恢复真实 wire casing) diff --git a/backend/internal/service/gateway_upstream_request.go b/backend/internal/service/gateway_upstream_request.go index cdf2319ebb..d5f08f9a85 100644 --- a/backend/internal/service/gateway_upstream_request.go +++ b/backend/internal/service/gateway_upstream_request.go @@ -27,8 +27,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex // 确定目标URL targetURL := claudeAPIURL + baseURL := "" if account.Type == AccountTypeAPIKey { - baseURL := account.GetBaseURL() + baseURL = getAnthropicGatewayBaseURL(account) if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { @@ -124,9 +125,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } // Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的 - // base 取值同源(GetBaseURL),仅实际上游为 ollama.com 且映射后出站模型 + // base 取值同源,仅实际上游为 ollama.com 且映射后出站模型 // 为 DeepSeek 系时压到 cap,详见 helper 注释。 - body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body) + body = clampOllamaCloudAnthropicMessagesMaxTokens(account, baseURL, body) req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body)) if err != nil { @@ -139,7 +140,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex } else { // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方 // targetURL 的 base 取值),其余保持 extra/default 行为。 - setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL()) + setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL) } // 白名单透传 headers @@ -225,6 +226,16 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex return req, body, nil } +func getAnthropicGatewayBaseURL(account *Account) string { + if account == nil { + return "" + } + if baseURL := account.GetAnthropicProtocolBaseURL(); baseURL != "" { + return baseURL + } + return account.GetBaseURL() +} + // vertexSupportedBetaTokens 是 Vertex AI 的 Anthropic 端点接受的 anthropic-beta // 白名单。Vertex 对任何未知 token 直接 HTTP 400,故采用白名单(与 Bedrock 的 // bedrockSupportedBetaTokens 同思路)而非黑名单:未来 Claude Code 新增的、Vertex 尚未 From 16611a8ee0b459ee002d50efb5a90d9d06d92ff0 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Wed, 14 Oct 2026 18:34:52 +0800 Subject: [PATCH 02/10] fix(openai): isolate Codex imports by workspace --- .../handler/admin/account_codex_import.go | 12 ++- .../admin/account_codex_import_test.go | 80 ++++++++++++++++++- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/backend/internal/handler/admin/account_codex_import.go b/backend/internal/handler/admin/account_codex_import.go index 0de8078669..8337488931 100644 --- a/backend/internal/handler/admin/account_codex_import.go +++ b/backend/internal/handler/admin/account_codex_import.go @@ -908,14 +908,16 @@ func buildCodexAgentIdentityKeys(accountID string) []string { return []string{"account:" + accountID} } -// buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度, -// 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。 +// buildCodexStoredIdentityKeys 生成存量账号索引键,按 workspace/account +// 和 user 组合隔离身份,避免同一用户在不同 workspace 的账号误合并。 func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string { keys := make([]string, 0, 3) accountID = strings.TrimSpace(accountID) userID = strings.TrimSpace(userID) accessToken = strings.TrimSpace(accessToken) - if userID != "" { + if accountID != "" && userID != "" { + keys = append(keys, "account_user:"+codexCompositeIdentityKey(accountID, userID)) + } else if userID != "" { keys = append(keys, "user:"+userID) } if accountID == "" && userID == "" { @@ -932,6 +934,10 @@ func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) return keys } +func codexCompositeIdentityKey(accountID, userID string) string { + return fmt.Sprintf("%d:%s%d:%s", len(accountID), accountID, len(userID), userID) +} + func buildCodexAccountIndex(accounts []service.Account) *codexAccountIndex { index := &codexAccountIndex{ accountsByKey: map[string][]service.Account{}, diff --git a/backend/internal/handler/admin/account_codex_import_test.go b/backend/internal/handler/admin/account_codex_import_test.go index dc2903ccbe..e26a112dad 100644 --- a/backend/internal/handler/admin/account_codex_import_test.go +++ b/backend/internal/handler/admin/account_codex_import_test.go @@ -303,8 +303,8 @@ func TestResolveCodexImportExpiryForNoRefreshTokenUsesEarlierRequestExpiry(t *te func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) { keys := buildCodexImportIdentityKeys("acct-1", "user-1", "same@example.com", "token", "refresh") - if len(keys) == 0 || keys[0] != "user:user-1" { - t.Fatalf("user key should have highest priority when refresh token exists: %v", keys) + if len(keys) == 0 || keys[0] != "account_user:6:acct-16:user-1" { + t.Fatalf("workspace-scoped user key should have highest priority when refresh token exists: %v", keys) } if keys[len(keys)-1] != "account:acct-1" { t.Fatalf("shared account key should be the last fallback: %v", keys) @@ -465,12 +465,84 @@ func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) { if got == nil || got.ID != member.ID { t.Fatalf("Find by user key = %v, want member account ID %d", got, member.ID) } - if matchedKey != "user:user-2" { - t.Fatalf("matched key = %q, want user:user-2", matchedKey) + if matchedKey != "account_user:6:team-16:user-2" { + t.Fatalf("matched key = %q, want account_user:6:team-16:user-2", matchedKey) } } } +func TestImportCodexSessionsSameUserDifferentWorkspacesCreatesSeparateAccounts(t *testing.T) { + svc := newCodexImportMemoryAdminService(nil) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + entries := []codexImportEntry{ + {Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "shared-user", "refresh-1")}, + {Index: 2, Value: buildCodexRefreshImportValue(t, "workspace-2", "shared-user", "refresh-2")}, + } + + result, err := handler.importCodexSessions(context.Background(), req, entries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if result.Created != 2 || result.Updated != 0 || result.Skipped != 0 || result.Failed != 0 { + t.Fatalf("result = %+v, want two created accounts", result) + } + if len(svc.createdAccounts) != 2 { + t.Fatalf("created accounts = %d, want 2", len(svc.createdAccounts)) + } + if got := svc.createdAccounts[0].Credentials["chatgpt_account_id"]; got != "workspace-1" { + t.Fatalf("first workspace = %v, want workspace-1", got) + } + if got := svc.createdAccounts[1].Credentials["chatgpt_account_id"]; got != "workspace-2" { + t.Fatalf("second workspace = %v, want workspace-2", got) + } +} + +func TestCodexImportIdentityKeySeparatesDelimiterValues(t *testing.T) { + left := buildCodexImportIdentityKeys("ab:c", "shared-user", "", "", "refresh") + right := buildCodexImportIdentityKeys("ab", "c:shared-user", "", "", "refresh") + if left[0] == right[0] { + t.Fatalf("composite identity keys collided: %q", left[0]) + } +} + +func TestImportCodexSessionsSameWorkspaceAccountUpdatesAndDifferentUserIsolated(t *testing.T) { + svc := newCodexImportMemoryAdminService(nil) + handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)} + initialEntries := []codexImportEntry{ + {Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-1")}, + {Index: 2, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-2", "refresh-2")}, + } + + initialResult, err := handler.importCodexSessions(context.Background(), req, initialEntries) + if err != nil { + t.Fatalf("importCodexSessions error = %v", err) + } + if initialResult.Created != 2 || initialResult.Updated != 0 || initialResult.Skipped != 0 || initialResult.Failed != 0 { + t.Fatalf("initial result = %+v, want two created accounts", initialResult) + } + + updateResult, err := handler.importCodexSessions(context.Background(), req, []codexImportEntry{ + {Index: 3, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-3")}, + }) + if err != nil { + t.Fatalf("importCodexSessions update error = %v", err) + } + if updateResult.Created != 0 || updateResult.Updated != 1 || updateResult.Skipped != 0 || updateResult.Failed != 0 { + t.Fatalf("update result = %+v, want one updated account", updateResult) + } + if len(svc.createdAccounts) != 2 || len(svc.updatedAccounts) != 1 { + t.Fatalf("created = %d, updated = %d, want 2 and 1", len(svc.createdAccounts), len(svc.updatedAccounts)) + } + if svc.updatedAccounts[0].id != 100 { + t.Fatalf("updated account ID = %d, want user-1 account ID 100", svc.updatedAccounts[0].id) + } + if got := svc.updatedAccounts[0].input.Credentials["chatgpt_user_id"]; got != "user-1" { + t.Fatalf("updated user = %v, want user-1", got) + } +} + func TestCodexAccountIndexUpsertReplacesSameAccount(t *testing.T) { legacy := service.Account{ ID: 40, From 2a8cb088af205586b514a27f6a63e9bcf7adb4e6 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 15 Oct 2026 01:56:00 +0800 Subject: [PATCH 03/10] feat(apicompat): configure minimum output token floor --- backend/internal/config/config.go | 7 +++ backend/internal/config/config_test.go | 23 ++++++++ .../pkg/apicompat/anthropic_to_responses.go | 10 +++- .../chatcompletions_anthropic_bridge.go | 10 +++- .../chatcompletions_anthropic_bridge_test.go | 37 ++++++++++++ .../chatcompletions_responses_test.go | 56 +++++++++++++++++++ .../apicompat/chatcompletions_to_responses.go | 10 +++- backend/internal/pkg/apicompat/types.go | 9 ++- .../service/antigravity_gateway_compat.go | 2 +- backend/internal/service/apicompat_config.go | 28 ++++++++++ .../gateway_forward_as_chat_completions.go | 2 +- .../service/gateway_forward_as_responses.go | 2 +- .../gemini_chat_completions_compat_service.go | 2 +- .../openai_gateway_chat_completions.go | 2 +- ...teway_chat_completions_anthropic_native.go | 2 +- .../service/openai_gateway_forward.go | 2 +- .../openai_gateway_grok_chat_bridge.go | 2 +- .../service/openai_gateway_messages.go | 2 +- .../openai_gateway_messages_chat_fallback.go | 5 +- .../service/openai_gateway_passthrough.go | 1 + .../service/openai_gateway_request_body.go | 8 ++- .../openai_responses_ingress_compat.go | 11 +++- .../service/openai_ws_forwarder_ingress.go | 2 +- .../openai_ws_v2_passthrough_adapter.go | 4 +- deploy/config.example.yaml | 3 + 25 files changed, 216 insertions(+), 26 deletions(-) create mode 100644 backend/internal/service/apicompat_config.go diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e99b547ec8..1f488c89af 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -979,6 +979,9 @@ const ( // GatewayConfig API网关相关配置 type GatewayConfig struct { + // MinOutputTokens is the minimum max_output_tokens used when + // converting Chat Completions or Anthropic requests to Responses. + MinOutputTokens int `mapstructure:"min_output_tokens"` // 等待上游响应头的超时时间(秒),0表示无超时 // 注意:这不影响流式数据传输,只控制等待响应头的时间 ResponseHeaderTimeout int `mapstructure:"response_header_timeout"` @@ -2035,6 +2038,7 @@ func configureConfigSource(setConfigFile, addConfigPath func(string)) { func setDefaults() { viper.SetDefault("run_mode", RunModeStandard) + viper.SetDefault("gateway.min_output_tokens", 128) viper.SetDefault("simple_mode.auto_create_default_groups", true) viper.SetDefault("simple_mode_key_rate_limit_enabled", false) @@ -2707,6 +2711,9 @@ func setEnvReachableDefaults() { } func (c *Config) Validate() error { + if c.Gateway.MinOutputTokens < 16 { + return fmt.Errorf("gateway.min_output_tokens must be at least 16") + } forwardedClientIPHeaders, err := NormalizeForwardedClientIPHeaders(c.Security.ForwardedClientIPHeaders) if err != nil { return fmt.Errorf("security.forwarded_client_ip_headers: %w", err) diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 96cac60f43..434b889955 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -31,6 +31,29 @@ func TestLoadDefaultModelsListReadMaxBytes(t *testing.T) { require.Equal(t, DefaultModelsListReadMaxBytes, cfg.Gateway.ModelsListReadMaxBytes) } +func TestLoadDefaultMinOutputTokens(t *testing.T) { + resetViperWithJWTSecret(t) + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, 128, cfg.Gateway.MinOutputTokens) +} + +func TestLoadMinOutputTokensFromEnv(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("GATEWAY_MIN_OUTPUT_TOKENS", "16") + cfg, err := Load() + require.NoError(t, err) + require.Equal(t, 16, cfg.Gateway.MinOutputTokens) +} + +func TestValidateMinOutputTokensMinimum(t *testing.T) { + resetViperWithJWTSecret(t) + cfg, err := Load() + require.NoError(t, err) + cfg.Gateway.MinOutputTokens = 15 + require.EqualError(t, cfg.Validate(), "gateway.min_output_tokens must be at least 16") +} + func TestLoadTimezonePrecedence(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses.go b/backend/internal/pkg/apicompat/anthropic_to_responses.go index bb46a5e48e..a54ca94133 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses.go @@ -13,6 +13,12 @@ import ( // Chat Completions intermediary round-trip (e.g. thinking, cache_control, // structured system prompts). func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) { + return AnthropicToResponsesWithMinOutputTokens(req, DefaultMinMaxOutputTokens) +} + +// AnthropicToResponsesWithMinOutputTokens converts an Anthropic request using +// the supplied max_output_tokens floor. +func AnthropicToResponsesWithMinOutputTokens(req *AnthropicRequest, minOutputTokens int) (*ResponsesRequest, error) { if err := openai.ValidateGPT61SolReasoningEffort(req.Model, anthropicReasoningEffort(req)); err != nil { return nil, err } @@ -50,8 +56,8 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) { if req.MaxTokens > 0 { v := req.MaxTokens - if v < minMaxOutputTokens { - v = minMaxOutputTokens + if v < minOutputTokens { + v = minOutputTokens } out.MaxOutputTokens = &v } diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go index fc73afa3a4..20a590c1c6 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go @@ -40,6 +40,12 @@ import ( // ResponsesToChatCompletionsRequest but avoids materializing the intermediate // ResponsesRequest and the extra marshal/unmarshal cycle. func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsRequest, error) { + return AnthropicToChatCompletionsRequestWithMinOutputTokens(req, DefaultMinMaxOutputTokens) +} + +// AnthropicToChatCompletionsRequestWithMinOutputTokens converts an Anthropic +// Messages request using the supplied max_completion_tokens floor. +func AnthropicToChatCompletionsRequestWithMinOutputTokens(req *AnthropicRequest, minOutputTokens int) (*ChatCompletionsRequest, error) { if req == nil { return nil, fmt.Errorf("anthropic request is nil") } @@ -63,8 +69,8 @@ func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsR if req.MaxTokens > 0 { v := req.MaxTokens - if v < minMaxOutputTokens { - v = minMaxOutputTokens + if v < minOutputTokens { + v = minOutputTokens } out.MaxCompletionTokens = &v } diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go index b278c28a83..13cc945ca7 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -267,6 +267,43 @@ func TestAnthropicToChatCompletionsRequest_MaxTokensFloor(t *testing.T) { require.Equal(t, minMaxOutputTokens, *out.MaxCompletionTokens) } +func TestAnthropicToChatCompletionsRequestWithMinOutputTokens_ConfiguredFloor(t *testing.T) { + req := &AnthropicRequest{ + Model: "claude-sonnet-4-20250514", + MaxTokens: 16, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, + } + + out, err := AnthropicToChatCompletionsRequestWithMinOutputTokens(req, 64) + require.NoError(t, err) + require.NotNil(t, out.MaxCompletionTokens) + require.Equal(t, 64, *out.MaxCompletionTokens) +} + +func TestAnthropicToResponses_ConfiguredMaxTokensFloor(t *testing.T) { + for _, input := range []int{16, 64, 127} { + req := &AnthropicRequest{ + Model: "claude-sonnet-4-20250514", + MaxTokens: input, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, + } + out, err := AnthropicToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, out.MaxOutputTokens) + require.Equal(t, input, *out.MaxOutputTokens) + } + + req := &AnthropicRequest{ + Model: "claude-sonnet-4-20250514", + MaxTokens: 1, + Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}}, + } + out, err := AnthropicToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, out.MaxOutputTokens) + require.Equal(t, 16, *out.MaxOutputTokens) +} + func TestAnthropicToChatCompletionsRequest_ReasoningEffortMapping(t *testing.T) { req := &AnthropicRequest{ Model: "gpt-5.4", diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go index 1a27ef2374..b94bc5c394 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_test.go @@ -2,6 +2,7 @@ package apicompat import ( "encoding/json" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -290,6 +291,46 @@ func mustMarshalJSON(t *testing.T, value any) []byte { } func TestChatCompletionsToResponses_MaxTokens(t *testing.T) { + t.Run("default floor", func(t *testing.T) { + maxTokens := 1 + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + MaxTokens: &maxTokens, + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + } + resp, err := ChatCompletionsToResponses(req) + require.NoError(t, err) + require.NotNil(t, resp.MaxOutputTokens) + assert.Equal(t, DefaultMinMaxOutputTokens, *resp.MaxOutputTokens) + }) + + t.Run("configured floor", func(t *testing.T) { + for _, input := range []int{16, 64, 127, 128} { + input := input + t.Run(fmt.Sprintf("input_%d", input), func(t *testing.T) { + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + MaxTokens: &input, + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + } + resp, err := ChatCompletionsToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, resp.MaxOutputTokens) + assert.Equal(t, input, *resp.MaxOutputTokens) + }) + } + input := 1 + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + MaxTokens: &input, + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + } + resp, err := ChatCompletionsToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, resp.MaxOutputTokens) + assert.Equal(t, 16, *resp.MaxOutputTokens) + }) + t.Run("max_tokens", func(t *testing.T) { maxTokens := 100 req := &ChatCompletionsRequest{ @@ -318,6 +359,21 @@ func TestChatCompletionsToResponses_MaxTokens(t *testing.T) { require.NotNil(t, resp.MaxOutputTokens) assert.Equal(t, 500, *resp.MaxOutputTokens) }) + + t.Run("max_completion_tokens_preferred before configured floor", func(t *testing.T) { + maxTokens := 500 + maxCompletion := 1 + req := &ChatCompletionsRequest{ + Model: "gpt-4o", + MaxTokens: &maxTokens, + MaxCompletionTokens: &maxCompletion, + Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}}, + } + resp, err := ChatCompletionsToResponsesWithMinOutputTokens(req, 16) + require.NoError(t, err) + require.NotNil(t, resp.MaxOutputTokens) + assert.Equal(t, 16, *resp.MaxOutputTokens) + }) } func TestChatCompletionsToResponses_ReasoningEffort(t *testing.T) { diff --git a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go index 5f4267c831..9f221a064a 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_to_responses.go +++ b/backend/internal/pkg/apicompat/chatcompletions_to_responses.go @@ -18,6 +18,12 @@ type chatMessageContent struct { // true. store is always false and reasoning.encrypted_content is always // included so that the response translator has full context. func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, error) { + return ChatCompletionsToResponsesWithMinOutputTokens(req, DefaultMinMaxOutputTokens) +} + +// ChatCompletionsToResponsesWithMinOutputTokens converts a Chat Completions +// request using the supplied max_output_tokens floor. +func ChatCompletionsToResponsesWithMinOutputTokens(req *ChatCompletionsRequest, minOutputTokens int) (*ResponsesRequest, error) { if err := openai.ValidateGPT61SolReasoningEffort(req.Model, req.ReasoningEffort); err != nil { return nil, err } @@ -62,8 +68,8 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, } if maxTokens > 0 { v := maxTokens - if v < minMaxOutputTokens { - v = minMaxOutputTokens + if v < minOutputTokens { + v = minOutputTokens } out.MaxOutputTokens = &v } diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index b5864f2f2f..956101bd73 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -860,6 +860,9 @@ func (d ChatDelta) reasoningText() *string { // Shared constants // --------------------------------------------------------------------------- -// minMaxOutputTokens is the floor for max_output_tokens in a Responses request. -// Very small values may cause upstream API errors, so we enforce a minimum. -const minMaxOutputTokens = 128 +// DefaultMinMaxOutputTokens is the default floor for max_output_tokens in a +// Responses request. Very small values may cause upstream API errors, so we +// enforce a minimum. +const DefaultMinMaxOutputTokens = 128 + +const minMaxOutputTokens = DefaultMinMaxOutputTokens diff --git a/backend/internal/service/antigravity_gateway_compat.go b/backend/internal/service/antigravity_gateway_compat.go index b62e32c842..60ecd977ed 100644 --- a/backend/internal/service/antigravity_gateway_compat.go +++ b/backend/internal/service/antigravity_gateway_compat.go @@ -71,7 +71,7 @@ func (s *AntigravityGatewayService) ForwardAsChatCompletions( return nil, s.writeAntigravityCompatError(c, http.StatusBadRequest, "invalid_request_error", "model is required") } - responsesRequest, err := apicompat.ChatCompletionsToResponses(&request) + responsesRequest, err := chatCompletionsToResponses(settingServiceConfig(s.settingService), &request) if err != nil { return nil, s.writeAntigravityCompatError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) } diff --git a/backend/internal/service/apicompat_config.go b/backend/internal/service/apicompat_config.go new file mode 100644 index 0000000000..e9605e0a5c --- /dev/null +++ b/backend/internal/service/apicompat_config.go @@ -0,0 +1,28 @@ +package service + +import ( + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" +) + +func configuredMinOutputTokens(cfg *config.Config) int { + if cfg != nil && cfg.Gateway.MinOutputTokens >= 16 { + return cfg.Gateway.MinOutputTokens + } + return apicompat.DefaultMinMaxOutputTokens +} + +func chatCompletionsToResponses(cfg *config.Config, req *apicompat.ChatCompletionsRequest) (*apicompat.ResponsesRequest, error) { + return apicompat.ChatCompletionsToResponsesWithMinOutputTokens(req, configuredMinOutputTokens(cfg)) +} + +func anthropicToResponses(cfg *config.Config, req *apicompat.AnthropicRequest) (*apicompat.ResponsesRequest, error) { + return apicompat.AnthropicToResponsesWithMinOutputTokens(req, configuredMinOutputTokens(cfg)) +} + +func settingServiceConfig(settingService *SettingService) *config.Config { + if settingService == nil { + return nil + } + return settingService.cfg +} diff --git a/backend/internal/service/gateway_forward_as_chat_completions.go b/backend/internal/service/gateway_forward_as_chat_completions.go index 60b14488cd..83d82e6ed0 100644 --- a/backend/internal/service/gateway_forward_as_chat_completions.go +++ b/backend/internal/service/gateway_forward_as_chat_completions.go @@ -44,7 +44,7 @@ func (s *GatewayService) ForwardAsChatCompletions( clientStream := ccReq.Stream // 2. Convert CC → Responses → Anthropic (chained conversion) - responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) + responsesReq, err := chatCompletionsToResponses(s.cfg, &ccReq) if err != nil { return nil, fmt.Errorf("convert chat completions to responses: %w", err) } diff --git a/backend/internal/service/gateway_forward_as_responses.go b/backend/internal/service/gateway_forward_as_responses.go index db8033ebe4..189c086192 100644 --- a/backend/internal/service/gateway_forward_as_responses.go +++ b/backend/internal/service/gateway_forward_as_responses.go @@ -37,7 +37,7 @@ func (s *GatewayService) ForwardAsResponses( ) (*ForwardResult, error) { startTime := time.Now() - normalizedBody, normalized, err := normalizeOpenAIResponsesLegacyIngress(body) + normalizedBody, normalized, err := normalizeOpenAIResponsesLegacyIngressWithConfig(s.cfg, body) if err != nil { return nil, err } diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go index 1ad2b487e8..848b40d887 100644 --- a/backend/internal/service/gemini_chat_completions_compat_service.go +++ b/backend/internal/service/gemini_chat_completions_compat_service.go @@ -42,7 +42,7 @@ func (s *GeminiMessagesCompatService) ForwardAsChatCompletions( clientStream := ccReq.Stream includeUsage := ccReq.StreamOptions != nil && ccReq.StreamOptions.IncludeUsage - responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) + responsesReq, err := chatCompletionsToResponses(s.cfg, &ccReq) if err != nil { return nil, s.writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) } diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index b7341ccda3..af4eae36ea 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -231,7 +231,7 @@ func (s *OpenAIGatewayService) forwardAsChatCompletions( // Normal path: convert Chat Completions → Responses. // ChatCompletionsToResponses always sets Stream=true (upstream always streams). chatReq.Model = upstreamModel - responsesReq, err = apicompat.ChatCompletionsToResponses(&chatReq) + responsesReq, err = chatCompletionsToResponses(s.cfg, &chatReq) if err != nil { return nil, fmt.Errorf("convert chat completions to responses: %w", err) } diff --git a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go index 6c0affe2bf..f76a457370 100644 --- a/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go +++ b/backend/internal/service/openai_gateway_chat_completions_anthropic_native.go @@ -57,7 +57,7 @@ func (s *OpenAIGatewayService) forwardChatCompletionsViaNativeAnthropic( clientStream := ccReq.Stream // 2. Convert CC → Responses → Anthropic (chained conversion) - responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq) + responsesReq, err := chatCompletionsToResponses(s.cfg, &ccReq) if err != nil { writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", "Failed to convert request") return nil, fmt.Errorf("convert chat completions to responses: %w", err) diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 5faa086380..27aafd997d 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -65,7 +65,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if normalized { body = normalizedBody } - legacyIngressBody, legacyIngressChanged, legacyIngressErr := normalizeOpenAIResponsesLegacyIngress(body) + legacyIngressBody, legacyIngressChanged, legacyIngressErr := normalizeOpenAIResponsesLegacyIngressWithConfig(s.cfg, body) if legacyIngressErr != nil { return nil, legacyIngressErr } diff --git a/backend/internal/service/openai_gateway_grok_chat_bridge.go b/backend/internal/service/openai_gateway_grok_chat_bridge.go index 284b0dcf9f..546b3007f2 100644 --- a/backend/internal/service/openai_gateway_grok_chat_bridge.go +++ b/backend/internal/service/openai_gateway_grok_chat_bridge.go @@ -541,7 +541,7 @@ func (s *OpenAIGatewayService) forwardGrokChatCompletionsViaResponses( return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } - responsesReq, err := apicompat.ChatCompletionsToResponses(&chatReq) + responsesReq, err := chatCompletionsToResponses(s.cfg, &chatReq) if err != nil { return nil, fmt.Errorf("convert grok chat completions to responses: %w", err) } diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go index 75c83c7138..18c4fb1372 100644 --- a/backend/internal/service/openai_gateway_messages.go +++ b/backend/internal/service/openai_gateway_messages.go @@ -144,7 +144,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic( // 3. Convert Anthropic → Responses after compatibility-only replay guard. anthropicReq.Model = upstreamModel - responsesReq, err := apicompat.AnthropicToResponses(&anthropicReq) + responsesReq, err := anthropicToResponses(s.cfg, &anthropicReq) if err != nil { return nil, fmt.Errorf("convert anthropic to responses: %w", err) } diff --git a/backend/internal/service/openai_gateway_messages_chat_fallback.go b/backend/internal/service/openai_gateway_messages_chat_fallback.go index 2d2c997b97..d12918a3bb 100644 --- a/backend/internal/service/openai_gateway_messages_chat_fallback.go +++ b/backend/internal/service/openai_gateway_messages_chat_fallback.go @@ -52,7 +52,10 @@ func (s *OpenAIGatewayService) forwardAnthropicViaRawChatCompletions( clientStream := anthropicReq.Stream // 2. Anthropic → Chat Completions (direct, no Responses intermediary) - chatReq, err := apicompat.AnthropicToChatCompletionsRequest(&anthropicReq) + chatReq, err := apicompat.AnthropicToChatCompletionsRequestWithMinOutputTokens( + &anthropicReq, + configuredMinOutputTokens(s.cfg), + ) if err != nil { writeAnthropicError(c, http.StatusBadRequest, "invalid_request_error", err.Error()) return nil, fmt.Errorf("convert anthropic to chat completions: %w", err) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index bf74b242d9..6feadb8fb6 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -214,6 +214,7 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( if account != nil && account.IsOpenAI() { responsesLite := isOpenAIResponsesLiteHeader(c.GetHeader(responsesLiteHeader)) || isOpenAIResponsesLiteWebSocketPayload(body) normalizedBody, normalized, normalizeErr := normalizeOpenAIResponsesCompatibilityBodyWithOptions(body, account, openAIResponsesCompatibilityOptions{ + Config: s.cfg, ResponsesLite: responsesLite, Compact: isOpenAIResponsesCompactPath(c), }) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index 7ae18793e7..52ca1d57b4 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -11,6 +11,7 @@ import ( "strings" "unicode/utf8" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/Wei-Shaw/sub2api/internal/util/urlvalidator" @@ -1433,7 +1434,12 @@ func normalizeOpenAIResponsesWebSocketCompatibilityBody(body []byte, account *Ac return normalizeOpenAIResponsesCompatibilityBodyWithOptions(body, account, openAIResponsesCompatibilityOptions{ResponsesLite: responsesLite}) } +func normalizeOpenAIResponsesWebSocketCompatibilityBodyWithConfig(cfg *config.Config, body []byte, account *Account, responsesLite bool) ([]byte, bool, error) { + return normalizeOpenAIResponsesCompatibilityBodyWithOptions(body, account, openAIResponsesCompatibilityOptions{Config: cfg, ResponsesLite: responsesLite}) +} + type openAIResponsesCompatibilityOptions struct { + Config *config.Config ResponsesLite bool // Compact marks the /responses/compact wire shape, which is left as-is // by request-shape compatibility rewrites such as web_search history. @@ -1449,7 +1455,7 @@ func normalizeOpenAIResponsesCompatibilityBodyWithOptions(body []byte, account * changed := false if account.IsOpenAIOAuthLike() { var err error - normalized, changed, err = normalizeOpenAIResponsesLegacyIngress(body) + normalized, changed, err = normalizeOpenAIResponsesLegacyIngressWithConfig(opts.Config, body) if err != nil { return body, false, err } diff --git a/backend/internal/service/openai_responses_ingress_compat.go b/backend/internal/service/openai_responses_ingress_compat.go index b5a289dd3e..d7a2adafb8 100644 --- a/backend/internal/service/openai_responses_ingress_compat.go +++ b/backend/internal/service/openai_responses_ingress_compat.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat" "github.com/tidwall/gjson" ) @@ -13,6 +14,10 @@ import ( // payloads that a few Responses clients still emit. Native Responses input is // always authoritative because this path has no separate full-replay attempt. func normalizeOpenAIResponsesLegacyIngress(body []byte) ([]byte, bool, error) { + return normalizeOpenAIResponsesLegacyIngressWithConfig(nil, body) +} + +func normalizeOpenAIResponsesLegacyIngressWithConfig(cfg *config.Config, body []byte) ([]byte, bool, error) { if len(body) == 0 { return body, false, nil } @@ -43,7 +48,7 @@ func normalizeOpenAIResponsesLegacyIngress(body []byte) ([]byte, bool, error) { if hasMessages { messages, messagesAreArray := messagesValue.([]any) if messagesAreArray && len(messages) > 0 { - legacy, err := convertLegacyResponsesMessages(body) + legacy, err := convertLegacyResponsesMessages(cfg, body) if err != nil { return body, false, err } @@ -105,13 +110,13 @@ type convertedLegacyResponsesMessages struct { hasReasoningEffort bool } -func convertLegacyResponsesMessages(body []byte) (convertedLegacyResponsesMessages, error) { +func convertLegacyResponsesMessages(cfg *config.Config, body []byte) (convertedLegacyResponsesMessages, error) { var converted convertedLegacyResponsesMessages var chatRequest apicompat.ChatCompletionsRequest if err := json.Unmarshal(body, &chatRequest); err != nil { return converted, fmt.Errorf("normalize legacy Responses messages: %w", err) } - responsesRequest, err := apicompat.ChatCompletionsToResponses(&chatRequest) + responsesRequest, err := chatCompletionsToResponses(cfg, &chatRequest) if err != nil { return converted, fmt.Errorf("normalize legacy Responses messages: %w", err) } diff --git a/backend/internal/service/openai_ws_forwarder_ingress.go b/backend/internal/service/openai_ws_forwarder_ingress.go index 09ea02276a..10bb77450d 100644 --- a/backend/internal/service/openai_ws_forwarder_ingress.go +++ b/backend/internal/service/openai_ws_forwarder_ingress.go @@ -269,7 +269,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient( normalized = next } responsesLite := isOpenAIResponsesLiteWebSocketPayload(normalized) - if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, account, responsesLite); compatibilityErr != nil { + if compatibilityBody, compatibilityChanged, compatibilityErr := normalizeOpenAIResponsesWebSocketCompatibilityBodyWithConfig(s.cfg, normalized, account, responsesLite); compatibilityErr != nil { return openAIWSClientPayload{}, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", compatibilityErr) } else if compatibilityChanged { normalized = compatibilityBody diff --git a/backend/internal/service/openai_ws_v2_passthrough_adapter.go b/backend/internal/service/openai_ws_v2_passthrough_adapter.go index 5b5f25e37f..e7d0a0d09b 100644 --- a/backend/internal/service/openai_ws_v2_passthrough_adapter.go +++ b/backend/internal/service/openai_ws_v2_passthrough_adapter.go @@ -746,7 +746,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( firstClientMessage = s.ReplaceModelInBody(firstClientMessage, capturedSessionModel) } firstMessageResponsesLite := isOpenAIResponsesLiteWebSocketPayload(firstClientMessage) - if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(firstClientMessage, account, firstMessageResponsesLite); normalizeErr != nil { + if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBodyWithConfig(s.cfg, firstClientMessage, account, firstMessageResponsesLite); normalizeErr != nil { return fmt.Errorf("normalize first websocket response.create: %w", normalizeErr) } else if compatibilityChanged { firstClientMessage = normalized @@ -998,7 +998,7 @@ func (s *OpenAIGatewayService) proxyResponsesWebSocketV2Passthrough( } responsesLite := isResponseCreate && isOpenAIResponsesLiteWebSocketPayload(payload) if isResponseCreate { - if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBody(payload, account, responsesLite); normalizeErr != nil { + if normalized, compatibilityChanged, normalizeErr := normalizeOpenAIResponsesWebSocketCompatibilityBodyWithConfig(s.cfg, payload, account, responsesLite); normalizeErr != nil { return payload, nil, NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "invalid websocket request payload", normalizeErr) } else if compatibilityChanged { payload = normalized diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 6b8b049838..36e92361d4 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -217,6 +217,9 @@ security: # 网关配置 # ============================================================================= gateway: + # Minimum max_output_tokens when converting Chat Completions or Anthropic requests to Responses (minimum 16, default 128) + # Chat Completions 或 Anthropic 请求转换为 Responses 时的最小 max_output_tokens(最小 16,默认 128) + min_output_tokens: 128 # Timeout for waiting upstream response headers (seconds) # 等待上游响应头超时时间(秒) response_header_timeout: 600 From c2cca510f2dc46f400bd8f9dcc4a5de55859cf51 Mon Sep 17 00:00:00 2001 From: shaw Date: Thu, 15 Oct 2026 20:44:29 +0800 Subject: [PATCH 04/10] revert(accounts): restore inline account filters (#7853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert 679a7186f ("feat(accounts): compact account search and filters"), which moved the type / privacy / group filters behind a collapsed "More filters" panel on /admin/accounts. Polish on top of the revert: - Widen the privacy select (w-48) so "全部Privacy状态" / "All Privacy States" no longer truncates. - Narrow the search input to w-52 so all six controls fit on one row at 1440px. - Below sm, lay the filters out as a two-column grid that fills the width instead of fixed-width selects with a ragged edge. --- .../admin/account/AccountTableFilters.vue | 52 ++---- .../__tests__/AccountTableFilters.spec.ts | 149 ------------------ .../src/i18n/locales/en/admin/accounts.ts | 2 - .../src/i18n/locales/zh/admin/accounts.ts | 2 - frontend/src/views/admin/AccountsView.vue | 1 - 5 files changed, 15 insertions(+), 191 deletions(-) delete mode 100644 frontend/src/components/admin/account/__tests__/AccountTableFilters.spec.ts diff --git a/frontend/src/components/admin/account/AccountTableFilters.vue b/frontend/src/components/admin/account/AccountTableFilters.vue index ea3d01e282..ce7009c66c 100644 --- a/frontend/src/components/admin/account/AccountTableFilters.vue +++ b/frontend/src/components/admin/account/AccountTableFilters.vue @@ -1,49 +1,27 @@