mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #2450 from wucm667/codex/issue-2431-responses-api-support
feat: 支持后台配置 OpenAI Responses API 路由
This commit is contained in:
@@ -294,7 +294,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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user