Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .downstream/gpt6-routing-and-prompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 覆盖不被重置。

Expand Down
1 change: 1 addition & 0 deletions backend/internal/repository/codex_connection_lease.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ func (s *httpUpstreamService) DoWithCodexConnectionLease(req *http.Request, prox
return nil, "", service.ErrCodexConnectionLeaseTransport
}
decompressResponseBody(response)
restoreCodexEventStreamContentType(response)
return response, leaseID, nil
}

Expand Down
16 changes: 16 additions & 0 deletions backend/internal/repository/decompress_response_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions backend/internal/repository/http_upstream.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i

// 如果上游返回了压缩内容,解压后再交给业务层
decompressResponseBody(resp)
restoreCodexEventStreamContentType(resp)

// 包装响应体,在关闭时自动减少计数并更新时间戳
// 这确保了流式响应(如 SSE)在完全读取前不会被淘汰
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(长度已不准确)。
Expand Down
4 changes: 3 additions & 1 deletion backend/internal/service/codex_routing_completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions backend/internal/service/codex_routing_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions backend/internal/service/codex_routing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading