diff --git a/.downstream/gpt6-routing-and-prompts.md b/.downstream/gpt6-routing-and-prompts.md index a9d5374c3b2c..40f5ba5bffd6 100644 --- a/.downstream/gpt6-routing-and-prompts.md +++ b/.downstream/gpt6-routing-and-prompts.md @@ -29,6 +29,7 @@ API Key 使用完整 Responses。OAuth 目录保留实际 Lite 与上下文字 - 已登记且有请求需求的账号在到期前 20 秒续获。每周期最多一次采集与一次复验,连续两个失败周期后持久停止;闲置账号不持续探测。`fail_closed` 保留。 - 旧 292 不转换为 Cookie 验证成功,旧停止状态保留。原先默认的 Astra/5.6-Sol 名单只在旧配置版本迁移时追加 6-Sol/6-Luna;自定义名单保留,之后显式修改不自动补回。 - 原生 turn-state 在同一轮固定首次值,下一轮或换账号清除。 +- ChatGPT 边缘自 2026-09-23 起不再为 `/backend-api/codex/responses` 的流式响应发送 `Content-Type`。宿主对声明接受 SSE 的 200 响应补回 `text/event-stream`,资格读取对无类型响应按 SSE 帧识别;缺少这一处理时采集、复验和业务观测都只能得到 `routing_incomplete`。 账号详情右栏的“Codex 出站指纹”读取配置和已经记录的出站事实,刷新不会发模型请求。页面展示 profile 来源、UA、版本、身份摘要及一致性、传输、Cookie 名称和模型验证结果。新建账号采用官方 Windows CLI 0.156.0 的应用层参照;已有配置保留,可显式选择新版参照,设备种子和手动 UA 覆盖不被重置。 diff --git a/backend/internal/repository/codex_connection_lease.go b/backend/internal/repository/codex_connection_lease.go index 03b2c048c25d..01a61d6ddcad 100644 --- a/backend/internal/repository/codex_connection_lease.go +++ b/backend/internal/repository/codex_connection_lease.go @@ -131,6 +131,7 @@ func (s *httpUpstreamService) DoWithCodexConnectionLease(req *http.Request, prox return nil, "", service.ErrCodexConnectionLeaseTransport } decompressResponseBody(response) + restoreCodexEventStreamContentType(response) return response, leaseID, nil } diff --git a/backend/internal/repository/decompress_response_test.go b/backend/internal/repository/decompress_response_test.go index 8de64ef0f097..0b6c1c2f86c1 100644 --- a/backend/internal/repository/decompress_response_test.go +++ b/backend/internal/repository/decompress_response_test.go @@ -34,6 +34,22 @@ func TestDecompressResponseBodyZstdUsage(t *testing.T) { require.NoError(t, resp.Body.Close()) } +func TestRestoreCodexEventStreamContentType(t *testing.T) { + response := func(path string) *http.Response { + req, err := http.NewRequest(http.MethodPost, "https://chatgpt.com"+path, nil) + require.NoError(t, err) + req.Header.Set("Accept", "text/event-stream") + return &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Request: req} + } + stream := response("/backend-api/codex/responses") + restoreCodexEventStreamContentType(stream) + require.Equal(t, "text/event-stream", stream.Header.Get("Content-Type")) + + compact := response("/backend-api/codex/responses/compact") + restoreCodexEventStreamContentType(compact) + require.Empty(t, compact.Header.Get("Content-Type")) +} + func TestDecompressResponseBodyExistingEncodings(t *testing.T) { payload := []byte(`{"ok":true}`) tests := []struct { diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index e9f4e92facce..8ee93ca6a743 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -231,6 +231,7 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i // 如果上游返回了压缩内容,解压后再交给业务层 decompressResponseBody(resp) + restoreCodexEventStreamContentType(resp) // 包装响应体,在关闭时自动减少计数并更新时间戳 // 这确保了流式响应(如 SSE)在完全读取前不会被淘汰 @@ -292,6 +293,7 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco } decompressResponseBody(resp) + restoreCodexEventStreamContentType(resp) resp.Body = wrapTrackedBody(resp.Body, func() { atomic.AddInt64(&entry.inFlight, -1) @@ -1499,6 +1501,21 @@ func wrapTrackedBody(body io.ReadCloser, onClose func()) io.ReadCloser { return &trackedBody{ReadCloser: body, onClose: onClose} } +// restoreCodexEventStreamContentType 为缺少 Content-Type 的 ChatGPT Codex /responses 流补回 +// text/event-stream。ChatGPT 边缘自 2026-09-23 起不再为这类 SSE 响应发送该头,而路由观测、 +// 工具名映射和图片流等消费方都按它识别流;请求声明接受 SSE 且上游 200 时响应体就是 SSE。 +func restoreCodexEventStreamContentType(resp *http.Response) { + if resp == nil || resp.StatusCode != http.StatusOK || resp.Header == nil || resp.Header.Get("Content-Type") != "" { + return + } + req := resp.Request + if req == nil || req.URL == nil || req.Method != http.MethodPost || !strings.EqualFold(req.URL.Hostname(), "chatgpt.com") || + req.URL.Path != "/backend-api/codex/responses" || !strings.Contains(strings.ToLower(req.Header.Get("Accept")), "text/event-stream") { + return + } + resp.Header.Set("Content-Type", "text/event-stream") +} + // decompressResponseBody 根据 Content-Encoding 解压响应体。 // 当请求显式设置了 accept-encoding 时,Go 的 Transport 不会自动解压,需要手动处理。 // 解压成功后会删除 Content-Encoding 和 Content-Length header(长度已不准确)。 diff --git a/backend/internal/service/codex_routing_completion.go b/backend/internal/service/codex_routing_completion.go index 74d618213e36..ef02949ee346 100644 --- a/backend/internal/service/codex_routing_completion.go +++ b/backend/internal/service/codex_routing_completion.go @@ -93,7 +93,9 @@ func readCodexRoutingCompletion(body io.Reader, contentType, requested string, o return errCodexRoutingUnavailable } var completed codexRoutingCompletion - if strings.Contains(strings.ToLower(contentType), "text/event-stream") { + // The ChatGPT edge omits Content-Type on streamed replies (2026-09-23); + // an untyped body is still recognised by its SSE framing. + if strings.Contains(strings.ToLower(contentType), "text/event-stream") || (strings.TrimSpace(contentType) == "" && bodyHasSSEFraming(data)) { completed.feed(data) } else { completed.json(data) diff --git a/backend/internal/service/codex_routing_host.go b/backend/internal/service/codex_routing_host.go index 9d9e768363ee..abfc01f4ed40 100644 --- a/backend/internal/service/codex_routing_host.go +++ b/backend/internal/service/codex_routing_host.go @@ -320,7 +320,16 @@ func (s *OpenAIGatewayService) buildCodexRoutingProbe(ctx context.Context, accou session, turn := uuid.Must(uuid.NewV7()).String(), uuid.Must(uuid.NewV7()).String() metadata := map[string]any{"session_id": session, "thread_id": session, "turn_id": turn, "x-codex-window-id": session + ":0"} body := map[string]any{"model": model, "store": false, "stream": true, "instructions": "Reply with exactly: pong", "input": []any{map[string]any{"role": "user", "content": []any{map[string]any{"type": "input_text", "text": "ping"}}}}, "client_metadata": metadata, "prompt_cache_key": session} + identity, err := resolveCodexOutboundIdentityForAccountContext(ctx, account, codexAccountIdentityOverrideUA(account)) + if err != nil { + return nil, err + } headers := http.Header{} + // A probe has no inbound client, so it speaks as the account's outbound + // identity: the same triple the routing scope profile hashes. + headers.Set("originator", identity.originator) + headers.Set("user-agent", identity.userAgent) + headers.Set("version", identity.version) headers.Set("session-id", session) headers.Set("thread-id", session) headers.Set("x-client-request-id", session) diff --git a/backend/internal/service/codex_routing_test.go b/backend/internal/service/codex_routing_test.go index c19e1930f34b..6e7646f75ee5 100644 --- a/backend/internal/service/codex_routing_test.go +++ b/backend/internal/service/codex_routing_test.go @@ -202,6 +202,22 @@ func TestCodexRoutingCompletionRequiresOriginalModelAndDelimitedTerminal(t *test require.Equal(t, test.ok, o.ModelMatched) }) } + var untyped extensionv1.CodexRoutingObservation + stream := `event: response.completed +data: {"type":"response.completed","response":{"status":"completed","model":"gpt-6-astra"}} + +` + require.NoError(t, readCodexRoutingCompletion(strings.NewReader(stream), "", "gpt-6-astra", &untyped), "ChatGPT streams arrive without Content-Type") +} + +func TestCodexRoutingProbeSpeaksAsAccountIdentity(t *testing.T) { + account := &Account{ID: 7, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "acct-7"}} + request, err := (&OpenAIGatewayService{}).buildCodexRoutingProbe(context.Background(), account, "gpt-6-astra", "token", "") + require.NoError(t, err) + identity, err := resolveCodexOutboundIdentityForAccountContext(context.Background(), account, codexAccountIdentityOverrideUA(account)) + require.NoError(t, err) + require.Equal(t, identity.originator, request.Header.Get("originator")) + require.Equal(t, identity.userAgent, request.Header.Get("User-Agent"), "probe must not fall back to Go-http-client") } func TestCodexRoutingHostSeparatesCandidateQualifiedAndPrivateMaterial(t *testing.T) {