背景:Codex CLI / Codex App 会从 provider 的 GET {base_url}/models?client_version=...
(自定义 provider 模式)或 GET /backend-api/codex/models(chatgpt_base_url 模式)
刷新模型选单,期望 ChatGPT Codex manifest 格式({"models":[{slug,...}]})。
sub2api 此前只提供 OpenAI 兼容格式的 /v1/models,Codex 客户端解析失败后静默
回落到本地缓存,导致指向 sub2api 的 Codex 客户端模型选单永久冻结在切换
provider 当天的状态,新模型(如 gpt-5.6 系列)永远不会出现。
方案:新增 manifest 透传——用组内可调度 OAuth 账号的凭据向
chatgpt.com/backend-api/codex/models 实时转发请求,响应体与 ETag 原样透传。
不在网关侧解析或维护模型清单:manifest schema 随 Codex 客户端版本演进,
透传保证网关无需跟进 schema 变化,且返回的始终是账号真实的模型权限
(区别于静态 DefaultModels 的"理论列表")。
路由:
- GET /backend-api/codex/models(新增)
- GET /v1/models 带 client_version 查询参数且组平台为 openai 时分发到
manifest 透传(client_version 是 Codex 客户端的天然指纹,普通 OpenAI
客户端不携带);其余请求保持原有 OpenAI 格式行为不变。
上游失败时按 fast-fail 返回错误,不伪造列表;Codex 客户端自身会回落缓存。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
139 lines
4.5 KiB
Go
139 lines
4.5 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func newCodexModelsTestAccount() *Account {
|
|
return &Account{
|
|
ID: 1,
|
|
Platform: PlatformOpenAI,
|
|
Type: AccountTypeOAuth,
|
|
Credentials: map[string]any{
|
|
"access_token": "test-access-token",
|
|
"chatgpt_account_id": "acc-123",
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestPassthrough(t *testing.T) {
|
|
manifestBody := `{"models":[{"slug":"gpt-5.5","display_name":"GPT-5.5"}]}`
|
|
|
|
var gotAuth, gotAccountID, gotOriginator, gotClientVersion string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
gotAccountID = r.Header.Get("chatgpt-account-id")
|
|
gotOriginator = r.Header.Get("Originator")
|
|
gotClientVersion = r.URL.Query().Get("client_version")
|
|
w.Header().Set("ETag", `W/"abc123"`)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(manifestBody))
|
|
}))
|
|
defer server.Close()
|
|
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
s := &OpenAIGatewayService{}
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", "")
|
|
if err != nil {
|
|
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
|
|
}
|
|
|
|
if string(manifest.Body) != manifestBody {
|
|
t.Errorf("body not passed through verbatim: got %q", manifest.Body)
|
|
}
|
|
if manifest.ETag != `W/"abc123"` {
|
|
t.Errorf("etag not passed through: got %q", manifest.ETag)
|
|
}
|
|
if gotAuth != "Bearer test-access-token" {
|
|
t.Errorf("authorization header: got %q", gotAuth)
|
|
}
|
|
if gotAccountID != "acc-123" {
|
|
t.Errorf("chatgpt-account-id header: got %q", gotAccountID)
|
|
}
|
|
if gotOriginator != "codex_cli_rs" {
|
|
t.Errorf("originator header: got %q", gotOriginator)
|
|
}
|
|
if gotClientVersion != "0.137.0" {
|
|
t.Errorf("client_version query: got %q", gotClientVersion)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestDefaultClientVersion(t *testing.T) {
|
|
var gotClientVersion string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotClientVersion = r.URL.Query().Get("client_version")
|
|
_, _ = w.Write([]byte(`{"models":[]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
s := &OpenAIGatewayService{}
|
|
if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "", ""); err != nil {
|
|
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
|
|
}
|
|
if gotClientVersion != openAICodexProbeVersion {
|
|
t.Errorf("default client_version: got %q, want %q", gotClientVersion, openAICodexProbeVersion)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestNotModified(t *testing.T) {
|
|
var gotIfNoneMatch string
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotIfNoneMatch = r.Header.Get("If-None-Match")
|
|
w.Header().Set("ETag", `W/"abc123"`)
|
|
w.WriteHeader(http.StatusNotModified)
|
|
}))
|
|
defer server.Close()
|
|
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
s := &OpenAIGatewayService{}
|
|
manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", `W/"abc123"`)
|
|
if err != nil {
|
|
t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
|
|
}
|
|
if !manifest.NotModified {
|
|
t.Error("expected NotModified to be true")
|
|
}
|
|
if gotIfNoneMatch != `W/"abc123"` {
|
|
t.Errorf("if-none-match header: got %q", gotIfNoneMatch)
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestUpstreamError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, `{"detail":"boom"}`, http.StatusInternalServerError)
|
|
}))
|
|
defer server.Close()
|
|
|
|
original := chatgptCodexModelsURL
|
|
chatgptCodexModelsURL = server.URL
|
|
defer func() { chatgptCodexModelsURL = original }()
|
|
|
|
s := &OpenAIGatewayService{}
|
|
if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", ""); err == nil {
|
|
t.Fatal("expected error for upstream 500, got nil")
|
|
}
|
|
}
|
|
|
|
func TestFetchCodexModelsManifestMissingToken(t *testing.T) {
|
|
account := newCodexModelsTestAccount()
|
|
delete(account.Credentials, "access_token")
|
|
|
|
s := &OpenAIGatewayService{}
|
|
if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", ""); err == nil {
|
|
t.Fatal("expected error for missing access token, got nil")
|
|
}
|
|
}
|