From 901a77cfb1097cc9808ee2f6a041b044d3c278dd Mon Sep 17 00:00:00 2001 From: li Date: Fri, 14 Aug 2026 10:33:47 +0800 Subject: [PATCH 01/46] =?UTF-8?q?fix(apicompat):=20Anthropic=E2=86=92Chat?= =?UTF-8?q?=20=E6=A1=A5=E5=9B=9E=E4=BC=A0=E5=B7=A5=E5=85=B7=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E7=9A=84=20thinking=EF=BC=8C=E4=BF=AE=E5=A4=8D=20Deep?= =?UTF-8?q?Seek=20=E5=A4=9A=E8=BD=AE=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /v1/messages 打到只支持 Chat Completions 的 OpenAI 兼容上游时, anthropicAssistantToChatMessages 把历史 assistant 消息里的 thinking 块整块丢弃。 DeepSeek 的 thinking mode 要求产生工具调用的 reasoning_content 随该 assistant 消息回传,缺失即 400 —— 表现为「单轮正常,一进多轮工具对话必现失败」。 这些 thinking 块本来就是同一个文件的出站方向 (chatMessageToAnthropicBlocks) 用上游 reasoning_content 生成的,客户端只是 原样回传:桥自己造出来的东西,回来时被自己丢掉。 兄弟路径 Responses→Chat (buildChatMessagesFromItems 的 pendingReasoning) 早已 处理同一约束。本次把同一策略补到漏掉的这条桥上,作用域严格对齐:reasoning 只挂 到带 tool_calls 的 assistant 消息,纯文本轮次维持现状。 Fixes #5528 --- .../chatcompletions_anthropic_bridge.go | 35 ++- .../chatcompletions_anthropic_bridge_test.go | 5 +- ...tions_anthropic_reasoning_passback_test.go | 229 ++++++++++++++++++ 3 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 backend/internal/pkg/apicompat/chatcompletions_anthropic_reasoning_passback_test.go diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go index 1a62f5620f..47d4601c24 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge.go @@ -249,8 +249,8 @@ func anthropicUserToChatMessages(raw json.RawMessage) ([]ChatMessage, error) { // anthropicAssistantToChatMessages handles an Anthropic assistant message. // Text content → assistant message content; tool_use blocks → tool_calls on the -// same assistant message; thinking blocks are dropped (Chat Completions has no -// inbound thinking field, matching anthropicAssistantToResponses). +// same assistant message; thinking blocks → reasoning_content, but only on a +// message that carries tool calls (see anthropicThinkingToReasoningContent). func anthropicAssistantToChatMessages(raw json.RawMessage) ([]ChatMessage, error) { // Plain string → single assistant message. var s string @@ -289,9 +289,40 @@ func anthropicAssistantToChatMessages(raw json.RawMessage) ([]ChatMessage, error }) } + msg.ReasoningContent = anthropicThinkingToReasoningContent(blocks, len(msg.ToolCalls) > 0) + return []ChatMessage{msg}, nil } +// anthropicThinkingToReasoningContent folds thinking blocks back into the +// Chat Completions reasoning_content field. +// +// chatMessageToAnthropicBlocks emits the upstream's reasoning_content as a +// thinking block on the way out, so a multi-turn client echoes it back on the +// next request; dropping it here made the bridge lose exactly what it had just +// produced. DeepSeek's thinking mode requires the reasoning_content that +// produced a tool call to be replayed on that assistant message and answers +// 400 otherwise, which is why buildChatMessagesFromItems already carries +// pendingReasoning onto assistant tool-call messages in the Responses→Chat +// bridge. hasToolCalls keeps the scope identical to that sibling: reasoning +// rides along with tool calls only, never on a plain assistant text turn. +// +// redacted_thinking blocks and signature-only placeholders carry no plaintext +// and contribute nothing. Multiple blocks join with "\n", matching +// extractResponsesReasoningText. +func anthropicThinkingToReasoningContent(blocks []AnthropicContentBlock, hasToolCalls bool) string { + if !hasToolCalls { + return "" + } + var parts []string + for _, b := range blocks { + if b.Type == "thinking" && b.Thinking != "" { + parts = append(parts, b.Thinking) + } + } + return strings.Join(parts, "\n") +} + // anthropicToolsToChatTools maps Anthropic tool definitions to Chat Completions // function tools. Server-side tools (web_search_*) are dropped — they have no // Chat Completions equivalent. diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go index 00726f5ef7..eb979f70e0 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_bridge_test.go @@ -143,8 +143,11 @@ func TestAnthropicToChatCompletionsRequest_ThinkingDropped(t *testing.T) { out, err := AnthropicToChatCompletionsRequest(req) require.NoError(t, err) require.Len(t, out.Messages, 1) - // Only text survives; thinking is dropped + // Only text survives. Thinking is dropped because this turn carries no tool + // calls — reasoning rides along with tool calls only, matching the + // Responses→Chat bridge (see anthropicThinkingToReasoningContent). require.Equal(t, `"answer"`, string(out.Messages[0].Content)) + require.Empty(t, out.Messages[0].ReasoningContent) } func TestAnthropicToChatCompletionsRequest_ToolChoiceAuto(t *testing.T) { diff --git a/backend/internal/pkg/apicompat/chatcompletions_anthropic_reasoning_passback_test.go b/backend/internal/pkg/apicompat/chatcompletions_anthropic_reasoning_passback_test.go new file mode 100644 index 0000000000..007a5face6 --- /dev/null +++ b/backend/internal/pkg/apicompat/chatcompletions_anthropic_reasoning_passback_test.go @@ -0,0 +1,229 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// issue #5528:/v1/messages 客户端(Claude Code 等)打到只会 Chat Completions 的 +// OpenAI 兼容上游时,历史 assistant 消息里的 thinking 块被整块丢弃。DeepSeek 的 +// thinking mode 要求产生工具调用的 reasoning_content 随该 assistant 消息回传, +// 于是「单轮正常、一进多轮工具对话必现 400」。 + +func anthropicAssistantMsg(t *testing.T, blocks string) *AnthropicRequest { + t.Helper() + return &AnthropicRequest{ + Model: "deepseek-v4-flash", + MaxTokens: 256, + Messages: []AnthropicMessage{ + {Role: "user", Content: json.RawMessage(`"what's the weather?"`)}, + {Role: "assistant", Content: json.RawMessage(blocks)}, + {Role: "user", Content: json.RawMessage(`[{"type":"tool_result","tool_use_id":"toolu_1","content":"sunny"}]`)}, + }, + } +} + +const anthropicThinkingToolTurn = `[ + {"type":"thinking","thinking":"user wants weather, call the tool"}, + {"type":"text","text":"checking"}, + {"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"SF"}} +]` + +func TestAnthropicToChatCompletionsRequest_ThinkingBecomesReasoningContentOnToolTurn(t *testing.T) { + out, err := AnthropicToChatCompletionsRequest(anthropicAssistantMsg(t, anthropicThinkingToolTurn)) + require.NoError(t, err) + + var assistant *ChatMessage + for i := range out.Messages { + if out.Messages[i].Role == "assistant" { + assistant = &out.Messages[i] + break + } + } + require.NotNil(t, assistant, "assistant message must survive the bridge") + require.Equal(t, "user wants weather, call the tool", assistant.ReasoningContent, + "产生工具调用的 thinking 必须作为 reasoning_content 回传,否则 DeepSeek 400") + require.Len(t, assistant.ToolCalls, 1) + require.Equal(t, `"checking"`, string(assistant.Content), "text/tool_use 处理保持不变") +} + +// 上游线格式才是上游看到的东西:字段没序列化出去,等于没修。 +func TestAnthropicToChatCompletionsRequest_ReasoningContentSerializesOnWire(t *testing.T) { + out, err := AnthropicToChatCompletionsRequest(anthropicAssistantMsg(t, anthropicThinkingToolTurn)) + require.NoError(t, err) + + payload, err := json.Marshal(out) + require.NoError(t, err) + require.Contains(t, string(payload), `"reasoning_content":"user wants weather, call the tool"`) +} + +// 闭环不变式:thinking 块本来就是本桥出站时用上游 reasoning_content 生成的 +// (chatMessageToAnthropicBlocks),客户端只是原样回传。出站造、入站丢 = 自己丢自己的东西。 +func TestAnthropicChatBridge_ReasoningSurvivesOutboundInboundRoundTrip(t *testing.T) { + upstream := ChatMessage{ + Role: "assistant", + ReasoningContent: "step 1: need the weather tool", + Content: json.RawMessage(`"checking"`), + ToolCalls: []ChatToolCall{{ + ID: "call_1", + Type: "function", + Function: ChatFunctionCall{Name: "get_weather", Arguments: `{"city":"SF"}`}, + }}, + } + + // 出站:Chat 响应 → Anthropic content blocks + blocks := chatMessageToAnthropicBlocks(upstream) + require.Equal(t, "thinking", blocks[0].Type) + require.Equal(t, upstream.ReasoningContent, blocks[0].Thinking) + + // 客户端下一轮把同一组 blocks 原样回传 + raw, err := json.Marshal(blocks) + require.NoError(t, err) + + // 入站:Anthropic content blocks → Chat 请求 + back, err := anthropicAssistantToChatMessages(raw) + require.NoError(t, err) + require.Len(t, back, 1) + require.Equal(t, upstream.ReasoningContent, back[0].ReasoningContent, + "出站生成的 thinking 必须能原样还原回 reasoning_content") + require.Len(t, back[0].ToolCalls, 1) +} + +// 兄弟不变式:Responses→Chat 桥(buildChatMessagesFromItems 的 pendingReasoning) +// 早就把 reasoning 挂到带 tool_calls 的 assistant 消息上了。等价历史下两条桥必须一致。 +func TestAnthropicChatBridge_MatchesResponsesChatBridgeReasoningPlacement(t *testing.T) { + responsesReq := &ResponsesRequest{ + Model: "deepseek-v4-flash", + Input: json.RawMessage(`[ + {"type":"message","role":"user","content":[{"type":"input_text","text":"what's the weather?"}]}, + {"type":"reasoning","summary":[{"type":"summary_text","text":"call the tool"}]}, + {"type":"function_call","call_id":"call_1","name":"get_weather","arguments":"{\"city\":\"SF\"}"}, + {"type":"function_call_output","call_id":"call_1","output":"sunny"} + ]`), + } + viaResponses, err := ResponsesToChatCompletionsRequest(responsesReq) + require.NoError(t, err) + + viaAnthropic, err := AnthropicToChatCompletionsRequest(anthropicAssistantMsg(t, `[ + {"type":"thinking","thinking":"call the tool"}, + {"type":"tool_use","id":"toolu_1","name":"get_weather","input":{"city":"SF"}} + ]`)) + require.NoError(t, err) + + reasoningOnToolCallMessage := func(msgs []ChatMessage) string { + for _, m := range msgs { + if m.Role == "assistant" && len(m.ToolCalls) > 0 { + return m.ReasoningContent + } + } + return "" + } + require.Equal(t, "call the tool", reasoningOnToolCallMessage(viaResponses.Messages), + "前置条件:兄弟桥本来就带 reasoning_content") + require.Equal(t, reasoningOnToolCallMessage(viaResponses.Messages), + reasoningOnToolCallMessage(viaAnthropic.Messages), + "两条桥对等价历史必须产出同样的 reasoning_content 位置") +} + +// 作用域守卫:不带工具调用的纯文本轮次维持现状(与兄弟桥一致 —— reasoning 只随 +// 工具调用回传),避免把 reasoning_content 撒到不需要它的上游请求上。 +func TestAnthropicToChatCompletionsRequest_ThinkingWithoutToolCallsStaysDropped(t *testing.T) { + req := &AnthropicRequest{ + Model: "deepseek-v4-flash", + MaxTokens: 100, + Messages: []AnthropicMessage{ + {Role: "assistant", Content: json.RawMessage( + `[{"type":"thinking","thinking":"secret thoughts"},{"type":"text","text":"answer"}]`)}, + }, + } + + out, err := AnthropicToChatCompletionsRequest(req) + require.NoError(t, err) + require.Len(t, out.Messages, 1) + require.Empty(t, out.Messages[0].ReasoningContent) + require.Equal(t, `"answer"`, string(out.Messages[0].Content)) + + payload, err := json.Marshal(out) + require.NoError(t, err) + require.NotContains(t, string(payload), "reasoning_content") +} + +func TestAnthropicThinkingToReasoningContent(t *testing.T) { + blocksOf := func(t *testing.T, raw string) []AnthropicContentBlock { + t.Helper() + var blocks []AnthropicContentBlock + require.NoError(t, json.Unmarshal([]byte(raw), &blocks)) + return blocks + } + + cases := []struct { + name string + raw string + hasToolCalls bool + want string + }{ + { + name: "single_thinking_block", + raw: `[{"type":"thinking","thinking":"a"}]`, + hasToolCalls: true, + want: "a", + }, + { + // 多个 thinking 块用 "\n" 连接,与 extractResponsesReasoningText 一致。 + name: "multiple_blocks_join_with_newline", + raw: `[{"type":"thinking","thinking":"a"},{"type":"text","text":"x"},{"type":"thinking","thinking":"b"}]`, + hasToolCalls: true, + want: "a\nb", + }, + { + // redacted_thinking 没有明文可回传。 + name: "redacted_thinking_has_no_plaintext", + raw: `[{"type":"redacted_thinking","signature":"abc"}]`, + hasToolCalls: true, + want: "", + }, + { + // 只带 signature 的 thinking 占位块(xAI/Codex 密文回放形态)同样无明文。 + name: "signature_only_thinking", + raw: `[{"type":"thinking","thinking":"","signature":"gAAAAxxx"}]`, + hasToolCalls: true, + want: "", + }, + { + name: "no_tool_calls_returns_empty", + raw: `[{"type":"thinking","thinking":"a"}]`, + hasToolCalls: false, + want: "", + }, + { + name: "no_thinking_blocks", + raw: `[{"type":"text","text":"x"}]`, + hasToolCalls: true, + want: "", + }, + { + name: "empty_blocks", + raw: `[]`, + hasToolCalls: true, + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, + anthropicThinkingToReasoningContent(blocksOf(t, tc.raw), tc.hasToolCalls)) + }) + } +} + +// 纯字符串形态的 assistant content 没有 blocks 可读,走早返回分支,不得 panic。 +func TestAnthropicAssistantToChatMessages_PlainStringContentUnaffected(t *testing.T) { + msgs, err := anthropicAssistantToChatMessages(json.RawMessage(`"just text"`)) + require.NoError(t, err) + require.Len(t, msgs, 1) + require.Empty(t, msgs[0].ReasoningContent) + require.Equal(t, `"just text"`, string(msgs[0].Content)) +} From 3c5553e253acfd01a147e0cca48cbf0cf36de38a Mon Sep 17 00:00:00 2001 From: li Date: Fri, 14 Aug 2026 11:05:29 +0800 Subject: [PATCH 02/46] =?UTF-8?q?fix(responses):=20=E7=BD=91=E5=85=B3?= =?UTF-8?q?=E5=90=88=E6=88=90=E7=9A=84=20Responses=20=E5=AF=B9=E8=B1=A1?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=20created=5Fat=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B8=A5=E6=A0=BC=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=8F=8D=E5=BA=8F?= =?UTF-8?q?=E5=88=97=E5=8C=96=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 严格的 Responses 客户端(Rust serde 系,如 Codex / Grok CLI)把 created_at 声明为 必填字段,缺失即 `missing field 'created_at'` 直接读不出响应。 apicompat.ResponsesResponse 此前根本没有这个字段,六个构造点全部不写: Chat→Responses 与 Anthropic→Responses 两座桥的非流式结果、response.created、 终止事件,以及两处手写的 response.failed 帧。 两个流式 state 早就在 New*State() 里采集了 Created 时间戳,只是没有出口—— 反方向(Responses→Chat)每个 chunk 都老老实实发 created,正方向直接丢在地上。 原生 Responses 透传走 gjson/sjson 字节级改写,保留上游原样,不受影响; 本次只补合成路径。 Fixes #5601 --- .../internal/handler/stream_error_event.go | 27 +-- .../handler/stream_error_event_test.go | 16 ++ .../anthropic_to_responses_response.go | 21 ++- .../chatcompletions_responses_bridge.go | 31 +++- .../responses_created_at_wire_test.go | 166 ++++++++++++++++++ backend/internal/pkg/apicompat/types.go | 17 +- .../service/openai_compact_stream_bridge.go | 8 +- ..._compact_stream_failure_created_at_test.go | 49 ++++++ 8 files changed, 299 insertions(+), 36 deletions(-) create mode 100644 backend/internal/pkg/apicompat/responses_created_at_wire_test.go create mode 100644 backend/internal/service/openai_compact_stream_failure_created_at_test.go diff --git a/backend/internal/handler/stream_error_event.go b/backend/internal/handler/stream_error_event.go index 2af098dbed..8ba6095cfb 100644 --- a/backend/internal/handler/stream_error_event.go +++ b/backend/internal/handler/stream_error_event.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "strings" + "time" "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey" "github.com/gin-gonic/gin" @@ -19,13 +20,16 @@ type responsesFailedError struct { // responsesFailedBody 对齐 apicompat.makeResponsesCompletedEvent 输出的 response 子对象字段集。 // Output 用空 slice(不是 nil)确保 marshal 为 `[]` 而非 `null`。 +// CreatedAt 不带 omitempty:严格客户端把它当必填字段,缺失会以 +// `missing field 'created_at'` 反序列化失败——那正是本文件要避免的"客户端读不懂终止事件"。 type responsesFailedBody struct { - ID string `json:"id"` - Object string `json:"object"` - Model string `json:"model,omitempty"` - Status string `json:"status"` - Output []any `json:"output"` - Error responsesFailedError `json:"error"` + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int64 `json:"created_at"` + Model string `json:"model,omitempty"` + Status string `json:"status"` + Output []any `json:"output"` + Error responsesFailedError `json:"error"` } // responsesFailedEvent 是写入 SSE data 行的顶层结构。 @@ -61,11 +65,12 @@ func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool { payload, err := json.Marshal(responsesFailedEvent{ Type: "response.failed", Response: responsesFailedBody{ - ID: synthesizeResponseID(c), - Object: "response", - Model: requestModel(c), - Status: "failed", - Output: []any{}, + ID: synthesizeResponseID(c), + Object: "response", + CreatedAt: time.Now().Unix(), + Model: requestModel(c), + Status: "failed", + Output: []any{}, Error: responsesFailedError{ Code: mapResponsesErrorCode(errType), Message: message, diff --git a/backend/internal/handler/stream_error_event_test.go b/backend/internal/handler/stream_error_event_test.go index f24cf97fff..187deda19d 100644 --- a/backend/internal/handler/stream_error_event_test.go +++ b/backend/internal/handler/stream_error_event_test.go @@ -227,6 +227,22 @@ func TestOpenAIHandleStreamingAwareError_BareResponsesRouteEmitsResponseFailed(t } // Synthesized response.failed id falls back to uuid when no request_id is present. +// issue #5601:严格的 Responses 客户端把 created_at 当必填字段,缺失即 +// `missing field 'created_at'`。合成的终止事件若解析不了,本文件存在的意义 +// (给客户端一个可识别的终止事件而不是盲重连)就落空了。 +func TestOpenAIHandleStreamingAwareError_ResponsesStreamingCarriesCreatedAt(t *testing.T) { + c, w := newGinContextForEndpoint(t, EndpointResponses) + h := &OpenAIGatewayHandler{} + h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", "boom", true) + + resp, _ := parseResponsesFailedSSE(t, w.Body.String()) + raw, ok := resp["created_at"] + assert.True(t, ok, "response.failed 必须带 created_at") + createdAt, ok := raw.(float64) + assert.True(t, ok, "created_at 必须是数字,得到 %T", raw) + assert.Greater(t, int64(createdAt), int64(0), "created_at 必须是有效的 unix 时间戳") +} + func TestSynthesizeResponseID_FallbackUUID(t *testing.T) { c, _ := newGinContextForEndpoint(t, EndpointResponses) id := synthesizeResponseID(c) diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go index db0343cd73..d5a7057036 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go @@ -21,10 +21,13 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { id = generateResponsesID() } + // Anthropic responses carry no creation timestamp, so stamp now — the same + // synthesize-what-the-client-requires rule the generated id above follows. out := &ResponsesResponse{ - ID: id, - Object: "response", - Model: resp.Model, + ID: id, + Object: "response", + CreatedAt: time.Now().Unix(), + Model: resp.Model, } var outputs []ResponsesOutput @@ -551,11 +554,12 @@ func makeResponsesCreatedEvent(state *AnthropicEventToResponsesState) ResponsesS Type: "response.created", SequenceNumber: seq, Response: &ResponsesResponse{ - ID: state.ResponseID, - Object: "response", - Model: state.Model, - Status: "in_progress", - Output: []ResponsesOutput{}, + ID: state.ResponseID, + Object: "response", + CreatedAt: state.Created, + Model: state.Model, + Status: "in_progress", + Output: []ResponsesOutput{}, }, } } @@ -602,6 +606,7 @@ func makeResponsesCompletedEvent( Response: &ResponsesResponse{ ID: state.ResponseID, Object: "response", + CreatedAt: state.Created, Model: state.Model, Status: status, Output: outputs, diff --git a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go index 70dee035d8..c58caa1a56 100644 --- a/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go +++ b/backend/internal/pkg/apicompat/chatcompletions_responses_bridge.go @@ -1042,11 +1042,22 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str id = generateResponsesID() } + // Carry the upstream's own creation timestamp when it sent one; otherwise + // stamp now, same fallback shape as the generated id above. + createdAt := int64(0) + if resp != nil { + createdAt = resp.Created + } + if createdAt <= 0 { + createdAt = time.Now().Unix() + } + out := &ResponsesResponse{ - ID: id, - Object: "response", - Model: model, - Status: "completed", + ID: id, + Object: "response", + CreatedAt: createdAt, + Model: model, + Status: "completed", } if resp == nil { out.Output = []ResponsesOutput{emptyResponsesMessageOutput()} @@ -1493,6 +1504,7 @@ func FinalizeChatCompletionsResponsesStream(state *ChatCompletionsToResponsesStr Response: &ResponsesResponse{ ID: state.ResponseID, Object: "response", + CreatedAt: state.Created, Model: state.Model, Status: status, Output: state.chatOutput(), @@ -1510,11 +1522,12 @@ func ensureChatToResponsesCreated(state *ChatCompletionsToResponsesStreamState) state.CreatedSent = true return []ResponsesStreamEvent{chatToResponsesEvent(state, "response.created", &ResponsesStreamEvent{ Response: &ResponsesResponse{ - ID: state.ResponseID, - Object: "response", - Model: state.Model, - Status: "in_progress", - Output: []ResponsesOutput{}, + ID: state.ResponseID, + Object: "response", + CreatedAt: state.Created, + Model: state.Model, + Status: "in_progress", + Output: []ResponsesOutput{}, }, })} } diff --git a/backend/internal/pkg/apicompat/responses_created_at_wire_test.go b/backend/internal/pkg/apicompat/responses_created_at_wire_test.go new file mode 100644 index 0000000000..48468409d6 --- /dev/null +++ b/backend/internal/pkg/apicompat/responses_created_at_wire_test.go @@ -0,0 +1,166 @@ +package apicompat + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// issue #5601:严格的 Responses 客户端(Rust serde 系,如 Codex / Grok CLI)把 +// created_at 声明为必填字段,缺失即 `missing field 'created_at'` 反序列化失败。 +// 网关合成的 Responses 对象(Chat→Responses、Anthropic→Responses 两座桥)此前从不 +// 写这个字段——尽管两个流式 state 早就采集好了 Created 时间戳,只是没有出口。 +// 原生 Responses 透传走 gjson/sjson 字节级改写,不受影响。 + +// responseObjectOf 取出事件里的 response 子对象(按线格式,而不是按 Go 结构体)。 +func responseObjectOf(t *testing.T, evt ResponsesStreamEvent) map[string]any { + t.Helper() + m := marshalEvent(t, evt) + resp, ok := m["response"].(map[string]any) + require.True(t, ok, "event must carry a response object: %v", m) + return resp +} + +func requireCreatedAt(t *testing.T, resp map[string]any) int64 { + t.Helper() + raw, ok := resp["created_at"] + require.True(t, ok, "response 对象必须带 created_at,否则严格客户端直接反序列化失败") + value, ok := raw.(float64) + require.True(t, ok, "created_at 必须是数字,得到 %T", raw) + require.Greater(t, int64(value), int64(0), "created_at 必须是有效的 unix 时间戳") + return int64(value) +} + +// omitempty 陷阱守卫:created_at 为 0 时也必须出现在线格式里, +// 否则「字段存在」这件事就依赖于运行时恰好非零。 +func TestWire_CreatedAtPresentEvenAtZero(t *testing.T) { + resp := responseObjectOf(t, ResponsesStreamEvent{ + Type: "response.created", + Response: &ResponsesResponse{ID: "resp_1", Object: "response", Status: "in_progress"}, + }) + require.Contains(t, resp, "created_at", "created_at 不得带 omitempty") + require.EqualValues(t, 0, resp["created_at"]) +} + +// --------------------------------------------------------------------------- +// Chat Completions → Responses +// --------------------------------------------------------------------------- + +func TestChatCompletionsResponseToResponses_CarriesCreatedAt(t *testing.T) { + t.Run("uses_upstream_created_when_present", func(t *testing.T) { + out := ChatCompletionsResponseToResponses(&ChatCompletionsResponse{ + ID: "chatcmpl_1", + Created: 1700000000, + Model: "deepseek-v4-flash", + Choices: []ChatChoice{{Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}}}, + }, "deepseek-v4-flash", nil, false, nil) + require.EqualValues(t, 1700000000, out.CreatedAt, "上游给了 created 就照搬,不要另起时间") + }) + + t.Run("stamps_now_when_upstream_omits_created", func(t *testing.T) { + out := ChatCompletionsResponseToResponses(&ChatCompletionsResponse{ + ID: "chatcmpl_2", + Model: "deepseek-v4-flash", + Choices: []ChatChoice{{Message: ChatMessage{Role: "assistant", Content: json.RawMessage(`"hi"`)}}}, + }, "deepseek-v4-flash", nil, false, nil) + require.Greater(t, out.CreatedAt, int64(0)) + }) + + t.Run("nil_upstream_response_still_stamps", func(t *testing.T) { + out := ChatCompletionsResponseToResponses(nil, "deepseek-v4-flash", nil, false, nil) + require.Greater(t, out.CreatedAt, int64(0), "空上游响应也必须产出可解析的对象") + }) +} + +// 同一条流里 response.created 与终止事件必须报同一个 created_at +// (官方语义:created_at 是这次 response 的创建时刻,不随事件变化)。 +func TestChatCompletionsToResponsesStream_CreatedAtStableAcrossEvents(t *testing.T) { + state := NewChatCompletionsToResponsesStreamState("deepseek-v4-flash") + require.Greater(t, state.Created, int64(0), "前提:state 早就采集了时间戳") + + var chunk ChatCompletionsChunk + require.NoError(t, json.Unmarshal( + []byte(`{"choices":[{"index":0,"delta":{"content":"hi"}}]}`), &chunk)) + + events := ChatCompletionsChunkToResponsesEvents(&chunk, state) + events = append(events, FinalizeChatCompletionsResponsesStream(state)...) + + seen := map[string]int64{} + for _, evt := range events { + if evt.Response == nil { + continue + } + seen[evt.Type] = requireCreatedAt(t, responseObjectOf(t, evt)) + } + + require.Contains(t, seen, "response.created") + require.Contains(t, seen, "response.completed") + require.Equal(t, state.Created, seen["response.created"]) + require.Equal(t, seen["response.created"], seen["response.completed"], + "同一条流的 created_at 必须恒定") +} + +// --------------------------------------------------------------------------- +// Anthropic → Responses +// --------------------------------------------------------------------------- + +func TestAnthropicToResponsesResponse_StampsCreatedAt(t *testing.T) { + out := AnthropicToResponsesResponse(&AnthropicResponse{ + ID: "msg_1", + Type: "message", + Role: "assistant", + Model: "claude-sonnet-4-20250514", + Content: []AnthropicContentBlock{{Type: "text", Text: "hi"}}, + }) + require.Greater(t, out.CreatedAt, int64(0), + "Anthropic 响应不带时间戳,网关必须自己盖一个") +} + +func TestAnthropicEventToResponsesStream_CreatedAtStableAcrossEvents(t *testing.T) { + state := NewAnthropicEventToResponsesState() + state.Model = "claude-sonnet-4-20250514" + require.Greater(t, state.Created, int64(0), "前提:state 早就采集了时间戳") + + var events []ResponsesStreamEvent + for _, raw := range []string{ + `{"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"usage":{"input_tokens":3,"output_tokens":0}}}`, + `{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`, + `{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}`, + `{"type":"content_block_stop","index":0}`, + `{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":2}}`, + `{"type":"message_stop"}`, + } { + var evt AnthropicStreamEvent + require.NoError(t, json.Unmarshal([]byte(raw), &evt)) + events = append(events, AnthropicEventToResponsesEvents(&evt, state)...) + } + events = append(events, FinalizeAnthropicResponsesStream(state)...) + + seen := map[string]int64{} + for _, evt := range events { + if evt.Response == nil { + continue + } + seen[evt.Type] = requireCreatedAt(t, responseObjectOf(t, evt)) + } + + require.Contains(t, seen, "response.created") + require.Contains(t, seen, "response.completed") + require.Equal(t, state.Created, seen["response.created"]) + require.Equal(t, seen["response.created"], seen["response.completed"], + "同一条流的 created_at 必须恒定") +} + +// ResponsesClientToolStreamRestorer 对部分事件走 unmarshal→re-marshal。 +// 结构体没有该字段时,上游带来的 created_at 会在这一步被静默抹掉。 +func TestResponsesStreamEvent_CreatedAtSurvivesUnmarshalRemarshal(t *testing.T) { + upstream := []byte(`{"type":"response.completed","response":{"id":"resp_9","object":"response",` + + `"created_at":1700000123,"model":"gpt-5.5","status":"completed","output":[]}}`) + + var evt ResponsesStreamEvent + require.NoError(t, json.Unmarshal(upstream, &evt)) + require.EqualValues(t, 1700000123, evt.Response.CreatedAt) + + require.EqualValues(t, 1700000123, requireCreatedAt(t, responseObjectOf(t, evt))) +} diff --git a/backend/internal/pkg/apicompat/types.go b/backend/internal/pkg/apicompat/types.go index bcc8cd481e..84175f2a89 100644 --- a/backend/internal/pkg/apicompat/types.go +++ b/backend/internal/pkg/apicompat/types.go @@ -339,12 +339,17 @@ func (t *ResponsesTool) UnmarshalJSON(data []byte) error { // ResponsesResponse is the non-streaming response from POST /v1/responses. type ResponsesResponse struct { - ID string `json:"id"` - Object string `json:"object"` // "response" - Model string `json:"model"` - Status string `json:"status"` // "completed" | "incomplete" | "failed" - Output []ResponsesOutput `json:"output"` - Usage *ResponsesUsage `json:"usage,omitempty"` + ID string `json:"id"` + Object string `json:"object"` // "response" + // CreatedAt is the unix creation timestamp. Strict Responses clients declare + // it non-optional and abort with `missing field 'created_at'` when it is + // absent, so it is always emitted — no omitempty. Same rule as ID (see the + // "clients treat it as required" fallback in ChatCompletionsResponseToAnthropic). + CreatedAt int64 `json:"created_at"` + Model string `json:"model"` + Status string `json:"status"` // "completed" | "incomplete" | "failed" + Output []ResponsesOutput `json:"output"` + Usage *ResponsesUsage `json:"usage,omitempty"` // incomplete_details is present when status="incomplete" IncompleteDetails *ResponsesIncompleteDetails `json:"incomplete_details,omitempty"` diff --git a/backend/internal/service/openai_compact_stream_bridge.go b/backend/internal/service/openai_compact_stream_bridge.go index bcac8304c2..1c36f9f27c 100644 --- a/backend/internal/service/openai_compact_stream_bridge.go +++ b/backend/internal/service/openai_compact_stream_bridge.go @@ -6,6 +6,7 @@ import ( "net/http" "strconv" "strings" + "time" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -117,8 +118,11 @@ func writeOpenAICompactSSEFailureMessage(c *gin.Context, statusCode int, errType "response": map[string]any{ "id": "resp_" + strings.ReplaceAll(uuid.NewString(), "-", ""), "object": "response", - "status": "failed", - "output": []any{}, + // 严格客户端把 created_at 当必填字段,缺失会反序列化失败, + // 终止事件就白发了(退化成盲重连)。与 writeResponsesFailedSSE 对齐。 + "created_at": time.Now().Unix(), + "status": "failed", + "output": []any{}, "error": map[string]any{ "code": errType, "message": message, diff --git a/backend/internal/service/openai_compact_stream_failure_created_at_test.go b/backend/internal/service/openai_compact_stream_failure_created_at_test.go new file mode 100644 index 0000000000..a3b6f9bd78 --- /dev/null +++ b/backend/internal/service/openai_compact_stream_failure_created_at_test.go @@ -0,0 +1,49 @@ +//go:build unit + +package service + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +// issue #5601:严格的 Responses 客户端把 created_at 当必填字段,缺失即 +// `missing field 'created_at'`。writeOpenAICompactSSEFailureMessage 存在的理由就是 +// 让 Codex 能把这帧识别成合法终止事件;解析不了就退化回它想避免的盲重连。 +func TestWriteOpenAICompactSSEFailureMessage_CarriesCreatedAt(t *testing.T) { + gin.SetMode(gin.TestMode) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil) + + writeOpenAICompactSSEFailureMessage(c, http.StatusBadGateway, "upstream_error", "boom") + + body := rec.Body.String() + require.Contains(t, body, "event: response.failed") + + _, payload, found := strings.Cut(body, "data: ") + require.True(t, found, "SSE 帧必须带 data 行: %q", body) + + var event struct { + Type string `json:"type"` + Response struct { + ID string `json:"id"` + Object string `json:"object"` + CreatedAt int64 `json:"created_at"` + Status string `json:"status"` + } `json:"response"` + } + require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(payload)), &event)) + + require.Equal(t, "response.failed", event.Type) + require.Equal(t, "response", event.Response.Object) + require.Equal(t, "failed", event.Response.Status) + require.Greater(t, event.Response.CreatedAt, int64(0), + "response.failed 必须带有效的 created_at,否则严格客户端读不出这帧") +} From 22e1b8144acd88b1b922050dbc312433d961744a Mon Sep 17 00:00:00 2001 From: Long Li Date: Fri, 21 Aug 2026 01:41:04 +0900 Subject: [PATCH 03/46] feat(gateway): expose routed Codex model catalogs Codex model discovery expects a top-level models manifest, but Composite and other non-OpenAI groups either reached the OpenAI live-manifest handler or had no Codex-specific response at all. API key users also had no supported way to fetch that manifest and reference it from config.toml. Generate minimal manifests from each group's effective model list while preserving the official OpenAI live path and the ordinary /models response. Add the Use Key flow for authenticated catalog download and model_catalog_json configuration without writing the API key into the downloaded file. --- backend/internal/handler/gateway_handler.go | 53 +++++ .../internal/handler/gateway_models_test.go | 150 ++++++++++++ backend/internal/server/routes/gateway.go | 20 +- .../routes/gateway_codex_models_test.go | 40 ++++ .../service/openai_codex_models_service.go | 35 ++- .../openai_codex_models_service_test.go | 23 ++ frontend/src/api/__tests__/codex.spec.ts | 51 +++++ frontend/src/api/codex.ts | 56 +++++ frontend/src/components/keys/UseKeyModal.vue | 215 ++++++++++++++++++ .../keys/__tests__/UseKeyModal.spec.ts | 90 +++++++- frontend/src/i18n/locales/en/dashboard.ts | 21 ++ frontend/src/i18n/locales/zh/dashboard.ts | 21 ++ 12 files changed, 758 insertions(+), 17 deletions(-) create mode 100644 frontend/src/api/__tests__/codex.spec.ts create mode 100644 frontend/src/api/codex.ts diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 1128329be8..14908e9bb0 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -1140,6 +1140,59 @@ func (h *GatewayHandler) Models(c *gin.Context) { }) } +// CodexModels returns the effective group model list using the manifest shape +// expected by Codex custom providers. Official OpenAI groups continue to use +// OpenAIGatewayHandler.CodexModels so their live upstream metadata is preserved. +func (h *GatewayHandler) CodexModels(c *gin.Context) { + apiKey, ok := middleware2.GetAPIKeyFromContext(c) + if !ok || apiKey == nil || apiKey.Group == nil { + h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required") + return + } + + modelIDs := h.codexModelIDsForGroup(c.Request.Context(), apiKey.Group) + body, err := service.BuildCodexModelsManifest(modelIDs) + if err != nil { + h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest") + return + } + c.Data(http.StatusOK, "application/json", body) +} + +func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *service.Group) []string { + if h == nil || h.gatewayService == nil || group == nil { + return nil + } + + groupID := &group.ID + platform := group.Platform + if platform == service.PlatformComposite { + availableModels := h.compositeAvailableModels(ctx, groupID) + fallbackModels := defaultModelIDsForPlatform(service.PlatformComposite) + if group.CustomModelsListEnabled() { + return filterModelsByCustomList(availableModels, fallbackModels, group.ModelsListConfig.Models) + } + if len(availableModels) > 0 { + return availableModels + } + return fallbackModels + } + + availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform) + fallbackModels := defaultModelIDsForPlatform(platform) + if group.CustomModelsListEnabled() { + return filterModelsByCustomList( + customModelsListSource(platform, availableModels, fallbackModels), + fallbackModels, + group.ModelsListConfig.Models, + ) + } + if len(availableModels) > 0 { + return availableModels + } + return fallbackModels +} + func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string { if h == nil || h.gatewayService == nil { return nil diff --git a/backend/internal/handler/gateway_models_test.go b/backend/internal/handler/gateway_models_test.go index 313b3216fb..9ab9485626 100644 --- a/backend/internal/handler/gateway_models_test.go +++ b/backend/internal/handler/gateway_models_test.go @@ -25,6 +25,12 @@ type gatewayModelsResponseForTest struct { Data []gatewayModelItemForTest `json:"data"` } +type codexModelsResponseForTest struct { + Models []struct { + Slug string `json:"slug"` + } `json:"models"` +} + type gatewayModelItemForTest struct { ID string `json:"id"` Object string `json:"object"` @@ -70,6 +76,150 @@ func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) { require.Contains(t, compositeIDs, antigravityIDs[0]) } +// Scenario: non-OpenAI groups return a Codex manifest instead of a standard model list. +func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) { + tests := []struct { + name string + platform string + model string + }{ + {name: "Grok", platform: service.PlatformGrok, model: "grok-4.6"}, + {name: "DeepSeek", platform: service.PlatformDeepseek, model: "deepseek-v4-pro"}, + } + + for index, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gin.SetMode(gin.TestMode) + groupID := int64(100 + index) + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: tt.platform, + Credentials: map[string]any{ + "model_mapping": map[string]any{tt.model: tt.model}, + }, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: tt.platform}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Len(t, got.Models, 1) + require.Equal(t, tt.model, got.Models[0].Slug) + }) + } +} + +// Scenario: Composite manifests aggregate models across routed platforms. +func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 120 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"}, + }, + }, + { + ID: 2, + Platform: service.PlatformGrok, + Credentials: map[string]any{ + "model_mapping": map[string]any{"grok-4.6": "grok-4.6"}, + }, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ID: groupID, Platform: service.PlatformComposite}, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models)) +} + +// Scenario: group models_list_config limits the generated Codex manifest. +func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) { + gin.SetMode(gin.TestMode) + const groupID int64 = 121 + h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{ + byGroup: map[int64][]service.Account{ + groupID: { + { + ID: 1, + Platform: service.PlatformOpenAI, + Credentials: map[string]any{ + "model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"}, + }, + }, + { + ID: 2, + Platform: service.PlatformGrok, + Credentials: map[string]any{ + "model_mapping": map[string]any{"grok-4.6": "grok-4.6"}, + }, + }, + }, + }, + }) + + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{ + ID: groupID, + Platform: service.PlatformComposite, + ModelsListConfig: service.GroupModelsListConfig{ + Enabled: true, + Models: []string{"grok-4.6"}, + }, + }, + }) + + h.CodexModels(c) + + require.Equal(t, http.StatusOK, rec.Code) + var got codexModelsResponseForTest + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + require.Equal(t, []string{"grok-4.6"}, codexModelSlugsForTest(got.Models)) +} + +func codexModelSlugsForTest(models []struct { + Slug string `json:"slug"` +}) []string { + slugs := make([]string, 0, len(models)) + for _, model := range models { + slugs = append(slugs, model.Slug) + } + return slugs +} + func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/backend/internal/server/routes/gateway.go b/backend/internal/server/routes/gateway.go index 6c0bfd908d..fe753b2389 100644 --- a/backend/internal/server/routes/gateway.go +++ b/backend/internal/server/routes/gateway.go @@ -65,13 +65,13 @@ func RegisterGatewayRoutes( h.Gateway.CountTokens(c) } } + codexModelsHandler := func(c *gin.Context) { + dispatchCodexModelsGateway(c, h.OpenAIGateway.CodexModels, h.Gateway.CodexModels) + } modelsHandler := func(c *gin.Context) { if c.Query("client_version") != "" { - switch getGroupPlatform(c) { - case service.PlatformOpenAI, service.PlatformComposite: - h.OpenAIGateway.CodexModels(c) - return - } + codexModelsHandler(c) + return } h.Gateway.Models(c) } @@ -377,7 +377,7 @@ func RegisterGatewayRoutes( codexDirect.GET("/responses", func(c *gin.Context) { h.OpenAIGateway.ResponsesWebSocket(c) }) - codexDirect.GET("/models", h.OpenAIGateway.CodexModels) + codexDirect.GET("/models", codexModelsHandler) } // OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) { @@ -504,6 +504,14 @@ func RegisterGatewayRoutes( } +func dispatchCodexModelsGateway(c *gin.Context, openAIHandler, generatedHandler gin.HandlerFunc) { + if getGroupPlatform(c) == service.PlatformOpenAI { + openAIHandler(c) + return + } + generatedHandler(c) +} + // getGroupPlatform extracts the group platform from the API Key stored in context. func getGroupPlatform(c *gin.Context) string { apiKey, ok := middleware.GetAPIKeyFromContext(c) diff --git a/backend/internal/server/routes/gateway_codex_models_test.go b/backend/internal/server/routes/gateway_codex_models_test.go index 04a8b8fa67..0c6542a0fa 100644 --- a/backend/internal/server/routes/gateway_codex_models_test.go +++ b/backend/internal/server/routes/gateway_codex_models_test.go @@ -2,8 +2,12 @@ package routes import ( "net/http" + "net/http/httptest" "testing" + "github.com/Wei-Shaw/sub2api/internal/server/middleware" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) @@ -22,3 +26,39 @@ func TestGatewayRoutesCodexModelsManifestPathIsRegistered(t *testing.T) { require.NotEmpty(t, registered["/models"], "GET /models should be registered") require.Equal(t, registered["/v1/models"], registered["/models"], "root alias should use the same platform-aware handler") } + +func TestDispatchCodexModelsGatewayKeepsOnlyOpenAIOnLiveManifestHandler(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []struct { + platform string + wantOpenAI bool + }{ + {platform: service.PlatformOpenAI, wantOpenAI: true}, + {platform: service.PlatformComposite}, + {platform: service.PlatformGrok}, + {platform: service.PlatformDeepseek}, + } + + for _, tt := range tests { + t.Run(tt.platform, func(t *testing.T) { + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil) + c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{ + Group: &service.Group{Platform: tt.platform}, + }) + called := "" + + dispatchCodexModelsGateway(c, + func(c *gin.Context) { called = "openai" }, + func(c *gin.Context) { called = "generated" }, + ) + + if tt.wantOpenAI { + require.Equal(t, "openai", called) + } else { + require.Equal(t, "generated", called) + } + }) + } +} diff --git a/backend/internal/service/openai_codex_models_service.go b/backend/internal/service/openai_codex_models_service.go index ee52003017..6ccc78d9f6 100644 --- a/backend/internal/service/openai_codex_models_service.go +++ b/backend/internal/service/openai_codex_models_service.go @@ -647,27 +647,48 @@ func convertOpenAIModelListToCodexManifest(body []byte) []byte { if err := json.Unmarshal(data, &entries); err != nil { return body } - type codexModelEntry struct { - Slug string `json:"slug"` - } - models := make([]codexModelEntry, 0, len(entries)) + modelIDs := make([]string, 0, len(entries)) for _, entry := range entries { id := strings.TrimSpace(entry.ID) if id == "" { continue } - models = append(models, codexModelEntry{Slug: id}) + modelIDs = append(modelIDs, id) } - if len(models) == 0 { + if len(modelIDs) == 0 { return body } - converted, err := json.Marshal(map[string][]codexModelEntry{"models": models}) + converted, err := BuildCodexModelsManifest(modelIDs) if err != nil { return body } return converted } +// BuildCodexModelsManifest creates the minimal manifest accepted by Codex +// custom providers while preserving the effective model order for the group. +func BuildCodexModelsManifest(modelIDs []string) ([]byte, error) { + type codexModelEntry struct { + Slug string `json:"slug"` + } + + seen := make(map[string]struct{}, len(modelIDs)) + models := make([]codexModelEntry, 0, len(modelIDs)) + for _, rawModelID := range modelIDs { + modelID := strings.TrimSpace(rawModelID) + if modelID == "" { + continue + } + if _, exists := seen[modelID]; exists { + continue + } + seen[modelID] = struct{}{} + models = append(models, codexModelEntry{Slug: modelID}) + } + + return json.Marshal(map[string][]codexModelEntry{"models": models}) +} + func validateCodexModelsManifestEnvelope(body []byte) error { var envelope map[string]json.RawMessage if err := json.Unmarshal(body, &envelope); err != nil { diff --git a/backend/internal/service/openai_codex_models_service_test.go b/backend/internal/service/openai_codex_models_service_test.go index 8f4c495755..bad1488ef2 100644 --- a/backend/internal/service/openai_codex_models_service_test.go +++ b/backend/internal/service/openai_codex_models_service_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -23,6 +24,28 @@ import ( "golang.org/x/net/http2" ) +// Scenario: non-OpenAI groups expose their effective model IDs as a Codex manifest. +func TestBuildCodexModelsManifest(t *testing.T) { + body, err := BuildCodexModelsManifest([]string{ + " grok-4.6 ", + "deepseek-v4-pro", + "grok-4.6", + "", + }) + require.NoError(t, err) + + var got struct { + Models []struct { + Slug string `json:"slug"` + } `json:"models"` + } + require.NoError(t, json.Unmarshal(body, &got)) + require.Equal(t, []string{"grok-4.6", "deepseek-v4-pro"}, []string{ + got.Models[0].Slug, + got.Models[1].Slug, + }) +} + type codexModelsHTTPUpstreamStub struct { do func(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) } diff --git a/frontend/src/api/__tests__/codex.spec.ts b/frontend/src/api/__tests__/codex.spec.ts new file mode 100644 index 0000000000..a6883ff5da --- /dev/null +++ b/frontend/src/api/__tests__/codex.spec.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + buildCodexModelsManifestUrl, + fetchCodexModelsManifest +} from '../codex' + +describe('Codex models API', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('builds the authenticated Codex manifest endpoint from the public API base', () => { + expect(buildCodexModelsManifestUrl('https://example.com/api/v1/')).toBe( + 'https://example.com/api/v1/models?client_version=0.147.0' + ) + }) + + it('fetches a manifest with the current API key without adding it to the catalog', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ models: [{ slug: 'grok-4.6' }, { slug: 'deepseek-v4-pro' }] }) + }) + vi.stubGlobal('fetch', fetchMock) + + const result = await fetchCodexModelsManifest('https://example.com/v1', 'sk-user-test') + + expect(fetchMock).toHaveBeenCalledWith( + 'https://example.com/v1/models?client_version=0.147.0', + expect.objectContaining({ + headers: { + Accept: 'application/json', + Authorization: 'Bearer sk-user-test' + } + }) + ) + expect(result.modelCount).toBe(2) + expect(result.content).not.toContain('sk-user-test') + }) + + it('rejects a successful response that is not a Codex manifest', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ object: 'list', data: [] }) + })) + + await expect(fetchCodexModelsManifest('https://example.com/v1', 'sk-user-test')) + .rejects.toThrow('valid manifest') + }) +}) diff --git a/frontend/src/api/codex.ts b/frontend/src/api/codex.ts new file mode 100644 index 0000000000..948fa1eee9 --- /dev/null +++ b/frontend/src/api/codex.ts @@ -0,0 +1,56 @@ +export interface CodexModelsManifestResult { + content: string + modelCount: number +} + +const DEFAULT_CODEX_CLIENT_VERSION = '0.147.0' + +function normalizeCodexBaseUrl(baseUrl: string): string { + const fallback = typeof window !== 'undefined' ? window.location.origin : '' + const value = (baseUrl || fallback).trim().replace(/\/+$/, '') + if (!value) return '/v1' + return /\/v1$/i.test(value) ? value : `${value}/v1` +} + +export function buildCodexModelsManifestUrl( + baseUrl: string, + clientVersion = DEFAULT_CODEX_CLIENT_VERSION +): string { + const url = normalizeCodexBaseUrl(baseUrl) + const params = new URLSearchParams({ client_version: clientVersion }) + return `${url}/models?${params.toString()}` +} + +function isCodexModelsManifest(value: unknown): value is { models: unknown[] } { + return typeof value === 'object' && value !== null && Array.isArray((value as { models?: unknown }).models) +} + +export async function fetchCodexModelsManifest( + baseUrl: string, + apiKey: string, + signal?: AbortSignal +): Promise { + const response = await fetch(buildCodexModelsManifestUrl(baseUrl), { + method: 'GET', + headers: { + Accept: 'application/json', + Authorization: `Bearer ${apiKey}` + }, + cache: 'no-store', + signal + }) + + if (!response.ok) { + throw new Error(`Codex models request failed with status ${response.status}`) + } + + const payload: unknown = await response.json() + if (!isCodexModelsManifest(payload)) { + throw new Error('Codex models response is not a valid manifest') + } + + return { + content: JSON.stringify(payload, null, 2), + modelCount: payload.models.length + } +} diff --git a/frontend/src/components/keys/UseKeyModal.vue b/frontend/src/components/keys/UseKeyModal.vue index c8ae79d229..9646667bbf 100644 --- a/frontend/src/components/keys/UseKeyModal.vue +++ b/frontend/src/components/keys/UseKeyModal.vue @@ -172,6 +172,65 @@ +
+
+
+

+ {{ t('keys.useKeyModal.codexModelCatalog.title') }} +

+

+ {{ t('keys.useKeyModal.codexModelCatalog.description') }} +

+

+ {{ codexModelCatalogPath }} +

+
+ + +
+

+ {{ t('keys.useKeyModal.codexModelCatalog.modelsCount', { count: codexModelManifestModelCount }) }} +

+

+ {{ t('keys.useKeyModal.codexModelCatalog.errorDescription') }} +

+
+
@@ -198,9 +257,11 @@