From 862819042ca871726710e2cafce0dc9c218b2087 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Thu, 14 May 2026 11:46:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(openai):=20=E6=94=AF=E6=8C=81=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E9=85=8D=E7=BD=AE=20Responses=20API=20=E8=B7=AF?= =?UTF-8?q?=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/openai_chat_completions.go | 2 +- .../pkg/openai_compat/upstream_capability.go | 46 ++++++++++++- .../openai_compat/upstream_capability_test.go | 33 ++++++++++ .../account_repo_compact_extra_test.go | 11 ++++ .../internal/repository/scheduler_cache.go | 2 + .../repository/scheduler_cache_unit_test.go | 4 ++ .../openai_gateway_chat_completions.go | 10 +-- .../components/account/EditAccountModal.vue | 66 ++++++++++++++++++- .../__tests__/EditAccountModal.spec.ts | 42 ++++++++++++ frontend/src/i18n/locales/en.ts | 11 ++++ frontend/src/i18n/locales/zh.ts | 11 ++++ frontend/src/types/index.ts | 6 ++ 12 files changed, 234 insertions(+), 10 deletions(-) diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index de384710..9dab07fb 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -292,7 +292,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) { // resolveRawCCUpstreamEndpoint returns the actual upstream endpoint for // OpenAI Chat Completions requests. For APIKey accounts whose upstream -// has been probed to not support the Responses API, the request is +// is forced or probed to not support the Responses API, the request is // forwarded directly to /v1/chat/completions — not through the default // CC→Responses conversion path. func resolveRawCCUpstreamEndpoint(c *gin.Context, account *service.Account) string { diff --git a/backend/internal/pkg/openai_compat/upstream_capability.go b/backend/internal/pkg/openai_compat/upstream_capability.go index ff05afe5..154a01fb 100644 --- a/backend/internal/pkg/openai_compat/upstream_capability.go +++ b/backend/internal/pkg/openai_compat/upstream_capability.go @@ -17,7 +17,7 @@ // pensieve/short-term/maxims/preserve-existing-runtime-behavior-when-replacing-logic-in-stateful-systems) package openai_compat -// AccountResponsesSupport 描述账号上游对 OpenAI Responses API 的支持状态。 +// AccountResponsesSupport 描述账号上游对 OpenAI Responses API 的有效支持状态。 // // 仅用于 platform=openai + type=apikey 的账号;其他账号类型不应调用本包判定。 type AccountResponsesSupport int @@ -35,11 +35,43 @@ const ( ResponsesSupportNo ) -// ExtraKeyResponsesSupported 是 accounts.extra JSON 中存储探测结果的键名。 +// ResponsesSupportMode 描述账号级 Responses API 路由覆盖模式。 +type ResponsesSupportMode string + +const ( + // ResponsesSupportModeAuto 表示跟随自动探测结果。 + ResponsesSupportModeAuto ResponsesSupportMode = "auto" + + // ResponsesSupportModeForceResponses 强制使用 /v1/responses。 + ResponsesSupportModeForceResponses ResponsesSupportMode = "force_responses" + + // ResponsesSupportModeForceChatCompletions 强制使用 /v1/chat/completions。 + ResponsesSupportModeForceChatCompletions ResponsesSupportMode = "force_chat_completions" +) + +// ExtraKeyResponsesMode 是 accounts.extra JSON 中存储手动覆盖模式的键名。 +// 值类型为 string:auto=跟随探测,force_responses=强制 Responses, +// force_chat_completions=强制 Chat Completions。 +const ExtraKeyResponsesMode = "openai_responses_mode" + +// ExtraKeyResponsesSupported 是 accounts.extra JSON 中存储自动探测结果的键名。 // 值类型为 bool:true=支持、false=不支持、键缺失=未探测。 const ExtraKeyResponsesSupported = "openai_responses_supported" -// ResolveResponsesSupport 从账号的 extra map 中读取探测标记。 +// NormalizeResponsesSupportMode 归一化账号级 Responses API 路由覆盖模式。 +// 缺失或非法值按 auto 处理,以保持存量行为。 +func NormalizeResponsesSupportMode(mode string) ResponsesSupportMode { + switch ResponsesSupportMode(mode) { + case ResponsesSupportModeForceResponses: + return ResponsesSupportModeForceResponses + case ResponsesSupportModeForceChatCompletions: + return ResponsesSupportModeForceChatCompletions + default: + return ResponsesSupportModeAuto + } +} + +// ResolveResponsesSupport 从账号的 extra map 中读取手动覆盖模式与探测标记。 // // 标记缺失或类型不匹配时返回 ResponsesSupportUnknown——调用方应按 // "未探测=保留旧行为=走 Responses" 处理(参见 ShouldUseResponsesAPI)。 @@ -47,6 +79,14 @@ func ResolveResponsesSupport(extra map[string]any) AccountResponsesSupport { if extra == nil { return ResponsesSupportUnknown } + if mode, ok := extra[ExtraKeyResponsesMode].(string); ok { + switch NormalizeResponsesSupportMode(mode) { + case ResponsesSupportModeForceResponses: + return ResponsesSupportYes + case ResponsesSupportModeForceChatCompletions: + return ResponsesSupportNo + } + } v, ok := extra[ExtraKeyResponsesSupported] if !ok { return ResponsesSupportUnknown diff --git a/backend/internal/pkg/openai_compat/upstream_capability_test.go b/backend/internal/pkg/openai_compat/upstream_capability_test.go index d650daa4..008579a7 100644 --- a/backend/internal/pkg/openai_compat/upstream_capability_test.go +++ b/backend/internal/pkg/openai_compat/upstream_capability_test.go @@ -16,6 +16,12 @@ func TestResolveResponsesSupport(t *testing.T) { {"value wrong type string", map[string]any{ExtraKeyResponsesSupported: "true"}, ResponsesSupportUnknown}, {"value wrong type number", map[string]any{ExtraKeyResponsesSupported: 1}, ResponsesSupportUnknown}, {"value nil", map[string]any{ExtraKeyResponsesSupported: nil}, ResponsesSupportUnknown}, + {"force responses", map[string]any{ExtraKeyResponsesMode: string(ResponsesSupportModeForceResponses)}, ResponsesSupportYes}, + {"force chat completions", map[string]any{ExtraKeyResponsesMode: string(ResponsesSupportModeForceChatCompletions)}, ResponsesSupportNo}, + {"auto follows probe", map[string]any{ExtraKeyResponsesMode: string(ResponsesSupportModeAuto), ExtraKeyResponsesSupported: false}, ResponsesSupportNo}, + {"invalid mode follows probe", map[string]any{ExtraKeyResponsesMode: "bogus", ExtraKeyResponsesSupported: true}, ResponsesSupportYes}, + {"force responses overrides probe false", map[string]any{ExtraKeyResponsesMode: string(ResponsesSupportModeForceResponses), ExtraKeyResponsesSupported: false}, ResponsesSupportYes}, + {"force chat completions overrides probe true", map[string]any{ExtraKeyResponsesMode: string(ResponsesSupportModeForceChatCompletions), ExtraKeyResponsesSupported: true}, ResponsesSupportNo}, } for _, tc := range tests { @@ -42,6 +48,10 @@ func TestShouldUseResponsesAPI(t *testing.T) { // 已探测:标记决定 {"explicitly supported", map[string]any{ExtraKeyResponsesSupported: true}, true}, {"explicitly unsupported", map[string]any{ExtraKeyResponsesSupported: false}, false}, + + // 手动覆盖:覆盖自动探测结果 + {"force responses overrides unsupported probe", map[string]any{ExtraKeyResponsesMode: string(ResponsesSupportModeForceResponses), ExtraKeyResponsesSupported: false}, true}, + {"force chat completions overrides supported probe", map[string]any{ExtraKeyResponsesMode: string(ResponsesSupportModeForceChatCompletions), ExtraKeyResponsesSupported: true}, false}, } for _, tc := range tests { @@ -53,3 +63,26 @@ func TestShouldUseResponsesAPI(t *testing.T) { }) } } + +func TestNormalizeResponsesSupportMode(t *testing.T) { + tests := []struct { + name string + mode string + want ResponsesSupportMode + }{ + {"empty", "", ResponsesSupportModeAuto}, + {"auto", "auto", ResponsesSupportModeAuto}, + {"force responses", "force_responses", ResponsesSupportModeForceResponses}, + {"force chat completions", "force_chat_completions", ResponsesSupportModeForceChatCompletions}, + {"invalid", "enabled", ResponsesSupportModeAuto}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := NormalizeResponsesSupportMode(tc.mode) + if got != tc.want { + t.Errorf("NormalizeResponsesSupportMode(%q) = %q, want %q", tc.mode, got, tc.want) + } + }) + } +} diff --git a/backend/internal/repository/account_repo_compact_extra_test.go b/backend/internal/repository/account_repo_compact_extra_test.go index 604f392e..e2ce6602 100644 --- a/backend/internal/repository/account_repo_compact_extra_test.go +++ b/backend/internal/repository/account_repo_compact_extra_test.go @@ -12,3 +12,14 @@ func TestShouldEnqueueSchedulerOutboxForExtraUpdates_CompactCapabilityKeysAreRel t.Fatalf("expected compact capability updates to enqueue scheduler outbox") } } + +func TestShouldEnqueueSchedulerOutboxForExtraUpdates_OpenAIResponsesCapabilityKeysAreRelevant(t *testing.T) { + updates := map[string]any{ + "openai_responses_mode": "force_chat_completions", + "openai_responses_supported": false, + } + + if !shouldEnqueueSchedulerOutboxForExtraUpdates(updates) { + t.Fatalf("expected responses capability updates to enqueue scheduler outbox") + } +} diff --git a/backend/internal/repository/scheduler_cache.go b/backend/internal/repository/scheduler_cache.go index 590ddaa3..ab01a863 100644 --- a/backend/internal/repository/scheduler_cache.go +++ b/backend/internal/repository/scheduler_cache.go @@ -546,6 +546,8 @@ func filterSchedulerExtra(extra map[string]any) map[string]any { "responses_websockets_v2_enabled", "openai_ws_enabled", "openai_ws_force_http", + "openai_responses_mode", + "openai_responses_supported", } filtered := make(map[string]any) for _, key := range keys { diff --git a/backend/internal/repository/scheduler_cache_unit_test.go b/backend/internal/repository/scheduler_cache_unit_test.go index 33f3b581..86de87c7 100644 --- a/backend/internal/repository/scheduler_cache_unit_test.go +++ b/backend/internal/repository/scheduler_cache_unit_test.go @@ -18,6 +18,8 @@ func TestBuildSchedulerMetadataAccount_KeepsOpenAIWSFlags(t *testing.T) { "openai_oauth_responses_websockets_v2_enabled": true, "openai_oauth_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough, "openai_ws_force_http": true, + "openai_responses_mode": "force_chat_completions", + "openai_responses_supported": false, "mixed_scheduling": true, "unused_large_field": "drop-me", }, @@ -28,6 +30,8 @@ func TestBuildSchedulerMetadataAccount_KeepsOpenAIWSFlags(t *testing.T) { require.Equal(t, true, got.Extra["openai_oauth_responses_websockets_v2_enabled"]) require.Equal(t, service.OpenAIWSIngressModePassthrough, got.Extra["openai_oauth_responses_websockets_v2_mode"]) require.Equal(t, true, got.Extra["openai_ws_force_http"]) + require.Equal(t, "force_chat_completions", got.Extra["openai_responses_mode"]) + require.Equal(t, false, got.Extra["openai_responses_supported"]) require.Equal(t, true, got.Extra["mixed_scheduling"]) require.Nil(t, got.Extra["unused_large_field"]) } diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 84d85c74..ea1fa00c 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -48,10 +48,10 @@ var cursorResponsesUnsupportedFields = []string{ // 正确的,但 sub2api 接入 DeepSeek/Kimi/GLM 等第三方 OpenAI 兼容上游后假设破裂: // 这些上游普遍只支持 /v1/chat/completions,无 /v1/responses 端点。 // -// 当前路由策略(基于账号探测标记,详见 openai_compat.ShouldUseResponsesAPI): -// - APIKey 账号 + 探测确认不支持 Responses → 走 forwardAsRawChatCompletions +// 当前路由策略(基于账号覆盖模式/探测标记,详见 openai_compat.ShouldUseResponsesAPI): +// - APIKey 账号 + 强制或探测确认不支持 Responses → 走 forwardAsRawChatCompletions // 直转上游 /v1/chat/completions,不做协议转换 -// - 其他所有情况(OAuth、APIKey 探测确认支持、未探测)→ 走原有 CC→Responses +// - 其他所有情况(OAuth、APIKey 强制/探测确认支持、未探测)→ 走原有 CC→Responses // 转换路径(保留旧行为,存量未探测账号零兼容破坏) func (s *OpenAIGatewayService) ForwardAsChatCompletions( ctx context.Context, @@ -61,8 +61,8 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( promptCacheKey string, defaultMappedModel string, ) (*OpenAIForwardResult, error) { - // 入口分流:APIKey 账号 + 已探测且确认上游不支持 Responses,走 CC 直转。 - // 标记缺失(未探测)按"现状即证据"原则继续走下方原 Responses 转换路径。 + // 入口分流:APIKey 账号 + 强制或已探测确认上游不支持 Responses,走 CC 直转。 + // 自动模式下标记缺失(未探测)按"现状即证据"原则继续走下方原 Responses 转换路径。 if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) { return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index 80f0b890..fd076bba 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1398,6 +1398,31 @@ + +
+
+
+ +

+ {{ t('admin.accounts.openai.responsesModeDesc') }} +

+
+
+