diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index df926b0297..3941794860 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -343,6 +343,9 @@ func (s *AccountTestService) buildAntigravityAPIKeyModelsRequest(ctx context.Con } func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { + if account.IsOpenAIOAuth() { + return s.buildOpenAIOAuthUpstreamModelsRequest(ctx, account) + } if account.Type != AccountTypeAPIKey { return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", account.Type), nil, @@ -375,6 +378,68 @@ func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Contex return req, nil } +// buildOpenAIOAuthUpstreamModelsRequest uses ChatGPT's Codex model manifest. +// OAuth subscriptions do not expose the public Platform API /v1/models endpoint, +// so treating them like API-key accounts makes the admin sync button fail locally. +func (s *AccountTestService) buildOpenAIOAuthUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { + credentialAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account) + if err != nil { + return nil, newUpstreamModelSyncConfigError("Failed to resolve OpenAI account credentials", err) + } + if !credentialAccount.IsOpenAIOAuth() { + return nil, newUpstreamModelSyncUnsupportedError( + fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", credentialAccount.Type), nil, + ) + } + + modelsURL, err := buildCodexModelsManifestURL( + chatgptCodexModelsURL, + false, + CodexCanonicalClientVersion(), + ) + if err != nil { + return nil, newUpstreamModelSyncConfigError("Invalid OpenAI Codex model list URL", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL.String(), nil) + if err != nil { + return nil, newUpstreamModelSyncConfigError("Invalid OpenAI Codex model list request", err) + } + + if credentialAccount.IsOpenAIAgentIdentity() { + authHeaders, authErr := buildAgentIdentityAuthenticationHeaders( + ctx, + s.accountRepo, + s.agentIdentityWS, + &s.agentIdentityTaskMu, + credentialAccount, + ) + if authErr != nil { + return nil, newUpstreamModelSyncUpstreamError("Failed to build OpenAI Agent Identity authentication", authErr) + } + for key, values := range authHeaders { + for _, value := range values { + req.Header.Add(key, value) + } + } + } else { + accessToken := strings.TrimSpace(credentialAccount.GetOpenAIAccessToken()) + if accessToken == "" { + return nil, newUpstreamModelSyncConfigError("No OpenAI access token is available", nil) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + } + + identity := resolveCodexOutboundIdentity(credentialAccount.GetOpenAIUserAgent()) + req.Header.Set("Accept", "application/json") + req.Header.Set("Originator", identity.originator) + req.Header.Set("User-Agent", identity.userAgent) + req.Header.Set("Version", identity.version) + setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) + credentialAccount.ApplyHeaderOverrides(req.Header) + enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) + return req, nil +} + func (s *AccountTestService) buildGeminiUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { baseURL := account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) if strings.TrimSpace(baseURL) == "" { @@ -498,6 +563,7 @@ func buildGeminiModelsURL(base string) string { type upstreamModelEntry struct { ID string `json:"id"` + Slug string `json:"slug"` Model string `json:"model"` ModelID string `json:"modelId"` ModelIDSnake string `json:"model_id"` @@ -561,6 +627,9 @@ func extractUpstreamModelIDsWithSelector(body []byte, selectID func(upstreamMode func upstreamModelEntryID(entry upstreamModelEntry) string { modelID := strings.TrimSpace(entry.ID) + if modelID == "" { + modelID = strings.TrimSpace(entry.Slug) + } if modelID == "" { modelID = strings.TrimSpace(entry.Name) } diff --git a/backend/internal/service/upstream_models_test.go b/backend/internal/service/upstream_models_test.go index 031d09b574..b6f3cb3f95 100644 --- a/backend/internal/service/upstream_models_test.go +++ b/backend/internal/service/upstream_models_test.go @@ -140,6 +140,11 @@ func TestExtractUpstreamModelIDs(t *testing.T) { body: `{"data":[{"id":"canonical-id","model":"display-model"}]}`, want: []string{"canonical-id"}, }, + { + name: "codex manifest uses slug", + body: `{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-5.5-codex"}]}`, + want: []string{"gpt-5.5-codex", "gpt-5.6-sol"}, + }, } for _, tt := range tests { @@ -154,6 +159,53 @@ func TestExtractUpstreamModelIDs(t *testing.T) { } } +func TestBuildUpstreamModelsRequestSupportsOpenAIOAuth(t *testing.T) { + svc := &AccountTestService{cfg: upstreamModelSyncTestConfig()} + account := &Account{ + ID: 11, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "openai-oauth-token", + "chatgpt_account_id": "chatgpt-account", + }, + } + + req, err := svc.buildUpstreamModelsRequest(context.Background(), account) + require.NoError(t, err) + require.Equal(t, chatgptCodexModelsURL, req.URL.Scheme+"://"+req.URL.Host+req.URL.Path) + require.NotEmpty(t, req.URL.Query().Get("client_version")) + require.Equal(t, "Bearer openai-oauth-token", req.Header.Get("Authorization")) + require.Equal(t, "chatgpt-account", req.Header.Get("chatgpt-account-id")) + require.NotEmpty(t, req.Header.Get("Originator")) + require.NotEmpty(t, req.Header.Get("User-Agent")) + require.NotEmpty(t, req.Header.Get("Version")) +} + +func TestFetchUpstreamSupportedModelsParsesOpenAIOAuthManifest(t *testing.T) { + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-5.5-codex"}]}`)), + }} + svc := &AccountTestService{ + httpUpstream: upstream, + cfg: upstreamModelSyncTestConfig(), + } + + models, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{ + ID: 12, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "openai-oauth-token", + }, + }) + require.NoError(t, err) + require.Equal(t, []string{"gpt-5.5-codex", "gpt-5.6-sol"}, models) + require.Equal(t, "Bearer openai-oauth-token", upstream.lastReq.Header.Get("Authorization")) +} + func TestExtractGrokUpstreamModelIDs(t *testing.T) { t.Parallel()