From 30708ae79da930e13627931ba2d3c1f72f2f169c Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Thu, 26 Mar 2026 22:35:57 -0400 Subject: [PATCH] feat(providers): support Codex OAuth pools with inherited routing defaults * feat(auth): support named chatgpt oauth providers - add provider-scoped ChatGPT OAuth routes and CLI support - persist refresh tokens per provider and reject provider-type collisions - wire provider OAuth setup flows in the dashboard and setup UI Refs #448 * feat(agent): add chatgpt oauth account routing - add agent other_config routing for manual and round-robin selection - reuse routed provider resolution across resolver and pending loaders - add router, parser, and agent advanced dialog coverage for multi-account use Refs #448 * docs(api): describe chatgpt oauth routing - document named-provider ChatGPT OAuth auth routes - describe agent-side account routing and round-robin behavior - update OpenAPI agent config schema and provider type enum Refs #448 * fix(store): add missing agent key context helpers * feat(ui): clarify chatgpt oauth account setup and routing * docs(providers): align chatgpt oauth alias examples * feat(agent): add codex pool activity dashboard * fix(providers): harden codex oauth alias setup * feat(codex-pool): improve routing dashboard UX - redesign the Codex/OpenAI pool page around saved-pool checkpoints and live evidence - add clearer selection, attention, and recent-proof states for pool members - make the lower panels fill the remaining desktop viewport while staying responsive * fix(store): resolve context helper merge duplication * feat(oauth): add codex pool quota and observation APIs - add quota inspection and observation endpoints for ChatGPT Subscription (OAuth) providers - teach codex routing to surface pool activity, observation metadata, and quota-aware readiness - extend tests and HTTP docs/OpenAPI for the new pool monitoring flows * feat(web): add codex pool quota monitor and controls - add provider quota fetching, readiness badges, and live routing evidence on the account pool page - redesign pool setup and activity panels for multi-account management with localized copy updates - keep the live monitor internally scrollable and compact the account cards for better viewport fit * fix(web): clarify pool routing labels - rename the recent request badge from Direct to Selected - restore compact quota bars in the live pool cards * feat(codex-pool): add runtime health dashboard - derive per-provider success and failure health from routed Codex traces - surface routing, quota, and recent request evidence in the pool UI - align provider alias guidance and owner access with the dashboard role model * docs(auth): document tenant scoping and key roles * fix(auth): harden tenant and codex pool access control * fix(providers): align codex pool runtime defaults * feat(ui): tighten codex pool responsive layout * feat(chatgpt-oauth): refine codex pool management UX * feat(chatgpt-oauth): surface quota bars on provider pages - add compact quota bars to Codex provider rows and provider detail - fetch quota only for ready visible provider rows and ready detail aliases - fix managed-member detail visibility and tighten provider locale copy --- cmd/auth.go | 52 +- cmd/gateway_http_handlers.go | 2 +- cmd/gateway_providers.go | 6 +- cmd/pkg-helper/main.go | 4 +- docs/02-providers.md | 48 + docs/18-http-api.md | 152 +++ docs/20-api-keys-auth.md | 19 +- docs/23-multi-tenant-architecture.md | 6 +- internal/agent/extractive_memory.go | 7 +- internal/agent/loop.go | 13 +- internal/agent/loop_tracing.go | 15 +- internal/agent/resolver.go | 14 +- internal/channels/instance_loader.go | 23 +- internal/config/config_load.go | 2 +- internal/gateway/client.go | 18 +- internal/gateway/methods/api_keys.go | 6 +- internal/gateway/router.go | 22 +- internal/http/agents.go | 54 +- internal/http/agents_codex_pool.go | 327 ++++++ internal/http/agents_codex_pool_activity.go | 240 +++++ .../http/agents_codex_pool_activity_test.go | 145 +++ internal/http/agents_codex_pool_test.go | 184 ++++ internal/http/api_key_cache_test.go | 16 +- internal/http/auth.go | 114 ++- internal/http/auth_test.go | 257 ++++- .../http/chatgpt_oauth_pool_validation.go | 168 +++ .../chatgpt_oauth_pool_validation_test.go | 165 +++ internal/http/file_token.go | 2 +- internal/http/oauth.go | 257 ++++- internal/http/oauth_quota.go | 40 + internal/http/oauth_quota_test.go | 184 ++++ internal/http/oauth_test.go | 331 ++++++ internal/http/openapi_spec.json | 220 +++- internal/http/pending_messages.go | 3 +- internal/http/providers.go | 63 +- internal/http/providers_test.go | 52 + internal/http/summoner_utils.go | 2 +- internal/mcp/manager_lazy_test.go | 9 +- internal/oauth/openai_claims.go | 126 +++ internal/oauth/openai_claims_test.go | 83 ++ internal/oauth/openai_quota.go | 409 ++++++++ internal/oauth/openai_quota_test.go | 305 ++++++ internal/oauth/openai_quota_transport.go | 115 +++ internal/oauth/token.go | 276 ++++- internal/oauth/token_test.go | 136 +++ internal/providerresolve/agent_provider.go | 52 + .../providerresolve/agent_provider_test.go | 235 +++++ .../providers/chatgpt_oauth_observation.go | 148 +++ .../chatgpt_oauth_observation_test.go | 42 + internal/providers/chatgpt_oauth_router.go | 258 +++++ .../providers/chatgpt_oauth_router_test.go | 280 +++++ internal/providers/codex.go | 40 +- internal/providers/registry.go | 8 +- internal/providers/types.go | 41 +- internal/safego/recover_test.go | 6 +- internal/sandbox/docker_resolve.go | 6 +- internal/store/agent_store.go | 201 +++- internal/store/agent_store_test.go | 178 ++++ internal/store/heartbeat_store.go | 9 +- internal/store/pg/memory_embedding_cache.go | 5 +- internal/store/provider_store.go | 22 + internal/tools/policy.go | 9 +- internal/tools/team_tool_dispatch.go | 27 +- ui/web/src/api/ws-client.ts | 4 +- .../src/components/shared/sticky-save-bar.tsx | 22 +- ui/web/src/constants/providers.ts | 29 + ui/web/src/i18n/locales/en/agents.json | 247 +++++ ui/web/src/i18n/locales/en/providers.json | 132 ++- ui/web/src/i18n/locales/en/setup.json | 8 +- ui/web/src/i18n/locales/vi/agents.json | 247 +++++ ui/web/src/i18n/locales/vi/providers.json | 126 ++- ui/web/src/i18n/locales/vi/setup.json | 8 +- ui/web/src/i18n/locales/zh/agents.json | 247 +++++ ui/web/src/i18n/locales/zh/providers.json | 144 ++- ui/web/src/i18n/locales/zh/setup.json | 8 +- ui/web/src/lib/constants.ts | 1 + ui/web/src/lib/query-keys.ts | 3 + ui/web/src/pages/agents/agent-card.tsx | 12 +- .../agent-detail/agent-advanced-dialog.tsx | 45 +- .../agent-detail/agent-codex-pool-page.tsx | 508 +++++++++ .../agents/agent-detail/agent-detail-page.tsx | 26 +- .../agent-detail/agent-display-utils.ts | 188 ++++ .../agents/agent-detail/agent-header.tsx | 15 +- .../agent-detail/agent-overview-tab.tsx | 6 +- .../chatgpt-oauth-quota-badges.tsx | 125 +++ .../chatgpt-oauth-quota-readiness-section.tsx | 180 ++++ .../chatgpt-oauth-quota-strip.tsx | 229 +++++ .../agent-detail/chatgpt-oauth-quota-utils.ts | 192 ++++ .../codex-pool-activity-panel.tsx | 964 ++++++++++++++++++ .../chatgpt-oauth-routing-section.tsx | 557 ++++++++++ .../agent-detail/config-sections/index.ts | 1 + .../hooks/use-codex-pool-activity.ts | 68 ++ .../chatgpt-oauth-routing-summary-section.tsx | 224 ++++ ui/web/src/pages/agents/agent-list-row.tsx | 12 +- .../use-chatgpt-oauth-provider-quotas.ts | 110 ++ .../use-chatgpt-oauth-provider-statuses.ts | 65 ++ .../providers/hooks/use-provider-models.ts | 2 +- .../pages/providers/hooks/use-providers.ts | 3 +- .../provider-advanced-dialog.tsx | 14 +- .../provider-detail/provider-header.tsx | 5 +- .../provider-oauth-account-section.tsx | 145 +++ .../provider-detail/provider-overview.tsx | 420 +++++++- .../pages/providers/provider-form-dialog.tsx | 41 +- .../src/pages/providers/provider-list-row.tsx | 176 +++- .../providers/provider-oauth-section.tsx | 128 ++- ui/web/src/pages/providers/provider-utils.tsx | 99 +- ui/web/src/pages/providers/providers-page.tsx | 138 ++- .../pages/setup/hooks/use-bootstrap-status.ts | 21 +- ui/web/src/pages/setup/setup-page.tsx | 15 +- ui/web/src/pages/setup/step-provider.tsx | 44 +- ui/web/src/routes.tsx | 4 + ui/web/src/types/agent.ts | 19 + ui/web/src/types/provider.ts | 66 ++ 113 files changed, 11816 insertions(+), 536 deletions(-) create mode 100644 internal/http/agents_codex_pool.go create mode 100644 internal/http/agents_codex_pool_activity.go create mode 100644 internal/http/agents_codex_pool_activity_test.go create mode 100644 internal/http/agents_codex_pool_test.go create mode 100644 internal/http/chatgpt_oauth_pool_validation.go create mode 100644 internal/http/chatgpt_oauth_pool_validation_test.go create mode 100644 internal/http/oauth_quota.go create mode 100644 internal/http/oauth_quota_test.go create mode 100644 internal/http/providers_test.go create mode 100644 internal/oauth/openai_claims.go create mode 100644 internal/oauth/openai_claims_test.go create mode 100644 internal/oauth/openai_quota.go create mode 100644 internal/oauth/openai_quota_test.go create mode 100644 internal/oauth/openai_quota_transport.go create mode 100644 internal/providerresolve/agent_provider.go create mode 100644 internal/providerresolve/agent_provider_test.go create mode 100644 internal/providers/chatgpt_oauth_observation.go create mode 100644 internal/providers/chatgpt_oauth_observation_test.go create mode 100644 internal/providers/chatgpt_oauth_router.go create mode 100644 internal/providers/chatgpt_oauth_router_test.go create mode 100644 internal/store/agent_store_test.go create mode 100644 ui/web/src/pages/agents/agent-detail/agent-codex-pool-page.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-badges.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-readiness-section.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-strip.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-utils.ts create mode 100644 ui/web/src/pages/agents/agent-detail/codex-pool-activity-panel.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-routing-section.tsx create mode 100644 ui/web/src/pages/agents/agent-detail/hooks/use-codex-pool-activity.ts create mode 100644 ui/web/src/pages/agents/agent-detail/overview-sections/chatgpt-oauth-routing-summary-section.tsx create mode 100644 ui/web/src/pages/providers/hooks/use-chatgpt-oauth-provider-quotas.ts create mode 100644 ui/web/src/pages/providers/hooks/use-chatgpt-oauth-provider-statuses.ts create mode 100644 ui/web/src/pages/providers/provider-detail/provider-oauth-account-section.tsx diff --git a/cmd/auth.go b/cmd/auth.go index 463767d3..5c431c66 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -5,17 +5,19 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "strings" + "github.com/nextlevelbuilder/goclaw/internal/oauth" "github.com/spf13/cobra" ) func authCmd() *cobra.Command { cmd := &cobra.Command{ Use: "auth", - Short: "Authenticate with LLM providers via OAuth", - Long: "Manage OAuth authentication via the running gateway. Requires the gateway to be running.", + Short: "Authenticate named ChatGPT OAuth accounts", + Long: "Manage ChatGPT OAuth authentication via the running gateway. Requires the gateway to be running.", } cmd.AddCommand(authStatusCmd()) cmd.AddCommand(authLogoutCmd()) @@ -74,22 +76,27 @@ func gatewayRequest(method, path string) (map[string]any, error) { func authStatusCmd() *cobra.Command { return &cobra.Command{ - Use: "status", + Use: "status [provider]", Short: "Show OAuth authentication status", - Long: "Check if ChatGPT OAuth is configured on the running gateway.", + Long: "Check if a named ChatGPT OAuth account is authenticated on the running gateway.", + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - result, err := gatewayRequest("GET", "/v1/auth/openai/status") + provider := resolveOAuthProviderArg(args) + result, err := gatewayRequest("GET", fmt.Sprintf("/v1/auth/chatgpt/%s/status", url.PathEscape(provider))) if err != nil { return err } if auth, _ := result["authenticated"].(bool); auth { name, _ := result["provider_name"].(string) - fmt.Printf("OpenAI OAuth: active (provider: %s)\n", name) - fmt.Println("Use model prefix 'openai-codex/' in agent config (e.g. openai-codex/gpt-4o).") + if name == "" { + name = provider + } + fmt.Printf("ChatGPT OAuth account: active (alias: %s)\n", name) + fmt.Printf("Use model prefix '%s/' in agent config (e.g. %s/gpt-5.4).\n", name, name) } else { - fmt.Println("No OAuth tokens found.") - fmt.Println("Use the web UI to authenticate with ChatGPT OAuth.") + fmt.Printf("No ChatGPT OAuth tokens found for alias '%s'.\n", provider) + fmt.Println("Use the web UI to authenticate this ChatGPT OAuth account.") } return nil }, @@ -99,25 +106,28 @@ func authStatusCmd() *cobra.Command { func authLogoutCmd() *cobra.Command { return &cobra.Command{ Use: "logout [provider]", - Short: "Remove stored OAuth tokens", + Short: "Disconnect stored ChatGPT OAuth tokens", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - provider := "openai" - if len(args) > 0 { - provider = args[0] - } - - if provider != "openai" { - return fmt.Errorf("unknown provider: %s (supported: openai)", provider) - } - - _, err := gatewayRequest("POST", "/v1/auth/openai/logout") + provider := resolveOAuthProviderArg(args) + _, err := gatewayRequest("POST", fmt.Sprintf("/v1/auth/chatgpt/%s/logout", url.PathEscape(provider))) if err != nil { return err } - fmt.Println("OpenAI OAuth token removed.") + fmt.Printf("ChatGPT OAuth account disconnected for alias '%s'.\n", provider) return nil }, } } + +func resolveOAuthProviderArg(args []string) string { + if len(args) == 0 { + return oauth.DefaultProviderName + } + provider := strings.TrimSpace(args[0]) + if provider == "" || provider == "openai" { + return oauth.DefaultProviderName + } + return provider +} diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index c32774ca..d7290ea0 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -25,7 +25,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir if providerReg != nil { summoner = httpapi.NewAgentSummoner(stores.Agents, providerReg, msgBus) } - agentsH = httpapi.NewAgentsHandler(stores.Agents, defaultWorkspace, msgBus, summoner, isOwner) + agentsH = httpapi.NewAgentsHandler(stores.Agents, stores.Providers, providerReg, stores.DB, defaultWorkspace, msgBus, summoner, isOwner) } if stores != nil && stores.Skills != nil { diff --git a/cmd/gateway_providers.go b/cmd/gateway_providers.go index 294d2db0..b2740239 100644 --- a/cmd/gateway_providers.go +++ b/cmd/gateway_providers.go @@ -297,7 +297,11 @@ func registerProvidersFromDB(registry *providers.Registry, provStore store.Provi switch p.ProviderType { case store.ProviderChatGPTOAuth: ts := oauth.NewDBTokenSource(provStore, secretStore, p.Name).WithTenantID(p.TenantID) - registry.RegisterForTenant(p.TenantID, providers.NewCodexProvider(p.Name, ts, p.APIBase, "")) + codex := providers.NewCodexProvider(p.Name, ts, p.APIBase, "") + if oauthSettings := store.ParseChatGPTOAuthProviderSettings(p.Settings); oauthSettings != nil { + codex.WithRoutingDefaults(oauthSettings.CodexPool.Strategy, oauthSettings.CodexPool.ExtraProviderNames) + } + registry.RegisterForTenant(p.TenantID, codex) case store.ProviderAnthropicNative: registry.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey, providers.WithAnthropicBaseURL(p.APIBase))) diff --git a/cmd/pkg-helper/main.go b/cmd/pkg-helper/main.go index 198baf01..7f1f8f9d 100644 --- a/cmd/pkg-helper/main.go +++ b/cmd/pkg-helper/main.go @@ -181,7 +181,7 @@ func persistAdd(pkg string) { // Check if already persisted (avoid duplicates). if data, err := os.ReadFile(listFile); err == nil { - for _, line := range strings.Split(string(data), "\n") { + for line := range strings.SplitSeq(string(data), "\n") { if strings.TrimSpace(line) == pkg { return // already persisted } @@ -217,7 +217,7 @@ func persistRemove(pkg string) { } var kept []string - for _, line := range strings.Split(string(data), "\n") { + for line := range strings.SplitSeq(string(data), "\n") { line = strings.TrimSpace(line) if line != "" && line != pkg { kept = append(kept, line) diff --git a/docs/02-providers.md b/docs/02-providers.md index d7f4a318..e2d889b9 100644 --- a/docs/02-providers.md +++ b/docs/02-providers.md @@ -627,6 +627,53 @@ Codex provider reports `SupportsThinking() = true`, allowing thinking_level to b Tracks prompt, completion, and total tokens. `CacheCreationTokens` and `CacheReadTokens` are supported for prompt caching if available. +### Provider-Level Defaults + Agent Overrides + +Multiple authenticated `chatgpt_oauth` providers can coexist in one tenant. Each provider name is one OpenAI Codex OAuth alias. Pool membership is authoritative at the provider layer: one alias owns the reusable pool, while member aliases stay leaf accounts. + +Provider default example: + +```json +{ + "name": "openai-codex", + "provider_type": "chatgpt_oauth", + "settings": { + "codex_pool": { + "strategy": "round_robin", + "extra_provider_names": ["codex-work"] + } + } +} +``` + +Agent override example: + +```json +{ + "provider": "openai-codex", + "other_config": { + "chatgpt_oauth_routing": { + "override_mode": "custom", + "strategy": "round_robin" + } + } +} +``` + +Routing behavior: +- The main `provider` field remains the preferred/default account. +- Provider aliases are arbitrary. `openai-codex` and `codex-work` are examples, not required prefixes. +- `settings.codex_pool.extra_provider_names` is the authoritative membership list for that pool owner. +- A provider listed in another pool cannot also manage its own pool. +- `override_mode: "inherit"` uses the primary provider's `settings.codex_pool`. +- `override_mode: "custom"` is limited to routing behavior for that provider-owned pool. +- `primary_first` keeps the preferred account fixed. When saved as a custom override with no extra names, it disables the pool for that agent and keeps the agent on the primary account only. +- `round_robin` rotates requests across the preferred account plus the provider-owned extra authenticated OpenAI Codex OAuth accounts. +- `priority_order` tries the preferred account first, then drains the provider-owned extra accounts in order. +- Retryable upstream failures can fall through to the next eligible OpenAI Codex OAuth account in the same request. +- Explicit provider names remain explicit. OAuth auth/logout is still provider-scoped. +- Runtime observability for one agent is available at `GET /v1/agents/{id}/codex-pool-activity`, which exposes recent routed traces plus per-alias health derived from those traces. + --- ## 14. File Reference @@ -652,6 +699,7 @@ Tracks prompt, completion, and total tokens. `CacheCreationTokens` and `CacheRea | `internal/providers/codex.go` | CodexProvider: OAuth-based ChatGPT Responses API | | `internal/providers/codex_build.go` | Codex request builder: message formatting, phase handling | | `internal/providers/codex_types.go` | Codex request/response types and OAuth token management | +| `internal/providers/chatgpt_oauth_router.go` | Agent-side routing across multiple authenticated OpenAI Codex OAuth providers | | `internal/providers/dashscope.go` | DashScope provider: OpenAI-compat wrapper with thinking budget, tools+streaming fallback | | `internal/providers/acp_provider.go` | ACPProvider: orchestrates ACP-compatible agent subprocesses | | `internal/providers/acp/types.go` | ACP protocol types: InitializeRequest, SessionUpdate, ContentBlock, etc. | diff --git a/docs/18-http-api.md b/docs/18-http-api.md index 4cae6e79..f327d48a 100644 --- a/docs/18-http-api.md +++ b/docs/18-http-api.md @@ -138,6 +138,82 @@ POST /v1/agents/{id}/wake Response: `{content, run_id, usage?}`. Used by orchestrators (n8n, Paperclip) to trigger agent runs. +### Codex/OpenAI OAuth Routing in `other_config` + +For agents whose main `provider` is a `chatgpt_oauth` provider, `other_config.chatgpt_oauth_routing` +can override or inherit routing behavior while keeping the main `provider` field as the preferred/default account alias. + +```json +{ + "provider": "openai-codex", + "model": "gpt-5.4", + "other_config": { + "chatgpt_oauth_routing": { + "override_mode": "custom", + "strategy": "round_robin" + } + } +} +``` + +Rules: +- Provider settings may define reusable `settings.codex_pool` defaults for a primary alias. +- `settings.codex_pool.extra_provider_names` is the authoritative membership list for that pool owner. +- A provider listed in another pool cannot also manage its own pool. +- `override_mode: "inherit"` tells the agent to follow those provider defaults. +- `override_mode: "custom"` stores an agent-local routing override for that provider-owned pool. +- `strategy: "primary_first"` keeps the main `provider` as the preferred account. When saved as a custom override with no extra names, it disables pooling for that agent. +- Provider aliases are arbitrary. `openai-codex`, `codex-work`, and `codex-team` are examples, not required prefixes. +- `strategy: "round_robin"` rotates requests across the main provider plus the provider-owned extra authenticated OpenAI Codex OAuth providers. +- `strategy: "priority_order"` tries the main provider first, then drains the provider-owned extra providers in order. +- Retryable upstream failures can fall through to the next eligible OpenAI Codex OAuth provider in the same request. +- Only enabled and authenticated `chatgpt_oauth` providers participate. +- Provider-scoped auth remains unchanged: `cmd/auth` and `/v1/auth/chatgpt/{provider}/*` still operate on explicit providers. + +Provider-level defaults example: + +```json +{ + "name": "openai-codex", + "provider_type": "chatgpt_oauth", + "settings": { + "codex_pool": { + "strategy": "round_robin", + "extra_provider_names": ["codex-work", "codex-team"] + } + } +} +``` + +### Codex Pool Activity + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/v1/agents/{id}/codex-pool-activity` | Summarize recent Codex/OpenAI OAuth pool usage for one agent | + +Query parameters: +- `limit` optional, defaults to `18`, max `50` + +Response fields: +- `strategy`: effective routing strategy (`primary_first`, `round_robin`, or `priority_order`) +- `pool_providers`: configured primary + extra provider aliases in pool order +- `stats_sample_size`: number of recent routed `llm_call` spans used to derive runtime health. The server derives health from `max(limit, 120)` recent spans even when `recent_requests` is still capped by the requested `limit`. +- `provider_counts`: per-alias routing evidence: + - `request_count`: backward-compatible count of direct selections + - `direct_selection_count`: times the router selected that alias first + - `failover_serve_count`: times that alias only served as failover + - `success_count`, `failure_count`: trace-backed runtime outcomes attributed to that alias. Success is attributed to the alias that actually served the request. On successful failover, earlier attempted aliases receive failures. On terminal error, every attempted alias receives a failure. + - `consecutive_failures`: current newest-first failure streak from recent trace evidence + - `success_rate`, `health_score`, `health_state`: additive runtime health summary. `health_score` is heuristic, but the stable bands are `idle` when there are no recent outcomes, `critical` at 3+ consecutive failures or score `< 40`, `degraded` below `80`, otherwise `healthy` + - `last_selected_at`, `last_failover_at`, `last_used_at`, `last_success_at`, `last_failure_at`: latest timestamps for each evidence type +- `recent_requests`: recent routed Codex calls: + - `span_id`, `trace_id`, `started_at`, `status`, `duration_ms`, `model` + - `selected_provider`: alias chosen first by the router + - `provider_name`: alias that actually served the request. This can be empty on terminal failures where no alias completed the call. + - `attempt_count`, `used_failover`, `failover_providers` + +Use `direct_selection_count` plus the `selected_provider` sequence to verify real round-robin behavior. A provider with `failover_serve_count > 0` and `direct_selection_count = 0` was only observed as a rescue target, not as a confirmed round-robin selection. + --- ## 5. Skills @@ -652,11 +728,87 @@ Admin-only endpoints for managing gateway API keys. See [20 — API Keys & Auth] | Method | Path | Description | |--------|------|-------------| +| `GET` | `/v1/auth/chatgpt/{provider}/status` | Check ChatGPT OAuth status for a provider | +| `GET` | `/v1/auth/chatgpt/{provider}/quota` | Fetch Codex/OpenAI quota state for a provider | +| `POST` | `/v1/auth/chatgpt/{provider}/start` | Start ChatGPT OAuth flow for a provider | +| `POST` | `/v1/auth/chatgpt/{provider}/callback` | Manual callback handler for a provider | +| `POST` | `/v1/auth/chatgpt/{provider}/logout` | Revoke ChatGPT OAuth token for a provider | | `GET` | `/v1/auth/openai/status` | Check OpenAI auth status | +| `GET` | `/v1/auth/openai/quota` | Fetch quota state for the default `openai-codex` provider | | `POST` | `/v1/auth/openai/start` | Start OAuth flow | | `POST` | `/v1/auth/openai/callback` | Manual callback handler | | `POST` | `/v1/auth/openai/logout` | Revoke token | +Legacy `/v1/auth/openai/*` routes remain as compatibility aliases for the default `openai-codex` OpenAI Codex OAuth provider. + +### Provider Quota Response + +`GET /v1/auth/chatgpt/{provider}/quota` and `GET /v1/auth/openai/quota` always return a provider-scoped quota envelope. + +Success payload: + +```json +{ + "provider_name": "openai-codex", + "success": true, + "plan_type": "team", + "windows": [ + { + "label": "Primary", + "used_percent": 24, + "remaining_percent": 76, + "reset_after_seconds": 3600, + "reset_at": "2026-03-24T20:15:00Z" + }, + { + "label": "Secondary", + "used_percent": 38, + "remaining_percent": 62, + "reset_after_seconds": 604800, + "reset_at": "2026-03-31T19:15:00Z" + } + ], + "core_usage": { + "five_hour": { + "label": "Primary", + "remaining_percent": 76, + "reset_after_seconds": 3600, + "reset_at": "2026-03-24T20:15:00Z" + }, + "weekly": { + "label": "Secondary", + "remaining_percent": 62, + "reset_after_seconds": 604800, + "reset_at": "2026-03-31T19:15:00Z" + } + }, + "last_updated": "2026-03-24T19:15:00Z" +} +``` + +Failure payload: + +```json +{ + "provider_name": "openai-codex", + "success": false, + "windows": [], + "error": "Quota metadata is missing for this account.", + "error_code": "missing_account_id", + "action_hint": "Sign in again so GoClaw can restore the ChatGPT account workspace metadata.", + "last_updated": "2026-03-24T19:15:00Z" +} +``` + +Notes: +- Invalid provider slugs return `400`. +- Missing provider still returns `404`, and provider type conflicts still return `409`. +- Missing quota metadata, expired workspace access, upstream `402`, upstream `403`, and upstream `429` return `200` with a structured failure payload so the dashboard can render actionable state inline. +- `needs_reauth`, `is_forbidden`, and `retryable` are boolean hints for UI/state-machine handling. +- `error_code` can be `missing_account_id`, `reauth_required`, `payment_required`, `quota_api_forbidden`, `quota_endpoint_not_found`, `rate_limited`, `provider_unavailable`, `network_timeout`, `network_error`, `quota_request_failed`, or `unknown_upstream_error`. +- Failure payloads still include `windows: []` so clients can treat the envelope consistently. +- `core_usage.five_hour` and `core_usage.weekly` are derived from upstream windows. When labels drift, GoClaw falls back to shortest-reset and longest-reset usage windows. + --- ## 26. System diff --git a/docs/20-api-keys-auth.md b/docs/20-api-keys-auth.md index 193d9860..83e6182d 100644 --- a/docs/20-api-keys-auth.md +++ b/docs/20-api-keys-auth.md @@ -97,10 +97,10 @@ The derived role is then used by the `PolicyEngine.CanAccess()` method to gate R GoClaw tries authentication methods in this priority order: -1. **Gateway token** (exact match via constant-time comparison) → `RoleAdmin` +1. **Gateway token** (exact match via constant-time comparison) → `RoleAdmin` or `RoleOwner` for configured owner IDs 2. **API key** (SHA-256 hash lookup in `api_keys` table) → role from scopes 3. **Browser pairing** (sender ID must be paired with "browser" device type) → `RoleOperator` (HTTP only; requires `X-GoClaw-Sender-Id` header) -4. **No auth configured** (backward compatibility: if no gateway token is set) → `RoleOperator` +4. **No auth configured** (backward compatibility: if no gateway token is set) → full-access dev mode 5. **No valid auth found** → `401 Unauthorized` ### HTTP Request Flow @@ -116,7 +116,7 @@ flowchart TD G -->|Yes| H[Derive role from scopes] G -->|No| I{Gateway token configured?} I -->|Yes| J[401 Unauthorized] - I -->|No| K[RoleOperator - backward compat] + I -->|No| K[Full-access backward compat] C -->|Check paired device| L{Device paired?} L -->|Yes| M[RoleOperator] L -->|No| J @@ -151,11 +151,20 @@ On successful API key authentication, `last_used_at` is updated asynchronously ( - **Bearer token**: `Authorization: Bearer ` — checked first for gateway token or API key - **User ID**: `X-GoClaw-User-Id: ` — optional external user identifier (max 255 chars) - **Browser pairing**: `X-GoClaw-Sender-Id: ` — identifies a previously-paired browser device +- **Tenant scope**: `X-GoClaw-Tenant-Id: ` — owner/system-key scope narrowing; non-owner gateway token and browser-pairing callers must already belong to the requested tenant - **Locale**: `Accept-Language` — user's preferred language (en, vi, zh; default: en) +### Tenant Scope Rules + +- **Gateway token + owner user ID**: may narrow to any tenant via `X-GoClaw-Tenant-Id` +- **Gateway token + non-owner user ID**: may only use `X-GoClaw-Tenant-Id` for a tenant where that user already has membership; otherwise auth fails +- **Browser pairing**: same tenant-membership rule as non-owner gateway-token HTTP requests +- **Tenant-bound API key**: always stays bound to its stored `tenant_id`; request headers cannot move it +- **System-level API key** (`tenant_id = NULL`): keeps its scope-derived role (`admin`, `operator`, or `viewer`) and may narrow requests to a tenant via `X-GoClaw-Tenant-Id`, but it does **not** become `owner` + ### Backward Compatibility -If no gateway token is configured (`gateway.token` is empty in `config.json`), all unauthenticated requests are granted `RoleOperator` access. This enables self-hosted deployments without strict authentication. Once a gateway token is configured, all requests must authenticate or use browser pairing. +If no gateway token is configured (`gateway.token` is empty in `config.json`), unauthenticated requests run in backward-compatibility full-access mode. This enables self-hosted deployments without strict authentication. Once a gateway token is configured, all requests must authenticate or use browser pairing. --- @@ -205,7 +214,7 @@ The partial index on `key_hash` ensures only active (non-revoked) keys are searc | `api_keys.create` | Create key | | `api_keys.revoke` | Revoke key | -All API key management operations require admin access (gateway token or API key with `operator.admin` scope). +All API key management operations require admin access (gateway token or API key with `operator.admin` scope). Owner callers are accepted because owner is a superset of admin. ### Create Request diff --git a/docs/23-multi-tenant-architecture.md b/docs/23-multi-tenant-architecture.md index ac801a84..7203043e 100644 --- a/docs/23-multi-tenant-architecture.md +++ b/docs/23-multi-tenant-architecture.md @@ -154,10 +154,10 @@ GoClaw determines the tenant from the credentials used to connect: | Credential | Tenant Resolution | Use Case | |------------|-------------------|----------| | **Gateway token** + owner user ID | All tenants (cross-tenant) | System administration | -| **Gateway token** + non-owner user ID | User's tenant membership | Dashboard users | +| **Gateway token** + non-owner user ID | Master tenant by default, or a membership-validated `X-GoClaw-Tenant-Id` / `tenant_id` hint | Dashboard users | | **API key** (tenant-bound) | Auto from key's `tenant_id` | Normal SaaS integration | -| **API key** (system-level) + `X-GoClaw-Tenant-Id` | Header value (UUID or slug) | Cross-tenant admin tools | -| **Browser pairing** | Paired tenant | Dashboard operators | +| **API key** (system-level) + `X-GoClaw-Tenant-Id` | Header value (UUID or slug), while keeping the key's original role | Cross-tenant tools | +| **Browser pairing** | Master tenant by default, or a membership-validated tenant hint | Dashboard operators | | **No credentials** | Master tenant | Dev/single-user mode | **Owner IDs:** Configured via `GOCLAW_OWNER_IDS` env var (comma-separated). Only owners get cross-tenant access with the gateway token. Default: `system`. diff --git a/internal/agent/extractive_memory.go b/internal/agent/extractive_memory.go index 24aefd42..d247922b 100644 --- a/internal/agent/extractive_memory.go +++ b/internal/agent/extractive_memory.go @@ -2,6 +2,7 @@ package agent import ( "regexp" + "slices" "strings" "github.com/nextlevelbuilder/goclaw/internal/providers" @@ -146,10 +147,8 @@ func dedup(items []string) []string { // appendIfAbsent appends s to slice only if not already present. func appendIfAbsent(slice []string, s string) []string { - for _, existing := range slice { - if existing == s { - return slice - } + if slices.Contains(slice, s) { + return slice } return append(slice, s) } diff --git a/internal/agent/loop.go b/internal/agent/loop.go index 5de90bb1..84ce9481 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -14,8 +14,8 @@ import ( "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/bus" - "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/safego" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -516,11 +516,12 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) var resp *providers.ChatResponse var err error + callCtx := providers.WithChatGPTOAuthRoutingObservation(ctx, providers.NewChatGPTOAuthRoutingObservation()) llmSpanStart := time.Now().UTC() - llmSpanID := l.emitLLMSpanStart(ctx, llmSpanStart, rs.iteration, messages) + llmSpanID := l.emitLLMSpanStart(callCtx, llmSpanStart, rs.iteration, messages) if req.Stream { - resp, err = l.provider.ChatStream(ctx, chatReq, func(chunk providers.StreamChunk) { + resp, err = l.provider.ChatStream(callCtx, chatReq, func(chunk providers.StreamChunk) { if chunk.Thinking != "" { emitRun(AgentEvent{ Type: protocol.ChatEventThinking, @@ -539,15 +540,15 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error) } }) } else { - resp, err = l.provider.Chat(ctx, chatReq) + resp, err = l.provider.Chat(callCtx, chatReq) } if err != nil { - l.emitLLMSpanEnd(ctx, llmSpanID, llmSpanStart, nil, err) + l.emitLLMSpanEnd(callCtx, llmSpanID, llmSpanStart, nil, err) return nil, fmt.Errorf("LLM call failed (iteration %d): %w", rs.iteration, err) } - l.emitLLMSpanEnd(ctx, llmSpanID, llmSpanStart, resp, nil) + l.emitLLMSpanEnd(callCtx, llmSpanID, llmSpanStart, resp, nil) // For non-streaming responses, emit thinking and content as single events if !req.Stream { diff --git a/internal/agent/loop_tracing.go b/internal/agent/loop_tracing.go index 92396ea5..1b466586 100644 --- a/internal/agent/loop_tracing.go +++ b/internal/agent/loop_tracing.go @@ -112,6 +112,7 @@ func (l *Loop) emitLLMSpanEnd(ctx context.Context, spanID uuid.UUID, start time. "duration_ms": int(now.Sub(start).Milliseconds()), "status": store.SpanStatusCompleted, } + var spanMetadata json.RawMessage if callErr != nil { updates["status"] = store.SpanStatusError @@ -133,7 +134,7 @@ func (l *Loop) emitLLMSpanEnd(ctx context.Context, spanID uuid.UUID, start time. meta["thinking_tokens"] = resp.Usage.ThinkingTokens } if b, err := json.Marshal(meta); err == nil { - updates["metadata"] = b + spanMetadata = b } } } @@ -152,6 +153,18 @@ func (l *Loop) emitLLMSpanEnd(ctx context.Context, spanID uuid.UUID, start time. } updates["output_preview"] = tracing.TruncateMid(preview, limit) } + if observation := providers.ChatGPTOAuthRoutingObservationFromContext(ctx); observation != nil { + evidence := observation.Snapshot() + if evidence.HasData() { + spanMetadata = providers.MergeChatGPTOAuthRoutingMetadata(spanMetadata, evidence) + if evidence.ServingProvider != "" { + updates["provider"] = evidence.ServingProvider + } + } + } + if len(spanMetadata) > 0 { + updates["metadata"] = spanMetadata + } collector.EmitSpanUpdate(spanID, traceID, updates) } diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go index 15336173..6a6f4a68 100644 --- a/internal/agent/resolver.go +++ b/internal/agent/resolver.go @@ -13,6 +13,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/config" mcpbridge "github.com/nextlevelbuilder/goclaw/internal/mcp" "github.com/nextlevelbuilder/goclaw/internal/media" + "github.com/nextlevelbuilder/goclaw/internal/providerresolve" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/sandbox" "github.com/nextlevelbuilder/goclaw/internal/skills" @@ -120,7 +121,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { } // Resolve provider (tenant-aware: tries tenant-specific first, falls back to master) - provider, err := deps.ProviderReg.GetForTenant(ag.TenantID, ag.Provider) + provider, err := providerresolve.ResolveConfiguredProvider(deps.ProviderReg, ag) if err != nil { // Fallback to any available provider for this tenant names := deps.ProviderReg.ListForTenant(ag.TenantID) @@ -257,11 +258,11 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc { toolsReg = deps.Tools.Clone() } var mcpOpts []mcpbridge.ManagerOption - mcpOpts = append(mcpOpts, mcpbridge.WithStore(deps.MCPStore)) - if deps.MCPPool != nil { - mcpOpts = append(mcpOpts, mcpbridge.WithPool(deps.MCPPool)) - } - mcpMgr := mcpbridge.NewManager(toolsReg, mcpOpts...) + mcpOpts = append(mcpOpts, mcpbridge.WithStore(deps.MCPStore)) + if deps.MCPPool != nil { + mcpOpts = append(mcpOpts, mcpbridge.WithPool(deps.MCPPool)) + } + mcpMgr := mcpbridge.NewManager(toolsReg, mcpOpts...) if err := mcpMgr.LoadForAgent(ctx, ag.ID, ""); err != nil { slog.Warn("failed to load MCP servers for agent", "agent", agentKey, "error", err) } else if mcpMgr.IsSearchMode() { @@ -436,4 +437,3 @@ func derefInt(p *int) int { } return *p } - diff --git a/internal/channels/instance_loader.go b/internal/channels/instance_loader.go index 51671096..e45579a3 100644 --- a/internal/channels/instance_loader.go +++ b/internal/channels/instance_loader.go @@ -13,6 +13,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" + "github.com/nextlevelbuilder/goclaw/internal/providerresolve" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -27,16 +28,16 @@ type ChannelFactory func(name string, creds json.RawMessage, cfg json.RawMessage // InstanceLoader loads channel instances from the database and registers them with the Manager. // Follows a load-all-at-startup pattern with cache invalidation for reload. type InstanceLoader struct { - store store.ChannelInstanceStore - agentStore store.AgentStore - providerReg *providers.Registry - pendingCompactCfg *config.PendingCompactionConfig - factories map[string]ChannelFactory - manager *Manager - msgBus *bus.MessageBus - pairingSvc store.PairingStore - mu sync.Mutex - loaded map[string]struct{} // channel names managed by this loader + store store.ChannelInstanceStore + agentStore store.AgentStore + providerReg *providers.Registry + pendingCompactCfg *config.PendingCompactionConfig + factories map[string]ChannelFactory + manager *Manager + msgBus *bus.MessageBus + pairingSvc store.PairingStore + mu sync.Mutex + loaded map[string]struct{} // channel names managed by this loader } // NewInstanceLoader creates a new InstanceLoader. @@ -267,7 +268,7 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns } // Fallback: agent's provider/model. if p == nil && ag != nil && ag.Provider != "" { - if ap, err := l.providerReg.Get(tctx, ag.Provider); err == nil { + if ap, err := providerresolve.ResolveConfiguredProvider(l.providerReg, ag); err == nil { p = ap model = ag.Model if model == "" { diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 5d414551..2f2b06d8 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -208,7 +208,7 @@ func (c *Config) applyEnvOverrides() { // Owner IDs from env (comma-separated, whitespace-trimmed) if v := os.Getenv("GOCLAW_OWNER_IDS"); v != "" { var ids []string - for _, id := range strings.Split(v, ",") { + for id := range strings.SplitSeq(v, ",") { if trimmed := strings.TrimSpace(id); trimmed != "" { ids = append(ids, trimmed) } diff --git a/internal/gateway/client.go b/internal/gateway/client.go index 75de73c9..82a1556a 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "log/slog" + "slices" "time" "github.com/google/uuid" @@ -26,14 +27,14 @@ type Client struct { connectedAt time.Time // when the client connected remoteAddr string // peer IP (extracted from proxy headers or RemoteAddr) - locale string // user's preferred locale (e.g. "en", "vi", "zh") + locale string // user's preferred locale (e.g. "en", "vi", "zh") scopes []permissions.Scope // API key scopes (empty = role-based auth, no scope restriction) // Browser pairing state - pairingCode string // 8-char code if pending approval - pairingPending bool // true while waiting for admin approval - pairedSenderID string // senderID used for browser pairing auth (for revocation lookup) - pairedChannel string // channel used for pairing auth (e.g., "browser") + pairingCode string // 8-char code if pending approval + pairingPending bool // true while waiting for admin approval + pairedSenderID string // senderID used for browser pairing auth (for revocation lookup) + pairedChannel string // channel used for pairing auth (e.g., "browser") // Team access cache for event filtering (lazily populated). teamIDs map[string]bool @@ -220,12 +221,7 @@ func (c *Client) IsOwner() bool { return c.role == permissions.RoleOwner } // HasScope reports whether the client has the given scope. func (c *Client) HasScope(scope permissions.Scope) bool { - for _, s := range c.scopes { - if s == scope { - return true - } - } - return false + return slices.Contains(c.scopes, scope) } // hasTeamAccess checks if the client has access to a team (for event filtering). diff --git a/internal/gateway/methods/api_keys.go b/internal/gateway/methods/api_keys.go index beec1b63..a3bf1f29 100644 --- a/internal/gateway/methods/api_keys.go +++ b/internal/gateway/methods/api_keys.go @@ -37,7 +37,7 @@ func (m *APIKeysMethods) handleList(ctx context.Context, client *gateway.Client, locale := store.LocaleFromContext(ctx) // Non-admin callers only see their own keys. ownerID := "" - if client.Role() != permissions.RoleAdmin { + if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) { ownerID = client.UserID() } keys, err := m.apiKeys.List(ctx, ownerID) @@ -88,7 +88,7 @@ func (m *APIKeysMethods) handleCreate(ctx context.Context, client *gateway.Clien // Non-admin callers always bind the key to their own user_id. ownerID := params.OwnerID - if client.Role() != permissions.RoleAdmin { + if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) { ownerID = client.UserID() } @@ -184,7 +184,7 @@ func (m *APIKeysMethods) handleRevoke(ctx context.Context, client *gateway.Clien // Non-admin callers can only revoke their own keys. ownerID := "" - if client.Role() != permissions.RoleAdmin { + if !permissions.HasMinRole(client.Role(), permissions.RoleAdmin) { ownerID = client.UserID() } diff --git a/internal/gateway/router.go b/internal/gateway/router.go index 4c7f8748..4b51c59c 100644 --- a/internal/gateway/router.go +++ b/internal/gateway/router.go @@ -5,6 +5,7 @@ import ( "crypto/subtle" "encoding/json" "log/slog" + "slices" "time" "github.com/google/uuid" @@ -23,8 +24,8 @@ type MethodHandler func(ctx context.Context, client *Client, req *protocol.Reque type MethodRouter struct { handlers map[string]MethodHandler server *Server - tenantStore store.TenantStore // optional, for enriching connect response - permCache *cache.PermissionCache // optional, for caching tenant membership checks + tenantStore store.TenantStore // optional, for enriching connect response + permCache *cache.PermissionCache // optional, for caching tenant membership checks } func NewMethodRouter(server *Server) *MethodRouter { @@ -108,9 +109,9 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p UserID string `json:"user_id"` SenderID string `json:"sender_id"` // browser pairing: stored sender ID for reconnect Locale string `json:"locale"` // user's preferred locale (en, vi, zh) - TenantHint string `json:"tenant_hint"` // optional tenant slug for browser pairing multi-tenant - TenantID string `json:"tenant_id"` // cross-tenant admin: narrow scope to specific tenant (UUID or slug) - TenantScope string `json:"tenant_scope"` // deprecated: alias for tenant_id (backward compat) + TenantHint string `json:"tenant_hint"` // optional tenant slug for browser pairing multi-tenant + TenantID string `json:"tenant_id"` // cross-tenant admin: narrow scope to specific tenant (UUID or slug) + TenantScope string `json:"tenant_scope"` // deprecated: alias for tenant_id (backward compat) } if req.Params != nil { json.Unmarshal(req.Params, ¶ms) @@ -180,8 +181,8 @@ func (r *MethodRouter) handleConnect(ctx context.Context, client *Client, req *p client.userID = params.UserID } if keyData.TenantID == uuid.Nil { - // API key with no tenant → owner scope - client.role = permissions.RoleOwner + // System-level API keys keep their scope-derived role and may + // optionally narrow to a tenant without gaining owner privileges. apiKeyScope := params.TenantID if apiKeyScope == "" { apiKeyScope = params.TenantScope // backward compat @@ -323,12 +324,7 @@ func isOwnerID(userID string, ownerIDs []string) bool { if len(ownerIDs) == 0 { return userID == "system" } - for _, id := range ownerIDs { - if id == userID { - return true - } - } - return false + return slices.Contains(ownerIDs, userID) } // resolveTenantHint resolves a tenant slug/UUID hint to a UUID with membership validation. diff --git a/internal/http/agents.go b/internal/http/agents.go index 65ff17e8..9464c430 100644 --- a/internal/http/agents.go +++ b/internal/http/agents.go @@ -1,6 +1,7 @@ package http import ( + "database/sql" "encoding/json" "fmt" "log/slog" @@ -13,6 +14,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) @@ -20,6 +22,9 @@ import ( // AgentsHandler handles agent CRUD and sharing endpoints. type AgentsHandler struct { agents store.AgentStore + providers store.ProviderStore + providerReg *providers.Registry + db *sql.DB defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace") msgBus *bus.MessageBus // for cache invalidation events (nil = no events) summoner *AgentSummoner // LLM-based agent setup (nil = disabled) @@ -28,8 +33,17 @@ type AgentsHandler struct { // NewAgentsHandler creates a handler for agent management endpoints. // isOwner is a function that checks if a user ID is in GOCLAW_OWNER_IDS (nil = disabled). -func NewAgentsHandler(agents store.AgentStore, defaultWorkspace string, msgBus *bus.MessageBus, summoner *AgentSummoner, isOwner func(string) bool) *AgentsHandler { - return &AgentsHandler{agents: agents, defaultWorkspace: defaultWorkspace, msgBus: msgBus, summoner: summoner, isOwner: isOwner} +func NewAgentsHandler(agents store.AgentStore, providers store.ProviderStore, providerReg *providers.Registry, db *sql.DB, defaultWorkspace string, msgBus *bus.MessageBus, summoner *AgentSummoner, isOwner func(string) bool) *AgentsHandler { + return &AgentsHandler{ + agents: agents, + providers: providers, + providerReg: providerReg, + db: db, + defaultWorkspace: defaultWorkspace, + msgBus: msgBus, + summoner: summoner, + isOwner: isOwner, + } } // isOwnerUser checks if the given user ID is a system owner. @@ -60,6 +74,7 @@ func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("DELETE /v1/agents/{id}/shares/{userID}", h.authMiddleware(h.handleRevokeShare)) mux.HandleFunc("POST /v1/agents/{id}/regenerate", h.authMiddleware(h.handleRegenerate)) mux.HandleFunc("POST /v1/agents/{id}/resummon", h.authMiddleware(h.handleResummon)) + mux.HandleFunc("GET /v1/agents/{id}/codex-pool-activity", h.authMiddleware(h.handleCodexPoolActivity)) mux.HandleFunc("GET /v1/agents/{id}/instances", h.authMiddleware(h.handleListInstances)) mux.HandleFunc("GET /v1/agents/{id}/instances/{userID}/files", h.authMiddleware(h.handleGetInstanceFiles)) mux.HandleFunc("PUT /v1/agents/{id}/instances/{userID}/files/{fileName}", h.authMiddleware(h.handleSetInstanceFile)) @@ -159,6 +174,16 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) { req.Status = store.AgentStatusActive } + if err := validateChatGPTOAuthAgentRouting( + r.Context(), + h.providers, + req.Provider, + req.ParseChatGPTOAuthRouting(), + ); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := h.agents.Create(r.Context(), &req); err != nil { if strings.Contains(err.Error(), "duplicate key") || strings.Contains(err.Error(), "23505") { writeJSON(w, http.StatusConflict, map[string]string{"error": i18n.T(locale, i18n.MsgAlreadyExists, "agent", req.AgentKey)}) @@ -253,6 +278,31 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) { allowed := filterAllowedKeys(updates, agentAllowedFields) allowed["restrict_to_workspace"] = true + validationProvider := ag.Provider + if providerName, ok := allowed["provider"].(string); ok && providerName != "" { + validationProvider = providerName + } + validationAgent := *ag + validationAgent.Provider = validationProvider + if otherConfig, ok := allowed["other_config"]; ok { + rawOtherConfig, err := marshalJSONRaw(otherConfig) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + validationAgent.OtherConfig = rawOtherConfig + } + + if err := validateChatGPTOAuthAgentRouting( + r.Context(), + h.providers, + validationAgent.Provider, + validationAgent.ParseChatGPTOAuthRouting(), + ); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := h.agents.Update(r.Context(), id, allowed); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return diff --git a/internal/http/agents_codex_pool.go b/internal/http/agents_codex_pool.go new file mode 100644 index 00000000..73df7a37 --- /dev/null +++ b/internal/http/agents_codex_pool.go @@ -0,0 +1,327 @@ +package http + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "slices" + "strconv" + "time" + + "github.com/google/uuid" + "github.com/lib/pq" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type codexPoolProviderCount struct { + ProviderName string `json:"provider_name"` + RequestCount int `json:"request_count"` + DirectSelectionCount int `json:"direct_selection_count"` + FailoverServeCount int `json:"failover_serve_count"` + SuccessCount int `json:"success_count"` + FailureCount int `json:"failure_count"` + ConsecutiveFailures int `json:"consecutive_failures"` + SuccessRate int `json:"success_rate"` + HealthScore int `json:"health_score"` + HealthState string `json:"health_state"` + LastSelectedAt *time.Time `json:"last_selected_at,omitempty"` + LastFailoverAt *time.Time `json:"last_failover_at,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + LastSuccessAt *time.Time `json:"last_success_at,omitempty"` + LastFailureAt *time.Time `json:"last_failure_at,omitempty"` +} + +type codexPoolRecentRequest struct { + SpanID uuid.UUID `json:"span_id"` + TraceID uuid.UUID `json:"trace_id"` + StartedAt time.Time `json:"started_at"` + Status string `json:"status"` + DurationMS int `json:"duration_ms"` + ProviderName string `json:"provider_name"` + SelectedProvider string `json:"selected_provider,omitempty"` + Model string `json:"model"` + AttemptCount int `json:"attempt_count"` + UsedFailover bool `json:"used_failover"` + FailoverProviders []string `json:"failover_providers,omitempty"` +} + +const runtimeNonCodexProviderType = "runtime_non_codex" + +func lookupProviderByNameWithMasterFallback( + ctx context.Context, + providerStore store.ProviderStore, + tenantID uuid.UUID, + name string, +) (*store.LLMProviderData, error) { + if providerStore == nil || name == "" { + return nil, errors.New("provider store unavailable") + } + + tenantIDs := []uuid.UUID{tenantID} + if tenantID != store.MasterTenantID { + tenantIDs = append(tenantIDs, store.MasterTenantID) + } + + var lastErr error + for _, scopedTenantID := range tenantIDs { + providerCtx := store.WithTenantID(ctx, scopedTenantID) + providerData, err := providerStore.GetProviderByName(providerCtx, name) + if err == nil { + return providerData, nil + } + lastErr = err + } + if lastErr == nil { + lastErr = errors.New("provider not found") + } + return nil, lastErr +} + +func registeredCodexPoolProviders( + providerReg *providers.Registry, + tenantID uuid.UUID, + names []string, +) []string { + if providerReg == nil || len(names) == 0 { + return nil + } + + poolProviders := make([]string, 0, len(names)) + for _, name := range names { + if name == "" || slices.Contains(poolProviders, name) { + continue + } + provider, err := providerReg.GetForTenant(tenantID, name) + if err != nil { + continue + } + if _, ok := provider.(*providers.CodexProvider); !ok { + continue + } + poolProviders = append(poolProviders, name) + } + return poolProviders +} + +func resolveCodexPoolRouting( + ctx context.Context, + providerStore store.ProviderStore, + providerReg *providers.Registry, + agent *store.AgentData, +) (string, *store.ChatGPTOAuthRoutingConfig, []string) { + if agent == nil { + return "", nil, nil + } + + agentRouting := agent.ParseChatGPTOAuthRouting() + + baseProviderType := "" + var defaults *store.ChatGPTOAuthRoutingConfig + + if providerData, err := lookupProviderByNameWithMasterFallback(ctx, providerStore, agent.TenantID, agent.Provider); err == nil { + baseProviderType = providerData.ProviderType + if providerData.ProviderType != store.ProviderChatGPTOAuth { + return providerData.ProviderType, nil, nil + } + if settings := store.ParseChatGPTOAuthProviderSettings(providerData.Settings); settings != nil { + defaults = settings.CodexPool + } + } + + if providerReg != nil && agent.Provider != "" { + runtimeProvider, err := providerReg.GetForTenant(agent.TenantID, agent.Provider) + if err == nil { + codex, ok := runtimeProvider.(*providers.CodexProvider) + if !ok { + if baseProviderType == "" { + baseProviderType = runtimeNonCodexProviderType + } + return baseProviderType, nil, nil + } + baseProviderType = store.ProviderChatGPTOAuth + defaults = nil + if runtimeDefaults := codex.RoutingDefaults(); runtimeDefaults != nil { + defaults = &store.ChatGPTOAuthRoutingConfig{ + Strategy: runtimeDefaults.Strategy, + ExtraProviderNames: runtimeDefaults.ExtraProviderNames, + } + } + } + } + + routing := store.ResolveEffectiveChatGPTOAuthRouting(defaults, agentRouting) + poolCandidates := make([]string, 0, 1+len(agentRoutingExtraNames(routing))) + if agent.Provider != "" && (baseProviderType == store.ProviderChatGPTOAuth || (baseProviderType == "" && routing != nil)) { + poolCandidates = append(poolCandidates, agent.Provider) + } + if routing != nil { + for _, name := range routing.ExtraProviderNames { + if name != "" && !slices.Contains(poolCandidates, name) { + poolCandidates = append(poolCandidates, name) + } + } + } + if providerReg != nil { + return baseProviderType, routing, registeredCodexPoolProviders(providerReg, agent.TenantID, poolCandidates) + } + if baseProviderType != store.ProviderChatGPTOAuth { + return baseProviderType, routing, nil + } + return baseProviderType, routing, poolCandidates +} + +func agentRoutingExtraNames(routing *store.ChatGPTOAuthRoutingConfig) []string { + if routing == nil { + return nil + } + return routing.ExtraProviderNames +} + +func (h *AgentsHandler) handleCodexPoolActivity(w http.ResponseWriter, r *http.Request) { + locale := store.LocaleFromContext(r.Context()) + if h.db == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, "database unavailable")}) + return + } + + agent, statusCode, err := h.lookupAccessibleAgent(r) + if err != nil { + writeJSON(w, statusCode, map[string]string{"error": err.Error()}) + return + } + + limit := 18 + if raw := r.URL.Query().Get("limit"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 50 { + limit = parsed + } + } + statsLimit := maxInt(limit, codexPoolRuntimeHealthSampleSize) + + baseProviderType, routing, poolProviders := resolveCodexPoolRouting(r.Context(), h.providers, h.providerReg, agent) + strategy := store.ChatGPTOAuthStrategyPrimaryFirst + if routing != nil && routing.Strategy != "" { + strategy = routing.Strategy + } + + if baseProviderType != "" && baseProviderType != store.ProviderChatGPTOAuth { + poolProviders = nil + } + if len(poolProviders) == 0 { + writeJSON(w, http.StatusOK, map[string]any{ + "strategy": strategy, + "pool_providers": []string{}, + "stats_sample_size": 0, + "provider_counts": []codexPoolProviderCount{}, + "recent_requests": []codexPoolRecentRequest{}, + }) + return + } + + const query = ` +SELECT + sp.id, + sp.trace_id, + sp.start_time, + COALESCE(sp.duration_ms, 0), + sp.status, + COALESCE(sp.provider, ''), + COALESCE(sp.model, ''), + COALESCE(sp.metadata, '{}'::jsonb) +FROM spans sp +JOIN traces t ON t.id = sp.trace_id +WHERE t.agent_id = $1 + AND t.tenant_id = $2 + AND t.parent_trace_id IS NULL + AND sp.tenant_id = $2 + AND sp.span_type = 'llm_call' + AND ( + sp.provider = ANY($3) + OR COALESCE(sp.metadata->'chatgpt_oauth_routing'->>'selected_provider', '') = ANY($3) + OR COALESCE(sp.metadata->'chatgpt_oauth_routing'->>'serving_provider', '') = ANY($3) + ) +ORDER BY sp.start_time DESC +LIMIT $4` + + rows, err := h.db.QueryContext(r.Context(), query, agent.ID, agent.TenantID, pq.Array(poolProviders), statsLimit) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + defer rows.Close() + + spans := make([]codexPoolSpanActivity, 0, statsLimit) + for rows.Next() { + var item codexPoolSpanActivity + var metadata json.RawMessage + if err := rows.Scan( + &item.SpanID, + &item.TraceID, + &item.StartedAt, + &item.DurationMS, + &item.Status, + &item.Provider, + &item.Model, + &metadata, + ); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + item.Metadata = metadata + if evidence := providers.ExtractChatGPTOAuthRoutingEvidence(metadata); evidence.HasData() { + if !providerInPool(poolProviders, evidence.SelectedProvider) && !providerInPool(poolProviders, evidence.ServingProvider) { + continue + } + } else if !providerInPool(poolProviders, item.Provider) { + continue + } + spans = append(spans, item) + } + if err := rows.Err(); err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + + providerCounts, recent := buildCodexPoolActivity(poolProviders, spans) + if len(recent) > limit { + recent = recent[:limit] + } + + writeJSON(w, http.StatusOK, map[string]any{ + "strategy": strategy, + "pool_providers": poolProviders, + "stats_sample_size": len(spans), + "provider_counts": providerCounts, + "recent_requests": recent, + }) +} + +func (h *AgentsHandler) lookupAccessibleAgent(r *http.Request) (*store.AgentData, int, error) { + userID := store.UserIDFromContext(r.Context()) + locale := store.LocaleFromContext(r.Context()) + isOwner := h.isOwnerUser(userID) + rawID := r.PathValue("id") + + var ( + agent *store.AgentData + err error + ) + if parsedID, parseErr := uuid.Parse(rawID); parseErr == nil { + agent, err = h.agents.GetByID(r.Context(), parsedID) + } else { + agent, err = h.agents.GetByKey(r.Context(), rawID) + } + if err != nil { + return nil, http.StatusNotFound, errors.New(i18n.T(locale, i18n.MsgNotFound, "agent", rawID)) + } + if userID != "" && !isOwner { + if ok, _, _ := h.agents.CanAccess(r.Context(), agent.ID, userID); !ok { + return nil, http.StatusForbidden, errors.New(i18n.T(locale, i18n.MsgNoAccess, "agent")) + } + } + return agent, http.StatusOK, nil +} diff --git a/internal/http/agents_codex_pool_activity.go b/internal/http/agents_codex_pool_activity.go new file mode 100644 index 00000000..aca3d47d --- /dev/null +++ b/internal/http/agents_codex_pool_activity.go @@ -0,0 +1,240 @@ +package http + +import ( + "encoding/json" + "math" + "slices" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +type codexPoolSpanActivity struct { + SpanID uuid.UUID + TraceID uuid.UUID + StartedAt time.Time + DurationMS int + Status string + Provider string + Model string + Metadata json.RawMessage +} + +const codexPoolRuntimeHealthSampleSize = 120 + +func buildCodexPoolActivity(poolProviders []string, spans []codexPoolSpanActivity) ([]codexPoolProviderCount, []codexPoolRecentRequest) { + sortedSpans := append([]codexPoolSpanActivity(nil), spans...) + slices.SortFunc(sortedSpans, func(a, b codexPoolSpanActivity) int { + return b.StartedAt.Compare(a.StartedAt) + }) + + countsByProvider := make(map[string]*codexPoolProviderCount, len(poolProviders)) + outcomesByProvider := make(map[string][]bool, len(poolProviders)) + for _, name := range poolProviders { + countsByProvider[name] = &codexPoolProviderCount{ProviderName: name} + } + + recent := make([]codexPoolRecentRequest, 0, len(sortedSpans)) + for _, span := range sortedSpans { + evidence := providers.ExtractChatGPTOAuthRoutingEvidence(span.Metadata) + selectedProvider := firstNonEmpty(span.Provider, evidence.SelectedProvider) + if evidence.SelectedProvider != "" { + selectedProvider = evidence.SelectedProvider + } + servingProvider := firstNonEmpty(evidence.ServingProvider) + if span.Status == "completed" { + servingProvider = firstNonEmpty(evidence.ServingProvider, span.Provider, selectedProvider) + } + failoverProviders := append([]string(nil), evidence.FailoverProviders...) + usedFailover := len(failoverProviders) > 0 || (selectedProvider != "" && servingProvider != "" && servingProvider != selectedProvider) + attemptedProviders := poolAttemptedProviders(poolProviders, evidence, selectedProvider, servingProvider) + + if stat := countsByProvider[selectedProvider]; stat != nil { + stat.RequestCount++ + stat.DirectSelectionCount++ + updateLatestTime(&stat.LastSelectedAt, span.StartedAt) + updateLatestTime(&stat.LastUsedAt, span.StartedAt) + } + if usedFailover && servingProvider != "" && servingProvider != selectedProvider { + if stat := countsByProvider[servingProvider]; stat != nil { + stat.FailoverServeCount++ + updateLatestTime(&stat.LastFailoverAt, span.StartedAt) + updateLatestTime(&stat.LastUsedAt, span.StartedAt) + } + } + recordCodexPoolOutcomes( + countsByProvider, + outcomesByProvider, + attemptedProviders, + servingProvider, + span.Status, + span.StartedAt, + ) + + recent = append(recent, codexPoolRecentRequest{ + SpanID: span.SpanID, + TraceID: span.TraceID, + StartedAt: span.StartedAt, + Status: span.Status, + DurationMS: span.DurationMS, + ProviderName: servingProvider, + SelectedProvider: selectedProvider, + Model: span.Model, + AttemptCount: maxInt(1, evidence.AttemptCount), + UsedFailover: usedFailover, + FailoverProviders: failoverProviders, + }) + } + + providerCounts := make([]codexPoolProviderCount, 0, len(poolProviders)) + for _, name := range poolProviders { + if stat := countsByProvider[name]; stat != nil { + finalizeCodexPoolProviderHealth(stat, outcomesByProvider[name]) + providerCounts = append(providerCounts, *stat) + } + } + return providerCounts, recent +} + +func poolAttemptedProviders( + poolProviders []string, + evidence providers.ChatGPTOAuthRoutingEvidence, + selectedProvider string, + servingProvider string, +) []string { + attempted := make([]string, 0, len(evidence.AttemptedProviders)+2) + for _, providerName := range evidence.AttemptedProviders { + if providerInPool(poolProviders, providerName) && !slices.Contains(attempted, providerName) { + attempted = append(attempted, providerName) + } + } + if providerInPool(poolProviders, selectedProvider) && !slices.Contains(attempted, selectedProvider) { + attempted = append(attempted, selectedProvider) + } + if providerInPool(poolProviders, servingProvider) && !slices.Contains(attempted, servingProvider) { + attempted = append(attempted, servingProvider) + } + return attempted +} + +func recordCodexPoolOutcomes( + countsByProvider map[string]*codexPoolProviderCount, + outcomesByProvider map[string][]bool, + attemptedProviders []string, + servingProvider string, + status string, + startedAt time.Time, +) { + switch status { + case "completed": + if stat := countsByProvider[servingProvider]; stat != nil { + stat.SuccessCount++ + updateLatestTime(&stat.LastSuccessAt, startedAt) + updateLatestTime(&stat.LastUsedAt, startedAt) + outcomesByProvider[servingProvider] = append(outcomesByProvider[servingProvider], true) + } + for _, providerName := range attemptedProviders { + if providerName == "" || providerName == servingProvider { + continue + } + if stat := countsByProvider[providerName]; stat != nil { + stat.FailureCount++ + updateLatestTime(&stat.LastFailureAt, startedAt) + updateLatestTime(&stat.LastUsedAt, startedAt) + outcomesByProvider[providerName] = append(outcomesByProvider[providerName], false) + } + } + case "error": + for _, providerName := range attemptedProviders { + if stat := countsByProvider[providerName]; stat != nil { + stat.FailureCount++ + updateLatestTime(&stat.LastFailureAt, startedAt) + updateLatestTime(&stat.LastUsedAt, startedAt) + outcomesByProvider[providerName] = append(outcomesByProvider[providerName], false) + } + } + } +} + +func finalizeCodexPoolProviderHealth(stat *codexPoolProviderCount, outcomes []bool) { + if stat == nil { + return + } + total := stat.SuccessCount + stat.FailureCount + if total > 0 { + stat.SuccessRate = int(math.Round(float64(stat.SuccessCount) * 100 / float64(total))) + } + for _, outcome := range outcomes { + if outcome { + break + } + stat.ConsecutiveFailures++ + } + if total == 0 { + stat.HealthScore = 0 + stat.HealthState = "idle" + return + } + score := stat.SuccessRate - minInt(45, stat.ConsecutiveFailures*15) + if stat.LastSuccessAt == nil && stat.FailureCount > 0 { + score -= 10 + } + stat.HealthScore = clampInt(score, 0, 100) + switch { + case stat.ConsecutiveFailures >= 3 || stat.HealthScore < 40: + stat.HealthState = "critical" + case stat.HealthScore < 80: + stat.HealthState = "degraded" + default: + stat.HealthState = "healthy" + } +} + +func updateLatestTime(target **time.Time, value time.Time) { + if target == nil { + return + } + if *target == nil || value.After(**target) { + seenAt := value + *target = &seenAt + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} + +func clampInt(value, minValue, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} + +func providerInPool(poolProviders []string, providerName string) bool { + return providerName != "" && slices.Contains(poolProviders, providerName) +} diff --git a/internal/http/agents_codex_pool_activity_test.go b/internal/http/agents_codex_pool_activity_test.go new file mode 100644 index 00000000..f7af36b5 --- /dev/null +++ b/internal/http/agents_codex_pool_activity_test.go @@ -0,0 +1,145 @@ +package http + +import ( + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +func TestBuildCodexPoolActivitySeparatesDirectSelectionAndFailover(t *testing.T) { + now := time.Date(2026, 3, 24, 20, 0, 0, 0, time.UTC) + directA := providers.MergeChatGPTOAuthRoutingMetadata(nil, providers.ChatGPTOAuthRoutingEvidence{ + Strategy: "round_robin", + PoolProviders: []string{"pool-a", "pool-b", "pool-c"}, + SelectedProvider: "pool-a", + ServingProvider: "pool-a", + AttemptCount: 1, + }) + directB := providers.MergeChatGPTOAuthRoutingMetadata(nil, providers.ChatGPTOAuthRoutingEvidence{ + Strategy: "round_robin", + PoolProviders: []string{"pool-a", "pool-b", "pool-c"}, + SelectedProvider: "pool-b", + ServingProvider: "pool-b", + AttemptCount: 1, + }) + failoverToB := providers.MergeChatGPTOAuthRoutingMetadata(nil, providers.ChatGPTOAuthRoutingEvidence{ + Strategy: "round_robin", + PoolProviders: []string{"pool-a", "pool-b", "pool-c"}, + SelectedProvider: "pool-a", + ServingProvider: "pool-b", + AttemptedProviders: []string{"pool-a", "pool-b"}, + FailoverProviders: []string{"pool-b"}, + AttemptCount: 2, + }) + + providerCounts, recent := buildCodexPoolActivity([]string{"pool-a", "pool-b", "pool-c"}, []codexPoolSpanActivity{ + { + SpanID: uuid.New(), + TraceID: uuid.New(), + StartedAt: now, + DurationMS: 450, + Status: "completed", + Provider: "pool-a", + Model: "gpt-5.4", + Metadata: directA, + }, + { + SpanID: uuid.New(), + TraceID: uuid.New(), + StartedAt: now.Add(1 * time.Minute), + DurationMS: 500, + Status: "completed", + Provider: "pool-b", + Model: "gpt-5.4", + Metadata: directB, + }, + { + SpanID: uuid.New(), + TraceID: uuid.New(), + StartedAt: now.Add(2 * time.Minute), + DurationMS: 620, + Status: "completed", + Provider: "pool-b", + Model: "gpt-5.4", + Metadata: failoverToB, + }, + }) + + if len(providerCounts) != 3 { + t.Fatalf("len(providerCounts) = %d, want 3", len(providerCounts)) + } + if providerCounts[0].ProviderName != "pool-a" || providerCounts[0].DirectSelectionCount != 2 || providerCounts[0].FailoverServeCount != 0 { + t.Fatalf("pool-a counts = %#v", providerCounts[0]) + } + if providerCounts[0].SuccessCount != 1 || providerCounts[0].FailureCount != 1 || providerCounts[0].ConsecutiveFailures != 1 { + t.Fatalf("pool-a runtime = %#v", providerCounts[0]) + } + if providerCounts[1].ProviderName != "pool-b" || providerCounts[1].DirectSelectionCount != 1 || providerCounts[1].FailoverServeCount != 1 { + t.Fatalf("pool-b counts = %#v", providerCounts[1]) + } + if providerCounts[1].SuccessCount != 2 || providerCounts[1].FailureCount != 0 || providerCounts[1].HealthState != "healthy" { + t.Fatalf("pool-b runtime = %#v", providerCounts[1]) + } + if providerCounts[2].ProviderName != "pool-c" || providerCounts[2].DirectSelectionCount != 0 || providerCounts[2].FailoverServeCount != 0 { + t.Fatalf("pool-c counts = %#v", providerCounts[2]) + } + if providerCounts[2].HealthState != "idle" || providerCounts[2].HealthScore != 0 { + t.Fatalf("pool-c health = %#v", providerCounts[2]) + } + if len(recent) != 3 { + t.Fatalf("len(recent) = %d, want 3", len(recent)) + } + last := recent[0] + if !last.UsedFailover { + t.Fatal("latest.UsedFailover = false, want true") + } + if last.SelectedProvider != "pool-a" || last.ProviderName != "pool-b" { + t.Fatalf("latest routing = selected %q provider %q", last.SelectedProvider, last.ProviderName) + } + if last.AttemptCount != 2 { + t.Fatalf("latest.AttemptCount = %d, want 2", last.AttemptCount) + } +} + +func TestBuildCodexPoolActivityTracksTerminalFailuresAcrossAttempts(t *testing.T) { + now := time.Date(2026, 3, 24, 22, 0, 0, 0, time.UTC) + failedAttempt := providers.MergeChatGPTOAuthRoutingMetadata(nil, providers.ChatGPTOAuthRoutingEvidence{ + Strategy: "round_robin", + PoolProviders: []string{"pool-a", "pool-b"}, + SelectedProvider: "pool-a", + AttemptedProviders: []string{"pool-a", "pool-b"}, + AttemptCount: 2, + }) + + providerCounts, recent := buildCodexPoolActivity([]string{"pool-a", "pool-b"}, []codexPoolSpanActivity{ + { + SpanID: uuid.New(), + TraceID: uuid.New(), + StartedAt: now, + DurationMS: 900, + Status: "error", + Provider: "pool-a", + Model: "gpt-5.4", + Metadata: failedAttempt, + }, + }) + + if len(recent) != 1 || recent[0].Status != "error" { + t.Fatalf("recent = %#v", recent) + } + if recent[0].ProviderName != "" { + t.Fatalf("recent[0].ProviderName = %q, want empty", recent[0].ProviderName) + } + if providerCounts[0].FailureCount != 1 || providerCounts[0].ConsecutiveFailures != 1 { + t.Fatalf("pool-a failure stats = %#v", providerCounts[0]) + } + if providerCounts[1].FailureCount != 1 || providerCounts[1].ConsecutiveFailures != 1 { + t.Fatalf("pool-b failure stats = %#v", providerCounts[1]) + } + if providerCounts[0].HealthState != "critical" || providerCounts[1].HealthState != "critical" { + t.Fatalf("health states = %#v %#v", providerCounts[0], providerCounts[1]) + } +} diff --git a/internal/http/agents_codex_pool_test.go b/internal/http/agents_codex_pool_test.go new file mode 100644 index 00000000..7f2e6467 --- /dev/null +++ b/internal/http/agents_codex_pool_test.go @@ -0,0 +1,184 @@ +package http + +import ( + "context" + "encoding/json" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type testTokenSource struct { + token string +} + +func (s *testTokenSource) Token() (string, error) { + return s.token, nil +} + +func (s *testTokenSource) RouteEligibility(context.Context) providers.RouteEligibility { + return providers.RouteEligibility{Class: providers.RouteEligibilityHealthy} +} + +func TestResolveCodexPoolRoutingUsesProviderDefaults(t *testing.T) { + providers := newMockProviderStore() + tenantID := uuid.New() + if err := providers.CreateProvider(context.Background(), &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "openai-codex", + ProviderType: store.ProviderChatGPTOAuth, + Enabled: true, + Settings: json.RawMessage(`{ + "codex_pool": { + "strategy": "round_robin", + "extra_provider_names": ["codex-work"] + } + }`), + }); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + agent := &store.AgentData{ + TenantID: tenantID, + Provider: "openai-codex", + } + + providerType, routing, poolProviders := resolveCodexPoolRouting(context.Background(), providers, nil, agent) + if providerType != store.ProviderChatGPTOAuth { + t.Fatalf("providerType = %q, want %q", providerType, store.ProviderChatGPTOAuth) + } + if routing == nil { + t.Fatal("routing = nil, want effective routing") + } + if routing.Strategy != store.ChatGPTOAuthStrategyRoundRobin { + t.Fatalf("Strategy = %q, want %q", routing.Strategy, store.ChatGPTOAuthStrategyRoundRobin) + } + if len(routing.ExtraProviderNames) != 1 || routing.ExtraProviderNames[0] != "codex-work" { + t.Fatalf("ExtraProviderNames = %#v, want [\"codex-work\"]", routing.ExtraProviderNames) + } + if len(poolProviders) != 2 || poolProviders[0] != "openai-codex" || poolProviders[1] != "codex-work" { + t.Fatalf("poolProviders = %#v, want primary + extra", poolProviders) + } +} + +func TestResolveCodexPoolRoutingHonorsInheritOverride(t *testing.T) { + providers := newMockProviderStore() + tenantID := uuid.New() + if err := providers.CreateProvider(context.Background(), &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "openai-codex", + ProviderType: store.ProviderChatGPTOAuth, + Enabled: true, + Settings: json.RawMessage(`{ + "codex_pool": { + "strategy": "priority_order", + "extra_provider_names": ["codex-team"] + } + }`), + }); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + agent := &store.AgentData{ + TenantID: tenantID, + Provider: "openai-codex", + OtherConfig: json.RawMessage(`{ + "chatgpt_oauth_routing": { + "override_mode": "inherit", + "strategy": "round_robin", + "extra_provider_names": ["ignored-backup"] + } + }`), + } + + _, routing, poolProviders := resolveCodexPoolRouting(context.Background(), providers, nil, agent) + if routing == nil { + t.Fatal("routing = nil, want inherited routing") + } + if routing.Strategy != store.ChatGPTOAuthStrategyPriority { + t.Fatalf("Strategy = %q, want %q", routing.Strategy, store.ChatGPTOAuthStrategyPriority) + } + if len(routing.ExtraProviderNames) != 1 || routing.ExtraProviderNames[0] != "codex-team" { + t.Fatalf("ExtraProviderNames = %#v, want [\"codex-team\"]", routing.ExtraProviderNames) + } + if len(poolProviders) != 2 || poolProviders[1] != "codex-team" { + t.Fatalf("poolProviders = %#v, want inherited pool order", poolProviders) + } +} + +func TestResolveCodexPoolRoutingIgnoresNonCodexBaseProvider(t *testing.T) { + providers := newMockProviderStore() + tenantID := uuid.New() + if err := providers.CreateProvider(context.Background(), &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "anthropic", + ProviderType: store.ProviderAnthropicNative, + Enabled: true, + }); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + agent := &store.AgentData{ + TenantID: tenantID, + Provider: "anthropic", + OtherConfig: json.RawMessage(`{ + "chatgpt_oauth_routing": { + "strategy": "round_robin", + "extra_provider_names": ["codex-backup"] + } + }`), + } + + providerType, routing, poolProviders := resolveCodexPoolRouting(context.Background(), providers, nil, agent) + if providerType != store.ProviderAnthropicNative { + t.Fatalf("providerType = %q, want %q", providerType, store.ProviderAnthropicNative) + } + if routing != nil { + t.Fatalf("routing = %#v, want nil for non-Codex provider", routing) + } + if len(poolProviders) != 0 { + t.Fatalf("poolProviders = %#v, want empty for non-Codex provider", poolProviders) + } +} + +func TestResolveCodexPoolRoutingUsesRegistryMasterFallback(t *testing.T) { + tenantID := uuid.New() + registry := providers.NewRegistry(nil) + registry.RegisterForTenant(providers.MasterTenantID, providers.NewCodexProvider( + "openai-codex", + &testTokenSource{token: "primary-token"}, + "http://127.0.0.1", + "gpt-5.4", + ).WithRoutingDefaults(store.ChatGPTOAuthStrategyRoundRobin, []string{"codex-work"})) + registry.RegisterForTenant(tenantID, providers.NewCodexProvider( + "codex-work", + &testTokenSource{token: "backup-token"}, + "http://127.0.0.1", + "gpt-5.4", + )) + + agent := &store.AgentData{ + TenantID: tenantID, + Provider: "openai-codex", + } + + providerType, routing, poolProviders := resolveCodexPoolRouting(context.Background(), nil, registry, agent) + if providerType != store.ProviderChatGPTOAuth { + t.Fatalf("providerType = %q, want %q", providerType, store.ProviderChatGPTOAuth) + } + if routing == nil { + t.Fatal("routing = nil, want effective routing") + } + if routing.Strategy != store.ChatGPTOAuthStrategyRoundRobin { + t.Fatalf("Strategy = %q, want %q", routing.Strategy, store.ChatGPTOAuthStrategyRoundRobin) + } + if len(poolProviders) != 2 || poolProviders[0] != "openai-codex" || poolProviders[1] != "codex-work" { + t.Fatalf("poolProviders = %#v, want master fallback pool", poolProviders) + } +} diff --git a/internal/http/api_key_cache_test.go b/internal/http/api_key_cache_test.go index 749a0fd2..6cc7dca4 100644 --- a/internal/http/api_key_cache_test.go +++ b/internal/http/api_key_cache_test.go @@ -41,10 +41,12 @@ func (m *mockAPIKeyStore) TouchLastUsed(_ context.Context, id uuid.UUID) error { return nil } -func (m *mockAPIKeyStore) Create(_ context.Context, _ *store.APIKeyData) error { return nil } -func (m *mockAPIKeyStore) List(_ context.Context, _ string) ([]store.APIKeyData, error) { return nil, nil } -func (m *mockAPIKeyStore) Revoke(_ context.Context, _ uuid.UUID, _ string) error { return nil } -func (m *mockAPIKeyStore) Delete(_ context.Context, _ uuid.UUID, _ string) error { return nil } +func (m *mockAPIKeyStore) Create(_ context.Context, _ *store.APIKeyData) error { return nil } +func (m *mockAPIKeyStore) List(_ context.Context, _ string) ([]store.APIKeyData, error) { + return nil, nil +} +func (m *mockAPIKeyStore) Revoke(_ context.Context, _ uuid.UUID, _ string) error { return nil } +func (m *mockAPIKeyStore) Delete(_ context.Context, _ uuid.UUID, _ string) error { return nil } func (m *mockAPIKeyStore) getCalls() int { m.mu.Lock() @@ -192,9 +194,7 @@ func TestCacheConcurrentAccess(t *testing.T) { var wg sync.WaitGroup for range 50 { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { key, role := c.getOrFetch(context.Background(), "concurrent") if key == nil { t.Error("expected key, got nil") @@ -202,7 +202,7 @@ func TestCacheConcurrentAccess(t *testing.T) { if role != permissions.RoleAdmin { t.Errorf("role = %v, want admin", role) } - }() + }) } wg.Wait() diff --git a/internal/http/auth.go b/internal/http/auth.go index f1ee3f78..f79cd57a 100644 --- a/internal/http/auth.go +++ b/internal/http/auth.go @@ -5,6 +5,7 @@ import ( "crypto/subtle" "log/slog" "net/http" + "slices" "strings" "time" @@ -14,6 +15,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) // extractBearerToken extracts a bearer token from the Authorization header. @@ -122,16 +124,11 @@ func isHTTPOwnerID(userID string, ownerIDs []string) bool { if len(ownerIDs) == 0 { return userID == "system" } - for _, id := range ownerIDs { - if id == userID { - return true - } - } - return false + return slices.Contains(ownerIDs, userID) } // InitTenantStore sets the tenant cache for HTTP auth with TTL and pubsub invalidation. -// Allows gateway token requests to scope to a specific tenant via X-GoClaw-Tenant-Id header. +// Tenant scoping is used by owner/system-key callers and for membership validation. func InitTenantStore(ts store.TenantStore, mb *bus.MessageBus) { pkgTenantCache = newTenantCache(ts, 5*time.Minute) if mb != nil { @@ -171,9 +168,9 @@ func resolveAuth(r *http.Request) authResult { // resolveAuthWithBearer is like resolveAuth but accepts a pre-extracted bearer token. // Useful for handlers that also accept tokens from query params. func resolveAuthWithBearer(r *http.Request, bearer string) authResult { - // Gateway token → admin - // Only owner IDs get cross-tenant access; others are tenant-scoped. - // If no owner IDs configured, all gateway token users get cross-tenant (backward compat). + // Gateway token → admin. + // Only configured owner IDs get unrestricted tenant scoping; other callers may + // only narrow to tenants where the supplied user already has membership. if pkgGatewayToken != "" && tokenMatch(bearer, pkgGatewayToken) { userID := extractUserID(r) isOwner := isHTTPOwnerID(userID, pkgOwnerIDs) @@ -183,38 +180,35 @@ func resolveAuthWithBearer(r *http.Request, bearer string) authResult { } res := authResult{Role: role, Authenticated: true} tenantVal := r.Header.Get("X-GoClaw-Tenant-Id") - if pkgTenantCache != nil && tenantVal != "" { - // Resolve tenant from header (works for both owner and non-owner) - if tid, err := uuid.Parse(tenantVal); err == nil { - if t, err := pkgTenantCache.GetTenant(r.Context(), tid); err == nil && t != nil { - res.TenantID = t.ID - res.TenantSlug = t.Slug - } - } else if t, err := pkgTenantCache.GetTenantBySlug(r.Context(), tenantVal); err == nil && t != nil { - res.TenantID = t.ID - res.TenantSlug = t.Slug + if isOwner { + res.TenantID = resolveScopedTenant(r.Context(), tenantVal) + } else { + tenantID, allowed := resolveTenantHint(r.Context(), tenantVal, userID) + if !allowed { + return authResult{} } + res.TenantID = tenantID } if res.TenantID == uuid.Nil { res.TenantID = store.MasterTenantID } + res.TenantSlug = resolveTenantSlug(r.Context(), res.TenantID) return res } // API key → role from scopes if keyData, role := ResolveAPIKey(r.Context(), bearer); role != "" { res := authResult{Role: role, Authenticated: true, KeyData: keyData} if keyData.TenantID == uuid.Nil { - // API key with no tenant → owner scope, default to master - res.Role = permissions.RoleOwner - res.TenantID = store.MasterTenantID + // System-level API keys keep their scope-derived role. They may + // optionally scope a request to a tenant, but they do not become owner. + res.TenantID = resolveScopedTenant(r.Context(), r.Header.Get("X-GoClaw-Tenant-Id")) + if res.TenantID == uuid.Nil { + res.TenantID = store.MasterTenantID + } + res.TenantSlug = resolveTenantSlug(r.Context(), res.TenantID) } else { res.TenantID = keyData.TenantID - // Resolve tenant slug for filesystem path scoping. - if pkgTenantCache != nil { - if t, err := pkgTenantCache.GetTenant(r.Context(), keyData.TenantID); err == nil && t != nil { - res.TenantSlug = t.Slug - } - } + res.TenantSlug = resolveTenantSlug(r.Context(), keyData.TenantID) } return res } @@ -222,7 +216,16 @@ func resolveAuthWithBearer(r *http.Request, bearer string) authResult { if senderID := r.Header.Get("X-GoClaw-Sender-Id"); senderID != "" && pkgPairingStore != nil { paired, err := pkgPairingStore.IsPaired(r.Context(), senderID, "browser") if err == nil && paired { - return authResult{Role: permissions.RoleOperator, Authenticated: true, TenantID: store.MasterTenantID} + tenantID, allowed := resolveTenantHint(r.Context(), r.Header.Get("X-GoClaw-Tenant-Id"), extractUserID(r)) + if !allowed { + return authResult{} + } + return authResult{ + Role: permissions.RoleOperator, + Authenticated: true, + TenantID: tenantID, + TenantSlug: resolveTenantSlug(r.Context(), tenantID), + } } if err != nil { slog.Warn("security.http_pairing_check_failed", "sender_id", senderID, "error", err) @@ -237,6 +240,57 @@ func resolveAuthWithBearer(r *http.Request, bearer string) authResult { return authResult{} } +func resolveScopedTenant(ctx context.Context, tenantVal string) uuid.UUID { + if tenantVal == "" || pkgTenantCache == nil { + return uuid.Nil + } + if tid, err := uuid.Parse(tenantVal); err == nil { + if t, err := pkgTenantCache.GetTenant(ctx, tid); err == nil && t != nil { + return t.ID + } + } else if t, err := pkgTenantCache.GetTenantBySlug(ctx, tenantVal); err == nil && t != nil { + return t.ID + } + slog.Debug("security.http_tenant_scope_unresolved", "tenant", tenantVal) + return uuid.Nil +} + +func resolveTenantSlug(ctx context.Context, tenantID uuid.UUID) string { + if tenantID == uuid.Nil || pkgTenantCache == nil { + return "" + } + if tenant, err := pkgTenantCache.GetTenant(ctx, tenantID); err == nil && tenant != nil { + return tenant.Slug + } + return "" +} + +func resolveTenantHint(ctx context.Context, hint, userID string) (uuid.UUID, bool) { + if hint == "" || pkgTenantCache == nil { + return store.MasterTenantID, true + } + tid := resolveScopedTenant(ctx, hint) + if tid == uuid.Nil { + return store.MasterTenantID, true + } + if userID == "" { + slog.Warn("security.http_tenant_hint_denied_anonymous", "hint", hint, "tenant_id", tid) + return uuid.Nil, false + } + role, err := pkgTenantCache.store.GetUserRole(ctx, tid, userID) + if err != nil || role == "" { + slog.Warn("security.http_tenant_access_revoked", + "hint", hint, + "user", userID, + "tenant_id", tid, + "error", err, + "code", protocol.ErrTenantAccessRevoked, + ) + return uuid.Nil, false + } + return tid, true +} + // httpMinRole returns the minimum role required for an HTTP endpoint based on HTTP method. func httpMinRole(method string) permissions.Role { switch method { diff --git a/internal/http/auth_test.go b/internal/http/auth_test.go index 3d602d51..34b66cc8 100644 --- a/internal/http/auth_test.go +++ b/internal/http/auth_test.go @@ -1,12 +1,16 @@ package http import ( + "context" + "fmt" + "maps" "net/http" "net/http/httptest" "testing" "time" "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/crypto" "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -18,9 +22,7 @@ import ( func setupTestCache(t *testing.T, keys map[string]*store.APIKeyData) *mockAPIKeyStore { t.Helper() ms := newMockAPIKeyStore() - for hash, key := range keys { - ms.keys[hash] = key - } + maps.Copy(ms.keys, keys) pkgAPIKeyCache = newAPIKeyCache(ms, 5*time.Minute) t.Cleanup(func() { pkgAPIKeyCache = nil }) return ms @@ -34,6 +36,110 @@ func setupTestToken(t *testing.T, token string) { t.Cleanup(func() { pkgGatewayToken = old }) } +func setupTestTenantStore(t *testing.T, ts store.TenantStore) { + t.Helper() + old := pkgTenantCache + pkgTenantCache = newTenantCache(ts, 5*time.Minute) + t.Cleanup(func() { pkgTenantCache = old }) +} + +func setupTestPairingStore(t *testing.T, ps store.PairingStore) { + t.Helper() + old := pkgPairingStore + pkgPairingStore = ps + t.Cleanup(func() { pkgPairingStore = old }) +} + +type mockTenantStore struct { + tenantsByID map[uuid.UUID]*store.TenantData + tenantsBySlug map[string]*store.TenantData + roles map[uuid.UUID]map[string]string +} + +func newMockTenantStore() *mockTenantStore { + return &mockTenantStore{ + tenantsByID: make(map[uuid.UUID]*store.TenantData), + tenantsBySlug: make(map[string]*store.TenantData), + roles: make(map[uuid.UUID]map[string]string), + } +} + +func (m *mockTenantStore) addTenant(id uuid.UUID, slug string) { + t := &store.TenantData{ID: id, Slug: slug, Name: slug} + m.tenantsByID[id] = t + m.tenantsBySlug[slug] = t +} + +func (m *mockTenantStore) setUserRole(tenantID uuid.UUID, userID, role string) { + if m.roles[tenantID] == nil { + m.roles[tenantID] = make(map[string]string) + } + m.roles[tenantID][userID] = role +} + +func (m *mockTenantStore) CreateTenant(context.Context, *store.TenantData) error { return nil } +func (m *mockTenantStore) GetTenant(_ context.Context, id uuid.UUID) (*store.TenantData, error) { + if t := m.tenantsByID[id]; t != nil { + return t, nil + } + return nil, fmt.Errorf("not found") +} +func (m *mockTenantStore) GetTenantBySlug(_ context.Context, slug string) (*store.TenantData, error) { + if t := m.tenantsBySlug[slug]; t != nil { + return t, nil + } + return nil, fmt.Errorf("not found") +} +func (m *mockTenantStore) ListTenants(context.Context) ([]store.TenantData, error) { return nil, nil } +func (m *mockTenantStore) UpdateTenant(context.Context, uuid.UUID, map[string]any) error { + return nil +} +func (m *mockTenantStore) AddUser(context.Context, uuid.UUID, string, string) error { return nil } +func (m *mockTenantStore) RemoveUser(context.Context, uuid.UUID, string) error { return nil } +func (m *mockTenantStore) GetUserRole(_ context.Context, tenantID uuid.UUID, userID string) (string, error) { + if role := m.roles[tenantID][userID]; role != "" { + return role, nil + } + return "", nil +} +func (m *mockTenantStore) ListUsers(context.Context, uuid.UUID) ([]store.TenantUserData, error) { + return nil, nil +} +func (m *mockTenantStore) ListUserTenants(context.Context, string) ([]store.TenantUserData, error) { + return nil, nil +} +func (m *mockTenantStore) ResolveUserTenant(context.Context, string) (uuid.UUID, error) { + return store.MasterTenantID, nil +} +func (m *mockTenantStore) GetTenantUser(context.Context, uuid.UUID) (*store.TenantUserData, error) { + return nil, fmt.Errorf("not found") +} +func (m *mockTenantStore) CreateTenantUserReturning(context.Context, uuid.UUID, string, string, string) (*store.TenantUserData, error) { + return nil, fmt.Errorf("not implemented") +} + +type mockPairingStore struct { + paired map[string]bool +} + +func newMockPairingStore() *mockPairingStore { + return &mockPairingStore{paired: make(map[string]bool)} +} + +func (m *mockPairingStore) RequestPairing(context.Context, string, string, string, string, map[string]string) (string, error) { + return "", nil +} +func (m *mockPairingStore) ApprovePairing(context.Context, string, string) (*store.PairedDeviceData, error) { + return nil, nil +} +func (m *mockPairingStore) DenyPairing(context.Context, string) error { return nil } +func (m *mockPairingStore) RevokePairing(context.Context, string, string) error { return nil } +func (m *mockPairingStore) IsPaired(_ context.Context, senderID, channel string) (bool, error) { + return m.paired[senderID+":"+channel], nil +} +func (m *mockPairingStore) ListPending(context.Context) []store.PairingRequestData { return nil } +func (m *mockPairingStore) ListPaired(context.Context) []store.PairedDeviceData { return nil } + func TestResolveAuth_GatewayToken(t *testing.T) { setupTestCache(t, nil) setupTestToken(t, "my-gateway-token") @@ -50,6 +156,50 @@ func TestResolveAuth_GatewayToken(t *testing.T) { } } +func TestResolveAuth_GatewayTokenScopesNonOwnerToMemberTenant(t *testing.T) { + setupTestCache(t, nil) + setupTestToken(t, "my-gateway-token") + ts := newMockTenantStore() + tenantID := uuid.New() + ts.addTenant(tenantID, "acme") + ts.setUserRole(tenantID, "user-1", store.TenantRoleAdmin) + setupTestTenantStore(t, ts) + + r := httptest.NewRequest("GET", "/v1/agents", nil) + r.Header.Set("Authorization", "Bearer my-gateway-token") + r.Header.Set("X-GoClaw-User-Id", "user-1") + r.Header.Set("X-GoClaw-Tenant-Id", "acme") + + auth := resolveAuth(r) + if !auth.Authenticated { + t.Fatal("expected authenticated") + } + if auth.Role != permissions.RoleAdmin { + t.Fatalf("role = %v, want admin", auth.Role) + } + if auth.TenantID != tenantID { + t.Fatalf("tenantID = %v, want %v", auth.TenantID, tenantID) + } +} + +func TestResolveAuth_GatewayTokenRejectsUnauthorizedTenantScope(t *testing.T) { + setupTestCache(t, nil) + setupTestToken(t, "my-gateway-token") + ts := newMockTenantStore() + ts.addTenant(uuid.New(), "acme") + setupTestTenantStore(t, ts) + + r := httptest.NewRequest("GET", "/v1/agents", nil) + r.Header.Set("Authorization", "Bearer my-gateway-token") + r.Header.Set("X-GoClaw-User-Id", "user-1") + r.Header.Set("X-GoClaw-Tenant-Id", "acme") + + auth := resolveAuth(r) + if auth.Authenticated { + t.Fatal("expected unauthenticated for unauthorized tenant scope") + } +} + func TestResolveAuth_WrongToken(t *testing.T) { setupTestCache(t, nil) setupTestToken(t, "correct-token") @@ -141,6 +291,107 @@ func TestResolveAuth_APIKeyWriteScope(t *testing.T) { } } +func TestResolveAuth_SystemAPIKeyKeepsScopeDerivedRole(t *testing.T) { + token := "system-admin-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(token): { + ID: uuid.New(), + Scopes: []string{"operator.admin"}, + }, + }) + + r := httptest.NewRequest("GET", "/v1/providers", nil) + r.Header.Set("Authorization", "Bearer "+token) + + auth := resolveAuth(r) + if !auth.Authenticated { + t.Fatal("expected authenticated") + } + if auth.Role != permissions.RoleAdmin { + t.Fatalf("role = %v, want admin", auth.Role) + } + if auth.TenantID != store.MasterTenantID { + t.Fatalf("tenantID = %v, want master tenant", auth.TenantID) + } +} + +func TestResolveAuth_SystemAPIKeyHonorsTenantScopeHeader(t *testing.T) { + token := "system-admin-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(token): { + ID: uuid.New(), + Scopes: []string{"operator.admin"}, + }, + }) + ts := newMockTenantStore() + tenantID := uuid.New() + ts.addTenant(tenantID, "acme") + setupTestTenantStore(t, ts) + + r := httptest.NewRequest("GET", "/v1/providers", nil) + r.Header.Set("Authorization", "Bearer "+token) + r.Header.Set("X-GoClaw-Tenant-Id", "acme") + + auth := resolveAuth(r) + if !auth.Authenticated { + t.Fatal("expected authenticated") + } + if auth.Role != permissions.RoleAdmin { + t.Fatalf("role = %v, want admin", auth.Role) + } + if auth.TenantID != tenantID { + t.Fatalf("tenantID = %v, want %v", auth.TenantID, tenantID) + } +} + +func TestResolveAuth_BrowserPairingScopesToMemberTenant(t *testing.T) { + setupTestToken(t, "gateway-token") + ps := newMockPairingStore() + ps.paired["browser-1:browser"] = true + setupTestPairingStore(t, ps) + ts := newMockTenantStore() + tenantID := uuid.New() + ts.addTenant(tenantID, "acme") + ts.setUserRole(tenantID, "user-1", store.TenantRoleAdmin) + setupTestTenantStore(t, ts) + + r := httptest.NewRequest("GET", "/v1/agents", nil) + r.Header.Set("X-GoClaw-Sender-Id", "browser-1") + r.Header.Set("X-GoClaw-User-Id", "user-1") + r.Header.Set("X-GoClaw-Tenant-Id", "acme") + + auth := resolveAuth(r) + if !auth.Authenticated { + t.Fatal("expected authenticated") + } + if auth.Role != permissions.RoleOperator { + t.Fatalf("role = %v, want operator", auth.Role) + } + if auth.TenantID != tenantID { + t.Fatalf("tenantID = %v, want %v", auth.TenantID, tenantID) + } +} + +func TestResolveAuth_BrowserPairingRejectsUnauthorizedTenantScope(t *testing.T) { + setupTestToken(t, "gateway-token") + ps := newMockPairingStore() + ps.paired["browser-1:browser"] = true + setupTestPairingStore(t, ps) + ts := newMockTenantStore() + ts.addTenant(uuid.New(), "acme") + setupTestTenantStore(t, ts) + + r := httptest.NewRequest("GET", "/v1/agents", nil) + r.Header.Set("X-GoClaw-Sender-Id", "browser-1") + r.Header.Set("X-GoClaw-User-Id", "user-1") + r.Header.Set("X-GoClaw-Tenant-Id", "acme") + + auth := resolveAuth(r) + if auth.Authenticated { + t.Fatal("expected unauthenticated for unauthorized tenant scope") + } +} + func TestHttpMinRole(t *testing.T) { tests := []struct { method string diff --git a/internal/http/chatgpt_oauth_pool_validation.go b/internal/http/chatgpt_oauth_pool_validation.go new file mode 100644 index 00000000..a578efff --- /dev/null +++ b/internal/http/chatgpt_oauth_pool_validation.go @@ -0,0 +1,168 @@ +package http + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func marshalJSONRaw(value any) (json.RawMessage, error) { + if value == nil { + return nil, nil + } + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + return json.RawMessage(data), nil +} + +func normalizedProviderNamesForValidation(names []string) []string { + if len(names) == 0 { + return nil + } + seen := make(map[string]bool, len(names)) + result := make([]string, 0, len(names)) + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" || seen[name] { + continue + } + seen[name] = true + result = append(result, name) + } + return result +} + +func validateChatGPTOAuthPoolGraph(providers []store.LLMProviderData) error { + providersByName := make(map[string]store.LLMProviderData, len(providers)) + for _, provider := range providers { + providersByName[provider.Name] = provider + } + + ownerByMember := map[string]string{} + hasPoolConfig := map[string]bool{} + + for _, provider := range providers { + settings := store.ParseChatGPTOAuthProviderSettings(provider.Settings) + if settings == nil { + continue + } + if provider.ProviderType != store.ProviderChatGPTOAuth { + return fmt.Errorf("provider %q must be chatgpt_oauth to manage an OpenAI Codex pool", provider.Name) + } + + hasPoolConfig[provider.Name] = true + for _, memberName := range normalizedProviderNamesForValidation(settings.CodexPool.ExtraProviderNames) { + if memberName == provider.Name { + return fmt.Errorf("provider %q cannot include itself in its OpenAI Codex pool", provider.Name) + } + + member, ok := providersByName[memberName] + if !ok { + return fmt.Errorf("provider %q references unknown OpenAI Codex pool member %q", provider.Name, memberName) + } + if member.ProviderType != store.ProviderChatGPTOAuth { + return fmt.Errorf("provider %q can only add chatgpt_oauth members; %q is %s", provider.Name, memberName, member.ProviderType) + } + + if existingOwner, ok := ownerByMember[memberName]; ok && existingOwner != provider.Name { + return fmt.Errorf("provider %q already belongs to pool %q", memberName, existingOwner) + } + ownerByMember[memberName] = provider.Name + } + } + + for ownerName := range hasPoolConfig { + if existingOwner, ok := ownerByMember[ownerName]; ok { + return fmt.Errorf("provider %q already belongs to pool %q and cannot manage its own pool", ownerName, existingOwner) + } + } + + return nil +} + +func validateChatGPTOAuthProviderCandidate( + ctx context.Context, + providerStore store.ProviderStore, + currentID uuid.UUID, + candidate *store.LLMProviderData, +) error { + if providerStore == nil || candidate == nil { + return nil + } + + existingProviders, err := providerStore.ListProviders(ctx) + if err != nil { + return err + } + + finalProviders := make([]store.LLMProviderData, 0, len(existingProviders)+1) + replaced := false + for _, provider := range existingProviders { + if currentID != uuid.Nil && provider.ID == currentID { + finalProviders = append(finalProviders, *candidate) + replaced = true + continue + } + finalProviders = append(finalProviders, provider) + } + if currentID == uuid.Nil || !replaced { + finalProviders = append(finalProviders, *candidate) + } + + return validateChatGPTOAuthPoolGraph(finalProviders) +} + +func validateChatGPTOAuthAgentRouting( + ctx context.Context, + providerStore store.ProviderStore, + providerName string, + routing *store.ChatGPTOAuthRoutingConfig, +) error { + if providerStore == nil || providerName == "" || routing == nil { + return nil + } + + tenantID := store.TenantIDFromContext(ctx) + baseProvider, err := lookupProviderByNameWithMasterFallback(ctx, providerStore, tenantID, providerName) + if err != nil || baseProvider == nil || baseProvider.ProviderType != store.ProviderChatGPTOAuth { + return nil + } + + defaultSettings := store.ParseChatGPTOAuthProviderSettings(baseProvider.Settings) + defaultMembers := []string{} + if defaultSettings != nil { + defaultMembers = normalizedProviderNamesForValidation(defaultSettings.CodexPool.ExtraProviderNames) + } + + if len(defaultMembers) == 0 { + if routing.Strategy != store.ChatGPTOAuthStrategyPrimaryFirst || len(routing.ExtraProviderNames) > 0 { + return fmt.Errorf("configure OpenAI Codex pool members on provider %q before enabling agent-level routing", providerName) + } + return nil + } + + if routing.OverrideMode == store.ChatGPTOAuthOverrideInherit { + return nil + } + + if len(routing.ExtraProviderNames) == 0 { + return nil + } + + if !slices.Equal( + normalizedProviderNamesForValidation(routing.ExtraProviderNames), + defaultMembers, + ) { + return fmt.Errorf("agent routing cannot change pool membership for provider %q; manage members on the provider instead", providerName) + } + + return nil +} diff --git a/internal/http/chatgpt_oauth_pool_validation_test.go b/internal/http/chatgpt_oauth_pool_validation_test.go new file mode 100644 index 00000000..ab96f982 --- /dev/null +++ b/internal/http/chatgpt_oauth_pool_validation_test.go @@ -0,0 +1,165 @@ +package http + +import ( + "context" + "encoding/json" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestValidateChatGPTOAuthProviderCandidateRejectsMemberReuseAcrossPools(t *testing.T) { + providerStore := newMockProviderStore() + tenantID := uuid.New() + ctx := store.WithTenantID(context.Background(), tenantID) + + for _, provider := range []*store.LLMProviderData{ + { + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "openai-codex", + ProviderType: store.ProviderChatGPTOAuth, + Enabled: true, + Settings: json.RawMessage(`{ + "codex_pool": { + "strategy": "round_robin", + "extra_provider_names": ["codex-work"] + } + }`), + }, + { + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "codex-work", + ProviderType: store.ProviderChatGPTOAuth, + Enabled: true, + }, + } { + if err := providerStore.CreateProvider(ctx, provider); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + } + + candidate := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "codex-team", + ProviderType: store.ProviderChatGPTOAuth, + Enabled: true, + Settings: json.RawMessage(`{ + "codex_pool": { + "strategy": "priority_order", + "extra_provider_names": ["codex-work"] + } + }`), + } + + if err := validateChatGPTOAuthProviderCandidate(ctx, providerStore, uuid.Nil, candidate); err == nil { + t.Fatal("validateChatGPTOAuthProviderCandidate() = nil, want membership conflict") + } +} + +func TestValidateChatGPTOAuthProviderCandidateRejectsPoolOnMember(t *testing.T) { + providerStore := newMockProviderStore() + tenantID := uuid.New() + ctx := store.WithTenantID(context.Background(), tenantID) + + owner := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "openai-codex", + ProviderType: store.ProviderChatGPTOAuth, + Enabled: true, + Settings: json.RawMessage(`{ + "codex_pool": { + "strategy": "round_robin", + "extra_provider_names": ["codex-work"] + } + }`), + } + member := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "codex-work", + ProviderType: store.ProviderChatGPTOAuth, + Enabled: true, + } + + if err := providerStore.CreateProvider(ctx, owner); err != nil { + t.Fatalf("CreateProvider(owner) error = %v", err) + } + if err := providerStore.CreateProvider(ctx, member); err != nil { + t.Fatalf("CreateProvider(member) error = %v", err) + } + + memberWithPool := *member + memberWithPool.Settings = json.RawMessage(`{ + "codex_pool": { + "strategy": "priority_order", + "extra_provider_names": ["codex-backup"] + } + }`) + + if err := validateChatGPTOAuthProviderCandidate(ctx, providerStore, member.ID, &memberWithPool); err == nil { + t.Fatal("validateChatGPTOAuthProviderCandidate() = nil, want member-owner conflict") + } +} + +func TestValidateChatGPTOAuthAgentRoutingRejectsCustomMembersWithoutProviderPool(t *testing.T) { + providerStore := newMockProviderStore() + tenantID := uuid.New() + ctx := store.WithTenantID(context.Background(), tenantID) + + if err := providerStore.CreateProvider(ctx, &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "openai-codex", + ProviderType: store.ProviderChatGPTOAuth, + Enabled: true, + }); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + routing := &store.ChatGPTOAuthRoutingConfig{ + OverrideMode: store.ChatGPTOAuthOverrideCustom, + Strategy: store.ChatGPTOAuthStrategyRoundRobin, + ExtraProviderNames: []string{"codex-work"}, + } + + if err := validateChatGPTOAuthAgentRouting(ctx, providerStore, "openai-codex", routing); err == nil { + t.Fatal("validateChatGPTOAuthAgentRouting() = nil, want provider-owned pool error") + } +} + +func TestValidateChatGPTOAuthAgentRoutingAllowsStrategyOnlyOverride(t *testing.T) { + providerStore := newMockProviderStore() + tenantID := uuid.New() + ctx := store.WithTenantID(context.Background(), tenantID) + + if err := providerStore.CreateProvider(ctx, &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: "openai-codex", + ProviderType: store.ProviderChatGPTOAuth, + Enabled: true, + Settings: json.RawMessage(`{ + "codex_pool": { + "strategy": "round_robin", + "extra_provider_names": ["codex-work"] + } + }`), + }); err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + routing := &store.ChatGPTOAuthRoutingConfig{ + OverrideMode: store.ChatGPTOAuthOverrideCustom, + Strategy: store.ChatGPTOAuthStrategyPriority, + } + + if err := validateChatGPTOAuthAgentRouting(ctx, providerStore, "openai-codex", routing); err != nil { + t.Fatalf("validateChatGPTOAuthAgentRouting() error = %v, want nil", err) + } +} diff --git a/internal/http/file_token.go b/internal/http/file_token.go index aae2643f..cca2a8ff 100644 --- a/internal/http/file_token.go +++ b/internal/http/file_token.go @@ -60,7 +60,7 @@ func VerifyFileToken(token, path, secret string) bool { // fileTokenHMAC computes the HMAC signature component. func fileTokenHMAC(path, secret string, expiry int64) string { mac := hmac.New(sha256.New, []byte(secret)) - mac.Write([]byte(fmt.Sprintf("%s:%d", path, expiry))) + mac.Write(fmt.Appendf(nil, "%s:%d", path, expiry)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)[:16]) } diff --git a/internal/http/oauth.go b/internal/http/oauth.go index 95d3bb40..bdafea2d 100644 --- a/internal/http/oauth.go +++ b/internal/http/oauth.go @@ -3,6 +3,7 @@ package http import ( "context" "encoding/json" + "errors" "log/slog" "net/http" "sync" @@ -13,6 +14,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/oauth" + "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -24,8 +26,20 @@ type OAuthHandler struct { providerReg *providers.Registry msgBus *bus.MessageBus - mu sync.Mutex - pending *oauth.PendingLogin // active OAuth flow (if any) + mu sync.Mutex + pending map[string]*pendingOAuthFlow + activeFlowKey string +} + +type pendingOAuthFlow struct { + login *oauth.PendingLogin + cancel context.CancelFunc + flowKey string + tenantID uuid.UUID + userID string + providerName string + displayName string + apiBase string } // NewOAuthHandler creates a handler for OAuth endpoints. @@ -35,31 +49,92 @@ func NewOAuthHandler(provStore store.ProviderStore, secretStore store.ConfigSecr secretStore: secretStore, providerReg: providerReg, msgBus: msgBus, + pending: make(map[string]*pendingOAuthFlow), } } // RegisterRoutes registers OAuth routes on the given mux. func (h *OAuthHandler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /v1/auth/chatgpt/{provider}/status", h.auth(h.handleStatus)) + mux.HandleFunc("GET /v1/auth/chatgpt/{provider}/quota", h.auth(h.handleQuota)) + mux.HandleFunc("POST /v1/auth/chatgpt/{provider}/start", h.auth(h.handleStart)) + mux.HandleFunc("POST /v1/auth/chatgpt/{provider}/callback", h.auth(h.handleManualCallback)) + mux.HandleFunc("POST /v1/auth/chatgpt/{provider}/logout", h.auth(h.handleLogout)) + mux.HandleFunc("GET /v1/auth/openai/status", h.auth(h.handleStatus)) + mux.HandleFunc("GET /v1/auth/openai/quota", h.auth(h.handleQuota)) mux.HandleFunc("POST /v1/auth/openai/start", h.auth(h.handleStart)) mux.HandleFunc("POST /v1/auth/openai/callback", h.auth(h.handleManualCallback)) mux.HandleFunc("POST /v1/auth/openai/logout", h.auth(h.handleLogout)) } func (h *OAuthHandler) auth(next http.HandlerFunc) http.HandlerFunc { - return requireAuth("", next) + return requireAuth(permissions.RoleAdmin, next) } -func (h *OAuthHandler) newTokenSource(r *http.Request) *oauth.DBTokenSource { - ts := oauth.NewDBTokenSource(h.provStore, h.secretStore, oauth.DefaultProviderName) - if tid := store.TenantIDFromContext(r.Context()); tid != uuid.Nil { - ts.WithTenantID(tid) +func oauthTenantID(ctx context.Context) uuid.UUID { + if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil { + return tid } - return ts + return store.MasterTenantID +} + +func oauthProviderName(r *http.Request) string { + if provider := r.PathValue("provider"); provider != "" { + return provider + } + return oauth.DefaultProviderName +} + +func oauthFlowKey(ctx context.Context, providerName string) string { + return oauthTenantID(ctx).String() + ":" + store.UserIDFromContext(ctx) + ":" + providerName +} + +func (h *OAuthHandler) newTokenSource(ctx context.Context, providerName, displayName, apiBase string) *oauth.DBTokenSource { + return oauth.NewDBTokenSource(h.provStore, h.secretStore, providerName). + WithTenantID(oauthTenantID(ctx)). + WithProviderMeta(displayName, apiBase) +} + +func (h *OAuthHandler) ensureOAuthProviderName(ctx context.Context, providerName string) error { + if h.provStore == nil { + return nil + } + p, err := h.provStore.GetProviderByName(ctx, providerName) + if err != nil { + return nil + } + if p.ProviderType != store.ProviderChatGPTOAuth { + return &oauth.ProviderTypeConflictError{ + ProviderName: providerName, + ProviderType: p.ProviderType, + } + } + return nil +} + +func writeOAuthProviderConflict(w http.ResponseWriter, err error) bool { + var conflict *oauth.ProviderTypeConflictError + if !errors.As(err, &conflict) { + return false + } + writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()}) + return true } func (h *OAuthHandler) handleStatus(w http.ResponseWriter, r *http.Request) { - ts := h.newTokenSource(r) + providerName := oauthProviderName(r) + if !isValidSlug(providerName) { + locale := extractLocale(r) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "provider")}) + return + } + if err := h.ensureOAuthProviderName(r.Context(), providerName); err != nil { + writeOAuthProviderConflict(w, err) + return + } + + ts := h.newTokenSource(r.Context(), providerName, "", "") if !ts.Exists(r.Context()) { writeJSON(w, http.StatusOK, map[string]any{"authenticated": false}) return @@ -75,28 +150,61 @@ func (h *OAuthHandler) handleStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "authenticated": true, - "provider_name": oauth.DefaultProviderName, + "provider_name": providerName, }) } func (h *OAuthHandler) handleStart(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) + providerName := oauthProviderName(r) + if !isValidSlug(providerName) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "provider")}) + return + } + + var body struct { + DisplayName string `json:"display_name"` + APIBase string `json:"api_base"` + } + if r.ContentLength > 0 { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + } + if err := h.ensureOAuthProviderName(r.Context(), providerName); err != nil { + writeOAuthProviderConflict(w, err) + return + } + h.mu.Lock() defer h.mu.Unlock() // Already authenticated? - ts := h.newTokenSource(r) + ts := h.newTokenSource(r.Context(), providerName, body.DisplayName, body.APIBase) if ts.Exists(r.Context()) { if _, err := ts.Token(); err == nil { - writeJSON(w, http.StatusOK, map[string]any{"status": "already_authenticated"}) + writeJSON(w, http.StatusOK, map[string]any{ + "status": "already_authenticated", + "provider_name": providerName, + }) return } } - // Shut down any previous pending flow to release port 1455 - if h.pending != nil { - h.pending.Shutdown() - h.pending = nil + flowKey := oauthFlowKey(r.Context(), providerName) + if pending := h.pending[flowKey]; pending != nil { + writeJSON(w, http.StatusOK, map[string]any{ + "auth_url": pending.login.AuthURL, + "provider_name": providerName, + }) + return + } + if h.activeFlowKey != "" && h.activeFlowKey != flowKey { + writeJSON(w, http.StatusConflict, map[string]string{ + "error": "another OAuth flow is already active on this server", + }) + return } pending, err := oauth.StartLoginOpenAI() @@ -108,43 +216,69 @@ func (h *OAuthHandler) handleStart(w http.ResponseWriter, r *http.Request) { return } - h.pending = pending + waitCtx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) + flow := &pendingOAuthFlow{ + login: pending, + cancel: cancel, + flowKey: flowKey, + tenantID: oauthTenantID(r.Context()), + userID: store.UserIDFromContext(r.Context()), + providerName: providerName, + displayName: body.DisplayName, + apiBase: body.APIBase, + } + h.pending[flowKey] = flow + h.activeFlowKey = flowKey // Wait for callback in background, save token when done - go h.waitForCallback(pending) + go h.waitForCallback(waitCtx, flow) emitAudit(h.msgBus, r, "oauth.login_started", "oauth", "openai") - writeJSON(w, http.StatusOK, map[string]any{"auth_url": pending.AuthURL}) + writeJSON(w, http.StatusOK, map[string]any{ + "auth_url": pending.AuthURL, + "provider_name": providerName, + }) } // waitForCallback waits for the OAuth callback and saves the token. -func (h *OAuthHandler) waitForCallback(pending *oauth.PendingLogin) { - ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) - defer cancel() - - tokenResp, err := pending.Wait(ctx) +func (h *OAuthHandler) waitForCallback(ctx context.Context, flow *pendingOAuthFlow) { + tokenResp, err := flow.login.Wait(ctx) h.mu.Lock() - if h.pending == pending { - h.pending = nil + if h.pending[flow.flowKey] == flow { + delete(h.pending, flow.flowKey) + } + if h.activeFlowKey == flow.flowKey { + h.activeFlowKey = "" } h.mu.Unlock() if err != nil { + if errors.Is(err, context.Canceled) { + slog.Info("oauth flow canceled", "provider", flow.providerName) + return + } slog.Warn("oauth.callback failed", "error", err) return } - if _, err := h.saveAndRegister(ctx, tokenResp); err != nil { + saveCtx := store.WithTenantID(context.Background(), flow.tenantID) + if _, err := h.saveAndRegister(saveCtx, flow.providerName, flow.displayName, flow.apiBase, tokenResp); err != nil { slog.Error("oauth.save_token", "error", err) return } - slog.Info("oauth: OpenAI token saved via web UI callback") + slog.Info("oauth: OpenAI token saved via web UI callback", "provider", flow.providerName) } func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Request) { locale := extractLocale(r) + providerName := oauthProviderName(r) + if !isValidSlug(providerName) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "provider")}) + return + } + var body struct { RedirectURL string `json:"redirect_url"` } @@ -152,17 +286,21 @@ func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgRequired, "redirect_url")}) return } + if err := h.ensureOAuthProviderName(r.Context(), providerName); err != nil { + writeOAuthProviderConflict(w, err) + return + } h.mu.Lock() - pending := h.pending + pending := h.pending[oauthFlowKey(r.Context(), providerName)] h.mu.Unlock() - if pending == nil { + if pending == nil || pending.providerName != providerName || pending.tenantID != oauthTenantID(r.Context()) || pending.userID != store.UserIDFromContext(r.Context()) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgNoPendingOAuth)}) return } - tokenResp, err := pending.ExchangeRedirectURL(body.RedirectURL) + tokenResp, err := pending.login.ExchangeRedirectURL(body.RedirectURL) if err != nil { slog.Warn("oauth.manual_callback", "error", err) writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) @@ -170,15 +308,22 @@ func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Reque } // Shut down the callback server and clear pending - pending.Shutdown() + pending.cancel() + pending.login.Shutdown() h.mu.Lock() - if h.pending == pending { - h.pending = nil + if h.pending[pending.flowKey] == pending { + delete(h.pending, pending.flowKey) + } + if h.activeFlowKey == pending.flowKey { + h.activeFlowKey = "" } h.mu.Unlock() - providerID, err := h.saveAndRegister(r.Context(), tokenResp) + providerID, err := h.saveAndRegister(r.Context(), providerName, pending.displayName, pending.apiBase, tokenResp) if err != nil { + if writeOAuthProviderConflict(w, err) { + return + } slog.Error("oauth.save_token", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToSaveToken)}) return @@ -187,13 +332,24 @@ func (h *OAuthHandler) handleManualCallback(w http.ResponseWriter, r *http.Reque slog.Info("oauth: OpenAI token saved via manual callback") writeJSON(w, http.StatusOK, map[string]any{ "authenticated": true, - "provider_name": oauth.DefaultProviderName, + "provider_name": providerName, "provider_id": providerID.String(), }) } func (h *OAuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) { - ts := h.newTokenSource(r) + providerName := oauthProviderName(r) + if !isValidSlug(providerName) { + locale := extractLocale(r) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "provider")}) + return + } + if err := h.ensureOAuthProviderName(r.Context(), providerName); err != nil { + writeOAuthProviderConflict(w, err) + return + } + + ts := h.newTokenSource(r.Context(), providerName, "", "") if err := ts.Delete(r.Context()); err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return @@ -204,7 +360,7 @@ func (h *OAuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) { if tid == uuid.Nil { tid = store.MasterTenantID } - h.providerReg.UnregisterForTenant(tid, oauth.DefaultProviderName) + h.providerReg.UnregisterForTenant(tid, providerName) } emitAudit(h.msgBus, r, "oauth.logout", "oauth", "openai") @@ -212,11 +368,8 @@ func (h *OAuthHandler) handleLogout(w http.ResponseWriter, r *http.Request) { } // saveAndRegister persists the OAuth result to DB and registers the CodexProvider in-memory. -func (h *OAuthHandler) saveAndRegister(ctx context.Context, tokenResp *oauth.OpenAITokenResponse) (uuid.UUID, error) { - ts := oauth.NewDBTokenSource(h.provStore, h.secretStore, oauth.DefaultProviderName) - if tid := store.TenantIDFromContext(ctx); tid != uuid.Nil { - ts.WithTenantID(tid) - } +func (h *OAuthHandler) saveAndRegister(ctx context.Context, providerName, displayName, apiBase string, tokenResp *oauth.OpenAITokenResponse) (uuid.UUID, error) { + ts := h.newTokenSource(ctx, providerName, displayName, apiBase) providerID, err := ts.SaveOAuthResult(ctx, tokenResp) if err != nil { return uuid.Nil, err @@ -224,8 +377,22 @@ func (h *OAuthHandler) saveAndRegister(ctx context.Context, tokenResp *oauth.Ope // Register CodexProvider in-memory for immediate use if h.providerReg != nil { - codex := providers.NewCodexProvider(oauth.DefaultProviderName, ts, "", "") - h.providerReg.Register(codex) + tid := oauthTenantID(ctx) + providerAPIBase := apiBase + codex := providers.NewCodexProvider(providerName, ts, providerAPIBase, "") + if h.provStore != nil { + providerCtx := store.WithTenantID(ctx, tid) + if providerData, err := h.provStore.GetProviderByName(providerCtx, providerName); err == nil { + if providerData.APIBase != "" { + providerAPIBase = providerData.APIBase + codex = providers.NewCodexProvider(providerName, ts, providerAPIBase, "") + } + if oauthSettings := store.ParseChatGPTOAuthProviderSettings(providerData.Settings); oauthSettings != nil { + codex.WithRoutingDefaults(oauthSettings.CodexPool.Strategy, oauthSettings.CodexPool.ExtraProviderNames) + } + } + } + h.providerReg.RegisterForTenant(tid, codex) } return providerID, nil diff --git a/internal/http/oauth_quota.go b/internal/http/oauth_quota.go new file mode 100644 index 00000000..5420ed86 --- /dev/null +++ b/internal/http/oauth_quota.go @@ -0,0 +1,40 @@ +package http + +import ( + "net/http" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/oauth" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func (h *OAuthHandler) handleQuota(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + providerName := oauthProviderName(r) + if !isValidSlug(providerName) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidSlug, "provider")}) + return + } + + provider, err := h.provStore.GetProviderByName(r.Context(), providerName) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{ + "error": err.Error(), + "code": "provider_not_found", + }) + return + } + if provider.ProviderType != store.ProviderChatGPTOAuth { + writeJSON(w, http.StatusConflict, map[string]string{ + "error": (&oauth.ProviderTypeConflictError{ + ProviderName: providerName, + ProviderType: provider.ProviderType, + }).Error(), + "code": "provider_type_conflict", + }) + return + } + + result := oauth.FetchOpenAIQuota(r.Context(), provider, h.newTokenSource(r.Context(), providerName, "", "")) + writeJSON(w, http.StatusOK, result) +} diff --git a/internal/http/oauth_quota_test.go b/internal/http/oauth_quota_test.go new file mode 100644 index 00000000..ba3bdb76 --- /dev/null +++ b/internal/http/oauth_quota_test.go @@ -0,0 +1,184 @@ +package http + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestOAuthHandlerQuotaSuccess(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("ChatGPT-Account-Id"); got != "acct_123" { + t.Fatalf("ChatGPT-Account-Id = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"plan_type":"team","rate_limit":{"primary_window":{"used_percent":24,"reset_after_seconds":60},"secondary_window":{"used_percent":38,"reset_after_seconds":3600}}}`)) + })) + defer server.Close() + + h, provStore, _ := newTestOAuthHandlerWithStores(t, "secret-token") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + createTestOAuthProvider(t, provStore, "openai-codex", store.ProviderChatGPTOAuth, server.URL, oauthSettingsJSON(t, map[string]any{ + "expires_at": time.Now().Add(time.Hour).Unix(), + "account_id": "acct_123", + "plan_type": "team", + }), true) + provStore.providers["openai-codex"].APIKey = "access-token" + + req := httptest.NewRequest(http.MethodGet, "/v1/auth/openai/quota", nil) + req.Header.Set("Authorization", "Bearer secret-token") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + + var result map[string]any + _ = json.NewDecoder(w.Body).Decode(&result) + if result["success"] != true { + t.Fatalf("success = %v, want true", result["success"]) + } +} + +func TestOAuthHandlerQuotaMissingProvider(t *testing.T) { + h := newTestOAuthHandler(t, "") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/v1/auth/chatgpt/missing/quota", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", w.Code) + } + + var result map[string]any + _ = json.NewDecoder(w.Body).Decode(&result) + if result["code"] != "provider_not_found" { + t.Fatalf("code = %v, want provider_not_found", result["code"]) + } +} + +func TestOAuthHandlerQuotaProviderConflict(t *testing.T) { + h, provStore, _ := newTestOAuthHandlerWithStores(t, "") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + createTestOAuthProvider(t, provStore, "openai-codex", store.ProviderOpenRouter, "https://example.com", nil, true) + + req := httptest.NewRequest(http.MethodGet, "/v1/auth/openai/quota", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("status = %d, want 409", w.Code) + } + + var result map[string]any + _ = json.NewDecoder(w.Body).Decode(&result) + if result["code"] != "provider_type_conflict" { + t.Fatalf("code = %v, want provider_type_conflict", result["code"]) + } +} + +func TestOAuthHandlerQuotaMissingAccountID(t *testing.T) { + h, provStore, _ := newTestOAuthHandlerWithStores(t, "") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + createTestOAuthProvider(t, provStore, "openai-codex", store.ProviderChatGPTOAuth, "https://example.com", oauthSettingsJSON(t, map[string]any{ + "expires_at": time.Now().Add(time.Hour).Unix(), + }), true) + provStore.providers["openai-codex"].APIKey = "access-token" + + req := httptest.NewRequest(http.MethodGet, "/v1/auth/openai/quota", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + var result map[string]any + _ = json.NewDecoder(w.Body).Decode(&result) + if result["success"] != false { + t.Fatalf("success = %v, want false", result["success"]) + } + if result["error_code"] != "missing_account_id" { + t.Fatalf("error_code = %v, want missing_account_id", result["error_code"]) + } +} + +func TestOAuthHandlerQuotaForbidden(t *testing.T) { + testOAuthHandlerQuotaFailureCode(t, http.StatusForbidden, "quota_api_forbidden", "is_forbidden") +} + +func TestOAuthHandlerQuotaReauthRequired(t *testing.T) { + testOAuthHandlerQuotaFailureCode(t, http.StatusUnauthorized, "reauth_required", "needs_reauth") +} + +func TestOAuthHandlerQuotaRateLimited(t *testing.T) { + testOAuthHandlerQuotaFailureCode(t, http.StatusTooManyRequests, "rate_limited", "retryable") +} + +func TestOAuthHandlerQuotaPaymentRequired(t *testing.T) { + testOAuthHandlerQuotaFailureCode(t, http.StatusPaymentRequired, "payment_required", "") +} + +func testOAuthHandlerQuotaFailureCode(t *testing.T, status int, errorCode, flag string) { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "upstream error", status) + })) + defer server.Close() + + h, provStore, _ := newTestOAuthHandlerWithStores(t, "") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + createTestOAuthProvider(t, provStore, "openai-codex", store.ProviderChatGPTOAuth, server.URL, oauthSettingsJSON(t, map[string]any{ + "expires_at": time.Now().Add(time.Hour).Unix(), + "account_id": "acct_123", + }), true) + provStore.providers["openai-codex"].APIKey = "access-token" + + req := httptest.NewRequest(http.MethodGet, "/v1/auth/openai/quota", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + var result map[string]any + _ = json.NewDecoder(w.Body).Decode(&result) + if result["error_code"] != errorCode { + t.Fatalf("error_code = %v, want %s", result["error_code"], errorCode) + } + if flag != "" && result[flag] != true { + t.Fatalf("%s = %v, want true", flag, result[flag]) + } + if status == http.StatusPaymentRequired && result["needs_reauth"] == true { + t.Fatalf("needs_reauth = %v, want false", result["needs_reauth"]) + } +} + +func createTestOAuthProvider(t *testing.T, provStore *mockProviderStore, name, providerType, apiBase string, settings json.RawMessage, enabled bool) { + t.Helper() + if settings == nil { + settings = json.RawMessage(`{}`) + } + provStore.providers[name] = &store.LLMProviderData{ + Name: name, + ProviderType: providerType, + APIBase: apiBase, + APIKey: "access-token", + Enabled: enabled, + Settings: settings, + } +} + +func oauthSettingsJSON(t *testing.T, value map[string]any) json.RawMessage { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + return json.RawMessage(data) +} diff --git a/internal/http/oauth_test.go b/internal/http/oauth_test.go index 372a7ccd..2dc5b881 100644 --- a/internal/http/oauth_test.go +++ b/internal/http/oauth_test.go @@ -6,10 +6,14 @@ import ( "fmt" "net/http" "net/http/httptest" + "strings" "testing" "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/oauth" + "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -130,6 +134,16 @@ func newTestOAuthHandler(t *testing.T, token string) *OAuthHandler { return NewOAuthHandler(newMockProviderStore(), newMockSecretsStore(), nil, nil) } +func newTestOAuthHandlerWithStores(t *testing.T, token string) (*OAuthHandler, *mockProviderStore, *mockSecretsStore) { + t.Helper() + old := pkgGatewayToken + pkgGatewayToken = token + t.Cleanup(func() { pkgGatewayToken = old }) + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + return NewOAuthHandler(provStore, secretStore, nil, nil), provStore, secretStore +} + // --- tests --- func TestOAuthHandlerStatusNoToken(t *testing.T) { @@ -178,6 +192,59 @@ func TestOAuthHandlerAuth(t *testing.T) { } } +func TestOAuthHandlerSaveAndRegisterAppliesCodexPoolDefaults(t *testing.T) { + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + providerReg := providers.NewRegistry(nil) + handler := NewOAuthHandler(provStore, secretStore, providerReg, nil) + + tenantID := uuid.New() + ctx := store.WithTenantID(context.Background(), tenantID) + if err := provStore.CreateProvider(ctx, &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: tenantID, + Name: oauth.DefaultProviderName, + ProviderType: store.ProviderChatGPTOAuth, + APIBase: oauth.DefaultProviderAPIBase, + Enabled: true, + Settings: json.RawMessage(`{ + "codex_pool": { + "strategy": "priority_order", + "extra_provider_names": ["openai-codex-team"] + } + }`), + }); err != nil { + t.Fatalf("CreateProvider: %v", err) + } + + if _, err := handler.saveAndRegister(ctx, oauth.DefaultProviderName, "", "", &oauth.OpenAITokenResponse{ + AccessToken: "access-token", + RefreshToken: "refresh-token", + ExpiresIn: 3600, + }); err != nil { + t.Fatalf("saveAndRegister: %v", err) + } + + runtimeProvider, err := providerReg.GetForTenant(tenantID, oauth.DefaultProviderName) + if err != nil { + t.Fatalf("GetForTenant: %v", err) + } + codex, ok := runtimeProvider.(*providers.CodexProvider) + if !ok { + t.Fatalf("runtime provider = %T, want *providers.CodexProvider", runtimeProvider) + } + defaults := codex.RoutingDefaults() + if defaults == nil { + t.Fatal("RoutingDefaults() = nil, want defaults") + } + if defaults.Strategy != store.ChatGPTOAuthStrategyPriority { + t.Fatalf("Strategy = %q, want %q", defaults.Strategy, store.ChatGPTOAuthStrategyPriority) + } + if len(defaults.ExtraProviderNames) != 1 || defaults.ExtraProviderNames[0] != "openai-codex-team" { + t.Fatalf("ExtraProviderNames = %#v, want [\"openai-codex-team\"]", defaults.ExtraProviderNames) + } +} + func TestOAuthHandlerLogoutNoProvider(t *testing.T) { h := newTestOAuthHandler(t, "") mux := http.NewServeMux() @@ -250,3 +317,267 @@ func TestOAuthHandlerStartReturnsAuthURL(t *testing.T) { t.Fatal("response has neither auth_url nor status") } } + +func TestOAuthHandlerProviderStatusRoute(t *testing.T) { + h, provStore, _ := newTestOAuthHandlerWithStores(t, "") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + if err := provStore.CreateProvider(context.Background(), &store.LLMProviderData{ + Name: "codex-work", + DisplayName: "Codex Work", + ProviderType: store.ProviderChatGPTOAuth, + APIBase: "https://chatgpt.com/backend-api", + APIKey: "token-work", + Enabled: true, + }); err != nil { + t.Fatalf("CreateProvider: %v", err) + } + + req := httptest.NewRequest("GET", "/v1/auth/chatgpt/codex-work/status", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d", w.Code, http.StatusOK) + } + + var result map[string]any + if err := json.NewDecoder(w.Body).Decode(&result); err != nil { + t.Fatalf("decode response: %v", err) + } + + if result["authenticated"] != true { + t.Fatalf("authenticated = %v, want true", result["authenticated"]) + } + if result["provider_name"] != "codex-work" { + t.Fatalf("provider_name = %v, want codex-work", result["provider_name"]) + } +} + +func TestOAuthHandlerProviderStatusRouteRejectsTypeConflict(t *testing.T) { + h, provStore, _ := newTestOAuthHandlerWithStores(t, "") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + if err := provStore.CreateProvider(context.Background(), &store.LLMProviderData{ + Name: "codex-work", + DisplayName: "Codex Work", + ProviderType: store.ProviderOpenRouter, + APIBase: "https://openrouter.ai/api/v1", + APIKey: "sk-live", + Enabled: true, + }); err != nil { + t.Fatalf("CreateProvider: %v", err) + } + + req := httptest.NewRequest("GET", "/v1/auth/chatgpt/codex-work/status", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Fatalf("status code = %d, want %d", w.Code, http.StatusConflict) + } +} + +func TestOAuthHandlerProviderLogoutRoute(t *testing.T) { + h, provStore, secretStore := newTestOAuthHandlerWithStores(t, "") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + if err := provStore.CreateProvider(context.Background(), &store.LLMProviderData{ + Name: "codex-work", + DisplayName: "Codex Work", + ProviderType: store.ProviderChatGPTOAuth, + APIBase: "https://chatgpt.com/backend-api", + APIKey: "token-work", + Enabled: true, + }); err != nil { + t.Fatalf("CreateProvider: %v", err) + } + if err := secretStore.Set(context.Background(), oauth.RefreshTokenSecretKey("codex-work"), "refresh-work"); err != nil { + t.Fatalf("Set refresh token: %v", err) + } + + req := httptest.NewRequest("POST", "/v1/auth/chatgpt/codex-work/logout", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d", w.Code, http.StatusOK) + } + if _, err := provStore.GetProviderByName(context.Background(), "codex-work"); err == nil { + t.Fatal("provider still exists after logout") + } + if _, err := secretStore.Get(context.Background(), oauth.RefreshTokenSecretKey("codex-work")); err == nil { + t.Fatal("refresh token still exists after logout") + } +} + +func TestProvidersHandlerRequiresAdmin(t *testing.T) { + token := "operator-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(token): { + ID: uuid.New(), + Scopes: []string{"operator.write"}, + TenantID: store.MasterTenantID, + }, + }) + + h := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), nil, "") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest("GET", "/v1/providers", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("status code = %d, want %d", w.Code, http.StatusForbidden) + } +} + +func TestOAuthHandlerRequiresAdmin(t *testing.T) { + token := "operator-key" + setupTestCache(t, map[string]*store.APIKeyData{ + crypto.HashAPIKey(token): { + ID: uuid.New(), + Scopes: []string{"operator.write"}, + TenantID: store.MasterTenantID, + }, + }) + h := newTestOAuthHandler(t, "") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest("GET", "/v1/auth/openai/status", nil) + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("status code = %d, want %d", w.Code, http.StatusForbidden) + } +} + +func TestOAuthHandlerStartReusesPendingFlowForSameRequester(t *testing.T) { + h := newTestOAuthHandler(t, "") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req1 := httptest.NewRequest("POST", "/v1/auth/chatgpt/codex-work/start", nil) + w1 := httptest.NewRecorder() + mux.ServeHTTP(w1, req1) + + if w1.Code == http.StatusInternalServerError { + t.Skip("port 1455 unavailable, skipping") + } + if w1.Code != http.StatusOK { + t.Fatalf("first start status = %d, want %d", w1.Code, http.StatusOK) + } + key := oauthFlowKey(req1.Context(), "codex-work") + firstPending := h.pending[key] + if firstPending == nil { + t.Fatal("pending flow = nil after first start") + } + defer func() { + firstPending.cancel() + firstPending.login.Shutdown() + }() + + req2 := httptest.NewRequest("POST", "/v1/auth/chatgpt/codex-work/start", nil) + w2 := httptest.NewRecorder() + mux.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("second start status = %d, want %d", w2.Code, http.StatusOK) + } + if h.pending[key] == nil { + t.Fatal("pending flow = nil after second start") + } + if h.pending[key] != firstPending { + t.Fatal("pending flow should be reused for the same requester") + } +} + +func TestOAuthHandlerStartRejectsConcurrentFlowForDifferentRequester(t *testing.T) { + h := newTestOAuthHandler(t, "") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req1 := httptest.NewRequest("POST", "/v1/auth/chatgpt/codex-work/start", nil) + req1.Header.Set("X-GoClaw-User-Id", "alice") + w1 := httptest.NewRecorder() + mux.ServeHTTP(w1, req1) + + if w1.Code == http.StatusInternalServerError { + t.Skip("port 1455 unavailable, skipping") + } + if w1.Code != http.StatusOK { + t.Fatalf("first start status = %d, want %d", w1.Code, http.StatusOK) + } + key := oauthFlowKey(req1.Context(), "codex-work") + firstPending := h.pending[key] + if firstPending == nil { + t.Fatal("pending flow = nil after first start") + } + defer func() { + firstPending.cancel() + firstPending.login.Shutdown() + }() + + req2 := httptest.NewRequest("POST", "/v1/auth/chatgpt/codex-personal/start", nil) + req2.Header.Set("X-GoClaw-User-Id", "bob") + w2 := httptest.NewRecorder() + mux.ServeHTTP(w2, req2) + + if w2.Code != http.StatusConflict { + t.Fatalf("second start status = %d, want %d", w2.Code, http.StatusConflict) + } + if h.pending[key] != firstPending { + t.Fatal("first pending flow should remain active") + } +} + +func TestOAuthHandlerManualCallbackRequiresSameRequester(t *testing.T) { + h := newTestOAuthHandler(t, "") + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req1 := httptest.NewRequest("POST", "/v1/auth/chatgpt/codex-work/start", nil) + req1.Header.Set("X-GoClaw-User-Id", "alice") + w1 := httptest.NewRecorder() + mux.ServeHTTP(w1, req1) + + if w1.Code == http.StatusInternalServerError { + t.Skip("port 1455 unavailable, skipping") + } + if w1.Code != http.StatusOK { + t.Fatalf("first start status = %d, want %d", w1.Code, http.StatusOK) + } + key := oauthFlowKey(req1.Context(), "codex-work") + firstPending := h.pending[key] + if firstPending == nil { + t.Fatal("pending flow = nil after first start") + } + defer func() { + firstPending.cancel() + firstPending.login.Shutdown() + }() + + req2 := httptest.NewRequest("POST", "/v1/auth/chatgpt/codex-work/callback", nil) + req2.Header.Set("X-GoClaw-User-Id", "bob") + req2 = httptest.NewRequest( + "POST", + "/v1/auth/chatgpt/codex-work/callback", + strings.NewReader(`{"redirect_url":"http://localhost:1455/auth/callback?code=test&state=wrong"}`), + ) + req2.Header.Set("X-GoClaw-User-Id", "bob") + w2 := httptest.NewRecorder() + mux.ServeHTTP(w2, req2) + + if w2.Code != http.StatusBadRequest { + t.Fatalf("callback status = %d, want %d", w2.Code, http.StatusBadRequest) + } +} diff --git a/internal/http/openapi_spec.json b/internal/http/openapi_spec.json index 683e1591..9f2e29d9 100644 --- a/internal/http/openapi_spec.json +++ b/internal/http/openapi_spec.json @@ -21,6 +21,7 @@ { "name": "Agents", "description": "Agent CRUD and configuration" }, { "name": "Sessions", "description": "Chat session management (via WebSocket RPC)" }, { "name": "Providers", "description": "LLM provider configuration" }, + { "name": "OAuth", "description": "Provider-scoped OAuth status and quota endpoints" }, { "name": "Skills", "description": "Skill management and grants" }, { "name": "MCP Servers", "description": "MCP server configuration and grants" }, { "name": "Custom Tools", "description": "Custom tool definitions" }, @@ -196,6 +197,112 @@ } } }, + "/v1/auth/chatgpt/{provider}/quota": { + "get": { + "tags": ["OAuth"], + "summary": "Fetch OpenAI Codex OAuth quota for a provider", + "description": "Returns structured Codex/OpenAI quota state for a specific OpenAI Codex OAuth (`chatgpt_oauth`) provider. Missing provider returns `404`, provider type conflicts return `409`, and upstream quota problems return `200` with `success=false` so dashboards can render inline action states.", + "parameters": [ + { + "name": "provider", + "in": "path", + "required": true, + "schema": { "type": "string" }, + "description": "Provider alias (for example `openai-codex` or `codex-work`)." + } + ], + "responses": { + "200": { + "description": "Provider quota payload", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["provider_name", "success", "windows", "last_updated"], + "properties": { + "provider_name": { "type": "string", "example": "openai-codex" }, + "success": { "type": "boolean" }, + "plan_type": { "type": "string", "nullable": true, "example": "team" }, + "windows": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { "type": "string", "example": "Primary" }, + "used_percent": { "type": "integer", "example": 24 }, + "remaining_percent": { "type": "integer", "example": 76 }, + "reset_after_seconds": { "type": "integer", "nullable": true, "example": 3600 }, + "reset_at": { "type": "string", "format": "date-time", "nullable": true } + } + } + }, + "core_usage": { + "type": "object", + "nullable": true, + "properties": { + "five_hour": { + "type": "object", + "nullable": true, + "properties": { + "label": { "type": "string" }, + "remaining_percent": { "type": "integer" }, + "reset_after_seconds": { "type": "integer", "nullable": true }, + "reset_at": { "type": "string", "format": "date-time", "nullable": true } + } + }, + "weekly": { + "type": "object", + "nullable": true, + "properties": { + "label": { "type": "string" }, + "remaining_percent": { "type": "integer" }, + "reset_after_seconds": { "type": "integer", "nullable": true }, + "reset_at": { "type": "string", "format": "date-time", "nullable": true } + } + } + } + }, + "last_updated": { "type": "string", "format": "date-time" }, + "error": { "type": "string" }, + "error_code": { "type": "string" }, + "action_hint": { "type": "string" }, + "needs_reauth": { "type": "boolean" }, + "is_forbidden": { "type": "boolean" }, + "retryable": { "type": "boolean" } + } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" } + } + } + }, + "/v1/auth/openai/quota": { + "get": { + "tags": ["OAuth"], + "summary": "Fetch quota for the default OpenAI Codex OAuth provider", + "description": "Compatibility alias for `/v1/auth/chatgpt/openai-codex/quota`.", + "responses": { + "200": { + "description": "Provider quota payload", + "content": { + "application/json": { + "schema": { + "$ref": "#/paths/~1v1~1auth~1chatgpt~1{provider}~1quota/get/responses/200/content/application~1json/schema" + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" } + } + } + }, "/v1/agents": { "get": { "tags": ["Agents"], @@ -242,6 +349,96 @@ "responses": { "200": { "description": "Agent deleted" }, "404": { "$ref": "#/components/responses/NotFound" } } } }, + "/v1/agents/{id}/codex-pool-activity": { + "get": { + "tags": ["Agents"], + "summary": "Summarize recent OpenAI Codex OAuth pool activity for an agent", + "description": "Returns explicit router-backed evidence for recent OpenAI Codex OAuth calls in the configured provider pool. Direct router selections are reported separately from failover serves, and each recent request shows both the selected alias and the alias that actually served the call.", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { "type": "integer", "default": 18, "minimum": 1, "maximum": 50 }, + "description": "Maximum number of recent routed Codex LLM calls to include." + } + ], + "responses": { + "200": { + "description": "Codex pool activity payload", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["strategy", "pool_providers", "stats_sample_size", "provider_counts", "recent_requests"], + "properties": { + "strategy": { "type": "string", "enum": ["primary_first", "round_robin", "priority_order"] }, + "pool_providers": { + "type": "array", + "items": { "type": "string" } + }, + "stats_sample_size": { + "type": "integer", + "description": "Number of recent routed llm_call spans used to derive runtime health. The server samples max(limit, 120) spans even when recent_requests remains capped by limit." + }, + "provider_counts": { + "type": "array", + "items": { + "type": "object", + "required": ["provider_name", "request_count", "direct_selection_count", "failover_serve_count", "success_count", "failure_count", "consecutive_failures", "success_rate", "health_score", "health_state"], + "properties": { + "provider_name": { "type": "string", "description": "Provider alias in the configured pool." }, + "request_count": { "type": "integer", "description": "Backward-compatible count of requests where this alias was directly selected by the router." }, + "direct_selection_count": { "type": "integer", "description": "Number of recent requests where the router selected this alias first." }, + "failover_serve_count": { "type": "integer", "description": "Number of recent requests this alias served after another alias was selected first." }, + "success_count": { "type": "integer", "description": "Trace-backed successes attributed to the alias that actually served the request." }, + "failure_count": { "type": "integer", "description": "Trace-backed failures attributed to earlier attempted aliases on successful failover, or to every attempted alias on terminal error." }, + "consecutive_failures": { "type": "integer", "description": "Newest-first failure streak for this alias." }, + "success_rate": { "type": "integer", "description": "Success rate percent derived from recent runtime outcomes." }, + "health_score": { "type": "integer", "description": "Heuristic runtime health score from 0 to 100." }, + "health_state": { "type": "string", "description": "Health band derived from recent runtime outcomes: idle when there are no outcomes, critical at 3+ consecutive failures or score < 40, degraded below 80, otherwise healthy.", "enum": ["idle", "healthy", "degraded", "critical"] }, + "last_selected_at": { "type": "string", "format": "date-time", "nullable": true }, + "last_failover_at": { "type": "string", "format": "date-time", "nullable": true }, + "last_used_at": { "type": "string", "format": "date-time", "nullable": true }, + "last_success_at": { "type": "string", "format": "date-time", "nullable": true }, + "last_failure_at": { "type": "string", "format": "date-time", "nullable": true } + } + } + }, + "recent_requests": { + "type": "array", + "items": { + "type": "object", + "required": ["span_id", "trace_id", "started_at", "status", "duration_ms", "provider_name", "model", "attempt_count", "used_failover"], + "properties": { + "span_id": { "type": "string", "format": "uuid" }, + "trace_id": { "type": "string", "format": "uuid" }, + "started_at": { "type": "string", "format": "date-time" }, + "status": { "type": "string" }, + "duration_ms": { "type": "integer" }, + "provider_name": { "type": "string", "description": "Alias that actually served the request. Empty when a terminal failure completed without any alias serving successfully." }, + "selected_provider": { "type": "string", "description": "Alias chosen first by the router before any failover." }, + "model": { "type": "string" }, + "attempt_count": { "type": "integer" }, + "used_failover": { "type": "boolean" }, + "failover_providers": { + "type": "array", + "items": { "type": "string" } + } + } + } + } + } + } + } + } + }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "$ref": "#/components/responses/NotFound" } + } + } + }, "/v1/agents/{id}/wake": { "post": { "tags": ["Agents"], @@ -640,7 +837,22 @@ "description": { "type": "string" }, "provider": { "type": "string", "description": "LLM provider name" }, "model": { "type": "string", "description": "Model ID" }, - "system_prompt": { "type": "string" } + "system_prompt": { "type": "string" }, + "other_config": { + "type": "object", + "description": "Optional per-agent JSON config.", + "properties": { + "chatgpt_oauth_routing": { + "type": "object", + "description": "Optional agent-side routing override for ChatGPT OAuth providers. The main provider field remains the preferred/default account, while provider settings may supply inherited defaults.", + "properties": { + "override_mode": { "type": "string", "enum": ["inherit", "custom"] }, + "strategy": { "type": "string", "enum": ["manual", "primary_first", "round_robin", "priority_order"] }, + "extra_provider_names": { "type": "array", "items": { "type": "string" } } + } + } + } + } } }, "ProviderInput": { @@ -649,7 +861,7 @@ "properties": { "name": { "type": "string", "description": "Unique provider slug" }, "display_name": { "type": "string" }, - "provider_type": { "type": "string", "enum": ["anthropic_native", "openai_compat", "gemini_native", "groq", "deepseek", "mistral", "xai", "ollama"] }, + "provider_type": { "type": "string", "enum": ["anthropic_native", "openai_compat", "gemini_native", "openrouter", "groq", "deepseek", "mistral", "xai", "minimax_native", "cohere", "perplexity", "dashscope", "bailian", "chatgpt_oauth", "claude_cli", "suno", "yescale", "zai", "zai_coding", "ollama", "ollama_cloud", "acp"] }, "api_base": { "type": "string" }, "api_key": { "type": "string" }, "enabled": { "type": "boolean", "default": true } @@ -674,6 +886,10 @@ "NotFound": { "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "Conflict": { + "description": "Resource state conflict", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } } } } diff --git a/internal/http/pending_messages.go b/internal/http/pending_messages.go index 3a105087..4b610f03 100644 --- a/internal/http/pending_messages.go +++ b/internal/http/pending_messages.go @@ -9,6 +9,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/providerresolve" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -183,7 +184,7 @@ func (h *PendingMessagesHandler) resolveProviderAndModel(ctx context.Context) (p // Fallback: default agent's provider+model. if h.agentStore != nil { if ag, err := h.agentStore.GetDefault(ctx); err == nil && ag.Provider != "" { - if p, err := h.providerReg.GetForTenant(ag.TenantID, ag.Provider); err == nil { + if p, err := providerresolve.ResolveConfiguredProvider(h.providerReg, ag); err == nil { model := ag.Model if model == "" { model = p.DefaultModel() diff --git a/internal/http/providers.go b/internal/http/providers.go index 28297411..f329f985 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -12,6 +12,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/i18n" "github.com/nextlevelbuilder/goclaw/internal/oauth" + "github.com/nextlevelbuilder/goclaw/internal/permissions" "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/pkg/protocol" @@ -22,10 +23,10 @@ type ProvidersHandler struct { store store.ProviderStore secretStore store.ConfigSecretsStore providerReg *providers.Registry - gatewayAddr string // for injecting MCP bridge into Claude CLI providers - mcpLookup providers.MCPServerLookup // optional: resolves per-agent MCP servers + gatewayAddr string // for injecting MCP bridge into Claude CLI providers + mcpLookup providers.MCPServerLookup // optional: resolves per-agent MCP servers apiBaseFallback func(providerType string) string // optional: config/env fallback for api_base - cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates + cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates msgBus *bus.MessageBus sysConfigStore store.SystemConfigStore } @@ -105,7 +106,7 @@ func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) { } func (h *ProvidersHandler) auth(next http.HandlerFunc) http.HandlerFunc { - return requireAuth("", next) + return requireAuth(permissions.RoleAdmin, next) } // maskAPIKey replaces non-empty API keys with "***". @@ -159,7 +160,11 @@ func (h *ProvidersHandler) registerInMemory(p *store.LLMProviderData) { switch p.ProviderType { case store.ProviderChatGPTOAuth: ts := oauth.NewDBTokenSource(h.store, h.secretStore, p.Name).WithTenantID(p.TenantID) - h.providerReg.RegisterForTenant(p.TenantID, providers.NewCodexProvider(p.Name, ts, apiBase, "")) + codex := providers.NewCodexProvider(p.Name, ts, apiBase, "") + if oauthSettings := store.ParseChatGPTOAuthProviderSettings(p.Settings); oauthSettings != nil { + codex.WithRoutingDefaults(oauthSettings.CodexPool.Strategy, oauthSettings.CodexPool.ExtraProviderNames) + } + h.providerReg.RegisterForTenant(p.TenantID, codex) case store.ProviderAnthropicNative: h.providerReg.RegisterForTenant(p.TenantID, providers.NewAnthropicProvider(p.APIKey, providers.WithAnthropicBaseURL(apiBase))) @@ -236,6 +241,11 @@ func (h *ProvidersHandler) handleCreateProvider(w http.ResponseWriter, r *http.R } } + if err := validateChatGPTOAuthProviderCandidate(r.Context(), h.store, uuid.Nil, &p); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + if err := h.store.CreateProvider(r.Context(), &p); err != nil { slog.Error("providers.create", "error", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) @@ -312,12 +322,49 @@ func (h *ProvidersHandler) handleUpdateProvider(w http.ResponseWriter, r *http.R // Allowlist: only permit known provider columns. updates = filterAllowedKeys(updates, providerAllowedFields) + currentProvider, err := h.store.GetProvider(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())}) + return + } + + candidate := *currentProvider + if name, ok := updates["name"].(string); ok && name != "" { + candidate.Name = name + } + if providerType, ok := updates["provider_type"].(string); ok && providerType != "" { + candidate.ProviderType = providerType + } + if apiKey, ok := updates["api_key"].(string); ok { + candidate.APIKey = apiKey + } + if apiBase, ok := updates["api_base"].(string); ok { + candidate.APIBase = apiBase + } + if enabled, ok := updates["enabled"].(bool); ok { + candidate.Enabled = enabled + } + if displayName, ok := updates["display_name"].(string); ok { + candidate.DisplayName = displayName + } + if settings, ok := updates["settings"]; ok { + rawSettings, err := marshalJSONRaw(settings) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)}) + return + } + candidate.Settings = rawSettings + } + + if err := validateChatGPTOAuthProviderCandidate(r.Context(), h.store, id, &candidate); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + // Track old name before update for registry cleanup var oldName string if h.providerReg != nil { - if old, err := h.store.GetProvider(r.Context(), id); err == nil { - oldName = old.Name - } + oldName = currentProvider.Name } if err := h.store.UpdateProvider(r.Context(), id, updates); err != nil { diff --git a/internal/http/providers_test.go b/internal/http/providers_test.go new file mode 100644 index 00000000..b949e95e --- /dev/null +++ b/internal/http/providers_test.go @@ -0,0 +1,52 @@ +package http + +import ( + "encoding/json" + "testing" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestProvidersHandlerRegisterInMemoryAppliesCodexPoolDefaults(t *testing.T) { + providerReg := providers.NewRegistry(nil) + handler := NewProvidersHandler(newMockProviderStore(), newMockSecretsStore(), providerReg, "") + + provider := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + TenantID: uuid.New(), + Name: "openai-codex", + ProviderType: store.ProviderChatGPTOAuth, + APIKey: "token", + Enabled: true, + Settings: json.RawMessage(`{ + "codex_pool": { + "strategy": "round_robin", + "extra_provider_names": ["codex-work"] + } + }`), + } + + handler.registerInMemory(provider) + + runtimeProvider, err := providerReg.GetForTenant(provider.TenantID, provider.Name) + if err != nil { + t.Fatalf("GetForTenant() error = %v", err) + } + codex, ok := runtimeProvider.(*providers.CodexProvider) + if !ok { + t.Fatalf("runtime provider = %T, want *providers.CodexProvider", runtimeProvider) + } + defaults := codex.RoutingDefaults() + if defaults == nil { + t.Fatal("RoutingDefaults() = nil, want defaults") + } + if defaults.Strategy != store.ChatGPTOAuthStrategyRoundRobin { + t.Fatalf("Strategy = %q, want %q", defaults.Strategy, store.ChatGPTOAuthStrategyRoundRobin) + } + if len(defaults.ExtraProviderNames) != 1 || defaults.ExtraProviderNames[0] != "codex-work" { + t.Fatalf("ExtraProviderNames = %#v, want [\"codex-work\"]", defaults.ExtraProviderNames) + } +} diff --git a/internal/http/summoner_utils.go b/internal/http/summoner_utils.go index b435ca58..e686d983 100644 --- a/internal/http/summoner_utils.go +++ b/internal/http/summoner_utils.go @@ -26,7 +26,7 @@ func extractIdentityName(content string) string { return "" } - for _, rawLine := range strings.Split(content, "\n") { + for rawLine := range strings.SplitSeq(content, "\n") { line := strings.TrimSpace(rawLine) switch { case strings.HasPrefix(line, "- **Name:**"): diff --git a/internal/mcp/manager_lazy_test.go b/internal/mcp/manager_lazy_test.go index 90f6e514..ed00a125 100644 --- a/internal/mcp/manager_lazy_test.go +++ b/internal/mcp/manager_lazy_test.go @@ -1,6 +1,7 @@ package mcp import ( + "slices" "sync" "testing" @@ -245,13 +246,7 @@ func TestRegistry_WiredToManager_ActivatedToolAppearsInList(t *testing.T) { reg.TryActivateDeferred(name) // Appears in List() after activation. - found := false - for _, n := range reg.List() { - if n == name { - found = true - break - } - } + found := slices.Contains(reg.List(), name) if !found { t.Errorf("tool %q should appear in registry.List() after activation", name) } diff --git a/internal/oauth/openai_claims.go b/internal/oauth/openai_claims.go new file mode 100644 index 00000000..ad8ad3f7 --- /dev/null +++ b/internal/oauth/openai_claims.go @@ -0,0 +1,126 @@ +package oauth + +import ( + "encoding/base64" + "encoding/json" + "strings" + "time" +) + +type openAITokenMetadata struct { + AccountID string + PlanType string +} + +type openAIJWTClaims struct { + Auth *openAIAuthClaims `json:"https://api.openai.com/auth"` + AccountID string `json:"https://api.openai.com/auth.chatgpt_account_id"` + PlanType string `json:"https://api.openai.com/auth.chatgpt_plan_type"` +} + +type openAIAuthClaims struct { + AccountID string `json:"chatgpt_account_id"` + PlanType string `json:"chatgpt_plan_type"` +} + +func parseOAuthSettings(raw json.RawMessage) OAuthSettings { + if len(raw) == 0 { + return OAuthSettings{} + } + var settings OAuthSettings + if err := json.Unmarshal(raw, &settings); err != nil { + return OAuthSettings{} + } + return settings +} + +func mergeOAuthSettings(existing OAuthSettings, tokenResp *OpenAITokenResponse, expiresAt time.Time) OAuthSettings { + settings := existing + settings.ExpiresAt = expiresAt.Unix() + + if scope := strings.TrimSpace(tokenResp.Scope); scope != "" { + settings.Scopes = scope + } + + metadata := openAIMetadataFromTokenResponse(tokenResp) + if metadata.AccountID != "" { + settings.AccountID = metadata.AccountID + } + if metadata.PlanType != "" { + settings.PlanType = metadata.PlanType + } + + return settings +} + +func openAIMetadataFromTokenResponse(tokenResp *OpenAITokenResponse) openAITokenMetadata { + for _, token := range []string{tokenResp.IDToken, tokenResp.AccessToken} { + metadata, ok := parseOpenAIJWTMetadata(token) + if ok { + return metadata + } + } + return openAITokenMetadata{} +} + +func parseOpenAIJWTMetadata(token string) (openAITokenMetadata, bool) { + token = strings.TrimSpace(token) + if token == "" { + return openAITokenMetadata{}, false + } + + parts := strings.Split(token, ".") + if len(parts) < 2 { + return openAITokenMetadata{}, false + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return openAITokenMetadata{}, false + } + + var claims openAIJWTClaims + if err := json.Unmarshal(payload, &claims); err != nil { + return openAITokenMetadata{}, false + } + + metadata := openAITokenMetadata{ + AccountID: firstNonEmpty( + strings.TrimSpace(claims.AccountID), + strings.TrimSpace(claims.NestedAccountID()), + ), + PlanType: firstNonEmpty( + strings.TrimSpace(claims.PlanType), + strings.TrimSpace(claims.NestedPlanType()), + ), + } + + if metadata.AccountID == "" && metadata.PlanType == "" { + return openAITokenMetadata{}, false + } + + return metadata, true +} + +func (c openAIJWTClaims) NestedAccountID() string { + if c.Auth == nil { + return "" + } + return c.Auth.AccountID +} + +func (c openAIJWTClaims) NestedPlanType() string { + if c.Auth == nil { + return "" + } + return c.Auth.PlanType +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/oauth/openai_claims_test.go b/internal/oauth/openai_claims_test.go new file mode 100644 index 00000000..dbbf90d2 --- /dev/null +++ b/internal/oauth/openai_claims_test.go @@ -0,0 +1,83 @@ +package oauth + +import ( + "encoding/base64" + "encoding/json" + "testing" + "time" +) + +func TestParseOpenAIJWTMetadataNestedClaims(t *testing.T) { + token := testJWT(t, map[string]any{ + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "acct_nested", + "chatgpt_plan_type": "team", + }, + }) + + metadata, ok := parseOpenAIJWTMetadata(token) + if !ok { + t.Fatal("parseOpenAIJWTMetadata() = false, want true") + } + if metadata.AccountID != "acct_nested" { + t.Fatalf("AccountID = %q, want acct_nested", metadata.AccountID) + } + if metadata.PlanType != "team" { + t.Fatalf("PlanType = %q, want team", metadata.PlanType) + } +} + +func TestParseOpenAIJWTMetadataFlatClaims(t *testing.T) { + token := testJWT(t, map[string]any{ + "https://api.openai.com/auth.chatgpt_account_id": "acct_flat", + "https://api.openai.com/auth.chatgpt_plan_type": "plus", + }) + + metadata, ok := parseOpenAIJWTMetadata(token) + if !ok { + t.Fatal("parseOpenAIJWTMetadata() = false, want true") + } + if metadata.AccountID != "acct_flat" { + t.Fatalf("AccountID = %q, want acct_flat", metadata.AccountID) + } + if metadata.PlanType != "plus" { + t.Fatalf("PlanType = %q, want plus", metadata.PlanType) + } +} + +func TestMergeOAuthSettingsPreservesExistingMetadata(t *testing.T) { + existing := OAuthSettings{ + ExpiresAt: time.Now().Add(-time.Hour).Unix(), + Scopes: "openid profile", + AccountID: "acct_keep", + PlanType: "team", + } + + settings := mergeOAuthSettings(existing, &OpenAITokenResponse{ + AccessToken: "not-a-jwt", + ExpiresIn: 3600, + }, time.Now().Add(time.Hour)) + + if settings.AccountID != "acct_keep" { + t.Fatalf("AccountID = %q, want acct_keep", settings.AccountID) + } + if settings.PlanType != "team" { + t.Fatalf("PlanType = %q, want team", settings.PlanType) + } + if settings.Scopes != "openid profile" { + t.Fatalf("Scopes = %q, want openid profile", settings.Scopes) + } +} + +func testJWT(t *testing.T, payload map[string]any) string { + t.Helper() + header, err := json.Marshal(map[string]string{"alg": "none", "typ": "JWT"}) + if err != nil { + t.Fatalf("marshal header: %v", err) + } + body, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + return base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(body) + "." +} diff --git a/internal/oauth/openai_quota.go b/internal/oauth/openai_quota.go new file mode 100644 index 00000000..73cbff47 --- /dev/null +++ b/internal/oauth/openai_quota.go @@ -0,0 +1,409 @@ +package oauth + +import ( + "context" + "encoding/json" + "log/slog" + "math" + "strings" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const ( + openAIQuotaTimeout = 12 * time.Second + openAIQuotaUserAgent = "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal" +) + +type OpenAIQuotaResult struct { + ProviderName string `json:"provider_name"` + Success bool `json:"success"` + PlanType string `json:"plan_type,omitempty"` + Windows []OpenAIQuotaWindow `json:"windows"` + CoreUsage *OpenAIQuotaCoreUsageSummary `json:"core_usage,omitempty"` + LastUpdated time.Time `json:"last_updated"` + Error string `json:"error,omitempty"` + ErrorCode string `json:"error_code,omitempty"` + ActionHint string `json:"action_hint,omitempty"` + NeedsReauth bool `json:"needs_reauth,omitempty"` + IsForbidden bool `json:"is_forbidden,omitempty"` + Retryable bool `json:"retryable,omitempty"` +} + +type OpenAIQuotaWindow struct { + Label string `json:"label"` + UsedPercent int `json:"used_percent"` + RemainingPercent int `json:"remaining_percent"` + ResetAfterSeconds *int `json:"reset_after_seconds"` + ResetAt *string `json:"reset_at"` +} + +type OpenAIQuotaCoreUsageSummary struct { + FiveHour *OpenAIQuotaCoreUsageWindow `json:"five_hour"` + Weekly *OpenAIQuotaCoreUsageWindow `json:"weekly"` +} + +type OpenAIQuotaCoreUsageWindow struct { + Label string `json:"label"` + RemainingPercent int `json:"remaining_percent"` + ResetAfterSeconds *int `json:"reset_after_seconds"` + ResetAt *string `json:"reset_at"` +} + +type openAIUsageResponse struct { + PlanType string `json:"plan_type"` + PlanTypeCamel string `json:"planType"` + RateLimit *openAIUsageWindows `json:"rate_limit"` + RateLimitCamel *openAIUsageWindows `json:"rateLimit"` + CodeReviewRateLimit *openAIUsageWindows `json:"code_review_rate_limit"` + CodeReviewCamel *openAIUsageWindows `json:"codeReviewRateLimit"` +} + +type openAIUsageWindows struct { + PrimaryWindow *openAIUsageWindow `json:"primary_window"` + PrimaryCamel *openAIUsageWindow `json:"primaryWindow"` + SecondaryWindow *openAIUsageWindow `json:"secondary_window"` + SecondaryCamel *openAIUsageWindow `json:"secondaryWindow"` +} + +type openAIUsageWindow struct { + UsedPercent *float64 `json:"used_percent"` + UsedPercentCamel *float64 `json:"usedPercent"` + ResetAfterSeconds *int `json:"reset_after_seconds"` + ResetAfterCamel *int `json:"resetAfterSeconds"` +} + +func FetchOpenAIQuota(ctx context.Context, provider *store.LLMProviderData, tokenSource *DBTokenSource) OpenAIQuotaResult { + settings := parseOAuthSettings(provider.Settings) + result := OpenAIQuotaResult{ + ProviderName: provider.Name, + PlanType: normalizeOpenAIPlanType(firstNonEmpty(settings.PlanType)), + Windows: []OpenAIQuotaWindow{}, + LastUpdated: time.Now().UTC(), + } + + if strings.TrimSpace(settings.AccountID) == "" { + if tokenSource != nil { + updatedProvider, err := tokenSource.BackfillProviderMetadata(ctx, provider) + if err != nil { + slog.Warn("oauth quota metadata backfill failed", "provider", provider.Name, "error", err) + } else if updatedProvider != nil { + provider = updatedProvider + settings = parseOAuthSettings(provider.Settings) + result.PlanType = normalizeOpenAIPlanType(firstNonEmpty(settings.PlanType)) + } + } + } + + if strings.TrimSpace(settings.AccountID) == "" { + return buildOpenAIQuotaFailure(result, "missing_account_id", "Quota metadata is missing for this account.", "Sign in again so GoClaw can restore the ChatGPT account workspace metadata.", false, false, false) + } + + token, err := tokenSource.Token() + if err != nil { + return buildOpenAIQuotaFailure(result, "reauth_required", "Token expired or invalid.", "Sign in again to refresh this OpenAI Codex account.", true, false, false) + } + + payload, err := providers.RetryDo(ctx, providers.RetryConfig{ + Attempts: 2, + MinDelay: 400 * time.Millisecond, + MaxDelay: 2 * time.Second, + Jitter: 0.1, + }, func() (*openAIUsageResponse, error) { + return requestOpenAIQuota(ctx, provider.APIBase, token, settings.AccountID) + }) + if err != nil { + return buildOpenAIQuotaRequestFailure(result, err) + } + + result.Success = true + result.PlanType = firstNonEmpty(normalizeOpenAIPlanType(payload.PlanType), normalizeOpenAIPlanType(payload.PlanTypeCamel), result.PlanType) + result.Windows = buildOpenAIQuotaWindows(payload) + result.CoreUsage = buildOpenAIQuotaCoreUsage(result.Windows) + return result +} + +func OpenAIQuotaRouteEligibility(result OpenAIQuotaResult) providers.RouteEligibility { + if result.Success { + signals := openAIQuotaCoreSignals(result) + if len(signals) == 0 { + return providers.RouteEligibility{Class: providers.RouteEligibilityUnknown, Reason: "unavailable"} + } + for _, signal := range signals { + if signal.RemainingPercent <= 0 { + return providers.RouteEligibility{Class: providers.RouteEligibilityBlocked, Reason: "exhausted"} + } + } + return providers.RouteEligibility{Class: providers.RouteEligibilityHealthy} + } + + switch { + case result.NeedsReauth: + return providers.RouteEligibility{Class: providers.RouteEligibilityBlocked, Reason: "reauth"} + case result.IsForbidden: + return providers.RouteEligibility{Class: providers.RouteEligibilityBlocked, Reason: "forbidden"} + case result.ErrorCode == "missing_account_id": + return providers.RouteEligibility{Class: providers.RouteEligibilityBlocked, Reason: "needs_setup"} + case result.ErrorCode == "payment_required": + return providers.RouteEligibility{Class: providers.RouteEligibilityBlocked, Reason: "billing"} + case result.Retryable: + return providers.RouteEligibility{Class: providers.RouteEligibilityUnknown, Reason: "retry_later"} + default: + return providers.RouteEligibility{Class: providers.RouteEligibilityUnknown, Reason: "unavailable"} + } +} + +func openAIQuotaCoreSignals(result OpenAIQuotaResult) []*OpenAIQuotaCoreUsageWindow { + if result.CoreUsage != nil { + signals := make([]*OpenAIQuotaCoreUsageWindow, 0, 2) + if result.CoreUsage.FiveHour != nil { + signals = append(signals, result.CoreUsage.FiveHour) + } + if result.CoreUsage.Weekly != nil { + signals = append(signals, result.CoreUsage.Weekly) + } + if len(signals) > 0 { + return signals + } + } + + windows := buildOpenAIQuotaCoreUsage(result.Windows) + if windows == nil { + return nil + } + + signals := make([]*OpenAIQuotaCoreUsageWindow, 0, 2) + if windows.FiveHour != nil { + signals = append(signals, windows.FiveHour) + } + if windows.Weekly != nil { + signals = append(signals, windows.Weekly) + } + return signals +} + +func buildOpenAIQuotaWindows(payload *openAIUsageResponse) []OpenAIQuotaWindow { + windows := make([]OpenAIQuotaWindow, 0, 4) + appendWindow := func(label string, window *openAIUsageWindow) { + if window == nil { + return + } + usedPercent := clampPercent(firstFloat(window.UsedPercent, window.UsedPercentCamel)) + resetAfter := firstInt(window.ResetAfterSeconds, window.ResetAfterCamel) + windows = append(windows, OpenAIQuotaWindow{ + Label: label, + UsedPercent: usedPercent, + RemainingPercent: maxInt(0, 100-usedPercent), + ResetAfterSeconds: resetAfter, + ResetAt: resetAtFromSeconds(resetAfter), + }) + } + + rateLimit := firstUsageWindows(payload.RateLimit, payload.RateLimitCamel) + codeReview := firstUsageWindows(payload.CodeReviewRateLimit, payload.CodeReviewCamel) + + appendWindow("Primary", firstUsageWindow(rateLimit, true)) + appendWindow("Secondary", firstUsageWindow(rateLimit, false)) + appendWindow("Code Review (Primary)", firstUsageWindow(codeReview, true)) + appendWindow("Code Review (Secondary)", firstUsageWindow(codeReview, false)) + return windows +} + +func buildOpenAIQuotaCoreUsage(windows []OpenAIQuotaWindow) *OpenAIQuotaCoreUsageSummary { + fiveHour := pickOpenAIQuotaWindow(windows, true) + weekly := pickOpenAIQuotaWindow(windows, false) + if countUsageWindows(windows) < 2 { + weekly = nil + } + if fiveHour != nil && weekly != nil && fiveHour.Label == weekly.Label && sameResetAt(fiveHour.ResetAt, weekly.ResetAt) { + weekly = nil + } + if fiveHour == nil && weekly == nil { + return nil + } + return &OpenAIQuotaCoreUsageSummary{ + FiveHour: coreUsageWindowFromQuota(fiveHour), + Weekly: coreUsageWindowFromQuota(weekly), + } +} + +func normalizeOpenAIPlanType(planType string) string { + planType = strings.ToLower(strings.TrimSpace(planType)) + return planType +} + +func clampPercent(raw float64) int { + raw = math.Max(0, math.Min(100, raw)) + return int(math.Round(raw)) +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func firstFloat(values ...*float64) float64 { + for _, value := range values { + if value != nil { + return *value + } + } + return 0 +} + +func firstInt(values ...*int) *int { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} + +func resetAtFromSeconds(resetAfter *int) *string { + if resetAfter == nil || *resetAfter <= 0 { + return nil + } + resetAt := time.Now().UTC().Add(time.Duration(*resetAfter) * time.Second).Format(time.RFC3339) + return &resetAt +} + +func marshalOAuthSettings(settings OAuthSettings) json.RawMessage { + data, _ := json.Marshal(settings) + return json.RawMessage(data) +} + +func marshalOAuthSettingsInto(raw json.RawMessage, settings OAuthSettings) json.RawMessage { + if len(raw) == 0 { + return marshalOAuthSettings(settings) + } + + next := make(map[string]any) + if err := json.Unmarshal(raw, &next); err != nil { + return marshalOAuthSettings(settings) + } + + next["expires_at"] = settings.ExpiresAt + if settings.Scopes != "" { + next["scopes"] = settings.Scopes + } else { + delete(next, "scopes") + } + if settings.AccountID != "" { + next["account_id"] = settings.AccountID + } else { + delete(next, "account_id") + } + if settings.PlanType != "" { + next["plan_type"] = settings.PlanType + } else { + delete(next, "plan_type") + } + + data, _ := json.Marshal(next) + return json.RawMessage(data) +} + +func firstUsageWindows(values ...*openAIUsageWindows) *openAIUsageWindows { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} + +func firstUsageWindow(windows *openAIUsageWindows, primary bool) *openAIUsageWindow { + if windows == nil { + return nil + } + if primary { + if windows.PrimaryWindow != nil { + return windows.PrimaryWindow + } + return windows.PrimaryCamel + } + if windows.SecondaryWindow != nil { + return windows.SecondaryWindow + } + return windows.SecondaryCamel +} + +func pickOpenAIQuotaWindow(windows []OpenAIQuotaWindow, pickShortest bool) *OpenAIQuotaWindow { + var labeled *OpenAIQuotaWindow + var unknown []*OpenAIQuotaWindow + for i := range windows { + window := &windows[i] + label := strings.ToLower(window.Label) + if strings.Contains(label, "code review") { + continue + } + if pickShortest && strings.Contains(label, "primary") { + return window + } + if !pickShortest && strings.Contains(label, "secondary") { + return window + } + unknown = append(unknown, window) + if labeled == nil { + labeled = window + } + } + if len(unknown) == 0 { + return labeled + } + best := unknown[0] + for _, window := range unknown[1:] { + if best.ResetAfterSeconds == nil { + best = window + continue + } + if window.ResetAfterSeconds == nil { + continue + } + if pickShortest && *window.ResetAfterSeconds < *best.ResetAfterSeconds { + best = window + } + if !pickShortest && *window.ResetAfterSeconds > *best.ResetAfterSeconds { + best = window + } + } + return best +} + +func coreUsageWindowFromQuota(window *OpenAIQuotaWindow) *OpenAIQuotaCoreUsageWindow { + if window == nil { + return nil + } + return &OpenAIQuotaCoreUsageWindow{ + Label: window.Label, + RemainingPercent: window.RemainingPercent, + ResetAfterSeconds: window.ResetAfterSeconds, + ResetAt: window.ResetAt, + } +} + +func countUsageWindows(windows []OpenAIQuotaWindow) int { + count := 0 + for _, window := range windows { + if strings.Contains(strings.ToLower(window.Label), "code review") { + continue + } + count += 1 + } + return count +} + +func sameResetAt(a, b *string) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return *a == *b +} diff --git a/internal/oauth/openai_quota_test.go b/internal/oauth/openai_quota_test.go new file mode 100644 index 00000000..f4fc8d00 --- /dev/null +++ b/internal/oauth/openai_quota_test.go @@ -0,0 +1,305 @@ +package oauth + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +func TestRequestOpenAIQuotaUsesExpectedHeaders(t *testing.T) { + oldClient := quotaHTTPClient + t.Cleanup(func() { quotaHTTPClient = oldClient }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/backend-api/wham/usage" { + t.Fatalf("path = %q, want /backend-api/wham/usage", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer access-token" { + t.Fatalf("Authorization = %q", got) + } + if got := r.Header.Get("ChatGPT-Account-Id"); got != "acct_123" { + t.Fatalf("ChatGPT-Account-Id = %q", got) + } + if got := r.Header.Get("User-Agent"); got != openAIQuotaUserAgent { + t.Fatalf("User-Agent = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"plan_type":"team","rate_limit":{"primary_window":{"used_percent":20,"reset_after_seconds":60},"secondary_window":{"used_percent":40,"reset_after_seconds":3600}}}`)) + })) + defer server.Close() + + quotaHTTPClient = server.Client() + payload, err := requestOpenAIQuota(t.Context(), server.URL+"/backend-api/", "access-token", "acct_123") + if err != nil { + t.Fatalf("requestOpenAIQuota() error = %v", err) + } + if payload.PlanType != "team" { + t.Fatalf("PlanType = %q, want team", payload.PlanType) + } +} + +func TestBuildOpenAIQuotaCoreUsageFallsBackToResetWindows(t *testing.T) { + short := 120 + long := 7200 + summary := buildOpenAIQuotaCoreUsage([]OpenAIQuotaWindow{ + {Label: "Window A", RemainingPercent: 81, ResetAfterSeconds: &short}, + {Label: "Window B", RemainingPercent: 52, ResetAfterSeconds: &long}, + }) + + if summary == nil || summary.FiveHour == nil || summary.Weekly == nil { + t.Fatal("expected both quota summary windows") + } + if summary.FiveHour.Label != "Window A" { + t.Fatalf("FiveHour.Label = %q, want Window A", summary.FiveHour.Label) + } + if summary.Weekly.Label != "Window B" { + t.Fatalf("Weekly.Label = %q, want Window B", summary.Weekly.Label) + } +} + +func TestBuildOpenAIQuotaCoreUsageDoesNotFabricateWeeklyFromSingleWindow(t *testing.T) { + short := 120 + summary := buildOpenAIQuotaCoreUsage([]OpenAIQuotaWindow{ + {Label: "Primary", RemainingPercent: 81, ResetAfterSeconds: &short}, + }) + + if summary == nil || summary.FiveHour == nil { + t.Fatal("expected five-hour quota summary") + } + if summary.Weekly != nil { + t.Fatalf("Weekly = %#v, want nil", summary.Weekly) + } +} + +func TestBuildOpenAIQuotaHTTPFailurePaymentRequiredDoesNotRequireReauth(t *testing.T) { + result := buildOpenAIQuotaHTTPFailure(OpenAIQuotaResult{}, http.StatusPaymentRequired, "billing blocked") + + if result.ErrorCode != "payment_required" { + t.Fatalf("ErrorCode = %q, want payment_required", result.ErrorCode) + } + if result.NeedsReauth { + t.Fatal("NeedsReauth = true, want false") + } + if result.Retryable { + t.Fatal("Retryable = true, want false") + } +} + +func TestOpenAIQuotaRouteEligibilityClassifiesSignals(t *testing.T) { + result := OpenAIQuotaResult{ + Success: true, + CoreUsage: &OpenAIQuotaCoreUsageSummary{ + FiveHour: &OpenAIQuotaCoreUsageWindow{RemainingPercent: 72}, + Weekly: &OpenAIQuotaCoreUsageWindow{RemainingPercent: 58}, + }, + } + + eligibility := OpenAIQuotaRouteEligibility(result) + if eligibility.Class != providers.RouteEligibilityHealthy { + t.Fatalf("eligibility.Class = %q, want %q", eligibility.Class, providers.RouteEligibilityHealthy) + } +} + +func TestOpenAIQuotaRouteEligibilityBlocksBillingAndExhausted(t *testing.T) { + billing := OpenAIQuotaRouteEligibility(OpenAIQuotaResult{ + Success: false, + ErrorCode: "payment_required", + }) + if billing.Class != providers.RouteEligibilityBlocked || billing.Reason != "billing" { + t.Fatalf("billing eligibility = %#v", billing) + } + + exhausted := OpenAIQuotaRouteEligibility(OpenAIQuotaResult{ + Success: true, + CoreUsage: &OpenAIQuotaCoreUsageSummary{ + FiveHour: &OpenAIQuotaCoreUsageWindow{RemainingPercent: 0}, + }, + }) + if exhausted.Class != providers.RouteEligibilityBlocked || exhausted.Reason != "exhausted" { + t.Fatalf("exhausted eligibility = %#v", exhausted) + } +} + +func TestOpenAIQuotaRouteEligibilityKeepsRetryableFailuresAsUnknown(t *testing.T) { + eligibility := OpenAIQuotaRouteEligibility(OpenAIQuotaResult{ + Success: false, + Retryable: true, + ErrorCode: "rate_limited", + }) + if eligibility.Class != providers.RouteEligibilityUnknown || eligibility.Reason != "retry_later" { + t.Fatalf("eligibility = %#v", eligibility) + } +} + +func TestFetchOpenAIQuotaBackfillsMetadataFromStoredJWT(t *testing.T) { + oldClient := quotaHTTPClient + oldRefresh := refreshOpenAITokenFunc + t.Cleanup(func() { quotaHTTPClient = oldClient }) + t.Cleanup(func() { refreshOpenAITokenFunc = oldRefresh }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got == "" { + t.Fatal("Authorization header is empty") + } + if got := r.Header.Get("ChatGPT-Account-Id"); got != "acct_jwt" { + t.Fatalf("ChatGPT-Account-Id = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"plan_type":"team","rate_limit":{"primary_window":{"used_percent":20,"reset_after_seconds":60},"secondary_window":{"used_percent":40,"reset_after_seconds":3600}}}`)) + })) + defer server.Close() + + quotaHTTPClient = server.Client() + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + provider := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + Name: DefaultProviderName, + ProviderType: store.ProviderChatGPTOAuth, + APIBase: server.URL + "/backend-api", + APIKey: "jwt-access-token", + Enabled: true, + Settings: marshalOAuthSettings(OAuthSettings{ + ExpiresAt: time.Now().Add(time.Hour).Unix(), + }), + } + provStore.providers[provider.Name] = provider + + refreshOpenAITokenFunc = func(string) (*OpenAITokenResponse, error) { + t.Fatal("refreshOpenAITokenFunc should not be called for JWT metadata backfill") + return nil, nil + } + + provider.APIKey = testJWT(t, map[string]any{ + "https://api.openai.com/auth.chatgpt_account_id": "acct_jwt", + "https://api.openai.com/auth.chatgpt_plan_type": "team", + }) + + result := FetchOpenAIQuota(t.Context(), provider, NewDBTokenSource(provStore, secretStore, provider.Name)) + if !result.Success { + t.Fatalf("Success = false, want true: %#v", result) + } + + settings := parseOAuthSettings(provider.Settings) + if settings.AccountID != "acct_jwt" { + t.Fatalf("AccountID = %q, want acct_jwt", settings.AccountID) + } + if settings.PlanType != "team" { + t.Fatalf("PlanType = %q, want team", settings.PlanType) + } +} + +func TestFetchOpenAIQuotaBackfillsMetadataFromRefresh(t *testing.T) { + oldClient := quotaHTTPClient + oldRefresh := refreshOpenAITokenFunc + t.Cleanup(func() { + quotaHTTPClient = oldClient + refreshOpenAITokenFunc = oldRefresh + }) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer refreshed-access-token" { + t.Fatalf("Authorization = %q", got) + } + if got := r.Header.Get("ChatGPT-Account-Id"); got != "acct_refresh" { + t.Fatalf("ChatGPT-Account-Id = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"plan_type":"team","rate_limit":{"primary_window":{"used_percent":12,"reset_after_seconds":60},"secondary_window":{"used_percent":31,"reset_after_seconds":3600}}}`)) + })) + defer server.Close() + + quotaHTTPClient = server.Client() + refreshOpenAITokenFunc = func(refreshToken string) (*OpenAITokenResponse, error) { + if refreshToken != "refresh-token" { + t.Fatalf("refresh token = %q, want refresh-token", refreshToken) + } + return &OpenAITokenResponse{ + AccessToken: "refreshed-access-token", + RefreshToken: "refresh-token-2", + ExpiresIn: 3600, + IDToken: testJWT(t, map[string]any{ + "https://api.openai.com/auth": map[string]any{ + "chatgpt_account_id": "acct_refresh", + "chatgpt_plan_type": "team", + }, + }), + }, nil + } + + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + secretStore.data[RefreshTokenSecretKey(DefaultProviderName)] = "refresh-token" + + provider := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + Name: DefaultProviderName, + ProviderType: store.ProviderChatGPTOAuth, + APIBase: server.URL + "/backend-api", + APIKey: "opaque-access-token", + Enabled: true, + Settings: marshalOAuthSettings(OAuthSettings{ + ExpiresAt: time.Now().Add(time.Hour).Unix(), + }), + } + provStore.providers[provider.Name] = provider + + result := FetchOpenAIQuota(t.Context(), provider, NewDBTokenSource(provStore, secretStore, provider.Name)) + if !result.Success { + t.Fatalf("Success = false, want true: %#v", result) + } + + settings := parseOAuthSettings(provider.Settings) + if settings.AccountID != "acct_refresh" { + t.Fatalf("AccountID = %q, want acct_refresh", settings.AccountID) + } + if settings.PlanType != "team" { + t.Fatalf("PlanType = %q, want team", settings.PlanType) + } + if provider.APIKey != "refreshed-access-token" { + t.Fatalf("APIKey = %q, want refreshed-access-token", provider.APIKey) + } +} + +func TestFetchOpenAIQuotaStillFailsWhenMetadataCannotBeRecovered(t *testing.T) { + oldRefresh := refreshOpenAITokenFunc + t.Cleanup(func() { refreshOpenAITokenFunc = oldRefresh }) + + refreshOpenAITokenFunc = func(string) (*OpenAITokenResponse, error) { + return &OpenAITokenResponse{ + AccessToken: "still-opaque", + ExpiresIn: 3600, + }, nil + } + + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + secretStore.data[RefreshTokenSecretKey(DefaultProviderName)] = "refresh-token" + + provider := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + Name: DefaultProviderName, + ProviderType: store.ProviderChatGPTOAuth, + APIBase: "https://example.com/backend-api", + APIKey: "opaque-access-token", + Enabled: true, + Settings: marshalOAuthSettings(OAuthSettings{ + ExpiresAt: time.Now().Add(time.Hour).Unix(), + }), + } + provStore.providers[provider.Name] = provider + + result := FetchOpenAIQuota(t.Context(), provider, NewDBTokenSource(provStore, secretStore, provider.Name)) + if result.Success { + t.Fatalf("Success = true, want false: %#v", result) + } + if result.ErrorCode != "missing_account_id" { + t.Fatalf("ErrorCode = %q, want missing_account_id", result.ErrorCode) + } +} diff --git a/internal/oauth/openai_quota_transport.go b/internal/oauth/openai_quota_transport.go new file mode 100644 index 00000000..41a0fe4f --- /dev/null +++ b/internal/oauth/openai_quota_transport.go @@ -0,0 +1,115 @@ +package oauth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + + "github.com/nextlevelbuilder/goclaw/internal/providers" +) + +var quotaHTTPClient = &http.Client{Timeout: openAIQuotaTimeout} + +func requestOpenAIQuota(ctx context.Context, apiBase, accessToken, accountID string) (*openAIUsageResponse, error) { + apiBase = strings.TrimRight(strings.TrimSpace(apiBase), "/") + if apiBase == "" { + apiBase = DefaultProviderAPIBase + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiBase+"/wham/usage", nil) + if err != nil { + return nil, fmt.Errorf("build quota request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("ChatGPT-Account-Id", accountID) + req.Header.Set("User-Agent", openAIQuotaUserAgent) + + resp, err := quotaHTTPClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode != http.StatusOK { + return nil, &providers.HTTPError{ + Status: resp.StatusCode, + Body: string(body), + RetryAfter: providers.ParseRetryAfter(resp.Header.Get("Retry-After")), + } + } + + var payload openAIUsageResponse + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("parse quota response: %w", err) + } + return &payload, nil +} + +func buildOpenAIQuotaRequestFailure(result OpenAIQuotaResult, err error) OpenAIQuotaResult { + var httpErr *providers.HTTPError + if errors.As(err, &httpErr) { + return buildOpenAIQuotaHTTPFailure(result, httpErr.Status, httpErr.Body) + } + + if errors.Is(err, context.DeadlineExceeded) { + return buildOpenAIQuotaFailure(result, "network_timeout", "Quota request timed out.", "Retry later. The upstream quota endpoint took too long to respond.", false, false, true) + } + + var netErr net.Error + if errors.As(err, &netErr) { + return buildOpenAIQuotaFailure(result, "network_error", "Quota request failed.", "Retry later or inspect network connectivity to the upstream quota endpoint.", false, false, true) + } + + return buildOpenAIQuotaFailure(result, "quota_request_failed", "Quota request failed.", "Retry later or inspect the upstream quota endpoint response.", false, false, false) +} + +func buildOpenAIQuotaHTTPFailure(result OpenAIQuotaResult, status int, body string) OpenAIQuotaResult { + switch status { + case http.StatusUnauthorized: + return buildOpenAIQuotaFailure(result, "reauth_required", "Token expired or invalid.", "Sign in again to refresh workspace access for this account.", true, false, false) + case http.StatusPaymentRequired: + return buildOpenAIQuotaFailure(result, "payment_required", "Workspace billing or entitlement is blocking quota access.", "Confirm the ChatGPT workspace plan or billing status for this account.", false, false, false) + case http.StatusForbidden: + return buildOpenAIQuotaFailure(result, "quota_api_forbidden", "Quota endpoint access is forbidden for this account.", "This account cannot read quota data from the upstream endpoint.", false, true, false) + case http.StatusNotFound: + return buildOpenAIQuotaFailure(result, "quota_endpoint_not_found", "Quota endpoint is unavailable.", "This provider API base does not expose the Codex quota endpoint.", false, false, false) + case http.StatusTooManyRequests: + return buildOpenAIQuotaFailure(result, "rate_limited", "Quota endpoint asked to retry later.", "Retry after a short delay. The upstream quota endpoint is rate limited right now.", false, false, true) + default: + if status >= http.StatusInternalServerError { + return buildOpenAIQuotaFailure(result, "provider_unavailable", "Quota service is temporarily unavailable.", "Retry later. This looks like a temporary upstream problem.", false, false, true) + } + _ = sanitizeOpenAIQuotaErrorBody(body) + return buildOpenAIQuotaFailure(result, "unknown_upstream_error", "Quota request failed.", "Inspect the upstream response and retry if appropriate.", false, false, false) + } +} + +func buildOpenAIQuotaFailure(result OpenAIQuotaResult, code, message, hint string, needsReauth, isForbidden, retryable bool) OpenAIQuotaResult { + result.Success = false + result.ErrorCode = code + result.Error = message + result.ActionHint = hint + result.NeedsReauth = needsReauth + result.IsForbidden = isForbidden + result.Retryable = retryable + return result +} + +func sanitizeOpenAIQuotaErrorBody(body string) string { + body = strings.TrimSpace(body) + if body == "" { + return "" + } + body = strings.ReplaceAll(body, "\n", " ") + body = strings.ReplaceAll(body, "\r", " ") + if len(body) > 240 { + return body[:226] + "...[truncated]" + } + return body +} diff --git a/internal/oauth/token.go b/internal/oauth/token.go index 9e9a7ff2..bae6b668 100644 --- a/internal/oauth/token.go +++ b/internal/oauth/token.go @@ -2,32 +2,57 @@ package oauth import ( "context" - "encoding/json" "fmt" "log/slog" + "strings" "sync" "time" "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/store" ) const ( - // DefaultProviderName is the provider name for ChatGPT OAuth. + // DefaultProviderName is the default alias for the primary OpenAI Codex OAuth account. DefaultProviderName = "openai-codex" + // DefaultProviderDisplayName stays empty so the UI can fall back to the alias unless the + // user explicitly sets a friendlier label for this OpenAI Codex OAuth account. + DefaultProviderDisplayName = "" + + // DefaultProviderAPIBase is the default API base for OpenAI Codex OAuth providers. + DefaultProviderAPIBase = "https://chatgpt.com/backend-api" + // refreshTokenSecretKey is the config_secrets key for the refresh token. refreshTokenSecretKey = "oauth.openai-codex.refresh_token" // refreshMargin is how early before expiry we refresh the token. refreshMargin = 5 * time.Minute + + routeEligibilityTTL = 20 * time.Second ) +var refreshOpenAITokenFunc = RefreshOpenAIToken + // OAuthSettings is stored in llm_providers.settings JSONB (non-sensitive metadata). type OAuthSettings struct { ExpiresAt int64 `json:"expires_at"` // unix timestamp Scopes string `json:"scopes,omitempty"` + AccountID string `json:"account_id,omitempty"` + PlanType string `json:"plan_type,omitempty"` +} + +// ProviderTypeConflictError reports that the requested OAuth provider name is +// already taken by a different provider type. +type ProviderTypeConflictError struct { + ProviderName string + ProviderType string +} + +func (e *ProviderTypeConflictError) Error() string { + return fmt.Sprintf("provider %q already exists as type %q", e.ProviderName, e.ProviderType) } // DBTokenSource provides a valid access token backed by the llm_providers + config_secrets tables. @@ -38,13 +63,22 @@ type DBTokenSource struct { providerName string tenantID uuid.UUID // tenant context for DB queries + providerDisplayName string + providerAPIBase string + mu sync.Mutex cachedToken string expiresAt time.Time + + cachedRouteEligibility providers.RouteEligibility + cachedRouteEligibilityAt time.Time } // NewDBTokenSource creates a DB-backed token source. func NewDBTokenSource(provStore store.ProviderStore, secretsStore store.ConfigSecretsStore, providerName string) *DBTokenSource { + if strings.TrimSpace(providerName) == "" { + providerName = DefaultProviderName + } return &DBTokenSource{ providerStore: provStore, secretsStore: secretsStore, @@ -59,6 +93,172 @@ func (ts *DBTokenSource) WithTenantID(tenantID uuid.UUID) *DBTokenSource { return ts } +// WithProviderMeta sets defaults used when creating or updating OAuth-backed providers. +func (ts *DBTokenSource) WithProviderMeta(displayName, apiBase string) *DBTokenSource { + if strings.TrimSpace(displayName) != "" { + ts.providerDisplayName = strings.TrimSpace(displayName) + } + if strings.TrimSpace(apiBase) != "" { + ts.providerAPIBase = strings.TrimSpace(apiBase) + } + return ts +} + +func (ts *DBTokenSource) resolvedDisplayName() string { + if ts.providerDisplayName != "" { + return ts.providerDisplayName + } + return DefaultProviderDisplayName +} + +func (ts *DBTokenSource) resolvedAPIBase() string { + if ts.providerAPIBase != "" { + return ts.providerAPIBase + } + return DefaultProviderAPIBase +} + +func (ts *DBTokenSource) RouteEligibility(ctx context.Context) providers.RouteEligibility { + ts.mu.Lock() + cached := ts.cachedRouteEligibility + cachedAt := ts.cachedRouteEligibilityAt + ts.mu.Unlock() + + if cached.Class != "" && time.Since(cachedAt) < routeEligibilityTTL { + return cached + } + + if ctx == nil { + ctx = context.Background() + } + if store.TenantIDFromContext(ctx) == uuid.Nil { + ctx = store.WithTenantID(ctx, ts.tenantID) + } + + eligibility := providers.RouteEligibility{Class: providers.RouteEligibilityUnknown, Reason: "unavailable"} + provider, err := ts.loadOAuthProvider(ctx) + if err == nil { + if !provider.Enabled { + eligibility = providers.RouteEligibility{Class: providers.RouteEligibilityBlocked, Reason: "disabled"} + } else { + eligibility = OpenAIQuotaRouteEligibility(FetchOpenAIQuota(ctx, provider, ts)) + } + } + + ts.mu.Lock() + ts.cachedRouteEligibility = eligibility + ts.cachedRouteEligibilityAt = time.Now() + ts.mu.Unlock() + return eligibility +} + +// RefreshTokenSecretKey returns the tenant-scoped secret key for a provider refresh token. +func RefreshTokenSecretKey(providerName string) string { + providerName = strings.TrimSpace(providerName) + if providerName == "" || providerName == DefaultProviderName { + return refreshTokenSecretKey + } + return fmt.Sprintf("oauth.%s.refresh_token", providerName) +} + +func (ts *DBTokenSource) loadOAuthProvider(ctx context.Context) (*store.LLMProviderData, error) { + p, err := ts.providerStore.GetProviderByName(ctx, ts.providerName) + if err != nil { + return nil, err + } + if p.ProviderType != store.ProviderChatGPTOAuth { + return nil, &ProviderTypeConflictError{ + ProviderName: ts.providerName, + ProviderType: p.ProviderType, + } + } + return p, nil +} + +func (ts *DBTokenSource) withTenantContext(ctx context.Context) context.Context { + if ctx == nil { + ctx = context.Background() + } + if store.TenantIDFromContext(ctx) == uuid.Nil { + ctx = store.WithTenantID(ctx, ts.tenantID) + } + return ctx +} + +func (ts *DBTokenSource) persistProviderMetadata(ctx context.Context, provider *store.LLMProviderData, metadata openAITokenMetadata) (*store.LLMProviderData, bool, error) { + settings := parseOAuthSettings(provider.Settings) + changed := false + + if settings.AccountID == "" && strings.TrimSpace(metadata.AccountID) != "" { + settings.AccountID = strings.TrimSpace(metadata.AccountID) + changed = true + } + if settings.PlanType == "" && strings.TrimSpace(metadata.PlanType) != "" { + settings.PlanType = strings.TrimSpace(metadata.PlanType) + changed = true + } + + if changed { + raw := marshalOAuthSettingsInto(provider.Settings, settings) + if err := ts.providerStore.UpdateProvider(ctx, provider.ID, map[string]any{ + "settings": raw, + }); err != nil { + return provider, false, err + } + provider.Settings = raw + ts.mu.Lock() + ts.cachedRouteEligibility = providers.RouteEligibility{} + ts.cachedRouteEligibilityAt = time.Time{} + ts.mu.Unlock() + } + + return provider, strings.TrimSpace(settings.AccountID) != "", nil +} + +func (ts *DBTokenSource) backfillProviderMetadataFromToken(ctx context.Context, provider *store.LLMProviderData, token string) (*store.LLMProviderData, bool, error) { + metadata, ok := parseOpenAIJWTMetadata(token) + if !ok { + return provider, false, nil + } + return ts.persistProviderMetadata(ctx, provider, metadata) +} + +// BackfillProviderMetadata restores missing ChatGPT workspace metadata for legacy OAuth providers. +// It first tries to recover metadata from the currently stored access token, then forces a token refresh +// so modern refresh responses can repopulate account settings when older providers were saved without them. +func (ts *DBTokenSource) BackfillProviderMetadata(ctx context.Context, provider *store.LLMProviderData) (*store.LLMProviderData, error) { + if provider == nil { + return nil, nil + } + + ctx = ts.withTenantContext(ctx) + settings := parseOAuthSettings(provider.Settings) + if strings.TrimSpace(settings.AccountID) != "" { + return provider, nil + } + + updatedProvider, recovered, err := ts.backfillProviderMetadataFromToken(ctx, provider, provider.APIKey) + if err != nil { + return provider, err + } + if recovered { + return updatedProvider, nil + } + + ts.mu.Lock() + refreshErr := ts.refresh(ctx) + ts.mu.Unlock() + if refreshErr != nil { + return provider, refreshErr + } + + refreshedProvider, err := ts.loadOAuthProvider(ctx) + if err != nil { + return provider, err + } + return refreshedProvider, nil +} + // Token returns a valid access token, refreshing if expired or about to expire. func (ts *DBTokenSource) Token() (string, error) { ts.mu.Lock() @@ -73,16 +273,13 @@ func (ts *DBTokenSource) Token() (string, error) { // Load from DB if not cached if ts.cachedToken == "" { - p, err := ts.providerStore.GetProviderByName(ctx, ts.providerName) + p, err := ts.loadOAuthProvider(ctx) if err != nil { return "", fmt.Errorf("load oauth provider %q: %w", ts.providerName, err) } ts.cachedToken = p.APIKey - var settings OAuthSettings - if len(p.Settings) > 0 { - _ = json.Unmarshal(p.Settings, &settings) - } + settings := parseOAuthSettings(p.Settings) if settings.ExpiresAt > 0 { ts.expiresAt = time.Unix(settings.ExpiresAt, 0) } @@ -105,13 +302,13 @@ func (ts *DBTokenSource) Token() (string, error) { // refresh gets the refresh token from config_secrets, calls RefreshOpenAIToken, and updates DB. func (ts *DBTokenSource) refresh(ctx context.Context) error { - refreshToken, err := ts.secretsStore.Get(ctx, refreshTokenSecretKey) + refreshToken, err := ts.secretsStore.Get(ctx, RefreshTokenSecretKey(ts.providerName)) if err != nil { return fmt.Errorf("get refresh token: %w", err) } slog.Info("refreshing OpenAI OAuth token") - newToken, err := RefreshOpenAIToken(refreshToken) + newToken, err := refreshOpenAITokenFunc(refreshToken) if err != nil { return err } @@ -119,28 +316,27 @@ func (ts *DBTokenSource) refresh(ctx context.Context) error { // Update cached values ts.cachedToken = newToken.AccessToken ts.expiresAt = time.Now().Add(time.Duration(newToken.ExpiresIn) * time.Second) + ts.cachedRouteEligibility = providers.RouteEligibility{} + ts.cachedRouteEligibilityAt = time.Time{} // Update provider api_key (access token) in DB - p, err := ts.providerStore.GetProviderByName(ctx, ts.providerName) + p, err := ts.loadOAuthProvider(ctx) if err != nil { return fmt.Errorf("get provider for update: %w", err) } - settings := OAuthSettings{ - ExpiresAt: ts.expiresAt.Unix(), - } - settingsJSON, _ := json.Marshal(settings) + settings := mergeOAuthSettings(parseOAuthSettings(p.Settings), newToken, ts.expiresAt) if err := ts.providerStore.UpdateProvider(ctx, p.ID, map[string]any{ "api_key": newToken.AccessToken, - "settings": json.RawMessage(settingsJSON), + "settings": marshalOAuthSettingsInto(p.Settings, settings), }); err != nil { slog.Warn("failed to persist refreshed access token", "error", err) } // Update refresh token if a new one was issued if newToken.RefreshToken != "" { - if err := ts.secretsStore.Set(ctx, refreshTokenSecretKey, newToken.RefreshToken); err != nil { + if err := ts.secretsStore.Set(ctx, RefreshTokenSecretKey(ts.providerName), newToken.RefreshToken); err != nil { slog.Warn("failed to persist new refresh token", "error", err) } } @@ -153,49 +349,58 @@ func (ts *DBTokenSource) refresh(ctx context.Context) error { // Returns the provider ID. func (ts *DBTokenSource) SaveOAuthResult(ctx context.Context, tokenResp *OpenAITokenResponse) (uuid.UUID, error) { expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) - settings := OAuthSettings{ - ExpiresAt: expiresAt.Unix(), - Scopes: tokenResp.Scope, - } - settingsJSON, _ := json.Marshal(settings) // Update cache ts.mu.Lock() ts.cachedToken = tokenResp.AccessToken ts.expiresAt = expiresAt + ts.cachedRouteEligibility = providers.RouteEligibility{} + ts.cachedRouteEligibilityAt = time.Time{} ts.mu.Unlock() // Check if provider already exists - existing, err := ts.providerStore.GetProviderByName(ctx, ts.providerName) + existing, err := ts.loadOAuthProvider(ctx) if err == nil { + settings := mergeOAuthSettings(parseOAuthSettings(existing.Settings), tokenResp, expiresAt) // Update existing provider - if err := ts.providerStore.UpdateProvider(ctx, existing.ID, map[string]any{ + updates := map[string]any{ "api_key": tokenResp.AccessToken, - "settings": json.RawMessage(settingsJSON), + "settings": marshalOAuthSettingsInto(existing.Settings, settings), "enabled": true, - }); err != nil { + } + if ts.providerDisplayName != "" { + updates["display_name"] = ts.providerDisplayName + } + if ts.providerAPIBase != "" { + updates["api_base"] = ts.providerAPIBase + } + if err := ts.providerStore.UpdateProvider(ctx, existing.ID, updates); err != nil { return uuid.Nil, fmt.Errorf("update provider: %w", err) } // Save refresh token if tokenResp.RefreshToken != "" { - if err := ts.secretsStore.Set(ctx, refreshTokenSecretKey, tokenResp.RefreshToken); err != nil { + if err := ts.secretsStore.Set(ctx, RefreshTokenSecretKey(ts.providerName), tokenResp.RefreshToken); err != nil { return uuid.Nil, fmt.Errorf("save refresh token: %w", err) } } return existing.ID, nil } + if _, ok := err.(*ProviderTypeConflictError); ok { + return uuid.Nil, err + } + settings := mergeOAuthSettings(OAuthSettings{}, tokenResp, expiresAt) // Create new provider p := &store.LLMProviderData{ Name: ts.providerName, - DisplayName: "ChatGPT (OAuth)", + DisplayName: ts.resolvedDisplayName(), ProviderType: store.ProviderChatGPTOAuth, - APIBase: "https://chatgpt.com/backend-api", + APIBase: ts.resolvedAPIBase(), APIKey: tokenResp.AccessToken, Enabled: true, - Settings: settingsJSON, + Settings: marshalOAuthSettings(settings), } if err := ts.providerStore.CreateProvider(ctx, p); err != nil { return uuid.Nil, fmt.Errorf("create provider: %w", err) @@ -203,7 +408,7 @@ func (ts *DBTokenSource) SaveOAuthResult(ctx context.Context, tokenResp *OpenAIT // Save refresh token if tokenResp.RefreshToken != "" { - if err := ts.secretsStore.Set(ctx, refreshTokenSecretKey, tokenResp.RefreshToken); err != nil { + if err := ts.secretsStore.Set(ctx, RefreshTokenSecretKey(ts.providerName), tokenResp.RefreshToken); err != nil { return uuid.Nil, fmt.Errorf("save refresh token: %w", err) } } @@ -216,14 +421,19 @@ func (ts *DBTokenSource) Delete(ctx context.Context) error { ts.mu.Lock() ts.cachedToken = "" ts.expiresAt = time.Time{} + ts.cachedRouteEligibility = providers.RouteEligibility{} + ts.cachedRouteEligibilityAt = time.Time{} ts.mu.Unlock() // Delete refresh token from config_secrets - _ = ts.secretsStore.Delete(ctx, refreshTokenSecretKey) + _ = ts.secretsStore.Delete(ctx, RefreshTokenSecretKey(ts.providerName)) // Delete provider from llm_providers - p, err := ts.providerStore.GetProviderByName(ctx, ts.providerName) + p, err := ts.loadOAuthProvider(ctx) if err != nil { + if _, ok := err.(*ProviderTypeConflictError); ok { + return err + } return nil // already gone } return ts.providerStore.DeleteProvider(ctx, p.ID) @@ -231,6 +441,6 @@ func (ts *DBTokenSource) Delete(ctx context.Context) error { // Exists checks if an OAuth provider exists and has a valid token. func (ts *DBTokenSource) Exists(ctx context.Context) bool { - p, err := ts.providerStore.GetProviderByName(ctx, ts.providerName) + p, err := ts.loadOAuthProvider(ctx) return err == nil && p.APIKey != "" } diff --git a/internal/oauth/token_test.go b/internal/oauth/token_test.go index 3dd15998..1dd5750e 100644 --- a/internal/oauth/token_test.go +++ b/internal/oauth/token_test.go @@ -3,6 +3,7 @@ package oauth import ( "context" "encoding/json" + "errors" "fmt" "testing" "time" @@ -209,6 +210,54 @@ func TestDBTokenSourceCaching(t *testing.T) { provStore.providers[DefaultProviderName] = p } +func TestDBTokenSourceSaveOAuthResultPreservesCodexPoolSettings(t *testing.T) { + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + ctx := context.Background() + existing := &store.LLMProviderData{ + BaseModel: store.BaseModel{ID: uuid.New()}, + Name: DefaultProviderName, + ProviderType: store.ProviderChatGPTOAuth, + APIBase: DefaultProviderAPIBase, + APIKey: "old-token", + Enabled: true, + Settings: json.RawMessage(`{ + "codex_pool": { + "strategy": "round_robin", + "extra_provider_names": ["openai-codex-backup"] + }, + "expires_at": 100 + }`), + } + if err := provStore.CreateProvider(ctx, existing); err != nil { + t.Fatalf("CreateProvider: %v", err) + } + + ts := NewDBTokenSource(provStore, secretStore, DefaultProviderName) + if _, err := ts.SaveOAuthResult(ctx, &OpenAITokenResponse{ + AccessToken: "new-token", + RefreshToken: "refresh-token", + ExpiresIn: 3600, + }); err != nil { + t.Fatalf("SaveOAuthResult: %v", err) + } + + updated, err := provStore.GetProviderByName(ctx, DefaultProviderName) + if err != nil { + t.Fatalf("GetProviderByName: %v", err) + } + settings := store.ParseChatGPTOAuthProviderSettings(updated.Settings) + if settings == nil || settings.CodexPool == nil { + t.Fatal("CodexPool settings lost after SaveOAuthResult") + } + if settings.CodexPool.Strategy != store.ChatGPTOAuthStrategyRoundRobin { + t.Fatalf("Strategy = %q, want %q", settings.CodexPool.Strategy, store.ChatGPTOAuthStrategyRoundRobin) + } + if len(settings.CodexPool.ExtraProviderNames) != 1 || settings.CodexPool.ExtraProviderNames[0] != "openai-codex-backup" { + t.Fatalf("ExtraProviderNames = %#v, want [\"openai-codex-backup\"]", settings.CodexPool.ExtraProviderNames) + } +} + func TestDBTokenSourceExists(t *testing.T) { provStore := newMockProviderStore() secretStore := newMockSecretsStore() @@ -261,6 +310,52 @@ func TestDBTokenSourceDelete(t *testing.T) { } } +func TestDBTokenSourceExistsIgnoresNonOAuthProvider(t *testing.T) { + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + ctx := context.Background() + + if err := provStore.CreateProvider(ctx, &store.LLMProviderData{ + Name: DefaultProviderName, + ProviderType: store.ProviderOpenRouter, + APIKey: "sk-live", + Enabled: true, + }); err != nil { + t.Fatalf("CreateProvider: %v", err) + } + + ts := NewDBTokenSource(provStore, secretStore, DefaultProviderName) + if ts.Exists(ctx) { + t.Fatal("Exists() = true, want false for non-chatgpt_oauth provider") + } +} + +func TestDBTokenSourceSaveOAuthResultRejectsProviderTypeConflict(t *testing.T) { + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + ctx := context.Background() + + if err := provStore.CreateProvider(ctx, &store.LLMProviderData{ + Name: DefaultProviderName, + ProviderType: store.ProviderOpenRouter, + APIKey: "sk-live", + Enabled: true, + }); err != nil { + t.Fatalf("CreateProvider: %v", err) + } + + ts := NewDBTokenSource(provStore, secretStore, DefaultProviderName) + _, err := ts.SaveOAuthResult(ctx, &OpenAITokenResponse{ + AccessToken: "oauth-token", + RefreshToken: "refresh-token", + ExpiresIn: 3600, + }) + var conflict *ProviderTypeConflictError + if err == nil || !errors.As(err, &conflict) { + t.Fatalf("SaveOAuthResult() error = %v, want ProviderTypeConflictError", err) + } +} + func TestDBTokenSourceUpdateExisting(t *testing.T) { provStore := newMockProviderStore() secretStore := newMockSecretsStore() @@ -330,3 +425,44 @@ func TestDBTokenSourceSettings(t *testing.T) { t.Errorf("ExpiresAt = %d, expected >= %d", settings.ExpiresAt, expectedMin-5) } } + +func TestDBTokenSourceProviderScopedRefreshTokens(t *testing.T) { + provStore := newMockProviderStore() + secretStore := newMockSecretsStore() + ctx := context.Background() + + first := NewDBTokenSource(provStore, secretStore, "codex-work") + second := NewDBTokenSource(provStore, secretStore, "codex-personal") + + if _, err := first.SaveOAuthResult(ctx, &OpenAITokenResponse{ + AccessToken: "token-work", + RefreshToken: "refresh-work", + ExpiresIn: 3600, + }); err != nil { + t.Fatalf("SaveOAuthResult work: %v", err) + } + + if _, err := second.SaveOAuthResult(ctx, &OpenAITokenResponse{ + AccessToken: "token-personal", + RefreshToken: "refresh-personal", + ExpiresIn: 3600, + }); err != nil { + t.Fatalf("SaveOAuthResult personal: %v", err) + } + + workToken, err := secretStore.Get(ctx, RefreshTokenSecretKey("codex-work")) + if err != nil { + t.Fatalf("Get work refresh token: %v", err) + } + if workToken != "refresh-work" { + t.Fatalf("work refresh token = %q, want %q", workToken, "refresh-work") + } + + personalToken, err := secretStore.Get(ctx, RefreshTokenSecretKey("codex-personal")) + if err != nil { + t.Fatalf("Get personal refresh token: %v", err) + } + if personalToken != "refresh-personal" { + t.Fatalf("personal refresh token = %q, want %q", personalToken, "refresh-personal") + } +} diff --git a/internal/providerresolve/agent_provider.go b/internal/providerresolve/agent_provider.go new file mode 100644 index 00000000..537e6744 --- /dev/null +++ b/internal/providerresolve/agent_provider.go @@ -0,0 +1,52 @@ +package providerresolve + +import ( + "fmt" + + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// ResolveConfiguredProvider resolves the provider an agent should actually use. +// It applies ChatGPT OAuth routing from agent other_config when present. +func ResolveConfiguredProvider(registry *providers.Registry, agent *store.AgentData) (providers.Provider, error) { + if registry == nil || agent == nil { + return nil, fmt.Errorf("provider registry unavailable") + } + + baseProvider, baseErr := registry.GetForTenant(agent.TenantID, agent.Provider) + if baseErr == nil { + if _, ok := baseProvider.(*providers.CodexProvider); !ok { + return baseProvider, nil + } + } + + var providerDefaults *store.ChatGPTOAuthRoutingConfig + if codex, ok := baseProvider.(*providers.CodexProvider); ok { + if defaults := codex.RoutingDefaults(); defaults != nil { + providerDefaults = &store.ChatGPTOAuthRoutingConfig{ + Strategy: defaults.Strategy, + ExtraProviderNames: defaults.ExtraProviderNames, + } + } + } + if routing := store.ResolveEffectiveChatGPTOAuthRouting(providerDefaults, agent.ParseChatGPTOAuthRouting()); routing != nil { + if routing.Strategy != store.ChatGPTOAuthStrategyPrimaryFirst || len(routing.ExtraProviderNames) > 0 { + router := providers.NewChatGPTOAuthRouter( + agent.TenantID, + registry, + agent.Provider, + routing.Strategy, + routing.ExtraProviderNames, + ) + if router != nil && router.HasRegisteredProviders() { + return router, nil + } + } + } + + if baseErr == nil { + return baseProvider, nil + } + return nil, baseErr +} diff --git a/internal/providerresolve/agent_provider_test.go b/internal/providerresolve/agent_provider_test.go new file mode 100644 index 00000000..4aa0b491 --- /dev/null +++ b/internal/providerresolve/agent_provider_test.go @@ -0,0 +1,235 @@ +package providerresolve + +import ( + "context" + "encoding/json" + "testing" + + "github.com/google/uuid" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type testTokenSource struct { + token string +} + +func (s *testTokenSource) Token() (string, error) { + return s.token, nil +} + +func (s *testTokenSource) RouteEligibility(context.Context) providers.RouteEligibility { + return providers.RouteEligibility{Class: providers.RouteEligibilityHealthy} +} + +type stubProvider struct { + name string + model string +} + +func (p *stubProvider) Chat(context.Context, providers.ChatRequest) (*providers.ChatResponse, error) { + return &providers.ChatResponse{FinishReason: "stop"}, nil +} + +func (p *stubProvider) ChatStream(context.Context, providers.ChatRequest, func(providers.StreamChunk)) (*providers.ChatResponse, error) { + return &providers.ChatResponse{FinishReason: "stop"}, nil +} + +func (p *stubProvider) DefaultModel() string { return p.model } +func (p *stubProvider) Name() string { return p.name } + +func TestResolveConfiguredProviderKeepsNonCodexBase(t *testing.T) { + tenantID := uuid.New() + registry := providers.NewRegistry(nil) + base := &stubProvider{name: "anthropic", model: "claude-sonnet-4"} + registry.RegisterForTenant(tenantID, base) + registry.RegisterForTenant(tenantID, providers.NewCodexProvider( + "openai-codex-backup", + &testTokenSource{token: "backup-token"}, + "http://127.0.0.1", + "gpt-5.4", + )) + + agent := &store.AgentData{ + TenantID: tenantID, + Provider: "anthropic", + OtherConfig: json.RawMessage(`{ + "chatgpt_oauth_routing": { + "strategy": "round_robin", + "extra_provider_names": ["openai-codex-backup"] + } + }`), + } + + resolved, err := ResolveConfiguredProvider(registry, agent) + if err != nil { + t.Fatalf("ResolveConfiguredProvider() error = %v", err) + } + if resolved != base { + t.Fatalf("ResolveConfiguredProvider() returned %T, want original non-Codex provider", resolved) + } +} + +func TestResolveConfiguredProviderUsesRouterForCodexAgents(t *testing.T) { + tenantID := uuid.New() + registry := providers.NewRegistry(nil) + registry.RegisterForTenant(tenantID, providers.NewCodexProvider( + "openai-codex", + &testTokenSource{token: "primary-token"}, + "http://127.0.0.1", + "gpt-5.4", + )) + registry.RegisterForTenant(tenantID, providers.NewCodexProvider( + "openai-codex-backup", + &testTokenSource{token: "backup-token"}, + "http://127.0.0.1", + "gpt-5.4", + )) + + agent := &store.AgentData{ + TenantID: tenantID, + Provider: "openai-codex", + OtherConfig: json.RawMessage(`{ + "chatgpt_oauth_routing": { + "strategy": "round_robin", + "extra_provider_names": ["openai-codex-backup"] + } + }`), + } + + resolved, err := ResolveConfiguredProvider(registry, agent) + if err != nil { + t.Fatalf("ResolveConfiguredProvider() error = %v", err) + } + router, ok := resolved.(*providers.ChatGPTOAuthRouter) + if !ok { + t.Fatalf("ResolveConfiguredProvider() returned %T, want *providers.ChatGPTOAuthRouter", resolved) + } + if !router.HasAvailableProviders() { + t.Fatal("router.HasAvailableProviders() = false, want true") + } + if router.Name() != "openai-codex" { + t.Fatalf("router.Name() = %q, want %q", router.Name(), "openai-codex") + } +} + +func TestResolveConfiguredProviderUsesProviderDefaultsWhenAgentHasNoOverride(t *testing.T) { + tenantID := uuid.New() + registry := providers.NewRegistry(nil) + registry.RegisterForTenant(tenantID, providers.NewCodexProvider( + "openai-codex", + &testTokenSource{token: "primary-token"}, + "http://127.0.0.1", + "gpt-5.4", + ).WithRoutingDefaults(store.ChatGPTOAuthStrategyRoundRobin, []string{"openai-codex-backup"})) + registry.RegisterForTenant(tenantID, providers.NewCodexProvider( + "openai-codex-backup", + &testTokenSource{token: "backup-token"}, + "http://127.0.0.1", + "gpt-5.4", + )) + + agent := &store.AgentData{ + TenantID: tenantID, + Provider: "openai-codex", + } + + resolved, err := ResolveConfiguredProvider(registry, agent) + if err != nil { + t.Fatalf("ResolveConfiguredProvider() error = %v", err) + } + router, ok := resolved.(*providers.ChatGPTOAuthRouter) + if !ok { + t.Fatalf("ResolveConfiguredProvider() returned %T, want *providers.ChatGPTOAuthRouter", resolved) + } + if !router.HasAvailableProviders() { + t.Fatal("router.HasAvailableProviders() = false, want true") + } +} + +func TestResolveConfiguredProviderKeepsExplicitSingleAccountOverride(t *testing.T) { + tenantID := uuid.New() + registry := providers.NewRegistry(nil) + baseProvider := providers.NewCodexProvider( + "openai-codex", + &testTokenSource{token: "primary-token"}, + "http://127.0.0.1", + "gpt-5.4", + ).WithRoutingDefaults(store.ChatGPTOAuthStrategyRoundRobin, []string{"openai-codex-backup"}) + registry.RegisterForTenant(tenantID, baseProvider) + registry.RegisterForTenant(tenantID, providers.NewCodexProvider( + "openai-codex-backup", + &testTokenSource{token: "backup-token"}, + "http://127.0.0.1", + "gpt-5.4", + )) + + agent := &store.AgentData{ + TenantID: tenantID, + Provider: "openai-codex", + OtherConfig: json.RawMessage(`{ + "chatgpt_oauth_routing": { + "strategy": "manual" + } + }`), + } + + resolved, err := ResolveConfiguredProvider(registry, agent) + if err != nil { + t.Fatalf("ResolveConfiguredProvider() error = %v", err) + } + if _, ok := resolved.(*providers.ChatGPTOAuthRouter); ok { + t.Fatalf("ResolveConfiguredProvider() returned %T, want base Codex provider", resolved) + } + if resolved.Name() != "openai-codex" { + t.Fatalf("resolved.Name() = %q, want %q", resolved.Name(), "openai-codex") + } +} + +type blockedTokenSource struct { + token string +} + +func (s *blockedTokenSource) Token() (string, error) { + return s.token, nil +} + +func (s *blockedTokenSource) RouteEligibility(context.Context) providers.RouteEligibility { + return providers.RouteEligibility{Class: providers.RouteEligibilityBlocked, Reason: "reauth"} +} + +func TestResolveConfiguredProviderReturnsRouterEvenWhenPrimaryNeedsFailover(t *testing.T) { + tenantID := uuid.New() + registry := providers.NewRegistry(nil) + registry.RegisterForTenant(tenantID, providers.NewCodexProvider( + "openai-codex", + &blockedTokenSource{token: "primary-token"}, + "http://127.0.0.1", + "gpt-5.4", + )) + registry.RegisterForTenant(tenantID, providers.NewCodexProvider( + "openai-codex-backup", + &testTokenSource{token: "backup-token"}, + "http://127.0.0.1", + "gpt-5.4", + )) + + agent := &store.AgentData{ + TenantID: tenantID, + Provider: "openai-codex", + OtherConfig: json.RawMessage(`{ + "chatgpt_oauth_routing": { + "strategy": "round_robin", + "extra_provider_names": ["openai-codex-backup"] + } + }`), + } + + resolved, err := ResolveConfiguredProvider(registry, agent) + if err != nil { + t.Fatalf("ResolveConfiguredProvider() error = %v", err) + } + if _, ok := resolved.(*providers.ChatGPTOAuthRouter); !ok { + t.Fatalf("ResolveConfiguredProvider() returned %T, want *providers.ChatGPTOAuthRouter", resolved) + } +} diff --git a/internal/providers/chatgpt_oauth_observation.go b/internal/providers/chatgpt_oauth_observation.go new file mode 100644 index 00000000..e4da025d --- /dev/null +++ b/internal/providers/chatgpt_oauth_observation.go @@ -0,0 +1,148 @@ +package providers + +import ( + "context" + "encoding/json" + "slices" + "sync" +) + +const ChatGPTOAuthRoutingMetadataKey = "chatgpt_oauth_routing" + +type chatGPTOAuthRoutingObservationKey struct{} + +type ChatGPTOAuthRoutingEvidence struct { + PoolOwnerProvider string `json:"pool_owner_provider,omitempty"` + Strategy string `json:"strategy,omitempty"` + PoolProviders []string `json:"pool_providers,omitempty"` + SelectedProvider string `json:"selected_provider,omitempty"` + ServingProvider string `json:"serving_provider,omitempty"` + AttemptedProviders []string `json:"attempted_providers,omitempty"` + FailoverProviders []string `json:"failover_providers,omitempty"` + AttemptCount int `json:"attempt_count,omitempty"` +} + +func (e ChatGPTOAuthRoutingEvidence) HasData() bool { + return e.SelectedProvider != "" || e.ServingProvider != "" || e.AttemptCount > 0 || len(e.PoolProviders) > 0 +} + +type ChatGPTOAuthRoutingObservation struct { + mu sync.Mutex + evidence ChatGPTOAuthRoutingEvidence +} + +func NewChatGPTOAuthRoutingObservation() *ChatGPTOAuthRoutingObservation { + return &ChatGPTOAuthRoutingObservation{} +} + +func WithChatGPTOAuthRoutingObservation(ctx context.Context, observation *ChatGPTOAuthRoutingObservation) context.Context { + return context.WithValue(ctx, chatGPTOAuthRoutingObservationKey{}, observation) +} + +func ChatGPTOAuthRoutingObservationFromContext(ctx context.Context) *ChatGPTOAuthRoutingObservation { + observation, _ := ctx.Value(chatGPTOAuthRoutingObservationKey{}).(*ChatGPTOAuthRoutingObservation) + return observation +} + +func (o *ChatGPTOAuthRoutingObservation) SetPool(poolOwnerProvider, strategy string, poolProviders []string) { + if o == nil { + return + } + o.mu.Lock() + defer o.mu.Unlock() + o.evidence.PoolOwnerProvider = poolOwnerProvider + o.evidence.Strategy = strategy + o.evidence.PoolProviders = append([]string(nil), poolProviders...) +} + +func (o *ChatGPTOAuthRoutingObservation) RecordAttempt(providerName string) { + if o == nil || providerName == "" { + return + } + o.mu.Lock() + defer o.mu.Unlock() + o.evidence.AttemptCount++ + if o.evidence.SelectedProvider == "" { + o.evidence.SelectedProvider = providerName + } + if !slices.Contains(o.evidence.AttemptedProviders, providerName) { + o.evidence.AttemptedProviders = append(o.evidence.AttemptedProviders, providerName) + } + if o.evidence.SelectedProvider != providerName && !slices.Contains(o.evidence.FailoverProviders, providerName) { + o.evidence.FailoverProviders = append(o.evidence.FailoverProviders, providerName) + } +} + +func (o *ChatGPTOAuthRoutingObservation) RecordSuccess(providerName string) { + if o == nil || providerName == "" { + return + } + o.mu.Lock() + defer o.mu.Unlock() + o.evidence.ServingProvider = providerName +} + +func (o *ChatGPTOAuthRoutingObservation) Snapshot() ChatGPTOAuthRoutingEvidence { + if o == nil { + return ChatGPTOAuthRoutingEvidence{} + } + o.mu.Lock() + defer o.mu.Unlock() + evidence := o.evidence + evidence.PoolProviders = append([]string(nil), o.evidence.PoolProviders...) + evidence.AttemptedProviders = append([]string(nil), o.evidence.AttemptedProviders...) + evidence.FailoverProviders = append([]string(nil), o.evidence.FailoverProviders...) + return evidence +} + +func MergeChatGPTOAuthRoutingMetadata(existing json.RawMessage, evidence ChatGPTOAuthRoutingEvidence) json.RawMessage { + if !evidence.HasData() { + return existing + } + payload := map[string]any{} + if len(existing) > 0 { + _ = json.Unmarshal(existing, &payload) + } + payload[ChatGPTOAuthRoutingMetadataKey] = evidence + data, err := json.Marshal(payload) + if err != nil { + return existing + } + return json.RawMessage(data) +} + +func ExtractChatGPTOAuthRoutingEvidence(raw json.RawMessage) ChatGPTOAuthRoutingEvidence { + if len(raw) == 0 { + return ChatGPTOAuthRoutingEvidence{} + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(raw, &payload); err != nil { + return ChatGPTOAuthRoutingEvidence{} + } + section := payload[ChatGPTOAuthRoutingMetadataKey] + if len(section) == 0 { + return ChatGPTOAuthRoutingEvidence{} + } + var evidence ChatGPTOAuthRoutingEvidence + if err := json.Unmarshal(section, &evidence); err != nil { + return ChatGPTOAuthRoutingEvidence{} + } + evidence.PoolProviders = uniqueStrings(evidence.PoolProviders) + evidence.AttemptedProviders = uniqueStrings(evidence.AttemptedProviders) + evidence.FailoverProviders = uniqueStrings(evidence.FailoverProviders) + return evidence +} + +func uniqueStrings(values []string) []string { + if len(values) == 0 { + return nil + } + result := make([]string, 0, len(values)) + for _, value := range values { + if value == "" || slices.Contains(result, value) { + continue + } + result = append(result, value) + } + return result +} diff --git a/internal/providers/chatgpt_oauth_observation_test.go b/internal/providers/chatgpt_oauth_observation_test.go new file mode 100644 index 00000000..73bc0d79 --- /dev/null +++ b/internal/providers/chatgpt_oauth_observation_test.go @@ -0,0 +1,42 @@ +package providers + +import ( + "testing" +) + +func TestChatGPTOAuthRoutingObservationRoundTrip(t *testing.T) { + observation := NewChatGPTOAuthRoutingObservation() + observation.SetPool("openai-codex", "round_robin", []string{"openai-codex", "codex-work"}) + observation.RecordAttempt("openai-codex") + observation.RecordAttempt("codex-work") + observation.RecordSuccess("codex-work") + + snapshot := observation.Snapshot() + if snapshot.PoolOwnerProvider != "openai-codex" { + t.Fatalf("PoolOwnerProvider = %q, want openai-codex", snapshot.PoolOwnerProvider) + } + if snapshot.Strategy != "round_robin" { + t.Fatalf("Strategy = %q, want round_robin", snapshot.Strategy) + } + if snapshot.SelectedProvider != "openai-codex" { + t.Fatalf("SelectedProvider = %q, want openai-codex", snapshot.SelectedProvider) + } + if snapshot.ServingProvider != "codex-work" { + t.Fatalf("ServingProvider = %q, want codex-work", snapshot.ServingProvider) + } + if snapshot.AttemptCount != 2 { + t.Fatalf("AttemptCount = %d, want 2", snapshot.AttemptCount) + } + + metadata := MergeChatGPTOAuthRoutingMetadata(nil, snapshot) + extracted := ExtractChatGPTOAuthRoutingEvidence(metadata) + if extracted.SelectedProvider != snapshot.SelectedProvider { + t.Fatalf("Extracted SelectedProvider = %q, want %q", extracted.SelectedProvider, snapshot.SelectedProvider) + } + if extracted.ServingProvider != snapshot.ServingProvider { + t.Fatalf("Extracted ServingProvider = %q, want %q", extracted.ServingProvider, snapshot.ServingProvider) + } + if len(extracted.FailoverProviders) != 1 || extracted.FailoverProviders[0] != "codex-work" { + t.Fatalf("FailoverProviders = %#v, want [codex-work]", extracted.FailoverProviders) + } +} diff --git a/internal/providers/chatgpt_oauth_router.go b/internal/providers/chatgpt_oauth_router.go new file mode 100644 index 00000000..39c18c39 --- /dev/null +++ b/internal/providers/chatgpt_oauth_router.go @@ -0,0 +1,258 @@ +package providers + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" + + "github.com/google/uuid" +) + +const chatGPTOAuthStrategyRoundRobin = "round_robin" +const chatGPTOAuthStrategyPriorityOrder = "priority_order" + +// ChatGPTOAuthRouter routes a ChatGPT OAuth-backed agent across multiple +// authenticated Codex providers while keeping the agent's primary provider as +// the preferred/default account. +type ChatGPTOAuthRouter struct { + tenantID uuid.UUID + registry *Registry + defaultProviderName string + extraProviderNames []string + strategy string + + mu sync.Mutex + next int +} + +type chatGPTOAuthRouteCandidate struct { + provider Provider + eligibility RouteEligibility +} + +// NewChatGPTOAuthRouter creates a provider wrapper for agent-side ChatGPT OAuth routing. +func NewChatGPTOAuthRouter( + tenantID uuid.UUID, + registry *Registry, + defaultProviderName string, + strategy string, + extraProviderNames []string, +) *ChatGPTOAuthRouter { + return &ChatGPTOAuthRouter{ + tenantID: tenantID, + registry: registry, + defaultProviderName: defaultProviderName, + extraProviderNames: extraProviderNames, + strategy: strategy, + } +} + +func (p *ChatGPTOAuthRouter) Name() string { + selection, err := p.orderedProviders(context.Background(), false) + if err != nil || len(selection) == 0 { + return p.defaultProviderName + } + return selection[0].Name() +} + +func (p *ChatGPTOAuthRouter) DefaultModel() string { + selection, err := p.orderedProviders(context.Background(), false) + if err != nil || len(selection) == 0 { + return "" + } + return selection[0].DefaultModel() +} + +func (p *ChatGPTOAuthRouter) SupportsThinking() bool { return true } + +func (p *ChatGPTOAuthRouter) HasRegisteredProviders() bool { + return len(p.registeredProviders()) > 0 +} + +// HasAvailableProviders reports whether at least one registered Codex provider is +// route-eligible right now after auth/quota readiness filtering. +func (p *ChatGPTOAuthRouter) HasAvailableProviders() bool { + _, err := p.orderedProviders(context.Background(), false) + return err == nil +} + +func (p *ChatGPTOAuthRouter) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) { + return p.call(ctx, func(provider Provider) (*ChatResponse, error) { + return provider.Chat(ctx, req) + }) +} + +func (p *ChatGPTOAuthRouter) ChatStream(ctx context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) { + return p.call(ctx, func(provider Provider) (*ChatResponse, error) { + return provider.ChatStream(ctx, req, onChunk) + }) +} + +func (p *ChatGPTOAuthRouter) call(ctx context.Context, fn func(Provider) (*ChatResponse, error)) (*ChatResponse, error) { + ordered, err := p.orderedProviders(ctx, true) + if err != nil { + return nil, err + } + if observation := ChatGPTOAuthRoutingObservationFromContext(ctx); observation != nil { + poolProviders := make([]string, 0, len(p.registeredProviders())) + for _, provider := range p.registeredProviders() { + poolProviders = append(poolProviders, provider.Name()) + } + observation.SetPool(p.defaultProviderName, p.strategy, poolProviders) + } + var lastErr error + for i, provider := range ordered { + if observation := ChatGPTOAuthRoutingObservationFromContext(ctx); observation != nil { + observation.RecordAttempt(provider.Name()) + } + resp, callErr := fn(provider) + if callErr == nil { + if observation := ChatGPTOAuthRoutingObservationFromContext(ctx); observation != nil { + observation.RecordSuccess(provider.Name()) + } + return resp, nil + } + lastErr = callErr + if !IsRetryableError(callErr) || i == len(ordered)-1 { + return nil, callErr + } + slog.Warn("chatgpt_oauth router failover", + "from", provider.Name(), + "to", ordered[i+1].Name(), + "error", callErr, + ) + } + return nil, lastErr +} + +func (p *ChatGPTOAuthRouter) orderedProviders(ctx context.Context, advance bool) ([]Provider, error) { + candidates := p.routeCandidates(ctx) + if len(candidates) == 0 { + return nil, fmt.Errorf("no authenticated chatgpt_oauth providers available") + } + + if p.strategy == chatGPTOAuthStrategyPriorityOrder { + return p.priorityOrderedProviders(candidates) + } + + healthy := make([]Provider, 0, len(candidates)) + unknown := make([]Provider, 0, len(candidates)) + blocked := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + switch candidate.eligibility.Class { + case RouteEligibilityHealthy: + healthy = append(healthy, candidate.provider) + case RouteEligibilityBlocked: + blocked = append(blocked, formatRouteBlockReason(candidate.provider.Name(), candidate.eligibility.Reason)) + default: + unknown = append(unknown, candidate.provider) + } + } + + active := healthy + fallback := unknown + if len(active) == 0 { + active = unknown + fallback = nil + } + if len(active) == 0 { + return nil, fmt.Errorf("no route-eligible chatgpt_oauth providers available: %s", strings.Join(blocked, ", ")) + } + if p.strategy != chatGPTOAuthStrategyRoundRobin || len(active) == 1 { + ordered := append([]Provider(nil), active...) + ordered = append(ordered, fallback...) + return ordered, nil + } + + start := 0 + p.mu.Lock() + if len(active) > 0 { + start = p.next % len(active) + if advance { + p.next = (p.next + 1) % len(active) + } + } + p.mu.Unlock() + + ordered := make([]Provider, 0, len(active)+len(fallback)) + ordered = append(ordered, active[start:]...) + ordered = append(ordered, active[:start]...) + ordered = append(ordered, fallback...) + return ordered, nil +} + +func (p *ChatGPTOAuthRouter) priorityOrderedProviders(candidates []chatGPTOAuthRouteCandidate) ([]Provider, error) { + ordered := make([]Provider, 0, len(candidates)) + blocked := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + if candidate.eligibility.Class == RouteEligibilityBlocked { + blocked = append(blocked, formatRouteBlockReason(candidate.provider.Name(), candidate.eligibility.Reason)) + continue + } + ordered = append(ordered, candidate.provider) + } + if len(ordered) == 0 { + return nil, fmt.Errorf("no route-eligible chatgpt_oauth providers available: %s", strings.Join(blocked, ", ")) + } + return ordered, nil +} + +func (p *ChatGPTOAuthRouter) routeCandidates(ctx context.Context) []chatGPTOAuthRouteCandidate { + registered := p.registeredProviders() + candidates := make([]chatGPTOAuthRouteCandidate, 0, len(registered)) + for _, provider := range registered { + eligibility := RouteEligibility{Class: RouteEligibilityHealthy} + if aware, ok := provider.(RouteEligibilityAware); ok { + eligibility = aware.RouteEligibility(ctx) + if eligibility.Class == "" { + eligibility.Class = RouteEligibilityUnknown + } + } + candidates = append(candidates, chatGPTOAuthRouteCandidate{ + provider: provider, + eligibility: eligibility, + }) + } + return candidates +} + +func (p *ChatGPTOAuthRouter) registeredProviders() []Provider { + if p.registry == nil { + return nil + } + names := make([]string, 0, 1+len(p.extraProviderNames)) + seen := make(map[string]bool, 1+len(p.extraProviderNames)) + appendName := func(name string) { + if name == "" || seen[name] { + return + } + seen[name] = true + names = append(names, name) + } + appendName(p.defaultProviderName) + for _, name := range p.extraProviderNames { + appendName(name) + } + + providers := make([]Provider, 0, len(names)) + for _, name := range names { + provider, err := p.registry.GetForTenant(p.tenantID, name) + if err != nil { + continue + } + if _, ok := provider.(*CodexProvider); !ok { + continue + } + providers = append(providers, provider) + } + return providers +} + +func formatRouteBlockReason(providerName, reason string) string { + if reason == "" { + return providerName + } + return providerName + ":" + reason +} diff --git a/internal/providers/chatgpt_oauth_router_test.go b/internal/providers/chatgpt_oauth_router_test.go new file mode 100644 index 00000000..21a1d125 --- /dev/null +++ b/internal/providers/chatgpt_oauth_router_test.go @@ -0,0 +1,280 @@ +package providers + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/uuid" +) + +type routeEligibilityTokenSource struct { + token string + eligibility RouteEligibility +} + +func (s *routeEligibilityTokenSource) Token() (string, error) { + return s.token, nil +} + +func (s *routeEligibilityTokenSource) RouteEligibility(context.Context) RouteEligibility { + return s.eligibility +} + +func TestChatGPTOAuthRouterRoundRobin(t *testing.T) { + var hitsA, hitsB int + serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitsA++ + writeSSEDone(w) + })) + defer serverA.Close() + + serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitsB++ + writeSSEDone(w) + })) + defer serverB.Close() + + tenantID := uuid.New() + registry := NewRegistry(nil) + providerA := NewCodexProvider("acct-a", &staticTokenSource{token: "token-a"}, serverA.URL, "gpt-5.4") + providerB := NewCodexProvider("acct-b", &staticTokenSource{token: "token-b"}, serverB.URL, "gpt-5.4") + providerA.retryConfig.Attempts = 1 + providerB.retryConfig.Attempts = 1 + registry.RegisterForTenant(tenantID, providerA) + registry.RegisterForTenant(tenantID, providerB) + + router := NewChatGPTOAuthRouter(tenantID, registry, "acct-a", "round_robin", []string{"acct-b"}) + + if _, err := router.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "first"}}, + }); err != nil { + t.Fatalf("first chat failed: %v", err) + } + if _, err := router.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "second"}}, + }); err != nil { + t.Fatalf("second chat failed: %v", err) + } + + if hitsA != 1 { + t.Fatalf("hitsA = %d, want 1", hitsA) + } + if hitsB != 1 { + t.Fatalf("hitsB = %d, want 1", hitsB) + } +} + +func TestChatGPTOAuthRouterFailoverOnRetryableError(t *testing.T) { + var hitsA, hitsB int + serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitsA++ + http.Error(w, "rate limited", http.StatusTooManyRequests) + })) + defer serverA.Close() + + serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitsB++ + writeSSEDone(w) + })) + defer serverB.Close() + + tenantID := uuid.New() + registry := NewRegistry(nil) + providerA := NewCodexProvider("acct-a", &staticTokenSource{token: "token-a"}, serverA.URL, "gpt-5.4") + providerB := NewCodexProvider("acct-b", &staticTokenSource{token: "token-b"}, serverB.URL, "gpt-5.4") + providerA.retryConfig.Attempts = 1 + providerB.retryConfig.Attempts = 1 + registry.RegisterForTenant(tenantID, providerA) + registry.RegisterForTenant(tenantID, providerB) + + router := NewChatGPTOAuthRouter(tenantID, registry, "acct-a", "round_robin", []string{"acct-b"}) + + if _, err := router.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "fail over"}}, + }); err != nil { + t.Fatalf("chat failed: %v", err) + } + + if hitsA != 1 { + t.Fatalf("hitsA = %d, want 1", hitsA) + } + if hitsB != 1 { + t.Fatalf("hitsB = %d, want 1", hitsB) + } +} + +func TestChatGPTOAuthRouterSkipsBlockedProviderBeforeFirstPick(t *testing.T) { + var hitsA, hitsB int + serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitsA++ + writeSSEDone(w) + })) + defer serverA.Close() + + serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitsB++ + writeSSEDone(w) + })) + defer serverB.Close() + + tenantID := uuid.New() + registry := NewRegistry(nil) + providerA := NewCodexProvider("acct-a", &routeEligibilityTokenSource{ + token: "token-a", + eligibility: RouteEligibility{Class: RouteEligibilityBlocked, Reason: "reauth"}, + }, serverA.URL, "gpt-5.4") + providerB := NewCodexProvider("acct-b", &routeEligibilityTokenSource{ + token: "token-b", + eligibility: RouteEligibility{Class: RouteEligibilityHealthy}, + }, serverB.URL, "gpt-5.4") + providerA.retryConfig.Attempts = 1 + providerB.retryConfig.Attempts = 1 + registry.RegisterForTenant(tenantID, providerA) + registry.RegisterForTenant(tenantID, providerB) + + router := NewChatGPTOAuthRouter(tenantID, registry, "acct-a", "manual", []string{"acct-b"}) + if _, err := router.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "route around blocked"}}, + }); err != nil { + t.Fatalf("chat failed: %v", err) + } + + if hitsA != 0 { + t.Fatalf("hitsA = %d, want 0", hitsA) + } + if hitsB != 1 { + t.Fatalf("hitsB = %d, want 1", hitsB) + } +} + +func TestChatGPTOAuthRouterRoundRobinPrefersHealthyBeforeUnknown(t *testing.T) { + var hitsA, hitsB, hitsC int + serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitsA++ + writeSSEDone(w) + })) + defer serverA.Close() + + serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitsB++ + writeSSEDone(w) + })) + defer serverB.Close() + + serverC := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitsC++ + writeSSEDone(w) + })) + defer serverC.Close() + + tenantID := uuid.New() + registry := NewRegistry(nil) + providerA := NewCodexProvider("acct-a", &routeEligibilityTokenSource{ + token: "token-a", + eligibility: RouteEligibility{Class: RouteEligibilityHealthy}, + }, serverA.URL, "gpt-5.4") + providerB := NewCodexProvider("acct-b", &routeEligibilityTokenSource{ + token: "token-b", + eligibility: RouteEligibility{Class: RouteEligibilityUnknown, Reason: "retry_later"}, + }, serverB.URL, "gpt-5.4") + providerC := NewCodexProvider("acct-c", &routeEligibilityTokenSource{ + token: "token-c", + eligibility: RouteEligibility{Class: RouteEligibilityHealthy}, + }, serverC.URL, "gpt-5.4") + providerA.retryConfig.Attempts = 1 + providerB.retryConfig.Attempts = 1 + providerC.retryConfig.Attempts = 1 + registry.RegisterForTenant(tenantID, providerA) + registry.RegisterForTenant(tenantID, providerB) + registry.RegisterForTenant(tenantID, providerC) + + router := NewChatGPTOAuthRouter(tenantID, registry, "acct-a", "round_robin", []string{"acct-b", "acct-c"}) + for i := range 2 { + if _, err := router.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "prefer healthy"}}, + }); err != nil { + t.Fatalf("chat %d failed: %v", i, err) + } + } + + if hitsA != 1 { + t.Fatalf("hitsA = %d, want 1", hitsA) + } + if hitsB != 0 { + t.Fatalf("hitsB = %d, want 0", hitsB) + } + if hitsC != 1 { + t.Fatalf("hitsC = %d, want 1", hitsC) + } +} + +func TestChatGPTOAuthRouterReportsWhenAllProvidersBlocked(t *testing.T) { + tenantID := uuid.New() + registry := NewRegistry(nil) + registry.RegisterForTenant(tenantID, NewCodexProvider("acct-a", &routeEligibilityTokenSource{ + token: "token-a", + eligibility: RouteEligibility{Class: RouteEligibilityBlocked, Reason: "reauth"}, + }, "http://127.0.0.1", "gpt-5.4")) + registry.RegisterForTenant(tenantID, NewCodexProvider("acct-b", &routeEligibilityTokenSource{ + token: "token-b", + eligibility: RouteEligibility{Class: RouteEligibilityBlocked, Reason: "exhausted"}, + }, "http://127.0.0.1", "gpt-5.4")) + + router := NewChatGPTOAuthRouter(tenantID, registry, "acct-a", "round_robin", []string{"acct-b"}) + _, err := router.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "all blocked"}}, + }) + if err == nil { + t.Fatal("router.Chat() error = nil, want blocked error") + } + if !strings.Contains(err.Error(), "reauth") || !strings.Contains(err.Error(), "exhausted") { + t.Fatalf("router.Chat() error = %q, want both block reasons", err.Error()) + } +} + +func TestChatGPTOAuthRouterPriorityOrderKeepsPrimaryAheadOfHealthyFallbacks(t *testing.T) { + var hitsA, hitsB int + serverA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitsA++ + writeSSEDone(w) + })) + defer serverA.Close() + + serverB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitsB++ + writeSSEDone(w) + })) + defer serverB.Close() + + tenantID := uuid.New() + registry := NewRegistry(nil) + providerA := NewCodexProvider("acct-a", &routeEligibilityTokenSource{ + token: "token-a", + eligibility: RouteEligibility{Class: RouteEligibilityUnknown, Reason: "retry_later"}, + }, serverA.URL, "gpt-5.4") + providerB := NewCodexProvider("acct-b", &routeEligibilityTokenSource{ + token: "token-b", + eligibility: RouteEligibility{Class: RouteEligibilityHealthy}, + }, serverB.URL, "gpt-5.4") + providerA.retryConfig.Attempts = 1 + providerB.retryConfig.Attempts = 1 + registry.RegisterForTenant(tenantID, providerA) + registry.RegisterForTenant(tenantID, providerB) + + router := NewChatGPTOAuthRouter(tenantID, registry, "acct-a", "priority_order", []string{"acct-b"}) + if _, err := router.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "priority first"}}, + }); err != nil { + t.Fatalf("chat failed: %v", err) + } + + if hitsA != 1 { + t.Fatalf("hitsA = %d, want 1", hitsA) + } + if hitsB != 0 { + t.Fatalf("hitsB = %d, want 0", hitsB) + } +} diff --git a/internal/providers/codex.go b/internal/providers/codex.go index 27dc7caa..1b733a80 100644 --- a/internal/providers/codex.go +++ b/internal/providers/codex.go @@ -10,16 +10,22 @@ import ( "strings" ) +type CodexRoutingDefaults struct { + Strategy string + ExtraProviderNames []string +} + // CodexProvider implements Provider for the OpenAI Responses API, // used with ChatGPT subscription via OAuth (Codex flow). // Wire format: POST /codex/responses on chatgpt.com backend. type CodexProvider struct { - name string - apiBase string // e.g. "https://api.openai.com/v1" or "https://chatgpt.com/backend-api" - defaultModel string - client *http.Client - retryConfig RetryConfig - tokenSource TokenSource + name string + apiBase string // e.g. "https://api.openai.com/v1" or "https://chatgpt.com/backend-api" + defaultModel string + client *http.Client + retryConfig RetryConfig + tokenSource TokenSource + routingDefaults *CodexRoutingDefaults } // NewCodexProvider creates a provider for the OpenAI Responses API with OAuth token. @@ -46,6 +52,28 @@ func NewCodexProvider(name string, tokenSource TokenSource, apiBase, defaultMode func (p *CodexProvider) Name() string { return p.name } func (p *CodexProvider) DefaultModel() string { return p.defaultModel } func (p *CodexProvider) SupportsThinking() bool { return true } +func (p *CodexProvider) WithRoutingDefaults(strategy string, extraProviderNames []string) *CodexProvider { + p.routingDefaults = &CodexRoutingDefaults{ + Strategy: strategy, + ExtraProviderNames: append([]string(nil), extraProviderNames...), + } + return p +} +func (p *CodexProvider) RoutingDefaults() *CodexRoutingDefaults { + if p.routingDefaults == nil { + return nil + } + return &CodexRoutingDefaults{ + Strategy: p.routingDefaults.Strategy, + ExtraProviderNames: append([]string(nil), p.routingDefaults.ExtraProviderNames...), + } +} +func (p *CodexProvider) RouteEligibility(ctx context.Context) RouteEligibility { + if aware, ok := p.tokenSource.(RouteEligibilityAware); ok { + return aware.RouteEligibility(ctx) + } + return RouteEligibility{Class: RouteEligibilityHealthy} +} func (p *CodexProvider) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) { // Codex Responses API requires stream=true; delegate to ChatStream with no chunk handler. diff --git a/internal/providers/registry.go b/internal/providers/registry.go index c06b0dc2..a141d4b2 100644 --- a/internal/providers/registry.go +++ b/internal/providers/registry.go @@ -139,8 +139,8 @@ func (r *Registry) ListForTenant(tenantID uuid.UUID) []string { // Add tenant-specific providers first if tenantID != MasterTenantID { for key := range r.providers { - if strings.HasPrefix(key, tenantPrefix) { - name := strings.TrimPrefix(key, tenantPrefix) + if after, ok := strings.CutPrefix(key, tenantPrefix); ok { + name := after if !seen[name] { seen[name] = true names = append(names, name) @@ -151,8 +151,8 @@ func (r *Registry) ListForTenant(tenantID uuid.UUID) []string { // Add master tenant providers (not already overridden) for key := range r.providers { - if strings.HasPrefix(key, masterPrefix) { - name := strings.TrimPrefix(key, masterPrefix) + if after, ok := strings.CutPrefix(key, masterPrefix); ok { + name := after if !seen[name] { seen[name] = true names = append(names, name) diff --git a/internal/providers/types.go b/internal/providers/types.go index 526982a7..4a3a7168 100644 --- a/internal/providers/types.go +++ b/internal/providers/types.go @@ -21,6 +21,23 @@ type TokenSource interface { Token() (string, error) } +type RouteEligibilityClass string + +const ( + RouteEligibilityHealthy RouteEligibilityClass = "healthy" + RouteEligibilityUnknown RouteEligibilityClass = "unknown" + RouteEligibilityBlocked RouteEligibilityClass = "blocked" +) + +type RouteEligibility struct { + Class RouteEligibilityClass + Reason string +} + +type RouteEligibilityAware interface { + RouteEligibility(ctx context.Context) RouteEligibility +} + // Provider is the interface all LLM providers must implement. type Provider interface { // Chat sends messages to the LLM and returns a response. @@ -86,22 +103,22 @@ type ImageContent struct { // Stored in session JSONB (~60 bytes each) instead of megabytes for base64. // On reload, MediaRefs are resolved to file paths and loaded into Images (for images). type MediaRef struct { - ID string `json:"id"` // unique media ID (uuid) - MimeType string `json:"mime_type"` // e.g. "image/jpeg", "application/pdf" - Kind string `json:"kind"` // "image", "video", "audio", "document" - Path string `json:"path,omitempty"` // absolute workspace path (persisted for /v1/files/ serving) + ID string `json:"id"` // unique media ID (uuid) + MimeType string `json:"mime_type"` // e.g. "image/jpeg", "application/pdf" + Kind string `json:"kind"` // "image", "video", "audio", "document" + Path string `json:"path,omitempty"` // absolute workspace path (persisted for /v1/files/ serving) } // Message represents a conversation message. type Message struct { - Role string `json:"role"` // "system", "user", "assistant", "tool" - Content string `json:"content"` - Thinking string `json:"thinking,omitempty"` // reasoning_content for thinking models (Kimi, DeepSeek, etc.) - Images []ImageContent `json:"-"` // vision: base64 images (runtime only, never persisted to DB) - MediaRefs []MediaRef `json:"media_refs,omitempty"` // persistent media file references - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` // for role="tool" responses - IsError bool `json:"is_error,omitempty"` // for role="tool" responses + Role string `json:"role"` // "system", "user", "assistant", "tool" + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` // reasoning_content for thinking models (Kimi, DeepSeek, etc.) + Images []ImageContent `json:"-"` // vision: base64 images (runtime only, never persisted to DB) + MediaRefs []MediaRef `json:"media_refs,omitempty"` // persistent media file references + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` // for role="tool" responses + IsError bool `json:"is_error,omitempty"` // for role="tool" responses // Phase is a Codex-specific field (gpt-5.3-codex) indicating message purpose. // Values: "commentary" (intermediate), "final_answer" (closeout), or "" (unset). diff --git a/internal/safego/recover_test.go b/internal/safego/recover_test.go index 12609726..094f0320 100644 --- a/internal/safego/recover_test.go +++ b/internal/safego/recover_test.go @@ -59,14 +59,12 @@ func TestRecover_Concurrent(t *testing.T) { errors := make(chan string, n) for range n { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { defer Recover(func(v any) { errors <- fmt.Sprint(v) }, "test", "concurrent") panic("concurrent boom") - }() + }) } wg.Wait() close(errors) diff --git a/internal/sandbox/docker_resolve.go b/internal/sandbox/docker_resolve.go index c90cf4cb..30d9004f 100644 --- a/internal/sandbox/docker_resolve.go +++ b/internal/sandbox/docker_resolve.go @@ -109,9 +109,9 @@ func detectContainerID() string { // Strategy 1: Parse /proc/self/mountinfo for docker container ID. // Lines contain paths like /docker/containers//... if data, err := os.ReadFile("/proc/self/mountinfo"); err == nil { - for _, line := range strings.Split(string(data), "\n") { - if idx := strings.Index(line, "/docker/containers/"); idx != -1 { - rest := line[idx+len("/docker/containers/"):] + for line := range strings.SplitSeq(string(data), "\n") { + if _, after, ok := strings.Cut(line, "/docker/containers/"); ok { + rest := after if slashIdx := strings.IndexByte(rest, '/'); slashIdx > 0 { id := rest[:slashIdx] if len(id) >= 12 { // Docker IDs are 64 hex chars, short form 12 diff --git a/internal/store/agent_store.go b/internal/store/agent_store.go index fac4089a..a0201871 100644 --- a/internal/store/agent_store.go +++ b/internal/store/agent_store.go @@ -32,9 +32,9 @@ const ( // Agent status constants. const ( - AgentStatusActive = "active" - AgentStatusInactive = "inactive" - AgentStatusSummoning = "summoning" + AgentStatusActive = "active" + AgentStatusInactive = "inactive" + AgentStatusSummoning = "summoning" AgentStatusSummonFailed = "summon_failed" ) @@ -43,18 +43,18 @@ type AgentData struct { BaseModel TenantID uuid.UUID `json:"tenant_id"` AgentKey string `json:"agent_key"` - DisplayName string `json:"display_name,omitempty"` - Frontmatter string `json:"frontmatter,omitempty"` // short expertise summary (NOT other_config.description which is the summoning prompt) - OwnerID string `json:"owner_id"` - Provider string `json:"provider"` - Model string `json:"model"` - ContextWindow int `json:"context_window"` - MaxToolIterations int `json:"max_tool_iterations"` - Workspace string `json:"workspace"` - RestrictToWorkspace bool `json:"restrict_to_workspace"` - AgentType string `json:"agent_type"` // "open" or "predefined" - IsDefault bool `json:"is_default"` - Status string `json:"status"` + DisplayName string `json:"display_name,omitempty"` + Frontmatter string `json:"frontmatter,omitempty"` // short expertise summary (NOT other_config.description which is the summoning prompt) + OwnerID string `json:"owner_id"` + Provider string `json:"provider"` + Model string `json:"model"` + ContextWindow int `json:"context_window"` + MaxToolIterations int `json:"max_tool_iterations"` + Workspace string `json:"workspace"` + RestrictToWorkspace bool `json:"restrict_to_workspace"` + AgentType string `json:"agent_type"` // "open" or "predefined" + IsDefault bool `json:"is_default"` + Status string `json:"status"` // Budget: optional monthly spending limit in cents (nil = unlimited) BudgetMonthlyCents *int `json:"budget_monthly_cents,omitempty"` @@ -242,11 +242,31 @@ func (a *AgentData) ParseSkillNudgeInterval() int { // When shared_dm/shared_group is true, users share the base workspace directory // instead of each getting an isolated subfolder. type WorkspaceSharingConfig struct { - SharedDM bool `json:"shared_dm"` - SharedGroup bool `json:"shared_group"` - SharedUsers []string `json:"shared_users,omitempty"` - ShareMemory bool `json:"share_memory"` - ShareKnowledgeGraph bool `json:"share_knowledge_graph"` + SharedDM bool `json:"shared_dm"` + SharedGroup bool `json:"shared_group"` + SharedUsers []string `json:"shared_users,omitempty"` + ShareMemory bool `json:"share_memory"` + ShareKnowledgeGraph bool `json:"share_knowledge_graph"` +} + +const ( + ChatGPTOAuthStrategyManual = "manual" // legacy alias + ChatGPTOAuthStrategyPrimaryFirst = "primary_first" + ChatGPTOAuthStrategyRoundRobin = "round_robin" + ChatGPTOAuthStrategyPriority = "priority_order" +) + +const ( + ChatGPTOAuthOverrideInherit = "inherit" + ChatGPTOAuthOverrideCustom = "custom" +) + +// ChatGPTOAuthRoutingConfig controls optional multi-account selection for agents +// whose primary provider is a ChatGPT OAuth-backed provider. +type ChatGPTOAuthRoutingConfig struct { + OverrideMode string `json:"override_mode,omitempty"` + Strategy string `json:"strategy,omitempty"` + ExtraProviderNames []string `json:"extra_provider_names,omitempty"` } // ParseWorkspaceSharing extracts workspace_sharing from other_config JSONB. @@ -267,6 +287,146 @@ func (a *AgentData) ParseWorkspaceSharing() *WorkspaceSharingConfig { return cfg.WS } +// ParseChatGPTOAuthRouting extracts chatgpt_oauth_routing from other_config JSONB. +// Returns nil when no routing is configured. +func (a *AgentData) ParseChatGPTOAuthRouting() *ChatGPTOAuthRoutingConfig { + if len(a.OtherConfig) == 0 { + return nil + } + var cfg struct { + Routing *ChatGPTOAuthRoutingConfig `json:"chatgpt_oauth_routing"` + } + if json.Unmarshal(a.OtherConfig, &cfg) != nil || cfg.Routing == nil { + return nil + } + explicitOverrideMode := strings.TrimSpace(cfg.Routing.OverrideMode) != "" + explicitStrategy := strings.TrimSpace(cfg.Routing.Strategy) != "" + explicitExtras := cfg.Routing.ExtraProviderNames != nil + routing := normalizeChatGPTOAuthRoutingConfig(cfg.Routing) + if routing == nil { + if !explicitOverrideMode && !explicitStrategy && !explicitExtras { + return nil + } + overrideMode := ChatGPTOAuthOverrideCustom + if explicitOverrideMode { + overrideMode = normalizeChatGPTOAuthOverrideMode(cfg.Routing.OverrideMode) + } + return &ChatGPTOAuthRoutingConfig{ + OverrideMode: overrideMode, + Strategy: normalizeChatGPTOAuthStrategy(cfg.Routing.Strategy), + ExtraProviderNames: normalizeProviderNames(cfg.Routing.ExtraProviderNames), + } + } + if explicitOverrideMode { + return routing + } + if explicitStrategy || explicitExtras { + routing.OverrideMode = ChatGPTOAuthOverrideCustom + return routing + } + routing.OverrideMode = "" + if routing.Strategy == ChatGPTOAuthStrategyPrimaryFirst && len(routing.ExtraProviderNames) == 0 { + return nil + } + return routing +} + +func normalizeChatGPTOAuthRoutingConfig(cfg *ChatGPTOAuthRoutingConfig) *ChatGPTOAuthRoutingConfig { + if cfg == nil { + return nil + } + routing := &ChatGPTOAuthRoutingConfig{ + OverrideMode: normalizeChatGPTOAuthOverrideMode(cfg.OverrideMode), + Strategy: normalizeChatGPTOAuthStrategy(cfg.Strategy), + ExtraProviderNames: normalizeProviderNames(cfg.ExtraProviderNames), + } + if routing.OverrideMode == "" && routing.Strategy == ChatGPTOAuthStrategyPrimaryFirst && len(routing.ExtraProviderNames) == 0 { + return nil + } + return routing +} + +func normalizeChatGPTOAuthOverrideMode(value string) string { + switch value { + case ChatGPTOAuthOverrideInherit: + return ChatGPTOAuthOverrideInherit + case "", ChatGPTOAuthOverrideCustom: + return ChatGPTOAuthOverrideCustom + default: + return ChatGPTOAuthOverrideCustom + } +} + +func normalizeChatGPTOAuthStrategy(value string) string { + switch value { + case "", ChatGPTOAuthStrategyManual, ChatGPTOAuthStrategyPrimaryFirst: + return ChatGPTOAuthStrategyPrimaryFirst + case ChatGPTOAuthStrategyRoundRobin, ChatGPTOAuthStrategyPriority: + return value + default: + return ChatGPTOAuthStrategyPrimaryFirst + } +} + +func CloneChatGPTOAuthRoutingConfig(cfg *ChatGPTOAuthRoutingConfig) *ChatGPTOAuthRoutingConfig { + if cfg == nil { + return nil + } + clone := *cfg + clone.ExtraProviderNames = append([]string(nil), cfg.ExtraProviderNames...) + return &clone +} + +func ResolveEffectiveChatGPTOAuthRouting(defaults, agentRouting *ChatGPTOAuthRoutingConfig) *ChatGPTOAuthRoutingConfig { + normalizedDefaults := normalizeChatGPTOAuthRoutingConfig(defaults) + normalizedAgent := normalizeChatGPTOAuthRoutingConfig(agentRouting) + if normalizedAgent == nil { + return CloneChatGPTOAuthRoutingConfig(normalizedDefaults) + } + if normalizedAgent.OverrideMode == ChatGPTOAuthOverrideInherit { + return CloneChatGPTOAuthRoutingConfig(normalizedDefaults) + } + effective := CloneChatGPTOAuthRoutingConfig(normalizedAgent) + if effective == nil { + return nil + } + effective.OverrideMode = "" + if normalizedDefaults != nil && len(normalizedDefaults.ExtraProviderNames) > 0 { + if effective.Strategy == ChatGPTOAuthStrategyPrimaryFirst && + len(normalizedAgent.ExtraProviderNames) == 0 { + effective.ExtraProviderNames = nil + } else { + effective.ExtraProviderNames = append([]string(nil), normalizedDefaults.ExtraProviderNames...) + } + } + if effective.Strategy == ChatGPTOAuthStrategyPrimaryFirst && + len(effective.ExtraProviderNames) == 0 && + normalizedAgent.OverrideMode != ChatGPTOAuthOverrideCustom { + return nil + } + return effective +} + +func normalizeProviderNames(names []string) []string { + if len(names) == 0 { + return nil + } + seen := make(map[string]bool, len(names)) + out := make([]string, 0, len(names)) + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" || seen[name] { + continue + } + seen[name] = true + out = append(out, name) + } + if len(out) == 0 { + return nil + } + return out +} + // ParseShellDenyGroups extracts shell_deny_groups from other_config JSONB. // Returns nil if not configured (all defaults apply). func (a *AgentData) ParseShellDenyGroups() map[string]bool { @@ -374,4 +534,3 @@ type UserInstanceData struct { FileCount int `json:"file_count"` Metadata map[string]string `json:"metadata,omitempty"` } - diff --git a/internal/store/agent_store_test.go b/internal/store/agent_store_test.go new file mode 100644 index 00000000..6af59945 --- /dev/null +++ b/internal/store/agent_store_test.go @@ -0,0 +1,178 @@ +package store + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestParseChatGPTOAuthRoutingNormalizesNames(t *testing.T) { + agent := &AgentData{ + OtherConfig: json.RawMessage(`{ + "chatgpt_oauth_routing": { + "strategy": "round_robin", + "extra_provider_names": [" openai-codex-backup ", "", "openai-codex-backup", "openai-codex-team"] + } + }`), + } + + got := agent.ParseChatGPTOAuthRouting() + if got == nil { + t.Fatal("ParseChatGPTOAuthRouting() = nil, want config") + } + if got.Strategy != ChatGPTOAuthStrategyRoundRobin { + t.Fatalf("Strategy = %q, want %q", got.Strategy, ChatGPTOAuthStrategyRoundRobin) + } + if got.OverrideMode != ChatGPTOAuthOverrideCustom { + t.Fatalf("OverrideMode = %q, want %q", got.OverrideMode, ChatGPTOAuthOverrideCustom) + } + + wantExtras := []string{"openai-codex-backup", "openai-codex-team"} + if !reflect.DeepEqual(got.ExtraProviderNames, wantExtras) { + t.Fatalf("ExtraProviderNames = %#v, want %#v", got.ExtraProviderNames, wantExtras) + } +} + +func TestParseChatGPTOAuthRoutingFallsBackToManual(t *testing.T) { + agent := &AgentData{ + OtherConfig: json.RawMessage(`{ + "chatgpt_oauth_routing": { + "strategy": "something_else", + "extra_provider_names": ["openai-codex-backup"] + } + }`), + } + + got := agent.ParseChatGPTOAuthRouting() + if got == nil { + t.Fatal("ParseChatGPTOAuthRouting() = nil, want config") + } + if got.Strategy != ChatGPTOAuthStrategyPrimaryFirst { + t.Fatalf("Strategy = %q, want %q", got.Strategy, ChatGPTOAuthStrategyPrimaryFirst) + } +} + +func TestParseChatGPTOAuthRoutingManualWithoutExtrasPreservesExplicitSingleAccount(t *testing.T) { + agent := &AgentData{ + OtherConfig: json.RawMessage(`{ + "chatgpt_oauth_routing": { + "strategy": "manual", + "extra_provider_names": [] + } + }`), + } + + got := agent.ParseChatGPTOAuthRouting() + if got == nil { + t.Fatal("ParseChatGPTOAuthRouting() = nil, want config") + } + if got.OverrideMode != ChatGPTOAuthOverrideCustom { + t.Fatalf("OverrideMode = %q, want %q", got.OverrideMode, ChatGPTOAuthOverrideCustom) + } + if got.Strategy != ChatGPTOAuthStrategyPrimaryFirst { + t.Fatalf("Strategy = %q, want %q", got.Strategy, ChatGPTOAuthStrategyPrimaryFirst) + } +} + +func TestParseChatGPTOAuthRoutingPreservesExplicitInheritMode(t *testing.T) { + agent := &AgentData{ + OtherConfig: json.RawMessage(`{ + "chatgpt_oauth_routing": { + "override_mode": "inherit" + } + }`), + } + + got := agent.ParseChatGPTOAuthRouting() + if got == nil { + t.Fatal("ParseChatGPTOAuthRouting() = nil, want config") + } + if got.OverrideMode != ChatGPTOAuthOverrideInherit { + t.Fatalf("OverrideMode = %q, want %q", got.OverrideMode, ChatGPTOAuthOverrideInherit) + } + if got.Strategy != ChatGPTOAuthStrategyPrimaryFirst { + t.Fatalf("Strategy = %q, want %q", got.Strategy, ChatGPTOAuthStrategyPrimaryFirst) + } +} + +func TestResolveEffectiveChatGPTOAuthRoutingUsesProviderDefaultsWhenAgentUnset(t *testing.T) { + defaults := &ChatGPTOAuthRoutingConfig{ + Strategy: ChatGPTOAuthStrategyRoundRobin, + ExtraProviderNames: []string{"codex-work"}, + } + + got := ResolveEffectiveChatGPTOAuthRouting(defaults, nil) + if got == nil { + t.Fatal("ResolveEffectiveChatGPTOAuthRouting() = nil, want config") + } + if got.Strategy != ChatGPTOAuthStrategyRoundRobin { + t.Fatalf("Strategy = %q, want %q", got.Strategy, ChatGPTOAuthStrategyRoundRobin) + } + if !reflect.DeepEqual(got.ExtraProviderNames, []string{"codex-work"}) { + t.Fatalf("ExtraProviderNames = %#v, want %#v", got.ExtraProviderNames, []string{"codex-work"}) + } +} + +func TestResolveEffectiveChatGPTOAuthRoutingAllowsCustomSingleAccountToDisableDefaults(t *testing.T) { + defaults := &ChatGPTOAuthRoutingConfig{ + Strategy: ChatGPTOAuthStrategyRoundRobin, + ExtraProviderNames: []string{"codex-work"}, + } + override := &ChatGPTOAuthRoutingConfig{ + OverrideMode: ChatGPTOAuthOverrideCustom, + Strategy: ChatGPTOAuthStrategyPrimaryFirst, + } + + got := ResolveEffectiveChatGPTOAuthRouting(defaults, override) + if got == nil { + t.Fatal("ResolveEffectiveChatGPTOAuthRouting() = nil, want config") + } + if got.Strategy != ChatGPTOAuthStrategyPrimaryFirst { + t.Fatalf("Strategy = %q, want %q", got.Strategy, ChatGPTOAuthStrategyPrimaryFirst) + } + if len(got.ExtraProviderNames) != 0 { + t.Fatalf("ExtraProviderNames = %#v, want empty", got.ExtraProviderNames) + } +} + +func TestResolveEffectiveChatGPTOAuthRoutingKeepsProviderOwnedMembersForStrategyOverride(t *testing.T) { + defaults := &ChatGPTOAuthRoutingConfig{ + Strategy: ChatGPTOAuthStrategyRoundRobin, + ExtraProviderNames: []string{"codex-work", "codex-team"}, + } + override := &ChatGPTOAuthRoutingConfig{ + OverrideMode: ChatGPTOAuthOverrideCustom, + Strategy: ChatGPTOAuthStrategyPriority, + } + + got := ResolveEffectiveChatGPTOAuthRouting(defaults, override) + if got == nil { + t.Fatal("ResolveEffectiveChatGPTOAuthRouting() = nil, want config") + } + if got.Strategy != ChatGPTOAuthStrategyPriority { + t.Fatalf("Strategy = %q, want %q", got.Strategy, ChatGPTOAuthStrategyPriority) + } + if !reflect.DeepEqual(got.ExtraProviderNames, defaults.ExtraProviderNames) { + t.Fatalf("ExtraProviderNames = %#v, want %#v", got.ExtraProviderNames, defaults.ExtraProviderNames) + } +} + +func TestResolveEffectiveChatGPTOAuthRoutingIgnoresCustomMembersWhenProviderOwnsPool(t *testing.T) { + defaults := &ChatGPTOAuthRoutingConfig{ + Strategy: ChatGPTOAuthStrategyRoundRobin, + ExtraProviderNames: []string{"codex-work", "codex-team"}, + } + override := &ChatGPTOAuthRoutingConfig{ + OverrideMode: ChatGPTOAuthOverrideCustom, + Strategy: ChatGPTOAuthStrategyRoundRobin, + ExtraProviderNames: []string{"rogue-provider"}, + } + + got := ResolveEffectiveChatGPTOAuthRouting(defaults, override) + if got == nil { + t.Fatal("ResolveEffectiveChatGPTOAuthRouting() = nil, want config") + } + if !reflect.DeepEqual(got.ExtraProviderNames, defaults.ExtraProviderNames) { + t.Fatalf("ExtraProviderNames = %#v, want provider defaults %#v", got.ExtraProviderNames, defaults.ExtraProviderNames) + } +} diff --git a/internal/store/heartbeat_store.go b/internal/store/heartbeat_store.go index a1089901..ac3f3fa5 100644 --- a/internal/store/heartbeat_store.go +++ b/internal/store/heartbeat_store.go @@ -76,10 +76,9 @@ func StaggerOffset(agentID uuid.UUID, intervalSec int) time.Duration { h ^= uint32(b) h *= 16777619 // FNV prime } - maxOffset := intervalSec / 10 // 10% of interval - if maxOffset < 1 { - maxOffset = 1 - } + maxOffset := max( + // 10% of interval + intervalSec/10, 1) offset := int(h) % maxOffset if offset < 0 { offset = -offset @@ -89,7 +88,7 @@ func StaggerOffset(agentID uuid.UUID, intervalSec int) time.Duration { // HeartbeatEvent represents a heartbeat lifecycle event sent to subscribers. type HeartbeatEvent struct { - Action string `json:"action"` // "running", "completed", "suppressed", "error", "skipped" + Action string `json:"action"` // "running", "completed", "suppressed", "error", "skipped" AgentID string `json:"agentId"` AgentKey string `json:"agentKey,omitempty"` Status string `json:"status,omitempty"` diff --git a/internal/store/pg/memory_embedding_cache.go b/internal/store/pg/memory_embedding_cache.go index e43e04a4..c11da277 100644 --- a/internal/store/pg/memory_embedding_cache.go +++ b/internal/store/pg/memory_embedding_cache.go @@ -72,10 +72,7 @@ func (s *PGMemoryStore) writeEmbeddingCache(ctx context.Context, entries []embed // Process in batches of 100 to avoid exceeding max query params const batchSize = 100 for start := 0; start < len(entries); start += batchSize { - end := start + batchSize - if end > len(entries) { - end = len(entries) - } + end := min(start+batchSize, len(entries)) batch := entries[start:end] var sb strings.Builder diff --git a/internal/store/provider_store.go b/internal/store/provider_store.go index c28dbd59..053b9b55 100644 --- a/internal/store/provider_store.go +++ b/internal/store/provider_store.go @@ -80,6 +80,11 @@ type EmbeddingSettings struct { Dimensions int `json:"dimensions,omitempty"` // truncate output to N dims (e.g. 1536); 0 = model default } +// ChatGPTOAuthProviderSettings holds provider-level defaults for Codex account pooling. +type ChatGPTOAuthProviderSettings struct { + CodexPool *ChatGPTOAuthRoutingConfig `json:"codex_pool,omitempty"` +} + // ParseEmbeddingSettings extracts embedding config from a provider's settings JSONB. // Returns nil if not configured. func ParseEmbeddingSettings(settings json.RawMessage) *EmbeddingSettings { @@ -95,6 +100,23 @@ func ParseEmbeddingSettings(settings json.RawMessage) *EmbeddingSettings { return s.Embedding } +// ParseChatGPTOAuthProviderSettings extracts provider-level Codex pool defaults from settings JSONB. +func ParseChatGPTOAuthProviderSettings(settings json.RawMessage) *ChatGPTOAuthProviderSettings { + if len(settings) == 0 { + return nil + } + var s ChatGPTOAuthProviderSettings + if json.Unmarshal(settings, &s) != nil { + return nil + } + s.CodexPool = normalizeChatGPTOAuthRoutingConfig(s.CodexPool) + if s.CodexPool == nil { + return nil + } + s.CodexPool.OverrideMode = "" + return &s +} + // NoEmbeddingTypes lists provider types that cannot serve embeddings. var NoEmbeddingTypes = map[string]bool{ ProviderAnthropicNative: true, // uses x-api-key auth, not Bearer; no embedding models diff --git a/internal/tools/policy.go b/internal/tools/policy.go index eaa70604..7432962d 100644 --- a/internal/tools/policy.go +++ b/internal/tools/policy.go @@ -2,6 +2,7 @@ package tools import ( "log/slog" + "slices" "strings" "sync" @@ -22,7 +23,7 @@ var toolGroups = map[string][]string{ "ui": {"browser"}, "automation": {"cron"}, "messaging": {"message", "create_forum_topic", "list_group_members"}, - "team": {"team_tasks"}, + "team": {"team_tasks"}, // Composite group: all goclaw native tools (excludes MCP/custom plugins). "goclaw": { "read_file", "write_file", "list_files", "edit", "exec", @@ -385,10 +386,8 @@ func matchDenySpec(name string, spec []string) bool { for _, s := range spec { if after, ok := strings.CutPrefix(s, "group:"); ok { if members, ok := toolGroups[after]; ok { - for _, m := range members { - if m == name { - return true - } + if slices.Contains(members, name) { + return true } } } else if s == name { diff --git a/internal/tools/team_tool_dispatch.go b/internal/tools/team_tool_dispatch.go index c3c1ed8b..b8444339 100644 --- a/internal/tools/team_tool_dispatch.go +++ b/internal/tools/team_tool_dispatch.go @@ -76,31 +76,32 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T return } - content := fmt.Sprintf("[Assigned task #%d (id: %s)]: %s", task.TaskNumber, task.ID, task.Subject) + var content strings.Builder + content.WriteString(fmt.Sprintf("[Assigned task #%d (id: %s)]: %s", task.TaskNumber, task.ID, task.Subject)) if task.Description != "" { - content += "\n\n" + task.Description + content.WriteString("\n\n" + task.Description) } // Hint: tell the agent it's on a team task and where the shared workspace is. if ws := taskTeamWorkspace(task); ws != "" { - content += fmt.Sprintf("\n\n[Team workspace: %s — use read_file/write_file/list_files to access shared files. All files you write are visible to the team lead and other members.]", ws) + content.WriteString(fmt.Sprintf("\n\n[Team workspace: %s — use read_file/write_file/list_files to access shared files. All files you write are visible to the team lead and other members.]", ws)) } // List attached files so member knows what's available to read. if files, ok := task.Metadata["attached_files"].([]any); ok && len(files) > 0 { - content += "\n\n[Attached files in team workspace — use read_file to access:]" + content.WriteString("\n\n[Attached files in team workspace — use read_file to access:]") for _, f := range files { if path, ok := f.(string); ok { - content += "\n- attachments/" + filepath.Base(path) + content.WriteString("\n- attachments/" + filepath.Base(path)) } } } // Hint: guide member on available team_tasks actions. - content += "\n\n[Instructions]\n" + + content.WriteString("\n\n[Instructions]\n" + "- Use team_tasks(action=\"progress\", percent=N, text=\"...\") to report progress\n" + "- Use team_tasks(action=\"comment\", text=\"...\") to share findings\n" + "- Use team_tasks(action=\"comment\", type=\"blocker\", text=\"...\") when BLOCKED and need leader input — auto-fails task and notifies leader\n" + "- When done: team_tasks(action=\"complete\", result=\"summary of your work\")\n" + - "- Write output files to team workspace so lead can review" + "- Write output files to team workspace so lead can review") // Use task's stored channel/chat as primary source for routing. // Falls back to ctx values for initial dispatch (task just created, fields match ctx). @@ -140,11 +141,11 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T "origin_peer_kind": originPeerKind, "origin_chat_id": originChatID, "origin_user_id": originUserID, - "from_agent": fromAgent, - "to_agent": ag.AgentKey, - "to_agent_display": ag.DisplayName, - "team_task_id": task.ID.String(), - "team_id": teamID.String(), + "from_agent": fromAgent, + "to_agent": ag.AgentKey, + "to_agent_display": ag.DisplayName, + "team_task_id": task.ID.String(), + "team_id": teamID.String(), } // Resolve local key from context; fallback to task metadata for deferred dispatches. localKey := ToolLocalKeyFromCtx(ctx) @@ -184,7 +185,7 @@ func (m *TeamToolManager) dispatchTaskToAgent(ctx context.Context, task *store.T Channel: "system", SenderID: "teammate:dashboard", ChatID: teamID.String(), - Content: content, + Content: content.String(), UserID: originUserID, TenantID: store.TenantIDFromContext(ctx), AgentID: ag.AgentKey, diff --git a/ui/web/src/api/ws-client.ts b/ui/web/src/api/ws-client.ts index 46ea97a8..e18d178f 100644 --- a/ui/web/src/api/ws-client.ts +++ b/ui/web/src/api/ws-client.ts @@ -24,7 +24,7 @@ export class WsClient { private connectGeneration = 0; /** Server-assigned role from connect response. */ - role: "admin" | "operator" | "viewer" | "" = ""; + role: "owner" | "admin" | "operator" | "viewer" | "" = ""; /** Tenant fields from connect response. */ tenantId = ""; @@ -235,7 +235,7 @@ export class WsClient { } this.authenticated = true; - this.role = (res?.role as "admin" | "operator" | "viewer") ?? ""; + this.role = (res?.role as "owner" | "admin" | "operator" | "viewer") ?? ""; this.tenantId = res?.tenant_id ?? ""; this.tenantName = res?.tenant_name ?? ""; this.tenantSlug = res?.tenant_slug ?? ""; diff --git a/ui/web/src/components/shared/sticky-save-bar.tsx b/ui/web/src/components/shared/sticky-save-bar.tsx index 95b50e56..dc1c96ee 100644 --- a/ui/web/src/components/shared/sticky-save-bar.tsx +++ b/ui/web/src/components/shared/sticky-save-bar.tsx @@ -9,23 +9,39 @@ interface StickySaveBarProps { disabled?: boolean; label?: string; savingLabel?: string; + variant?: "footer" | "floating"; } -/** Sticky footer bar with save button and loading spinner. Toast handles success/error feedback. */ +/** Sticky save action. Toast handles success/error feedback. */ export function StickySaveBar({ onSave, saving, disabled, label, savingLabel, + variant = "footer", }: StickySaveBarProps) { const { t } = useTranslation("common"); const showSpin = useMinLoading(saving, 600); const resolvedLabel = label ?? t("save"); const resolvedSavingLabel = savingLabel ?? t("saving"); + + if (variant === "floating") { + return ( +
+
+ +
+
+ ); + } + return ( -
-
+
+
+ +

+ {t("chatgptOAuthRouting.pageTitle")} +

+
+

+ {t("chatgptOAuthRouting.pageDescription", { name: title })} +

+ +
+ + {t(`chatgptOAuthRouting.verdict.${summaryTone}.title`)} + + + {t(strategyLabelKey(savedStrategy))} + + + {savedEffectiveRouting.overrideMode === "inherit" + ? t("chatgptOAuthRouting.mode.inherit") + : t("chatgptOAuthRouting.mode.custom")} + + + {recentRequestCount > 0 + ? t("chatgptOAuthRouting.sampleBadge", { + count: recentRequestCount, + }) + : t("chatgptOAuthRouting.noSampleBadge")} + + + {t("chatgptOAuthRouting.healthState.healthy")}{" "} + {runtimeHealthyEntries.length} + + {runtimeDegradedEntries.length > 0 ? ( + + {t("chatgptOAuthRouting.healthState.degraded")}{" "} + {runtimeDegradedEntries.length} + + ) : null} + {runtimeCriticalEntries.length > 0 ? ( + + {t("chatgptOAuthRouting.healthState.critical")}{" "} + {runtimeCriticalEntries.length} + + ) : null} + {isDirty ? ( + + {t("chatgptOAuthRouting.draftBadge")} + + ) : null} +
+
+ + {canManageProviders ? ( + + ) : null} +
+ + + {!isEligible ? ( + + + + {t("chatgptOAuthRouting.pageUnsupportedTitle")} + + + {t("chatgptOAuthRouting.pageUnsupportedDescription")} + + + ) : ( +
+
+ { + void Promise.all([refreshActivity(), refreshQuotas()]); + }} + className="h-full min-h-0" + /> + +
+ +
+
+
+ )} +
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx index 176b1f8f..e7dd6360 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-detail-page.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useAgentDetail } from "../hooks/use-agent-detail"; @@ -23,6 +24,7 @@ interface AgentDetailPageProps { export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { const { t } = useTranslation("agents"); + const navigate = useNavigate(); const { agent, files, loading, updateAgent, getFile, setFile, regenerateAgent, resummonAgent, refresh } = useAgentDetail(agentId); const { deleteAgent: deleteAgentById } = useAgents(); @@ -78,7 +80,13 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { - + navigate(`/agents/${agent.id}/codex-pool`)} + /> @@ -105,13 +113,15 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) { - + {advancedOpen ? ( + + ) : null} | null): NormalizedChatGPTOAuthRouting { + const raw = otherConfig?.chatgpt_oauth_routing; + if (!raw || typeof raw !== "object") { + return { + isExplicit: false, + overrideMode: "custom", + strategy: "primary_first", + extraProviderNames: [], + }; + } + const routing = raw as Record; + const hasStrategyField = + typeof routing.strategy === "string" && routing.strategy.trim().length > 0; + const hasExtraProviderField = Array.isArray(routing.extra_provider_names); + const overrideMode = routing.override_mode === "inherit" ? "inherit" : "custom"; + const extraProviderNames = Array.from( + new Set( + (Array.isArray(routing.extra_provider_names) ? routing.extra_provider_names : []) + .filter((name): name is string => typeof name === "string") + .map((name) => name.trim()) + .filter(Boolean), + ), + ); + const strategy = normalizeChatGPTOAuthStrategy(routing.strategy); + const isExplicit = + routing.override_mode === "inherit" || + routing.override_mode === "custom" || + hasStrategyField || + hasExtraProviderField || + strategy !== "primary_first" || + extraProviderNames.length > 0; + return { + isExplicit, + overrideMode, + strategy, + extraProviderNames, + }; +} + +/** Returns true when an agent has active multi-account ChatGPT OAuth routing configured. */ +export function hasActiveChatGPTOAuthRouting(otherConfig?: Record | null): boolean { + const routing = normalizeChatGPTOAuthRouting(otherConfig); + return routing.isExplicit && (routing.strategy !== "primary_first" || routing.extraProviderNames.length > 0); +} + +export function normalizeChatGPTOAuthRoutingInput( + routing?: ChatGPTOAuthRoutingConfig | null, +): NormalizedChatGPTOAuthRouting { + if (!routing) { + return { + isExplicit: false, + overrideMode: "custom", + strategy: "primary_first", + extraProviderNames: [], + }; + } + return normalizeChatGPTOAuthRouting({ + chatgpt_oauth_routing: routing, + }); +} + +export function resolveEffectiveChatGPTOAuthRouting( + baseProviderName: string, + providerSettings?: Record, + agentRouting?: NormalizedChatGPTOAuthRouting, +): EffectiveChatGPTOAuthRouting { + const providerDefaults = getChatGPTOAuthProviderRouting(providerSettings); + const normalizedAgent = + agentRouting ?? + ({ + isExplicit: false, + overrideMode: "custom", + strategy: "primary_first", + extraProviderNames: [], + } satisfies NormalizedChatGPTOAuthRouting); + + let source: EffectiveChatGPTOAuthRouting["source"] = "single"; + let strategy: EffectiveChatGPTOAuthRoutingStrategy = normalizedAgent.strategy; + let extraProviderNames = normalizedAgent.extraProviderNames; + let overrideMode: ChatGPTOAuthRoutingOverrideMode = normalizedAgent.overrideMode; + + if (normalizedAgent.overrideMode === "inherit") { + source = providerDefaults ? "provider_default" : "single"; + strategy = providerDefaults?.strategy ?? "primary_first"; + extraProviderNames = providerDefaults?.extraProviderNames ?? []; + overrideMode = "inherit"; + } else if (normalizedAgent.isExplicit) { + source = "agent_custom"; + overrideMode = "custom"; + } else if (providerDefaults) { + source = "provider_default"; + strategy = providerDefaults.strategy; + extraProviderNames = providerDefaults.extraProviderNames; + overrideMode = "inherit"; + } + + if ( + providerDefaults?.extraProviderNames.length && + source === "agent_custom" + ) { + if (strategy === "primary_first" && extraProviderNames.length === 0) { + extraProviderNames = []; + } else { + extraProviderNames = providerDefaults.extraProviderNames; + } + } + + return { + source, + overrideMode, + strategy, + extraProviderNames, + poolProviderNames: Array.from( + new Set([baseProviderName, ...extraProviderNames].filter(Boolean)), + ), + }; +} + +export function buildAgentOtherConfigWithChatGPTOAuthRouting( + agent: AgentData, + routing: ChatGPTOAuthRoutingConfig, + providerSettings?: Record, +): Record { + const existing = (agent.other_config as Record | null) ?? {}; + const otherBase: Record = { ...existing }; + const providerDefaults = getChatGPTOAuthProviderRouting(providerSettings); + const normalized = normalizeChatGPTOAuthRoutingInput(routing); + + delete otherBase.chatgpt_oauth_routing; + if (normalized.overrideMode === "inherit") { + otherBase.chatgpt_oauth_routing = { + override_mode: "inherit", + }; + return otherBase; + } + + if ( + providerDefaults || + normalized.isExplicit || + normalized.strategy !== "primary_first" || + normalized.extraProviderNames.length > 0 + ) { + const customRouting: Record = { + override_mode: "custom", + strategy: normalized.strategy, + }; + if ( + !providerDefaults || + (normalized.strategy === "primary_first" && + normalized.extraProviderNames.length === 0) + ) { + customRouting.extra_provider_names = normalized.extraProviderNames; + } + otherBase.chatgpt_oauth_routing = { + ...customRouting, + }; + } + + return otherBase; +} diff --git a/ui/web/src/pages/agents/agent-detail/agent-header.tsx b/ui/web/src/pages/agents/agent-detail/agent-header.tsx index 04756ea9..5bbab873 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-header.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-header.tsx @@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next"; import type { AgentData } from "@/types/agent"; import type { HeartbeatConfig } from "@/pages/agents/hooks/use-agent-heartbeat"; import { useCountdown } from "@/hooks/use-countdown"; -import { agentDisplayName, agentKeyDisplay } from "./agent-display-utils"; +import { agentDisplayName, agentKeyDisplay, hasActiveChatGPTOAuthRouting } from "./agent-display-utils"; import { cn } from "@/lib/utils"; interface AgentHeaderProps { @@ -26,6 +26,7 @@ export function AgentHeader({ agent, heartbeat, onBack, onDelete, onAdvanced, on const selfEvolve = Boolean(otherCfg.self_evolve); const title = agentDisplayName(agent, t("card.unnamedAgent")); const keyDisplay = agentKeyDisplay(agent.agent_key); + const hasOAuthRouting = hasActiveChatGPTOAuthRouting(agent.other_config); const hbConfigured = heartbeat != null; const hbEnabled = heartbeat?.enabled ?? false; @@ -96,6 +97,18 @@ export function AgentHeader({ agent, heartbeat, onBack, onDelete, onAdvanced, on )} + {hasOAuthRouting && ( + + + + {t("chatgptOAuthRouting.badge")} + + + + {t("chatgptOAuthRouting.badgeTooltip")} + + + )}
{keyDisplay} diff --git a/ui/web/src/pages/agents/agent-detail/agent-overview-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-overview-tab.tsx index 0b4acf42..31fc1bd3 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-overview-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-overview-tab.tsx @@ -9,6 +9,7 @@ import { ModelBudgetSection } from "./overview-sections/model-budget-section"; import { SkillsSection } from "./overview-sections/skills-section"; import { EvolutionSection } from "./overview-sections/evolution-section"; import { CapabilitiesSection } from "./overview-sections/capabilities-section"; +import { ChatGPTOAuthRoutingSummarySection } from "./overview-sections/chatgpt-oauth-routing-summary-section"; import { HeartbeatCard } from "./overview-sections/heartbeat-card"; import { MemorySection } from "./config-sections"; import type { UseAgentHeartbeatReturn } from "../hooks/use-agent-heartbeat"; @@ -17,9 +18,10 @@ interface AgentOverviewTabProps { agent: AgentData; onUpdate: (updates: Record) => Promise; heartbeat: UseAgentHeartbeatReturn; + onManageCodexPool: () => void; } -export function AgentOverviewTab({ agent, onUpdate, heartbeat }: AgentOverviewTabProps) { +export function AgentOverviewTab({ agent, onUpdate, heartbeat, onManageCodexPool }: AgentOverviewTabProps) { const { t } = useTranslation("agents"); const otherCfg = (agent.other_config ?? {}) as Record; @@ -126,6 +128,8 @@ export function AgentOverviewTab({ agent, onUpdate, heartbeat }: AgentOverviewTa onSaveBlockedChange={setLlmSaveBlocked} /> + + {/* Memory — always visible, per-agent overrides */} {t(`${translationKeyPrefix}.checking`)} + ); + } + if (!quota) return null; + + const failureKind = getQuotaFailureKind(quota); + const signals = getQuotaSignals(quota); + const planLabel = getQuotaPlanLabel(quota.plan_type); + + return ( + + + +
+ {failureKind ? ( + + {t(`${translationKeyPrefix}.failure.${failureKind}.label`)} + + ) : ( + <> + {planLabel && {planLabel}} + {signals.map((signal) => ( + + {signal.shortLabel} {signal.remaining}% + + ))} + + )} +
+
+ + + {failureKind ? ( +
+

+ {t(`${translationKeyPrefix}.failure.${failureKind}.label`)} +

+

+ {t(`${translationKeyPrefix}.failure.${failureKind}.description`)} +

+
+ ) : ( +
+ {planLabel && ( +
+ + {t(`${translationKeyPrefix}.plan`)} + + {planLabel} +
+ )} + {signals.map((signal) => ( +
+ {signal.shortLabel} + + {signal.remaining}% + {signal.resetAt ? ` · ${formatDate(signal.resetAt)}` : ""} + +
+ ))} +

+ {t(`${translationKeyPrefix}.lastChecked`, { + value: formatRelativeTime(quota.last_updated), + })} +

+
+ )} +
+
+
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-readiness-section.tsx b/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-readiness-section.tsx new file mode 100644 index 00000000..27902bca --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-readiness-section.tsx @@ -0,0 +1,180 @@ +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; +import { ArrowUpRight } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { formatRelativeTime } from "@/lib/format"; +import type { ChatGPTOAuthAvailability } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-statuses"; +import type { ChatGPTOAuthProviderQuota } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas"; +import { ChatGPTOAuthQuotaStrip } from "./chatgpt-oauth-quota-strip"; +import { getQuotaFailureKind, getQuotaSignals, getQuotaBadgeVariant, isQuotaUsable } from "./chatgpt-oauth-quota-utils"; + +export interface ChatGPTOAuthQuotaReadinessEntry { + name: string; + label: string; + role: "preferred" | "extra"; + availability: ChatGPTOAuthAvailability; + providerHref?: string; + quota?: ChatGPTOAuthProviderQuota | null; +} + +interface ChatGPTOAuthQuotaReadinessSectionProps { + entries: ChatGPTOAuthQuotaReadinessEntry[]; + loading?: boolean; + showProviderLinks?: boolean; +} + +function quotaStateVariant( + entry: ChatGPTOAuthQuotaReadinessEntry, +): "success" | "warning" | "destructive" | "outline" { + if (entry.availability !== "ready") return "outline"; + if (!entry.quota) return "outline"; + if (isQuotaUsable(entry.quota)) return "success"; + + const failureKind = getQuotaFailureKind(entry.quota); + if (failureKind === "retry_later" || failureKind === "needs_setup" || failureKind === "reauth") { + return "warning"; + } + return "destructive"; +} + +function quotaStateLabel( + entry: ChatGPTOAuthQuotaReadinessEntry, + t: (key: string, options?: Record) => string, +): string { + if (entry.availability !== "ready") { + return t(`chatgptOAuthRouting.status.${entry.availability}`); + } + if (!entry.quota) { + return t("chatgptOAuthRouting.quota.checking"); + } + if (isQuotaUsable(entry.quota)) { + return t("chatgptOAuthRouting.quota.readyLabel"); + } + + const failureKind = getQuotaFailureKind(entry.quota); + if (failureKind) { + return t(`chatgptOAuthRouting.quota.failure.${failureKind}.label`); + } + return t("chatgptOAuthRouting.quota.failure.unavailable.label"); +} + +function quotaStateDescription( + entry: ChatGPTOAuthQuotaReadinessEntry, + t: (key: string, options?: Record) => string, +): string { + if (entry.availability !== "ready") { + return t("chatgptOAuthRouting.quota.requiresReadyAlias"); + } + if (!entry.quota) { + return t("chatgptOAuthRouting.quota.checkingDescription"); + } + if (isQuotaUsable(entry.quota)) { + const signals = getQuotaSignals(entry.quota); + if (signals.length === 0) { + return t("chatgptOAuthRouting.quota.readyDescription"); + } + return signals.map((signal) => `${signal.shortLabel} ${signal.remaining}%`).join(" · "); + } + + const failureKind = getQuotaFailureKind(entry.quota); + if (entry.quota.action_hint) { + return entry.quota.action_hint; + } + if (failureKind) { + return t(`chatgptOAuthRouting.quota.failure.${failureKind}.description`); + } + return t("chatgptOAuthRouting.quota.failure.unavailable.description"); +} + +export function ChatGPTOAuthQuotaReadinessSection({ + entries, + loading = false, + showProviderLinks = true, +}: ChatGPTOAuthQuotaReadinessSectionProps) { + const { t } = useTranslation("agents"); + const readyEntries = entries.filter((entry) => entry.availability === "ready"); + const usableCount = readyEntries.filter((entry) => isQuotaUsable(entry.quota)).length; + const blockerCount = readyEntries.filter((entry) => !isQuotaUsable(entry.quota)).length; + const floorFiveHour = readyEntries.flatMap((entry) => getQuotaSignals(entry.quota)) + .filter((signal) => signal.shortLabel === "5h") + .reduce((min, signal) => (min == null ? signal.remaining : Math.min(min, signal.remaining)), null); + + return ( +
+
+
+

+ {t("chatgptOAuthRouting.quota.readinessTitle")} +

+

+ {t("chatgptOAuthRouting.quota.readinessDescription")} +

+
+ +
+ {loading ? ( + {t("chatgptOAuthRouting.quota.checking")} + ) : ( + <> + 0 ? "success" : "warning"}> + {t("chatgptOAuthRouting.quota.healthySummary", { usable: usableCount, total: readyEntries.length })} + + {blockerCount > 0 && ( + + {t("chatgptOAuthRouting.quota.needsAttention", { count: blockerCount })} + + )} + {floorFiveHour != null && ( + + {t("chatgptOAuthRouting.quota.floorFiveHour", { value: floorFiveHour })} + + )} + + )} +
+
+ +
+ {entries.map((entry) => ( +
+
+
+
+ {entry.label} + + {t(`chatgptOAuthRouting.role.${entry.role}`)} + + + {quotaStateLabel(entry, t)} + +
+

{entry.name}

+
+ + {showProviderLinks && entry.providerHref && ( + + )} +
+ +
+ +
+ +
+ {quotaStateDescription(entry, t)} + {entry.quota?.last_updated && ( + {t("chatgptOAuthRouting.quota.lastChecked", { value: formatRelativeTime(entry.quota.last_updated) })} + )} +
+
+ ))} +
+
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-strip.tsx b/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-strip.tsx new file mode 100644 index 00000000..265cad84 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-strip.tsx @@ -0,0 +1,229 @@ +import { useTranslation } from "react-i18next"; +import { Badge } from "@/components/ui/badge"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { formatDate, formatRelativeTime } from "@/lib/format"; +import { cn } from "@/lib/utils"; +import type { ChatGPTOAuthProviderQuota } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas"; +import { + getQuotaBadgeVariant, + getQuotaFailureKind, + getQuotaPlanLabel, + getQuotaSignals, +} from "./chatgpt-oauth-quota-utils"; + +interface ChatGPTOAuthQuotaStripProps { + quota?: ChatGPTOAuthProviderQuota | null; + loading?: boolean; + translationNamespace?: "agents" | "providers"; + translationKeyPrefix?: string; + className?: string; + compact?: boolean; + layout?: "stacked" | "inline"; + embedded?: boolean; + showSignalBadges?: boolean; +} + +const failureVariantByKind = { + billing: "destructive", + exhausted: "destructive", + reauth: "warning", + forbidden: "destructive", + needs_setup: "warning", + retry_later: "outline", + unavailable: "outline", +} as const; + +function quotaBarClass(remaining: number): string { + if (remaining <= 20) return "bg-destructive"; + if (remaining <= 50) return "bg-amber-500"; + return "bg-emerald-500"; +} + +export function ChatGPTOAuthQuotaStrip({ + quota, + loading = false, + translationNamespace = "agents", + translationKeyPrefix = "chatgptOAuthRouting.quota", + className, + compact = false, + layout = "stacked", + embedded = false, + showSignalBadges = !compact, +}: ChatGPTOAuthQuotaStripProps) { + const { t } = useTranslation(translationNamespace); + + if (loading && !quota) { + return ( + + {t(`${translationKeyPrefix}.checking`)} + + ); + } + + if (!quota) return null; + + const failureKind = getQuotaFailureKind(quota); + const signals = getQuotaSignals(quota); + const planLabel = getQuotaPlanLabel(quota.plan_type); + + return ( + + + +
+ {failureKind ? ( + + {t(`${translationKeyPrefix}.failure.${failureKind}.label`)} + + ) : layout === "inline" ? ( + <> + {planLabel && ( + + {planLabel} + + )} + {signals.map((signal) => ( +
+ + {signal.shortLabel} + +
+
+
+
+ ))} + + ) : ( + <> +
+ {planLabel && {planLabel}} + {showSignalBadges && + signals.map((signal) => ( + + {signal.shortLabel} {signal.remaining}% + + ))} +
+ + {signals.length > 0 && ( +
+ {signals.map((signal) => ( +
+ + {signal.shortLabel} + +
+
+
+ {!compact && ( + + {signal.remaining}% + + )} +
+ ))} +
+ )} + + )} +
+ + + + {failureKind ? ( +
+

+ {t(`${translationKeyPrefix}.failure.${failureKind}.label`)} +

+

+ {t(`${translationKeyPrefix}.failure.${failureKind}.description`)} +

+ {quota.action_hint && ( +

{quota.action_hint}

+ )} +
+ ) : ( +
+ {planLabel && ( +
+ + {t(`${translationKeyPrefix}.plan`)} + + {planLabel} +
+ )} + {signals.map((signal) => ( +
+ {signal.shortLabel} + + {signal.remaining}% + {signal.resetAt ? ` · ${formatDate(signal.resetAt)}` : ""} + +
+ ))} +

+ {t(`${translationKeyPrefix}.lastChecked`, { + value: formatRelativeTime(quota.last_updated), + })} +

+
+ )} +
+ + + ); +} diff --git a/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-utils.ts b/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-utils.ts new file mode 100644 index 00000000..795deb94 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/chatgpt-oauth-quota-utils.ts @@ -0,0 +1,192 @@ +import type { ChatGPTOAuthProviderQuota } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas"; +import type { ChatGPTOAuthAvailability } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-statuses"; + +export type ChatGPTOAuthQuotaFailureKind = + | "billing" + | "exhausted" + | "reauth" + | "forbidden" + | "needs_setup" + | "retry_later" + | "unavailable"; + +export type ChatGPTOAuthRouteReadiness = + | "healthy" + | "fallback" + | "checking" + | "blocked"; + +export interface ChatGPTOAuthQuotaSignal { + shortLabel: "5h" | "Wk"; + remaining: number; + resetAt?: string | null; +} + +export function getQuotaSignals( + quota?: ChatGPTOAuthProviderQuota | null, +): ChatGPTOAuthQuotaSignal[] { + if (!quota?.success) return []; + + const explicit: ChatGPTOAuthQuotaSignal[] = []; + if (quota.core_usage?.five_hour) { + explicit.push({ + shortLabel: "5h", + remaining: quota.core_usage.five_hour.remaining_percent, + resetAt: quota.core_usage.five_hour.reset_at, + }); + } + if (quota.core_usage?.weekly) { + explicit.push({ + shortLabel: "Wk", + remaining: quota.core_usage.weekly.remaining_percent, + resetAt: quota.core_usage.weekly.reset_at, + }); + } + + if (explicit.length > 0) return explicit; + + const usageWindows = quota.windows + .filter((window) => !window.label.toLowerCase().includes("code review")) + .sort( + (a, b) => + (a.reset_after_seconds ?? Number.MAX_SAFE_INTEGER) - + (b.reset_after_seconds ?? Number.MAX_SAFE_INTEGER), + ); + + if (usageWindows.length === 0) return []; + + const first = usageWindows[0]; + const last = usageWindows[usageWindows.length - 1]; + if (!first || !last) return []; + const signals: ChatGPTOAuthQuotaSignal[] = [ + { + shortLabel: "5h", + remaining: first.remaining_percent, + resetAt: first.reset_at, + }, + ]; + + if (last !== first) { + signals.push({ + shortLabel: "Wk", + remaining: last.remaining_percent, + resetAt: last.reset_at, + }); + } + + return signals; +} + +export function getQuotaFailureKind( + quota?: ChatGPTOAuthProviderQuota | null, +): ChatGPTOAuthQuotaFailureKind | null { + if (!quota) return null; + if (quota.success) { + const signals = getQuotaSignals(quota); + if (signals.length > 0 && signals.some((signal) => signal.remaining <= 0)) { + return "exhausted"; + } + return null; + } + if (quota.error_code === "payment_required") return "billing"; + if (quota.needs_reauth) return "reauth"; + if (quota.is_forbidden) return "forbidden"; + if (quota.error_code === "missing_account_id") return "needs_setup"; + if (quota.retryable || quota.error_code === "rate_limited") + return "retry_later"; + return "unavailable"; +} + +export function getQuotaPlanLabel(planType?: string | null): string | null { + if (!planType) return null; + return planType + .split(/[\s_-]+/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function getQuotaBadgeVariant( + remaining: number, +): "success" | "warning" | "destructive" { + if (remaining <= 20) return "destructive"; + if (remaining <= 50) return "warning"; + return "success"; +} + +export function isQuotaUsable( + quota?: ChatGPTOAuthProviderQuota | null, +): boolean { + const signals = getQuotaSignals(quota); + return ( + Boolean(quota?.success) && + signals.length > 0 && + signals.every((signal) => signal.remaining > 0) + ); +} + +export function getRouteReadiness( + availability: ChatGPTOAuthAvailability, + quota?: ChatGPTOAuthProviderQuota | null, +): ChatGPTOAuthRouteReadiness { + if (availability !== "ready") return "blocked"; + if (!quota) return "checking"; + if (isQuotaUsable(quota)) return "healthy"; + + const failureKind = getQuotaFailureKind(quota); + if ( + failureKind === "billing" || + failureKind === "exhausted" || + failureKind === "reauth" || + failureKind === "forbidden" || + failureKind === "needs_setup" + ) { + return "blocked"; + } + if (failureKind === "retry_later" || failureKind === "unavailable") { + return "fallback"; + } + return "fallback"; +} + +export function summarizeQuotaHealth< + T extends { quota?: ChatGPTOAuthProviderQuota | null }, +>(entries: T[]) { + let usable = 0; + let attention = 0; + let floorFiveHour: number | null = null; + let floorWeekly: number | null = null; + + for (const entry of entries) { + const quota = entry.quota; + if (!quota) continue; + + if (isQuotaUsable(quota)) { + usable += 1; + } else { + attention += 1; + } + + for (const signal of getQuotaSignals(quota)) { + if (signal.shortLabel === "5h") { + floorFiveHour = + floorFiveHour == null + ? signal.remaining + : Math.min(floorFiveHour, signal.remaining); + } + if (signal.shortLabel === "Wk") { + floorWeekly = + floorWeekly == null + ? signal.remaining + : Math.min(floorWeekly, signal.remaining); + } + } + } + + return { + usable, + attention, + floorFiveHour, + floorWeekly, + }; +} diff --git a/ui/web/src/pages/agents/agent-detail/codex-pool-activity-panel.tsx b/ui/web/src/pages/agents/agent-detail/codex-pool-activity-panel.tsx new file mode 100644 index 00000000..c776c085 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/codex-pool-activity-panel.tsx @@ -0,0 +1,964 @@ +import { useMemo } from "react"; +import { Link } from "react-router"; +import { useTranslation } from "react-i18next"; +import { ArrowUpRight, RefreshCw, Route } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { EmptyState } from "@/components/shared/empty-state"; +import { formatDuration, formatRelativeTime } from "@/lib/format"; +import { cn } from "@/lib/utils"; +import type { ChatGPTOAuthAvailability } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-statuses"; +import type { ChatGPTOAuthProviderQuota } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas"; +import type { EffectiveChatGPTOAuthRoutingStrategy } from "@/types/agent"; +import { ChatGPTOAuthQuotaStrip } from "./chatgpt-oauth-quota-strip"; +import { + getQuotaFailureKind, + getRouteReadiness, +} from "./chatgpt-oauth-quota-utils"; +import type { CodexPoolRecentRequest } from "./hooks/use-codex-pool-activity"; + +export interface CodexPoolEntry { + name: string; + label: string; + availability: ChatGPTOAuthAvailability; + role: "preferred" | "extra"; + requestCount: number; + directSelectionCount: number; + failoverServeCount: number; + successCount: number; + failureCount: number; + consecutiveFailures: number; + successRate: number; + healthScore: number; + healthState: "healthy" | "degraded" | "critical" | "idle"; + lastSelectedAt?: string; + lastFailoverAt?: string; + lastUsedAt?: string; + lastSuccessAt?: string; + lastFailureAt?: string; + providerHref?: string; + quota?: ChatGPTOAuthProviderQuota | null; +} + +interface CodexPoolActivityPanelProps { + entries: CodexPoolEntry[]; + strategy: EffectiveChatGPTOAuthRoutingStrategy; + recentRequests: CodexPoolRecentRequest[]; + statsSampleSize: number; + fetching: boolean; + showProviderLinks?: boolean; + onRefresh: () => void; + className?: string; +} + +interface CodexPoolRecentRequestsListProps { + recentRequests: CodexPoolRecentRequest[]; + loading: boolean; + compact?: boolean; + className?: string; +} + +function availabilityVariant( + availability: ChatGPTOAuthAvailability, +): "success" | "warning" | "outline" { + if (availability === "ready") return "success"; + if (availability === "needs_sign_in") return "warning"; + return "outline"; +} + +function requestStatusVariant( + status: string, +): "success" | "destructive" | "info" | "secondary" { + if (status === "ok" || status === "success" || status === "completed") + return "success"; + if (status === "error" || status === "failed") return "destructive"; + if (status === "running" || status === "pending") return "info"; + return "secondary"; +} + +function routeBadgeVariant( + state: ReturnType, +): "success" | "warning" | "outline" | "destructive" { + if (state === "healthy") return "success"; + if (state === "fallback") return "warning"; + if (state === "checking") return "outline"; + return "destructive"; +} + +function routeLabelKey(state: ReturnType): string { + if (state === "healthy") return "chatgptOAuthRouting.routerActiveTitle"; + if (state === "fallback") return "chatgptOAuthRouting.fallbackTitle"; + if (state === "checking") return "chatgptOAuthRouting.checkingTitle"; + return "chatgptOAuthRouting.blockedNowTitle"; +} + +function strategyLabelKey( + strategy: EffectiveChatGPTOAuthRoutingStrategy, +): string { + if (strategy === "round_robin") return "chatgptOAuthRouting.strategy.roundRobin"; + if (strategy === "priority_order") return "chatgptOAuthRouting.strategy.priorityOrder"; + return "chatgptOAuthRouting.strategy.primaryFirst"; +} + +function runtimeHealthVariant( + state: CodexPoolEntry["healthState"], +): "success" | "warning" | "destructive" | "outline" { + if (state === "healthy") return "success"; + if (state === "degraded") return "warning"; + if (state === "critical") return "destructive"; + return "outline"; +} + +function runtimeHealthBarWidths(entry: CodexPoolEntry) { + const total = entry.successCount + entry.failureCount; + if (total <= 0) { + return { success: 0, failure: 0 }; + } + const success = Math.max(0, Math.min(100, entry.successRate)); + return { + success, + failure: Math.max(0, 100 - success), + }; +} + +function poolRoleBadgeClass(role: CodexPoolEntry["role"]): string { + if (role === "preferred") { + return "border-primary/35 bg-primary/12 text-foreground shadow-sm dark:border-primary/40 dark:bg-primary/18"; + } + return "border-border/70 bg-background/80 text-muted-foreground"; +} + +function MonitorStat({ label, value }: { label: string; value: string }) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} + +function MemberMetric({ label, value }: { label: string; value: string }) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} + +function requestProviderSummary(request: CodexPoolRecentRequest): string { + const selected = request.selected_provider?.trim(); + const served = request.provider_name?.trim(); + if (request.used_failover && selected && served && selected !== served) { + return `${selected} -> ${served}`; + } + return served || selected || ""; +} + +interface RequestAccentClasses { + card: string; + glow: string; + stripe: string; + marker: string; + index: string; + directBadge: string; + pill: string; + trace: string; +} + +const REQUEST_ACCENTS: RequestAccentClasses[] = [ + { + card: "border-emerald-500/20", + glow: + "from-emerald-500/[0.12] via-emerald-500/[0.04] to-transparent dark:from-emerald-500/[0.16] dark:via-emerald-500/[0.06]", + stripe: "from-emerald-400/90 via-teal-400/75 to-cyan-400/35", + marker: "bg-emerald-500", + index: + "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-200", + directBadge: + "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:border-emerald-500/25 dark:bg-emerald-500/15 dark:text-emerald-200", + pill: + "border-emerald-500/15 bg-emerald-500/[0.07] text-emerald-950 dark:border-emerald-500/20 dark:bg-emerald-500/[0.12] dark:text-emerald-100", + trace: + "text-emerald-700 hover:border-emerald-500/25 hover:bg-emerald-500/10 hover:text-emerald-800 dark:text-emerald-200 dark:hover:border-emerald-500/30 dark:hover:bg-emerald-500/15 dark:hover:text-emerald-100", + }, + { + card: "border-sky-500/20", + glow: + "from-sky-500/[0.12] via-sky-500/[0.04] to-transparent dark:from-sky-500/[0.16] dark:via-sky-500/[0.06]", + stripe: "from-sky-400/90 via-cyan-400/75 to-blue-400/35", + marker: "bg-sky-500", + index: + "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/15 dark:text-sky-200", + directBadge: + "border-sky-500/20 bg-sky-500/10 text-sky-700 dark:border-sky-500/25 dark:bg-sky-500/15 dark:text-sky-200", + pill: + "border-sky-500/15 bg-sky-500/[0.07] text-sky-950 dark:border-sky-500/20 dark:bg-sky-500/[0.12] dark:text-sky-100", + trace: + "text-sky-700 hover:border-sky-500/25 hover:bg-sky-500/10 hover:text-sky-800 dark:text-sky-200 dark:hover:border-sky-500/30 dark:hover:bg-sky-500/15 dark:hover:text-sky-100", + }, + { + card: "border-amber-500/20", + glow: + "from-amber-500/[0.12] via-amber-500/[0.04] to-transparent dark:from-amber-500/[0.16] dark:via-amber-500/[0.06]", + stripe: "from-amber-400/90 via-orange-400/75 to-yellow-400/35", + marker: "bg-amber-500", + index: + "border-amber-500/30 bg-amber-500/10 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-100", + directBadge: + "border-amber-500/20 bg-amber-500/10 text-amber-800 dark:border-amber-500/25 dark:bg-amber-500/15 dark:text-amber-100", + pill: + "border-amber-500/15 bg-amber-500/[0.07] text-amber-950 dark:border-amber-500/20 dark:bg-amber-500/[0.12] dark:text-amber-50", + trace: + "text-amber-800 hover:border-amber-500/25 hover:bg-amber-500/10 hover:text-amber-900 dark:text-amber-100 dark:hover:border-amber-500/30 dark:hover:bg-amber-500/15 dark:hover:text-amber-50", + }, + { + card: "border-rose-500/20", + glow: + "from-rose-500/[0.12] via-rose-500/[0.04] to-transparent dark:from-rose-500/[0.16] dark:via-rose-500/[0.06]", + stripe: "from-rose-400/90 via-pink-400/75 to-orange-300/35", + marker: "bg-rose-500", + index: + "border-rose-500/30 bg-rose-500/10 text-rose-700 dark:border-rose-500/30 dark:bg-rose-500/15 dark:text-rose-200", + directBadge: + "border-rose-500/20 bg-rose-500/10 text-rose-700 dark:border-rose-500/25 dark:bg-rose-500/15 dark:text-rose-200", + pill: + "border-rose-500/15 bg-rose-500/[0.07] text-rose-950 dark:border-rose-500/20 dark:bg-rose-500/[0.12] dark:text-rose-100", + trace: + "text-rose-700 hover:border-rose-500/25 hover:bg-rose-500/10 hover:text-rose-800 dark:text-rose-200 dark:hover:border-rose-500/30 dark:hover:bg-rose-500/15 dark:hover:text-rose-100", + }, +]; + +function requestAccentSeed(request: CodexPoolRecentRequest): string { + return ( + request.provider_name?.trim() || + request.selected_provider?.trim() || + requestProviderSummary(request) || + request.model || + request.span_id + ); +} + +function requestAccentClasses(seed: string): RequestAccentClasses { + let hash = 0; + for (const char of seed) { + hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + } + return REQUEST_ACCENTS[hash % REQUEST_ACCENTS.length] ?? REQUEST_ACCENTS[0]!; +} + +function CodexPoolRecentRequestsList({ + recentRequests, + loading, + compact = false, + className, +}: CodexPoolRecentRequestsListProps) { + const { t } = useTranslation("agents"); + + if (loading) { + return ( +
+ {t("chatgptOAuthRouting.loadingEvidence")} +
+ ); + } + + if (recentRequests.length === 0) { + return compact ? ( +
+ {t("chatgptOAuthRouting.noEvidence")} +
+ ) : ( +
+ +
+ ); + } + + if (compact) { + return ( +
+
+ {recentRequests.map((request, index) => { + const providerSummary = + requestProviderSummary(request) || + request.model || + t("chatgptOAuthRouting.unknownModel"); + const accent = requestAccentClasses(requestAccentSeed(request)); + + return ( +
+
+
+ + + +
+
+
+ {index + 1} +
+ +
+
+ +

+ {providerSummary} +

+
+

+ {request.model || t("chatgptOAuthRouting.unknownModel")} +

+
+
+
+ +
+ + {request.used_failover + ? t("chatgptOAuthRouting.monitorFailoverLabel") + : t("chatgptOAuthRouting.monitorDirectLabel")} + + + {formatRelativeTime(request.started_at)} + + + · + + + {formatDuration(request.duration_ms)} + + {request.attempt_count > 1 && ( + <> + + · + + + {request.attempt_count}x + + + )} + {request.status !== "completed" && ( + + {request.status} + + )} +
+ + {request.used_failover && + request.failover_providers && + request.failover_providers.length > 0 && ( +

+ {t("chatgptOAuthRouting.failoverHint", { + providers: request.failover_providers.join(", "), + })} +

+ )} +
+ ); + })} +
+
+ ); + } + + return ( +
+
+ {recentRequests.map((request) => { + const providerSummary = requestProviderSummary(request); + return ( +
+
+
+
+ + {request.used_failover + ? t("chatgptOAuthRouting.monitorFailoverLabel") + : t("chatgptOAuthRouting.monitorDirectLabel")} + + {providerSummary ? ( + {providerSummary} + ) : ( + {request.status} + )} +
+ +

+ {request.model || t("chatgptOAuthRouting.unknownModel")} +

+ +
+ {formatRelativeTime(request.started_at)} + {formatDuration(request.duration_ms)} + + {t("chatgptOAuthRouting.attemptCount", { + count: request.attempt_count, + })} + +
+ + {request.used_failover && + request.failover_providers && + request.failover_providers.length > 0 && ( +

+ {t("chatgptOAuthRouting.failoverHint", { + providers: request.failover_providers.join(", "), + })} +

+ )} +
+ +
+ + {request.status} + + +
+
+
+ ); + })} +
+
+ ); +} + +export function CodexPoolActivityPanel({ + entries, + strategy, + recentRequests, + statsSampleSize, + fetching, + showProviderLinks = true, + onRefresh, + className, +}: CodexPoolActivityPanelProps) { + const { t } = useTranslation("agents"); + const routeEntries = useMemo( + () => + entries.map((entry) => ({ + ...entry, + routeReadiness: getRouteReadiness(entry.availability, entry.quota), + failureKind: getQuotaFailureKind(entry.quota), + })), + [entries], + ); + const blockedEntries = routeEntries.filter( + (entry) => entry.routeReadiness === "blocked", + ); + const directObservedProviders = routeEntries.filter( + (entry) => + entry.routeReadiness !== "blocked" && entry.directSelectionCount > 0, + ).length; + const failoverOnlyProviders = routeEntries.filter( + (entry) => entry.directSelectionCount === 0 && entry.failoverServeCount > 0, + ).length; + + return ( + + +
+
+ + {t("chatgptOAuthRouting.activityTitle")} + +

+ {t("chatgptOAuthRouting.activityDescription")} +

+
+ +
+ + {t(strategyLabelKey(strategy))} + + {blockedEntries.length > 0 && ( + + {t("chatgptOAuthRouting.blockedNowTitle")} {blockedEntries.length} + + )} + {failoverOnlyProviders > 0 && ( + + {t("chatgptOAuthRouting.failoverOnlyProviders", { + count: failoverOnlyProviders, + })} + + )} + +
+
+
+ + +
+ + + + request.used_failover) + .length, + )} + /> +
+ +
+
+

+ {t("chatgptOAuthRouting.sequenceTitle")} +

+ + {t("chatgptOAuthRouting.recentRequestsCount", { + count: recentRequests.length, + })} + +
+ + +
+ +
+
+

+ {t("chatgptOAuthRouting.poolMembersTitle")} +

+ + {t("chatgptOAuthRouting.selectedCount", { + count: entries.length, + })} + +
+ + {entries.length === 0 ? ( +
+ +
+ ) : ( +
+
+ {routeEntries.map((entry) => { + const accent = requestAccentClasses(entry.name); + return ( +
+
+
+ +
+ {(() => { + const totalOutcomes = + entry.successCount + entry.failureCount; + const barWidths = runtimeHealthBarWidths(entry); + const showAvailabilityBadge = + entry.availability !== "ready"; + const showHealthBadge = + entry.healthState !== "healthy" && + entry.healthState !== "idle"; + const showRouteBadge = + entry.routeReadiness !== "healthy"; + return ( + <> +
+
+
+ + + {entry.label} + + + {t(`chatgptOAuthRouting.role.${entry.role}`)} + + {showAvailabilityBadge && ( + + {t( + `chatgptOAuthRouting.status.${entry.availability}`, + )} + + )} + {showHealthBadge && ( + + {t( + `chatgptOAuthRouting.healthState.${entry.healthState}`, + )} + + )} + {showRouteBadge && ( + + {t(routeLabelKey(entry.routeReadiness))} + + )} +
+ {entry.label !== entry.name && ( +

+ {entry.name} +

+ )} +
+ + {showProviderLinks && entry.providerHref && ( + + )} +
+ + + +
+
+ {totalOutcomes > 0 ? ( +

+ {t( + "chatgptOAuthRouting.runtimeHealthSummary", + { + rate: entry.successRate, + score: entry.healthScore, + }, + )} +

+ ) : ( +

+ {t("chatgptOAuthRouting.noRuntimeSample")} +

+ )} + {entry.consecutiveFailures > 0 && ( + = 3 + ? "destructive" + : "warning" + } + className="h-5 shrink-0 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs" + > + {t("chatgptOAuthRouting.failureStreakBadge", { + count: entry.consecutiveFailures, + })} + + )} +
+ +
+ {totalOutcomes > 0 ? ( + <> +
+ {barWidths.failure > 0 && ( +
+ )} + + ) : ( +
+ )} +
+ +
+ + {t("chatgptOAuthRouting.runtimeSuccessCompact", { + count: entry.successCount, + })} + + + {t("chatgptOAuthRouting.runtimeFailureCompact", { + count: entry.failureCount, + })} + + {entry.lastFailureAt && ( + + {t("chatgptOAuthRouting.lastFailureLabel", { + value: formatRelativeTime(entry.lastFailureAt), + })} + + )} +
+
+ +
+ + + +
+ + ); + })()} +
+
+ ); + })} +
+
+ )} +
+
+
+ ); +} + +interface CodexPoolRecentRequestsPanelProps { + recentRequests: CodexPoolRecentRequest[]; + loading: boolean; + className?: string; +} + +export function CodexPoolRecentRequestsPanel({ + recentRequests, + loading, + className, +}: CodexPoolRecentRequestsPanelProps) { + const { t } = useTranslation("agents"); + + return ( + + +
+
+ + {t("chatgptOAuthRouting.sequenceTitle")} + +

+ {t("chatgptOAuthRouting.sequenceDescription")} +

+
+ + {t("chatgptOAuthRouting.recentRequestsCount", { + count: recentRequests.length, + })} + +
+
+ + + + +
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-routing-section.tsx b/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-routing-section.tsx new file mode 100644 index 00000000..dbd9b82b --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/config-sections/chatgpt-oauth-routing-section.tsx @@ -0,0 +1,557 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import { + useChatGPTOAuthProviderStatuses, + type ChatGPTOAuthAvailability, +} from "@/pages/providers/hooks/use-chatgpt-oauth-provider-statuses"; +import type { ChatGPTOAuthProviderQuota } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas"; +import type { + ChatGPTOAuthRoutingConfig, + EffectiveChatGPTOAuthRoutingStrategy, +} from "@/types/agent"; +import type { ProviderData } from "@/types/provider"; +import type { CodexPoolEntry } from "../codex-pool-activity-panel"; +import { + getQuotaFailureKind, + getRouteReadiness, +} from "../chatgpt-oauth-quota-utils"; + +interface ChatGPTOAuthRoutingSectionProps { + title?: string; + description?: string; + currentProvider: string; + providers: ProviderData[]; + value: ChatGPTOAuthRoutingConfig; + onChange: (value: ChatGPTOAuthRoutingConfig) => void; + showOverrideMode?: boolean; + defaultRouting?: { + strategy: EffectiveChatGPTOAuthRoutingStrategy; + extraProviderNames: string[]; + } | null; + canManageProviders?: boolean; + membershipEditable?: boolean; + membershipManagedByLabel?: string; + quotaByName?: Map; + quotaLoading?: boolean; + entries?: CodexPoolEntry[]; + isDirty?: boolean; + saving?: boolean; + onSave?: () => void; + contentScrollable?: boolean; + className?: string; +} + +function statusBadgeVariant( + availability: ChatGPTOAuthAvailability, +): "success" | "warning" | "outline" { + if (availability === "ready") return "success"; + if (availability === "needs_sign_in") return "warning"; + return "outline"; +} + +function routeBadgeVariant( + readiness: ReturnType, +): "success" | "warning" | "outline" | "destructive" { + if (readiness === "healthy") return "success"; + if (readiness === "fallback") return "warning"; + if (readiness === "checking") return "outline"; + return "destructive"; +} + +function routeLabelKey( + readiness: ReturnType, +): string { + if (readiness === "healthy") return "chatgptOAuthRouting.routerActiveTitle"; + if (readiness === "fallback") return "chatgptOAuthRouting.fallbackTitle"; + if (readiness === "checking") return "chatgptOAuthRouting.checkingTitle"; + return "chatgptOAuthRouting.blockedNowTitle"; +} + +function roleBadgeClass(role: "preferred" | "extra"): string { + if (role === "preferred") { + return "border-primary/35 bg-primary/12 text-foreground shadow-sm dark:border-primary/40 dark:bg-primary/18"; + } + return "border-border/70 bg-background/80 text-muted-foreground"; +} + +function StateGroup({ + title, + count, + variant, + entries, + emptyLabel, +}: { + title: string; + count: number; + variant: "success" | "warning" | "outline" | "destructive"; + entries: Array<{ name: string; label: string; detail?: string }>; + emptyLabel: string; +}) { + return ( +
+
+

+ {title} +

+ {count} +
+ {entries.length > 0 ? ( +
+ {entries.map((entry) => ( +
+ {entry.label} + {entry.detail ? ( + · {entry.detail} + ) : null} +
+ ))} +
+ ) : ( +

{emptyLabel}

+ )} +
+ ); +} + +export function ChatGPTOAuthRoutingSection({ + title, + description, + currentProvider, + providers, + value, + onChange, + showOverrideMode = true, + defaultRouting = null, + canManageProviders = true, + membershipEditable = true, + membershipManagedByLabel, + quotaByName, + quotaLoading = false, + entries = [], + isDirty = false, + saving = false, + onSave, + contentScrollable = false, + className, +}: ChatGPTOAuthRoutingSectionProps) { + const { t } = useTranslation("agents"); + const { t: tc } = useTranslation("common"); + const { statuses, isLoading } = useChatGPTOAuthProviderStatuses(providers); + + const oauthProviders = providers.filter( + (provider) => provider.provider_type === "chatgpt_oauth", + ); + const currentOAuthProvider = oauthProviders.find( + (provider) => provider.name === currentProvider, + ); + if (!currentOAuthProvider) return null; + + const statusByName = useMemo( + () => new Map(statuses.map((status) => [status.provider.name, status])), + [statuses], + ); + + const getAvailability = (provider: ProviderData): ChatGPTOAuthAvailability => + statusByName.get(provider.name)?.availability ?? + (provider.enabled ? "needs_sign_in" : "disabled"); + + const allExtraProviders = oauthProviders.filter( + (provider) => provider.name !== currentProvider, + ); + const readyExtraProviders = allExtraProviders.filter( + (provider) => getAvailability(provider) === "ready", + ); + const mode = value.override_mode === "inherit" ? "inherit" : "custom"; + const providerDefaultsAvailable = + defaultRouting != null && defaultRouting.extraProviderNames.length > 0; + const selectedExtras = new Set(value.extra_provider_names ?? []); + const selectedEntries = entries.map((entry) => ({ + ...entry, + routeReadiness: getRouteReadiness(entry.availability, entry.quota), + failureKind: getQuotaFailureKind(entry.quota), + })); + const healthyEntries = selectedEntries.filter( + (entry) => entry.routeReadiness === "healthy", + ); + const fallbackEntries = selectedEntries.filter( + (entry) => entry.routeReadiness === "fallback", + ); + const checkingEntries = selectedEntries.filter( + (entry) => entry.routeReadiness === "checking", + ); + const blockedEntries = selectedEntries.filter( + (entry) => entry.routeReadiness === "blocked", + ); + const routerActiveEntries = healthyEntries; + const standbyEntries = [...fallbackEntries, ...checkingEntries]; + const selectedStrategy: EffectiveChatGPTOAuthRoutingStrategy = + value.strategy === "round_robin" || value.strategy === "priority_order" + ? value.strategy + : "primary_first"; + const canEditMembership = canManageProviders && membershipEditable; + const canUsePoolStrategies = + canManageProviders && + mode !== "inherit" && + (membershipEditable || providerDefaultsAvailable || selectedEntries.length > 1); + + const setMode = (overrideMode: "inherit" | "custom") => { + onChange({ + ...value, + override_mode: overrideMode, + }); + }; + + const setStrategy = (strategy: EffectiveChatGPTOAuthRoutingStrategy) => { + onChange({ ...value, strategy }); + }; + + const toggleProvider = (providerName: string) => { + const next = new Set(selectedExtras); + if (next.has(providerName)) { + next.delete(providerName); + } else { + next.add(providerName); + } + onChange({ + ...value, + extra_provider_names: Array.from(next), + }); + }; + + const routeDetail = ( + entry: (typeof selectedEntries)[number], + ): string | undefined => { + if (entry.availability !== "ready") { + return t(`chatgptOAuthRouting.status.${entry.availability}`); + } + if (entry.failureKind) { + return t(`chatgptOAuthRouting.quota.failure.${entry.failureKind}.label`); + } + if (entry.routeReadiness === "checking") { + return t("chatgptOAuthRouting.quota.checking"); + } + return undefined; + }; + + return ( + + +
+
+ + {title ?? t("chatgptOAuthRouting.controlTitle")} + + + {description ?? t("chatgptOAuthRouting.controlDescription")} + +
+
+ {showOverrideMode ? ( + + {mode === "inherit" + ? t("chatgptOAuthRouting.mode.inherit") + : t("chatgptOAuthRouting.mode.custom")} + + ) : null} + {!canManageProviders ? ( + + {t("chatgptOAuthRouting.viewerMode")} + + ) : null} + {isDirty ? ( + + {t("chatgptOAuthRouting.draftBadge")} + + ) : null} + {(quotaLoading || isLoading) ? ( + + {t("chatgptOAuthRouting.quota.checking")} + + ) : null} +
+
+
+ + + {showOverrideMode ? ( +
+
+

+ {t("chatgptOAuthRouting.mode.label")} +

+
+ +
+ + +
+ + {!providerDefaultsAvailable ? ( +
+ {t("chatgptOAuthRouting.mode.noProviderDefault")} +
+ ) : null} +
+ ) : null} + +
+
+

+ {t("chatgptOAuthRouting.strategyLabel")} +

+
+ +
+ + + +
+
+ +
+
+

+ {membershipEditable + ? t("chatgptOAuthRouting.availableExtraAccountsLabel") + : t("chatgptOAuthRouting.poolMembershipLabel")} +

+
+ + {!membershipEditable ? ( +
+ {selectedEntries.length > 1 + ? t("chatgptOAuthRouting.membershipManagedAtProvider", { + provider: membershipManagedByLabel || currentProvider, + }) + : t("chatgptOAuthRouting.membershipConfigureProviderFirst", { + provider: membershipManagedByLabel || currentProvider, + })} +
+ ) : isLoading ? ( +
+ {t("chatgptOAuthRouting.loadingAccounts")} +
+ ) : readyExtraProviders.length > 0 ? ( +
+ {readyExtraProviders.map((provider) => { + const selected = selectedExtras.has(provider.name); + const failureKind = getQuotaFailureKind( + quotaByName?.get(provider.name), + ); + return ( + + ); + })} +
+ ) : ( +
+ {t("chatgptOAuthRouting.noReadyExtras")} +
+ )} +
+ +
+
+

+ {t("chatgptOAuthRouting.selectedAccountsLabel")} +

+ + {t("chatgptOAuthRouting.selectedCount", { + count: selectedEntries.length, + })} + +
+ + {selectedEntries.length > 0 ? ( +
+
+ {selectedEntries.map((entry) => ( +
+
+ + {entry.label} + + + {t(`chatgptOAuthRouting.role.${entry.role}`)} + + {entry.availability !== "ready" && ( + + {t(`chatgptOAuthRouting.status.${entry.availability}`)} + + )} + {entry.routeReadiness !== "healthy" && ( + + {t(routeLabelKey(entry.routeReadiness))} + + )} +
+
+ ))} +
+
+ ) : ( +
+ {t("chatgptOAuthRouting.emptySelected")} +
+ )} +
+ +
+

+ {t("chatgptOAuthRouting.poolStateTitle")} +

+ +
+ ({ + name: entry.name, + label: entry.label, + detail: routeDetail(entry), + }))} + emptyLabel={t("chatgptOAuthRouting.emptyGroup")} + /> + ({ + name: entry.name, + label: entry.label, + detail: routeDetail(entry), + }))} + emptyLabel={t("chatgptOAuthRouting.emptyGroup")} + /> + ({ + name: entry.name, + label: entry.label, + detail: routeDetail(entry), + }))} + emptyLabel={t("chatgptOAuthRouting.emptyGroup")} + /> +
+
+
+ + {canManageProviders && onSave && (isDirty || saving) ? ( +
+
+ +
+
+ ) : null} +
+ ); +} diff --git a/ui/web/src/pages/agents/agent-detail/config-sections/index.ts b/ui/web/src/pages/agents/agent-detail/config-sections/index.ts index 6a9adf67..cd86c58a 100644 --- a/ui/web/src/pages/agents/agent-detail/config-sections/index.ts +++ b/ui/web/src/pages/agents/agent-detail/config-sections/index.ts @@ -7,3 +7,4 @@ export { SandboxSection } from "./sandbox-section"; export { MemorySection } from "./memory-section"; export { ThinkingSection } from "./thinking-section"; export { WorkspaceSharingSection } from "./workspace-sharing-section"; +export { ChatGPTOAuthRoutingSection } from "./chatgpt-oauth-routing-section"; diff --git a/ui/web/src/pages/agents/agent-detail/hooks/use-codex-pool-activity.ts b/ui/web/src/pages/agents/agent-detail/hooks/use-codex-pool-activity.ts new file mode 100644 index 00000000..d72d4ab1 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/hooks/use-codex-pool-activity.ts @@ -0,0 +1,68 @@ +import type { EffectiveChatGPTOAuthRoutingStrategy } from "@/types/agent"; +import { useQuery } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; + +export interface CodexPoolProviderCount { + provider_name: string; + request_count: number; + direct_selection_count: number; + failover_serve_count: number; + success_count: number; + failure_count: number; + consecutive_failures: number; + success_rate: number; + health_score: number; + health_state: "healthy" | "degraded" | "critical" | "idle"; + last_selected_at?: string; + last_failover_at?: string; + last_used_at?: string; + last_success_at?: string; + last_failure_at?: string; +} + +export interface CodexPoolRecentRequest { + span_id: string; + trace_id: string; + started_at: string; + status: string; + duration_ms: number; + provider_name: string; + selected_provider?: string; + model: string; + attempt_count: number; + used_failover: boolean; + failover_providers?: string[]; +} + +interface CodexPoolActivityResponse { + strategy: EffectiveChatGPTOAuthRoutingStrategy; + pool_providers: string[]; + stats_sample_size: number; + provider_counts: CodexPoolProviderCount[]; + recent_requests: CodexPoolRecentRequest[]; +} + +export function useCodexPoolActivity(agentId: string, limit = 18, enabled = true) { + const http = useHttp(); + + const query = useQuery({ + queryKey: queryKeys.agents.codexPoolActivity(agentId, limit), + enabled: enabled && Boolean(agentId), + staleTime: 5_000, + queryFn: () => http.get(`/v1/agents/${agentId}/codex-pool-activity`, { + limit: String(limit), + }), + }); + + return { + ...query, + data: query.data ?? { + strategy: "primary_first" as const, + pool_providers: [], + stats_sample_size: 0, + provider_counts: [], + recent_requests: [], + }, + }; +} diff --git a/ui/web/src/pages/agents/agent-detail/overview-sections/chatgpt-oauth-routing-summary-section.tsx b/ui/web/src/pages/agents/agent-detail/overview-sections/chatgpt-oauth-routing-summary-section.tsx new file mode 100644 index 00000000..17310049 --- /dev/null +++ b/ui/web/src/pages/agents/agent-detail/overview-sections/chatgpt-oauth-routing-summary-section.tsx @@ -0,0 +1,224 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Settings2 } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { useProviders } from "@/pages/providers/hooks/use-providers"; +import { + useChatGPTOAuthProviderStatuses, + type ChatGPTOAuthAvailability, +} from "@/pages/providers/hooks/use-chatgpt-oauth-provider-statuses"; +import { useChatGPTOAuthProviderQuotas } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas"; +import { useAuthStore } from "@/stores/use-auth-store"; +import type { AgentData, EffectiveChatGPTOAuthRoutingStrategy } from "@/types/agent"; +import { getChatGPTOAuthProviderRouting } from "@/types/provider"; +import { ChatGPTOAuthQuotaBadges } from "../chatgpt-oauth-quota-badges"; +import { + normalizeChatGPTOAuthRouting, + resolveEffectiveChatGPTOAuthRouting, +} from "../agent-display-utils"; +import { summarizeQuotaHealth } from "../chatgpt-oauth-quota-utils"; + +interface ChatGPTOAuthRoutingSummarySectionProps { + agent: AgentData; + onManage: () => void; +} + +function statusBadgeClass(availability: ChatGPTOAuthAvailability): string { + if (availability === "ready") { + return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; + } + if (availability === "disabled") { + return "border-muted-foreground/30 bg-muted text-muted-foreground"; + } + return "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300"; +} + +function strategyLabelKey( + strategy: EffectiveChatGPTOAuthRoutingStrategy, +): string { + if (strategy === "round_robin") return "chatgptOAuthRouting.strategy.roundRobin"; + if (strategy === "priority_order") return "chatgptOAuthRouting.strategy.priorityOrder"; + return "chatgptOAuthRouting.strategy.primaryFirst"; +} + +export function ChatGPTOAuthRoutingSummarySection({ + agent, + onManage, +}: ChatGPTOAuthRoutingSummarySectionProps) { + const { t } = useTranslation("agents"); + const role = useAuthStore((state) => state.role); + const canManagePool = role === "admin" || role === "owner"; + const { providers } = useProviders(canManagePool); + const { statuses, isLoading } = useChatGPTOAuthProviderStatuses(providers, canManagePool); + const providerByName = useMemo( + () => new Map(providers.map((provider) => [provider.name, provider])), + [providers], + ); + const statusByName = useMemo( + () => new Map(statuses.map((status) => [status.provider.name, status])), + [statuses], + ); + const currentProvider = providerByName.get(agent.provider); + const savedRouting = normalizeChatGPTOAuthRouting(agent.other_config); + const providerDefaults = getChatGPTOAuthProviderRouting(currentProvider?.settings); + const effectiveRouting = resolveEffectiveChatGPTOAuthRouting( + agent.provider, + currentProvider?.settings, + savedRouting, + ); + const shouldShow = currentProvider?.provider_type === "chatgpt_oauth"; + const providerNames = effectiveRouting.poolProviderNames.filter( + (providerName) => providerByName.get(providerName)?.provider_type === "chatgpt_oauth", + ); + const { quotaByName } = useChatGPTOAuthProviderQuotas(providerNames, shouldShow); + + if (!canManagePool) return null; + if (!shouldShow) return null; + + const preferredLabel = currentProvider?.display_name || agent.provider; + const preferredAvailability = statusByName.get(agent.provider)?.availability + ?? (currentProvider?.enabled === false ? "disabled" : "needs_sign_in"); + const extraEntries = effectiveRouting.extraProviderNames.map((providerName) => { + const provider = providerByName.get(providerName); + const availability = statusByName.get(providerName)?.availability + ?? (provider?.enabled === false ? "disabled" : "needs_sign_in"); + return { + providerName, + label: provider?.display_name || providerName, + availability, + quota: quotaByName.get(providerName), + }; + }); + const readyExtraCount = extraEntries.filter((entry) => entry.availability === "ready").length; + const quotaEntries = [ + { availability: preferredAvailability, quota: quotaByName.get(agent.provider) }, + ...extraEntries.map((entry) => ({ availability: entry.availability, quota: entry.quota })), + ]; + const quotaSummary = summarizeQuotaHealth( + quotaEntries.filter((entry) => entry.availability === "ready"), + ); + const quotaSummaryTotal = quotaEntries.filter( + (entry) => entry.availability === "ready", + ).length; + + return ( +
+
+
+
+

{t("chatgptOAuthRouting.summaryTitle")}

+

{t("chatgptOAuthRouting.summaryDescription")}

+
+ +
+ +
+ {t(strategyLabelKey(effectiveRouting.strategy))} + + {effectiveRouting.overrideMode === "inherit" + ? t("chatgptOAuthRouting.mode.inherit") + : t("chatgptOAuthRouting.mode.custom")} + +
+
+ +
+
+

+ {t("chatgptOAuthRouting.defaultAccount")} +

+
+ {preferredLabel} + + {t(`chatgptOAuthRouting.status.${preferredAvailability}`)} + + +
+
+ +
+

+ {t("chatgptOAuthRouting.selectedAccountsLabel")} +

+

+ {t("chatgptOAuthRouting.selectedCount", { + count: effectiveRouting.poolProviderNames.length, + })} +

+

+ {effectiveRouting.overrideMode === "inherit" && providerDefaults + ? t("chatgptOAuthRouting.mode.summaryInherited") + : t("chatgptOAuthRouting.mode.summaryCustom")} +

+
+ +
+

+ {t("chatgptOAuthRouting.metrics.quotaReadyAccounts")} +

+

+ {t("chatgptOAuthRouting.readySummary", { + ready: readyExtraCount, + total: extraEntries.length, + })} +

+

+ {quotaSummary.attention > 0 + ? t("chatgptOAuthRouting.quota.needsAttention", { count: quotaSummary.attention }) + : t("chatgptOAuthRouting.quota.healthySummary", { + usable: quotaSummary.usable, + total: quotaSummaryTotal, + })} +

+
+
+ +
+

+ {t("chatgptOAuthRouting.extraAccountsLabel")} +

+ {extraEntries.length > 0 ? ( +
+ {extraEntries.map((entry) => ( +
+ {entry.label} + + {t(`chatgptOAuthRouting.status.${entry.availability}`)} + + +
+ ))} +
+ ) : ( +

{t("chatgptOAuthRouting.emptySelected")}

+ )} +
+ + + +

+ {preferredAvailability !== "ready" + ? t("chatgptOAuthRouting.preferredNeedsAttention") + : quotaSummary.attention > 0 + ? t("chatgptOAuthRouting.quota.needsAttention", { count: quotaSummary.attention }) + : extraEntries.length === 0 + ? t("chatgptOAuthRouting.singleAccountHint") + : t("chatgptOAuthRouting.readySummary", { ready: readyExtraCount, total: extraEntries.length })} +

+ {isLoading ?

{t("chatgptOAuthRouting.loadingAccounts")}

: null} +
+
+
+ ); +} diff --git a/ui/web/src/pages/agents/agent-list-row.tsx b/ui/web/src/pages/agents/agent-list-row.tsx index b9572c55..30b8107b 100644 --- a/ui/web/src/pages/agents/agent-list-row.tsx +++ b/ui/web/src/pages/agents/agent-list-row.tsx @@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { AgentData } from "@/types/agent"; +import { UUID_RE, agentDisplayName, hasActiveChatGPTOAuthRouting } from "./agent-detail/agent-display-utils"; interface AgentListRowProps { agent: AgentData; @@ -13,15 +14,13 @@ interface AgentListRowProps { onDelete?: () => void; } -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - export function AgentListRow({ agent, ownerName, onClick, onResummon, onDelete }: AgentListRowProps) { const { t } = useTranslation("agents"); - const displayName = agent.display_name - || (UUID_RE.test(agent.agent_key) ? t("card.unnamedAgent") : agent.agent_key); + const displayName = agentDisplayName(agent, t("card.unnamedAgent")); const otherCfg = (agent.other_config ?? {}) as Record; const selfEvolve = agent.agent_type === "predefined" && Boolean(otherCfg.self_evolve); const emoji = typeof otherCfg.emoji === "string" ? otherCfg.emoji : ""; + const hasOAuthRouting = hasActiveChatGPTOAuthRouting(agent.other_config); return (
{/* Owner */} diff --git a/ui/web/src/pages/providers/hooks/use-chatgpt-oauth-provider-quotas.ts b/ui/web/src/pages/providers/hooks/use-chatgpt-oauth-provider-quotas.ts new file mode 100644 index 00000000..5f5abde1 --- /dev/null +++ b/ui/web/src/pages/providers/hooks/use-chatgpt-oauth-provider-quotas.ts @@ -0,0 +1,110 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ApiError } from "@/api/errors"; +import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; + +export interface ChatGPTOAuthQuotaWindow { + label: string; + used_percent: number; + remaining_percent: number; + reset_after_seconds?: number | null; + reset_at?: string | null; +} + +export interface ChatGPTOAuthQuotaCoreUsageWindow { + label: string; + remaining_percent: number; + reset_after_seconds?: number | null; + reset_at?: string | null; +} + +export interface ChatGPTOAuthProviderQuota { + provider_name: string; + success: boolean; + plan_type?: string | null; + windows: ChatGPTOAuthQuotaWindow[]; + core_usage?: { + five_hour?: ChatGPTOAuthQuotaCoreUsageWindow | null; + weekly?: ChatGPTOAuthQuotaCoreUsageWindow | null; + } | null; + last_updated: string; + error?: string; + error_code?: string; + action_hint?: string; + needs_reauth?: boolean; + is_forbidden?: boolean; + retryable?: boolean; +} + +function failedQuota(providerName: string, error: unknown): ChatGPTOAuthProviderQuota { + const fallback: ChatGPTOAuthProviderQuota = { + provider_name: providerName, + success: false, + windows: [], + last_updated: new Date().toISOString(), + error_code: "quota_request_failed", + error: "Quota request failed", + }; + + if (error instanceof ApiError) { + return { + ...fallback, + error_code: error.code || fallback.error_code, + error: error.message || fallback.error, + }; + } + + if (error instanceof Error) { + return { + ...fallback, + error: error.message || fallback.error, + }; + } + + return fallback; +} + +export function useChatGPTOAuthProviderQuotas(providerNames: string[], enabled = true) { + const http = useHttp(); + const stableNames = useMemo( + () => Array.from(new Set(providerNames.filter(Boolean))).sort(), + [providerNames], + ); + + const query = useQuery({ + queryKey: queryKeys.providers.chatgptOAuthQuotas(stableNames), + enabled: enabled && stableNames.length > 0, + staleTime: 15_000, + queryFn: async () => { + const results = await Promise.allSettled( + stableNames.map((providerName) => http.get( + `/v1/auth/chatgpt/${encodeURIComponent(providerName)}/quota`, + )), + ); + + return stableNames.map((providerName, index) => { + const result = results[index]; + if (result?.status === "fulfilled") { + return { + ...result.value, + provider_name: result.value.provider_name || providerName, + }; + } + return failedQuota(providerName, result?.reason); + }); + }, + }); + + const quotas = query.data ?? []; + const quotaByName = useMemo( + () => new Map(quotas.map((quota) => [quota.provider_name, quota])), + [quotas], + ); + + return { + ...query, + quotas, + quotaByName, + }; +} diff --git a/ui/web/src/pages/providers/hooks/use-chatgpt-oauth-provider-statuses.ts b/ui/web/src/pages/providers/hooks/use-chatgpt-oauth-provider-statuses.ts new file mode 100644 index 00000000..7781c67a --- /dev/null +++ b/ui/web/src/pages/providers/hooks/use-chatgpt-oauth-provider-statuses.ts @@ -0,0 +1,65 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import type { ProviderData } from "@/types/provider"; + +interface ChatGPTOAuthStatusResponse { + authenticated: boolean; +} + +export type ChatGPTOAuthAvailability = "ready" | "needs_sign_in" | "disabled"; + +export interface ChatGPTOAuthProviderStatus { + provider: ProviderData; + authenticated: boolean; + availability: ChatGPTOAuthAvailability; +} + +export function useChatGPTOAuthProviderStatuses(providers: ProviderData[], enabled = true) { + const http = useHttp(); + const oauthProviders = useMemo( + () => providers.filter((provider) => provider.provider_type === "chatgpt_oauth"), + [providers], + ); + const providerKeys = oauthProviders.map((provider) => `${provider.name}:${provider.enabled ? "1" : "0"}`); + + const query = useQuery({ + queryKey: queryKeys.providers.chatgptOAuthStatuses(providerKeys), + enabled: enabled && oauthProviders.length > 0, + staleTime: 10_000, + queryFn: async () => Promise.all( + oauthProviders.map(async (provider) => { + if (!provider.enabled) { + return { + provider, + authenticated: false, + availability: "disabled" as const, + }; + } + + try { + const status = await http.get( + `/v1/auth/chatgpt/${encodeURIComponent(provider.name)}/status`, + ); + return { + provider, + authenticated: Boolean(status.authenticated), + availability: status.authenticated ? "ready" as const : "needs_sign_in" as const, + }; + } catch { + return { + provider, + authenticated: false, + availability: "needs_sign_in" as const, + }; + } + }), + ), + }); + + return { + ...query, + statuses: query.data ?? [], + }; +} diff --git a/ui/web/src/pages/providers/hooks/use-provider-models.ts b/ui/web/src/pages/providers/hooks/use-provider-models.ts index f8f855da..59799468 100644 --- a/ui/web/src/pages/providers/hooks/use-provider-models.ts +++ b/ui/web/src/pages/providers/hooks/use-provider-models.ts @@ -5,7 +5,7 @@ import type { ModelInfo } from "@/types/provider"; export type { ModelInfo }; -// Hardcoded models for ChatGPT OAuth provider. +// Hardcoded models for the OpenAI Codex OAuth provider. // OAuth token lacks api.model.read scope, so we can't call /v1/models. const CODEX_MODELS: ModelInfo[] = [ { id: "gpt-5.4", name: "GPT-5.4" }, diff --git a/ui/web/src/pages/providers/hooks/use-providers.ts b/ui/web/src/pages/providers/hooks/use-providers.ts index d116bba4..aa1dc29a 100644 --- a/ui/web/src/pages/providers/hooks/use-providers.ts +++ b/ui/web/src/pages/providers/hooks/use-providers.ts @@ -8,12 +8,13 @@ import type { ProviderData, ProviderInput } from "@/types/provider"; export type { ProviderData, ProviderInput }; -export function useProviders() { +export function useProviders(enabled = true) { const http = useHttp(); const queryClient = useQueryClient(); const { data: providers = [], isLoading: loading } = useQuery({ queryKey: queryKeys.providers.all, + enabled, queryFn: async () => { const res = await http.get<{ providers: ProviderData[] }>("/v1/providers"); return res.providers ?? []; diff --git a/ui/web/src/pages/providers/provider-detail/provider-advanced-dialog.tsx b/ui/web/src/pages/providers/provider-detail/provider-advanced-dialog.tsx index d9697c8e..4da63eff 100644 --- a/ui/web/src/pages/providers/provider-detail/provider-advanced-dialog.tsx +++ b/ui/web/src/pages/providers/provider-detail/provider-advanced-dialog.tsx @@ -126,13 +126,15 @@ export function ProviderAdvancedDialog({ description={t("detail.identityDesc")} />
- + -

{t("detail.nameReadonly")}

+

+ {isOAuth ? t("detail.oauthAliasReadonly") : t("detail.nameReadonly")} +

{/* Connection — standard providers only */} @@ -246,7 +248,13 @@ export function ProviderAdvancedDialog({ title={t("detail.oauthConfig")} description={t("detail.oauthConfigDesc")} /> - onOpenChange(false)} /> + onOpenChange(false)} + /> )}
diff --git a/ui/web/src/pages/providers/provider-detail/provider-header.tsx b/ui/web/src/pages/providers/provider-detail/provider-header.tsx index 5e40646c..eadde130 100644 --- a/ui/web/src/pages/providers/provider-detail/provider-header.tsx +++ b/ui/web/src/pages/providers/provider-detail/provider-header.tsx @@ -20,6 +20,9 @@ export function ProviderHeader({ provider, onBack, onAdvanced, onDelete }: Provi const typeInfo = PROVIDER_TYPES.find((pt) => pt.value === provider.provider_type); const typeLabel = typeInfo?.label ?? provider.provider_type; const displayTitle = provider.display_name || provider.name; + const subtitle = provider.provider_type === "chatgpt_oauth" + ? t("card.oauthAlias", { name: provider.name }) + : provider.name; return ( @@ -55,7 +58,7 @@ export function ProviderHeader({ provider, onBack, onAdvanced, onDelete }: Provi
- {provider.name} + {subtitle} · {typeLabel}
diff --git a/ui/web/src/pages/providers/provider-detail/provider-oauth-account-section.tsx b/ui/web/src/pages/providers/provider-detail/provider-oauth-account-section.tsx new file mode 100644 index 00000000..0213efc5 --- /dev/null +++ b/ui/web/src/pages/providers/provider-detail/provider-oauth-account-section.tsx @@ -0,0 +1,145 @@ +import { useTranslation } from "react-i18next"; +import { Copy, Info } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { ChatGPTOAuthQuotaStrip } from "@/pages/agents/agent-detail/chatgpt-oauth-quota-strip"; +import type { ChatGPTOAuthProviderQuota } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-quotas"; +import type { ChatGPTOAuthAvailability } from "@/pages/providers/hooks/use-chatgpt-oauth-provider-statuses"; +import { toast } from "@/stores/use-toast-store"; +import type { ProviderData } from "@/types/provider"; + +interface ProviderOAuthAccountSectionProps { + provider: ProviderData; + managedByProvider?: ProviderData; + managedMemberCount?: number; + availability: ChatGPTOAuthAvailability; + quota?: ChatGPTOAuthProviderQuota | null; + quotaLoading?: boolean; +} + +export function ProviderOAuthAccountSection({ + provider, + managedByProvider, + managedMemberCount = 0, + availability, + quota, + quotaLoading = false, +}: ProviderOAuthAccountSectionProps) { + const { t } = useTranslation("providers"); + const modelPrefix = `${provider.name}/`; + const role: "member" | "owner" | "standalone" = managedByProvider + ? "member" + : managedMemberCount > 0 + ? "owner" + : "standalone"; + + const handleCopyPrefix = () => { + navigator.clipboard.writeText(modelPrefix).catch(() => {}); + toast.success(t("detail.oauthModelPrefixCopied")); + }; + + return ( +
+
+
+

{t("detail.oauthAccountUsage")}

+ + {t(`detail.oauthPoolRole.${role}`)} + +
+

+ {managedByProvider + ? t("detail.oauthAccountUsageManagedDesc", { + provider: managedByProvider.display_name || managedByProvider.name, + }) + : managedMemberCount > 0 + ? t("detail.oauthAccountUsageOwnerDesc", { + count: managedMemberCount, + }) + : t("detail.oauthAccountUsageDesc")} +

+
+ +
+
+ + + {provider.name} + +
+ +
+ +
+ + {modelPrefix} + + +
+
+
+ +
+
+
+

+ {t("detail.oauthQuotaTitle")} +

+

+ {t("detail.oauthQuotaDescription")} +

+
+ +
+ {availability === "ready" || quotaLoading || quota ? ( + + ) : ( + + {t( + availability === "disabled" + ? "list.status.disabled" + : "list.status.needsSignIn", + )} + + )} +
+
+
+ + + + {t("detail.oauthAccountBadge")} + + {managedByProvider ? ( +

+ {t("detail.oauthManagedByHint", { + provider: managedByProvider.display_name || managedByProvider.name, + })} +

+ ) : ( + <> +

{t("detail.oauthPreferredHint")}

+

{t("detail.oauthProviderDefaultHint")}

+

{t("detail.oauthRoutingHint")}

+ {!provider.display_name && ( +

{t("detail.oauthDisplayNameRecommendation")}

+ )} + + )} +
+
+
+ ); +} diff --git a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx index e52f8b7c..641caa6c 100644 --- a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx +++ b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx @@ -1,17 +1,39 @@ -import { useState, useEffect } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Copy, Loader2, CheckCircle2, XCircle, AlertTriangle } from "lucide-react"; +import { + Copy, + Loader2, + CheckCircle2, + XCircle, + AlertTriangle, +} from "lucide-react"; +import { StickySaveBar } from "@/components/shared/sticky-save-bar"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { StickySaveBar } from "@/components/shared/sticky-save-bar"; import { PROVIDER_TYPES } from "@/constants/providers"; import { toast } from "@/stores/use-toast-store"; +import { useProviders } from "../hooks/use-providers"; import { useProviderVerify } from "../hooks/use-provider-verify"; -import { getEmbeddingSettings } from "@/types/provider"; +import { ProviderOAuthAccountSection } from "./provider-oauth-account-section"; +import { + buildProviderSettingsWithChatGPTOAuthRouting, + getChatGPTOAuthProviderRouting, + getEmbeddingSettings, +} from "@/types/provider"; import type { ProviderData, ProviderInput } from "@/types/provider"; +import type { ChatGPTOAuthRoutingConfig } from "@/types/agent"; +import { + useChatGPTOAuthProviderStatuses, + type ChatGPTOAuthAvailability, +} from "../hooks/use-chatgpt-oauth-provider-statuses"; +import { useChatGPTOAuthProviderQuotas } from "../hooks/use-chatgpt-oauth-provider-quotas"; +import { + ChatGPTOAuthRoutingSection, +} from "@/pages/agents/agent-detail/config-sections"; +import type { CodexPoolEntry } from "@/pages/agents/agent-detail/codex-pool-activity-panel"; interface ProviderOverviewProps { provider: ProviderData; @@ -19,79 +41,330 @@ interface ProviderOverviewProps { } const NO_API_KEY_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth"]); -const NO_EMBEDDING_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth", "suno", "anthropic_native"]); +const NO_EMBEDDING_TYPES = new Set([ + "claude_cli", + "acp", + "chatgpt_oauth", + "suno", + "anthropic_native", +]); + +function providerStatus( + providerName: string, + statusByName: Map, + enabled?: boolean, +): ChatGPTOAuthAvailability { + return ( + statusByName.get(providerName)?.availability ?? + (enabled === false ? "disabled" : "needs_sign_in") + ); +} + +function routingSignature(routing: ChatGPTOAuthRoutingConfig): string { + const extras = Array.from( + new Set((routing.extra_provider_names ?? []).map((name) => name.trim()).filter(Boolean)), + ); + const strategy = + routing.strategy === "round_robin" || routing.strategy === "priority_order" + ? routing.strategy + : "primary_first"; + return JSON.stringify({ + strategy, + extra_provider_names: extras, + }); +} + +function comparableAPIKeyValue( + apiKey: string, + savedAPIKey: string, + showApiKey: boolean, +): string { + if (!showApiKey) return ""; + if (apiKey === "***") return ""; + if (apiKey === "" && savedAPIKey === "***") return ""; + return apiKey; +} + +function providerFormSignature(input: { + displayName: string; + apiKey: string; + savedAPIKey: string; + showApiKey: boolean; + enabled: boolean; + embEnabled: boolean; + embModel: string; + embApiBase: string; + embDimensions: string; + routing: ChatGPTOAuthRoutingConfig; + isOAuth: boolean; +}): string { + return JSON.stringify({ + displayName: input.displayName, + apiKey: comparableAPIKeyValue(input.apiKey, input.savedAPIKey, input.showApiKey), + enabled: input.enabled, + embEnabled: input.embEnabled, + embModel: input.embModel, + embApiBase: input.embApiBase, + embDimensions: input.embDimensions, + routing: input.isOAuth ? routingSignature(input.routing) : "", + }); +} export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) { const { t } = useTranslation("providers"); const { t: tc } = useTranslation("common"); + const { providers } = useProviders(); + const { statuses } = useChatGPTOAuthProviderStatuses(providers); const typeInfo = PROVIDER_TYPES.find((pt) => pt.value === provider.provider_type); const typeLabel = typeInfo?.label ?? provider.provider_type; const showApiKey = !NO_API_KEY_TYPES.has(provider.provider_type); const showEmbedding = !NO_EMBEDDING_TYPES.has(provider.provider_type); + const isOAuth = provider.provider_type === "chatgpt_oauth"; + + const providerByName = useMemo( + () => new Map(providers.map((item) => [item.name, item])), + [providers], + ); + const poolOwnership = useMemo(() => { + const membersByOwner = new Map(); + const ownerByMember = new Map(); + for (const item of providers) { + if (item.provider_type !== "chatgpt_oauth") continue; + const routing = getChatGPTOAuthProviderRouting(item.settings); + if (!routing || routing.extraProviderNames.length === 0) continue; + membersByOwner.set(item.name, routing.extraProviderNames); + for (const memberName of routing.extraProviderNames) { + if (!ownerByMember.has(memberName)) { + ownerByMember.set(memberName, item.name); + } + } + } + return { membersByOwner, ownerByMember }; + }, [providers]); + const statusByName = useMemo( + () => new Map(statuses.map((status) => [status.provider.name, status])), + [statuses], + ); + const managedByOwnerName = isOAuth + ? poolOwnership.ownerByMember.get(provider.name) + : undefined; + const managedByProvider = managedByOwnerName + ? providerByName.get(managedByOwnerName) + : undefined; + const managedMemberCount = isOAuth + ? poolOwnership.membersByOwner.get(provider.name)?.length ?? 0 + : 0; + const canEditPoolRouting = isOAuth && !managedByOwnerName; + const currentOAuthAvailability = providerStatus( + provider.name, + statusByName, + provider.enabled, + ); + + const initialRouting = getChatGPTOAuthProviderRouting(provider.settings); - // Identity const [displayName, setDisplayName] = useState(provider.display_name || ""); - - // API Key const [apiKey, setApiKey] = useState(provider.api_key || ""); - - // Status const [enabled, setEnabled] = useState(provider.enabled); + const [poolRouting, setPoolRouting] = useState({ + strategy: initialRouting?.strategy ?? "primary_first", + extra_provider_names: initialRouting?.extraProviderNames ?? [], + }); - // Embedding const initEmb = getEmbeddingSettings(provider.settings); const [embEnabled, setEmbEnabled] = useState(initEmb?.enabled ?? false); const [embModel, setEmbModel] = useState(initEmb?.model ?? ""); const [embApiBase, setEmbApiBase] = useState(initEmb?.api_base ?? ""); const [embDimensions, setEmbDimensions] = useState(initEmb?.dimensions ? String(initEmb.dimensions) : ""); + const syncedProviderIDRef = useRef(provider.id); + + const savedFormSignature = useMemo( + () => + providerFormSignature({ + displayName: provider.display_name || "", + apiKey: provider.api_key || "", + savedAPIKey: provider.api_key || "", + showApiKey, + enabled: provider.enabled, + embEnabled: initEmb?.enabled ?? false, + embModel: initEmb?.model ?? "", + embApiBase: initEmb?.api_base ?? "", + embDimensions: initEmb?.dimensions ? String(initEmb.dimensions) : "", + routing: { + strategy: initialRouting?.strategy ?? "primary_first", + extra_provider_names: initialRouting?.extraProviderNames ?? [], + }, + isOAuth, + }), + [initEmb?.api_base, initEmb?.dimensions, initEmb?.enabled, initEmb?.model, initialRouting?.extraProviderNames, initialRouting?.strategy, isOAuth, provider.api_key, provider.display_name, provider.enabled, showApiKey], + ); + const savedFormSignatureRef = useRef(savedFormSignature); + const draftFormSignature = useMemo( + () => + providerFormSignature({ + displayName, + apiKey, + savedAPIKey: provider.api_key || "", + showApiKey, + enabled, + embEnabled, + embModel, + embApiBase, + embDimensions, + routing: poolRouting, + isOAuth, + }), + [apiKey, displayName, embApiBase, embDimensions, embEnabled, embModel, enabled, isOAuth, poolRouting, provider.api_key, showApiKey], + ); - // Re-sync embedding state when provider changes (e.g. after save) useEffect(() => { + const nextProviderID = provider.id; const es = getEmbeddingSettings(provider.settings); - setEmbEnabled(es?.enabled ?? false); - setEmbModel(es?.model ?? ""); - setEmbApiBase(es?.api_base ?? ""); - setEmbDimensions(es?.dimensions ? String(es.dimensions) : ""); - }, [provider.settings]); + const routing = getChatGPTOAuthProviderRouting(provider.settings); + const syncFromProvider = () => { + setEmbEnabled(es?.enabled ?? false); + setEmbModel(es?.model ?? ""); + setEmbApiBase(es?.api_base ?? ""); + setEmbDimensions(es?.dimensions ? String(es.dimensions) : ""); + setPoolRouting({ + strategy: routing?.strategy ?? "primary_first", + extra_provider_names: routing?.extraProviderNames ?? [], + }); + setDisplayName(provider.display_name || ""); + setApiKey(provider.api_key || ""); + setEnabled(provider.enabled); + }; + + if (nextProviderID !== syncedProviderIDRef.current) { + syncedProviderIDRef.current = nextProviderID; + savedFormSignatureRef.current = savedFormSignature; + syncFromProvider(); + return; + } + + const previousSavedSignature = savedFormSignatureRef.current; + if (savedFormSignature === previousSavedSignature) { + return; + } + + if (draftFormSignature === previousSavedSignature) { + syncFromProvider(); + } + savedFormSignatureRef.current = savedFormSignature; + }, [draftFormSignature, provider.api_key, provider.display_name, provider.enabled, provider.id, provider.settings, savedFormSignature]); + + const quotaProviderNames = useMemo( + () => { + if (!isOAuth) return []; + const candidateNames = [ + provider.name, + ...(canEditPoolRouting ? poolRouting.extra_provider_names ?? [] : []), + ]; + return Array.from( + new Set( + candidateNames.filter((providerName) => { + if (!providerName) return false; + const item = providerByName.get(providerName); + return ( + providerStatus(providerName, statusByName, item?.enabled) === "ready" + ); + }), + ), + ); + }, + [ + canEditPoolRouting, + isOAuth, + poolRouting.extra_provider_names, + provider.name, + providerByName, + statusByName, + ], + ); + const { + quotaByName, + isLoading: quotasLoading, + isFetching: quotasFetching, + } = useChatGPTOAuthProviderQuotas(quotaProviderNames, isOAuth); + const poolEntries = useMemo(() => { + if (!canEditPoolRouting) return []; + return quotaProviderNames.map((providerName) => { + const item = providerByName.get(providerName); + return { + name: providerName, + label: item?.display_name || providerName, + availability: providerStatus(providerName, statusByName, item?.enabled), + role: providerName === provider.name ? "preferred" : "extra", + requestCount: 0, + directSelectionCount: 0, + failoverServeCount: 0, + successCount: 0, + failureCount: 0, + consecutiveFailures: 0, + successRate: 0, + healthScore: 0, + healthState: "idle", + providerHref: item?.id ? `/providers/${item.id}` : undefined, + quota: quotaByName.get(providerName), + }; + }); + }, [canEditPoolRouting, provider.name, providerByName, quotaByName, quotaProviderNames, statusByName]); - // Verify embedding const { verifyEmbedding, embVerifying, embResult, resetEmb } = useProviderVerify(); useEffect(() => { resetEmb(); }, [embModel, embDimensions, resetEmb]); - // Save state const [saving, setSaving] = useState(false); const handleSave = async () => { setSaving(true); try { + const nextDisplayName = displayName.trim(); + const nextEmbModel = embModel.trim(); + const nextEmbAPIBase = embApiBase.trim(); + const parsedDims = embDimensions ? parseInt(embDimensions, 10) : 0; + const nextEmbDimensions = parsedDims > 0 ? String(parsedDims) : ""; + const submittedAPIKey = showApiKey && apiKey && apiKey !== "***" ? apiKey : ""; const data: ProviderInput = { name: provider.name, - display_name: displayName.trim() || undefined, + display_name: nextDisplayName || undefined, provider_type: provider.provider_type, enabled, }; - // Only include api_key if changed from the masked value - if (showApiKey && apiKey && apiKey !== "***") { - data.api_key = apiKey; + if (submittedAPIKey) { + data.api_key = submittedAPIKey; } - // Merge embedding settings into existing settings + + let nextSettings = { ...((provider.settings || {}) as Record) }; if (showEmbedding) { - const existing = (provider.settings || {}) as Record; - const parsedDims = embDimensions ? parseInt(embDimensions, 10) : 0; - data.settings = { - ...existing, + nextSettings = { + ...nextSettings, embedding: embEnabled ? { enabled: true, - model: embModel.trim() || undefined, - api_base: embApiBase.trim() || undefined, + model: nextEmbModel || undefined, + api_base: nextEmbAPIBase || undefined, dimensions: parsedDims > 0 ? parsedDims : undefined, } : { enabled: false }, }; } + if (isOAuth) { + nextSettings = buildProviderSettingsWithChatGPTOAuthRouting( + nextSettings, + poolRouting, + ); + } + data.settings = nextSettings; + await onUpdate(provider.id, data); + setDisplayName(nextDisplayName); + setEmbModel(nextEmbModel); + setEmbApiBase(nextEmbAPIBase); + setEmbDimensions(nextEmbDimensions); + if (submittedAPIKey) { + setApiKey("***"); + } } catch { // toast shown by hook } finally { @@ -109,9 +382,24 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) verifyEmbedding(provider.id, embModel.trim() || undefined, parsedDims > 0 ? parsedDims : undefined); }; + const displayNameDirty = displayName !== (provider.display_name || ""); + const enabledDirty = enabled !== provider.enabled; + const apiKeyDirty = + showApiKey && + comparableAPIKeyValue(apiKey, provider.api_key || "", showApiKey) !== ""; + const embeddingDirty = + embEnabled !== (initEmb?.enabled ?? false) + || embModel !== (initEmb?.model ?? "") + || embApiBase !== (initEmb?.api_base ?? "") + || embDimensions !== (initEmb?.dimensions ? String(initEmb.dimensions) : ""); + const routingDirty = isOAuth && routingSignature(poolRouting) !== routingSignature({ + strategy: initialRouting?.strategy ?? "primary_first", + extra_provider_names: initialRouting?.extraProviderNames ?? [], + }); + const isDirty = displayNameDirty || enabledDirty || apiKeyDirty || embeddingDirty || routingDirty; + return (
- {/* Identity */}

{t("detail.identity")}

@@ -121,9 +409,12 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) id="displayName" value={displayName} onChange={(e) => setDisplayName(e.target.value)} - placeholder={t("form.displayNamePlaceholder")} + placeholder={isOAuth ? t("form.oauthDisplayNamePlaceholder") : t("form.displayNamePlaceholder")} className="text-base md:text-sm" /> + {isOAuth ? ( +

{t("form.oauthDisplayNameHint")}

+ ) : null}
@@ -134,7 +425,7 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
- +
{provider.name} @@ -146,8 +437,34 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
- {/* API Key */} - {showApiKey && ( + {isOAuth ? ( + + ) : null} + + {canEditPoolRouting ? ( + + ) : null} + + {showApiKey ? (

{t("detail.apiKeySection")}

@@ -163,10 +480,9 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)

{t("form.apiKeySetHint")}

- )} + ) : null} - {/* Embedding */} - {showEmbedding && ( + {showEmbedding ? (

{t("detail.embeddingSection")}

@@ -179,7 +495,7 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
- {embEnabled && ( + {embEnabled ? (
@@ -229,14 +545,16 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) {embVerifying ? : null} {t("embedding.verify")} - {embResult && ( - + {embResult ? ( + {embResult.valid ? ( <> {embResult.dimension_mismatch ? ( @@ -255,14 +573,13 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) )} - )} + ) : null}
- )} + ) : null}
- )} + ) : null} - {/* Status */}

{t("detail.statusSection")}

@@ -279,6 +596,7 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps)
); diff --git a/ui/web/src/pages/providers/provider-form-dialog.tsx b/ui/web/src/pages/providers/provider-form-dialog.tsx index 0e83ac47..ac48e2ac 100644 --- a/ui/web/src/pages/providers/provider-form-dialog.tsx +++ b/ui/web/src/pages/providers/provider-form-dialog.tsx @@ -22,7 +22,7 @@ import { } from "@/components/ui/select"; import type { ProviderData, ProviderInput } from "./hooks/use-providers"; import { slugify, isValidSlug } from "@/lib/slug"; -import { PROVIDER_TYPES } from "@/constants/providers"; +import { DEFAULT_CODEX_OAUTH_ALIAS, PROVIDER_TYPES, suggestUniqueProviderAlias } from "@/constants/providers"; import { OAuthSection } from "./provider-oauth-section"; import { CLISection } from "./provider-cli-section"; import { ACPSection } from "./provider-acp-section"; @@ -121,7 +121,7 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi {t("form.createTitle")} - {t("form.configure")} + {isOAuth ? t("form.configureOauth") : t("form.configure")}
pt.value === v); setApiBase(preset?.apiBase || ""); if (v === "chatgpt_oauth") { - setName("openai-codex"); - setDisplayName("ChatGPT (OAuth)"); - } else { - if (name === "openai-codex") setName(""); + if (!name || providerType !== "chatgpt_oauth") { + setName(suggestUniqueProviderAlias(existingProviders)); + } if (displayName === "ChatGPT (OAuth)") setDisplayName(""); + } else { + if (name === DEFAULT_CODEX_OAUTH_ALIAS) setName(""); } }} /> @@ -147,15 +148,33 @@ export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProvi <>
- - + + setName(slugify(e.target.value))} + placeholder={t("form.oauthAliasPlaceholder")} + className="text-base md:text-sm" + />
- - + + setDisplayName(e.target.value)} + placeholder={t("form.oauthDisplayNamePlaceholder")} + className="text-base md:text-sm" + />
- { queryClient.invalidateQueries({ queryKey: ["providers"] }); onOpenChange(false); }} /> + { queryClient.invalidateQueries({ queryKey: ["providers"] }); onOpenChange(false); }} + /> ) : ( <> diff --git a/ui/web/src/pages/providers/provider-list-row.tsx b/ui/web/src/pages/providers/provider-list-row.tsx index 683fa5e2..2239033a 100644 --- a/ui/web/src/pages/providers/provider-list-row.tsx +++ b/ui/web/src/pages/providers/provider-list-row.tsx @@ -2,66 +2,202 @@ import { Cpu, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { PROVIDER_TYPE_BADGE, ProviderApiKeyBadge } from "./provider-utils"; +import { cn } from "@/lib/utils"; +import { ChatGPTOAuthQuotaStrip } from "@/pages/agents/agent-detail/chatgpt-oauth-quota-strip"; +import type { EffectiveChatGPTOAuthRoutingStrategy } from "@/types/agent"; +import type { ChatGPTOAuthProviderQuota } from "./hooks/use-chatgpt-oauth-provider-quotas"; +import type { ChatGPTOAuthAvailability } from "./hooks/use-chatgpt-oauth-provider-statuses"; import type { ProviderData } from "./hooks/use-providers"; +import { PROVIDER_TYPE_BADGE, ProviderApiKeyBadge } from "./provider-utils"; + +interface ProviderOAuthPoolSummary { + availability: ChatGPTOAuthAvailability; + role: "owner" | "member" | "standalone"; + managedByLabel?: string; + memberCount: number; + strategy: EffectiveChatGPTOAuthRoutingStrategy; + connectorPosition?: "none" | "single" | "first" | "middle" | "last"; + quota?: ChatGPTOAuthProviderQuota | null; + quotaLoading?: boolean; +} interface ProviderListRowProps { provider: ProviderData; + oauthPool?: ProviderOAuthPoolSummary; onClick: () => void; onDelete?: () => void; } -export function ProviderListRow({ provider, onClick, onDelete }: ProviderListRowProps) { +function strategyLabelKey(strategy: EffectiveChatGPTOAuthRoutingStrategy): string { + if (strategy === "round_robin") return "list.strategy.roundRobin"; + if (strategy === "priority_order") return "list.strategy.priorityOrder"; + return "list.strategy.primaryFirst"; +} + +export function ProviderListRow({ + provider, + oauthPool, + onClick, + onDelete, +}: ProviderListRowProps) { const { t: tc } = useTranslation("common"); + const { t } = useTranslation("providers"); const displayName = provider.display_name || provider.name; - const tb = PROVIDER_TYPE_BADGE[provider.provider_type] ?? { label: provider.provider_type, variant: "outline" as const }; + const typeBadge = PROVIDER_TYPE_BADGE[provider.provider_type] ?? { + label: provider.provider_type, + variant: "outline" as const, + }; + const subtitle = provider.provider_type === "chatgpt_oauth" + ? t("card.oauthAlias", { name: provider.name }) + : provider.display_name + ? provider.name + : null; + const showAvailabilityWarning = oauthPool && oauthPool.availability !== "ready"; + const hasPoolRole = oauthPool?.role === "owner" || oauthPool?.role === "member"; + const showMemberConnector = oauthPool?.role === "member" && oauthPool.connectorPosition && oauthPool.connectorPosition !== "none"; + const poolMeta = oauthPool?.role === "owner" + ? `${t(strategyLabelKey(oauthPool.strategy))} · ${t("list.memberCount", { count: oauthPool.memberCount })}` + : oauthPool?.role === "member" && oauthPool.managedByLabel + ? t("list.managedBy", { provider: oauthPool.managedByLabel }) + : null; + const secondaryText = [subtitle, poolMeta].filter(Boolean).join(" · "); + const availabilityWarningLabel = showAvailabilityWarning + ? t( + oauthPool?.availability === "disabled" + ? "list.status.disabled" + : "list.status.needsSignIn", + ) + : null; + const showQuota = provider.provider_type === "chatgpt_oauth" + && (oauthPool?.quotaLoading || Boolean(oauthPool?.quota)); + const connectorLineClass = oauthPool?.connectorPosition === "first" || oauthPool?.connectorPosition === "middle" + ? "top-[-0.75rem] h-[calc(100%+1.5rem)]" + : "top-[-0.75rem] h-[calc(50%+0.75rem)]"; return (
{ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } }} - className="flex w-full cursor-pointer items-center gap-3 rounded-lg border bg-card px-4 py-3 text-left transition-all hover:border-primary/30 hover:shadow-sm" + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onClick(); + } + }} + className={cn( + "flex w-full cursor-pointer items-center gap-3 rounded-lg border bg-card px-4 py-2.5 text-left transition-all hover:border-primary/30 hover:shadow-sm", + oauthPool?.role === "owner" && "border-primary/20 bg-primary/[0.02]", + oauthPool?.role === "member" && "relative border-sky-500/20 bg-sky-500/[0.025]", + showMemberConnector && "ml-4 w-[calc(100%-1rem)]", + )} > - {/* Icon */} -
+ {showMemberConnector && ( + <> +