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
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-03-27 09:35:57 +07:00
1 parent e183b459c9
commit 30708ae79d
113 files changed
+11816 -536

No files matched your search

+31 -21
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+5 -1
View File
@@ -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)))
+2 -2
View File
@@ -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)
+48
View File
@@ -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. |
+152
View File
@@ -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
+14 -5
View File
@@ -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 <token>` — checked first for gateway token or API key
- **User ID**: `X-GoClaw-User-Id: <user-id>` — optional external user identifier (max 255 chars)
- **Browser pairing**: `X-GoClaw-Sender-Id: <sender-id>` — identifies a previously-paired browser device
- **Tenant scope**: `X-GoClaw-Tenant-Id: <tenant-uuid-or-slug>` — 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
+3 -3
View File
@@ -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`.
+3 -4
View File
@@ -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)
}
+7 -6
View File
@@ -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 {
+14 -1
View File
@@ -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)
}
+7 -7
View File
@@ -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
}
+12 -11
View File
@@ -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 == "" {
+1 -1
View File
@@ -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)
}
+7 -11
View File
@@ -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).
+3 -3
View File
@@ -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()
}
+9 -13
View File
@@ -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, &params)
@@ -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.
+52 -2
View File
@@ -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
+327
View File
@@ -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
}
+240
View File
@@ -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)
}
@@ -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])
}
}
+184
View File
@@ -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)
}
}
+8 -8
View File
@@ -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()
+84 -30
View File
@@ -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 {
+254 -3
View File
@@ -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
@@ -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
}
@@ -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)
}
}
+1 -1
View File
@@ -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])
}
+212 -45
View File
@@ -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
+40
View File
@@ -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)
}
+184
View File
@@ -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)
}
+331
View File
@@ -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)
}
}
+218 -2
View File
@@ -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" } } }
}
}
}
+2 -1
View File
@@ -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()
+55 -8
View File
@@ -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 {
+52
View File
@@ -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)
}
}
+1 -1
View File
@@ -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:**"):
+2 -7
View File
@@ -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)
}
+126
View File
@@ -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 ""
}
+83
View File
@@ -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) + "."
}
+409
View File
@@ -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
}
+305
View File
@@ -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)
}
}
+115
View File
@@ -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
}
+243 -33
View File
@@ -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 != ""
}
+136
View File
@@ -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")
}
}
@@ -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
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
+258
View File
@@ -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
}
@@ -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)
}
}
+34 -6
View File
@@ -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.
+4 -4
View File
@@ -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)
+29 -12
View File
@@ -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).
+2 -4
View File
@@ -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)
+3 -3
View File
@@ -109,9 +109,9 @@ func detectContainerID() string {
// Strategy 1: Parse /proc/self/mountinfo for docker container ID.
// Lines contain paths like /docker/containers/<id>/...
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
+180 -21
View File
@@ -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"`
}
+178
View File
@@ -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)
}
}
+4 -5
View File
@@ -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"`
+1 -4
View File
@@ -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
+22
View File
@@ -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
+4 -5
View File
@@ -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 {
+14 -13
View File
@@ -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,
+2 -2
View File
@@ -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 ?? "";
@@ -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 (
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-30 flex justify-end px-4 sm:px-6 lg:px-8 safe-bottom">
<div className="pointer-events-auto flex items-center justify-end gap-2 rounded-xl border bg-background/95 p-2 shadow-lg backdrop-blur-sm">
<Button onClick={onSave} disabled={saving || showSpin || disabled}>
{showSpin ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{showSpin ? resolvedSavingLabel : resolvedLabel}
</Button>
</div>
</div>
);
}
return (
<div className="sticky bottom-0 z-10 -mx-3 mt-6 border-t bg-background/80 px-3 py-3 backdrop-blur-sm sm:-mx-4 sm:px-4">
<div className="flex items-center justify-end gap-2">
<div className="sticky bottom-0 z-20 -mx-3 mt-6 bg-gradient-to-t from-background via-background/95 to-background/0 px-3 pb-1 pt-6 sm:-mx-4 sm:px-4">
<div className="flex items-center justify-end border-t border-border/70 pt-3">
<Button onClick={onSave} disabled={saving || showSpin || disabled}>
{showSpin ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{showSpin ? resolvedSavingLabel : resolvedLabel}
+29
View File
@@ -1,3 +1,5 @@
import { slugify } from "@/lib/slug";
export interface ProviderTypeInfo {
value: string;
label: string;
@@ -5,6 +7,10 @@ export interface ProviderTypeInfo {
placeholder: string;
}
type ProviderAliasSource = string | { name?: string | null };
export const DEFAULT_CODEX_OAUTH_ALIAS = "openai-codex";
export const PROVIDER_TYPES: ProviderTypeInfo[] = [
{ value: "chatgpt_oauth", label: "ChatGPT Subscription (OAuth)", apiBase: "", placeholder: "" },
{ value: "anthropic_native", label: "Anthropic (Native)", apiBase: "", placeholder: "https://api.anthropic.com" },
@@ -28,3 +34,26 @@ export const PROVIDER_TYPES: ProviderTypeInfo[] = [
{ value: "claude_cli", label: "Claude CLI (Local)", apiBase: "", placeholder: "" },
{ value: "acp", label: "ACP Agent (Subprocess)", apiBase: "", placeholder: "claude" },
];
function providerAliasName(value: ProviderAliasSource): string {
if (typeof value === "string") return value.trim().toLowerCase();
return value.name?.trim().toLowerCase() ?? "";
}
export function suggestUniqueProviderAlias(
existingProviders: ProviderAliasSource[],
options?: { baseAlias?: string; excludeName?: string },
): string {
const baseAlias = slugify(options?.baseAlias ?? DEFAULT_CODEX_OAUTH_ALIAS) || DEFAULT_CODEX_OAUTH_ALIAS;
const taken = new Set(existingProviders.map(providerAliasName).filter(Boolean));
const excludeName = providerAliasName(options?.excludeName ?? "");
if (excludeName) taken.delete(excludeName);
if (!taken.has(baseAlias)) return baseAlias;
let suffix = 2;
while (taken.has(`${baseAlias}-${suffix}`)) {
suffix += 1;
}
return `${baseAlias}-${suffix}`;
}
+247
View File
@@ -124,6 +124,253 @@
"saved": "Saved",
"failedToSave": "Failed to save"
},
"chatgptOAuthRouting": {
"title": "Codex OAuth Pool",
"description": "Edit the saved pool, then check it live.",
"defaultAccount": "Primary Account",
"defaultHint": "The agent Provider field stays pinned to the primary alias.",
"badge": "Codex pool",
"badgeTooltip": "This agent can route Codex traffic across multiple OpenAI OAuth aliases.",
"summaryTitle": "OpenAI Account Pool",
"summaryDescription": "See the effective OpenAI Codex pool for this agent. Pool members are managed on the provider; this page only controls routing behavior.",
"strategyLabel": "Traffic Policy",
"strategyHint": "Primary First keeps the primary alias first. Round Robin rotates across all ready accounts. Priority Order drains accounts in the listed order after the primary alias.",
"extraAccountsLabel": "Extra Accounts",
"availableExtraAccountsLabel": "Available Extra Accounts",
"poolMembershipLabel": "Pool Membership",
"extraHint": "Only enabled and signed-in OpenAI Codex OAuth aliases can participate.",
"emptyExtras": "Sign in another OpenAI Codex OAuth alias to unlock pooling.",
"noReadyExtras": "Sign in and enable another OpenAI Codex OAuth alias to add it here.",
"extraSelectableHint": "Only enabled, signed-in OpenAI Codex OAuth aliases can enter the active pool.",
"selectedAccountsLabel": "Accounts in Pool",
"emptySelected": "No extra pool members selected yet.",
"manageAction": "Open Pool",
"singleAccountHint": "Configure another named OpenAI Codex OAuth provider on the Providers page to create a provider-owned pool.",
"membershipManagedAtProvider": "Pool members are managed on provider {{provider}}. Change membership there, then come back here to tune routing behavior.",
"membershipConfigureProviderFirst": "No provider-owned pool is saved on {{provider}} yet. Configure pool members there before enabling pooled routing for this agent.",
"readySummary": "{{ready}} of {{total}} extra accounts are ready.",
"preferredNeedsAttention": "The primary account needs attention before this agent can use it reliably.",
"loadingAccounts": "Checking OpenAI Codex OAuth account status...",
"attentionTitle": "Accounts Needing Attention",
"attentionHint": "Selected accounts that are disabled or signed out stay visible here until you remove them or sign in again.",
"pageTitle": "OpenAI Account Pool",
"pageDescription": "Review the live OpenAI Codex pool for {{name}}, then choose whether this agent inherits provider defaults, keeps the primary account only, or tunes the routing strategy.",
"backToAgent": "Back To Agent",
"openProviders": "Open Providers",
"pageUnsupportedTitle": "This agent is not using an OpenAI Codex OAuth provider",
"pageUnsupportedDescription": "Set the agent Provider to an OpenAI Codex OAuth alias first, then return here to manage the pool.",
"providerAccessTitle": "Provider management is admin-only",
"providerAccessDescription": "You can inspect the live pool here, but only an admin can sign accounts in, add aliases, or open the Providers pages.",
"providerAccessInline": "Only admins can sign in new aliases or manage the Providers page.",
"unsavedChangesTitle": "Draft changes are not live yet",
"unsavedChangesDescription": "The verification cards below reflect the last saved pool. Save this draft before using the evidence to validate round robin.",
"draftBadge": "Draft pending",
"savedPoolOnlyHint": "Live evidence still reflects the last saved pool.",
"verificationTitle": "How verification works",
"verificationDescription": "Evidence comes from recent LLM spans for this agent. Refresh this page after a few requests to compare the configured pool with the aliases that were actually used.",
"addPoolMembersTitle": "Add more aliases to this pool",
"addPoolMembersDescription": "Create more OpenAI Codex OAuth aliases on the Providers page, then return here to add them to this agent.",
"needsAttentionTitle": "Needs Attention",
"needsAttentionDescription": "{{count}} pool aliases are disabled or need sign-in before you can rely on them.",
"never": "Never",
"requestCount": "{{count}} recent requests",
"lastUsedAt": "Last used {{value}}",
"openProvider": "Open Provider",
"activityTitle": "Live Routing",
"activityDescription": "Runtime health and live routing proof.",
"refreshEvidence": "Refresh Live State",
"recentRequestsCount": "{{count}} recent requests",
"observedProviders": "{{observed}} of {{total}} accounts used directly",
"failoverOnlyProviders": "{{count}} aliases seen only via failover",
"selectedCountLabel": "{{count}} direct uses",
"failoverServeCount": "{{count}} failover serves",
"failoverOnlyLabel": "Failover only",
"switchRate": "{{switches}} provider switches across {{total}} request hops",
"poolMembersTitle": "Pool Accounts",
"poolMembersDescription": "Role, route state, recent proof, and quota.",
"sequenceTitle": "Recent Requests",
"sequenceColumns": {
"step": "#",
"account": "Account",
"route": "Route",
"when": "When",
"duration": "Time"
},
"sequenceDescription": "Recent proof from routed Codex calls.",
"sequenceEmptyTitle": "No recent proof yet",
"loadingEvidence": "Loading recent Codex routing evidence...",
"unknownModel": "Unknown model",
"attemptCount": "Attempts {{count}}",
"selectedProviderBadge": "Selected {{provider}}",
"servedProviderBadge": "Served {{provider}}",
"failoverHint": "Retryable failure fell through to: {{providers}}",
"traceAction": "Trace",
"openTrace": "Open Trace",
"noEvidence": "Send a few Codex requests, then refresh.",
"selectedCount": "{{count}} in pool",
"productLabel": "ChatGPT Subscription (OAuth)",
"controlTitle": "Pool Setup",
"controlDescription": "Pool members are managed on the provider. This page controls whether the agent inherits that pool, stays on the primary account, or overrides the routing strategy.",
"mode": {
"label": "Agent Routing Mode",
"inherit": "Use Provider Defaults",
"custom": "Custom For This Agent",
"noProviderDefault": "No provider-owned pool is saved yet. Configure reusable pool members on the Providers page first, or keep this agent on its primary account.",
"providerDefaultSummary": "{{count}} accounts in provider default",
"summaryInherited": "This agent currently follows the provider default pool.",
"summaryCustom": "This agent currently saves its own routing override for the provider-owned pool."
},
"poolStateTitle": "Pool State",
"routerActiveTitle": "Router Active",
"fallbackTitle": "Fallback Only",
"blockedNowTitle": "Blocked Now",
"checkingTitle": "Checking",
"emptyGroup": "None",
"viewerMode": "View only",
"consoleSummary": "{{active}} active · {{observed}} used directly · {{blocked}} blocked",
"monitorDirectLabel": "Direct",
"monitorDirectUseLabel": "Direct Hits",
"monitorFailoverLabel": "Failover",
"lastSeenLabel": "Last Seen",
"sampleBadge": "{{count}} traced spans",
"noSampleBadge": "No recent sample",
"runtimeHealthTitle": "Runtime Health",
"runtimeHealthSummary": "{{rate}}% success · score {{score}}",
"noRuntimeSample": "No recent runtime sample yet.",
"failureStreakBadge": "{{count}} failing now",
"runtimeSuccessCompact": "Success {{count}}",
"runtimeFailureCompact": "Failed {{count}}",
"lastSuccessLabel": "Last ok {{value}}",
"lastFailureLabel": "Last fail {{value}}",
"healthState": {
"healthy": "Healthy",
"degraded": "Degraded",
"critical": "Critical",
"idle": "Idle"
},
"checkpoints": {
"configured": "Configured",
"ready": "Ready",
"observed": "Used Directly",
"switching": "Switching"
},
"nextActionTitle": "Next Action",
"nextAction": {
"saveTitle": "Save the draft before trusting the evidence",
"saveDescription": "The saved pool and the draft have diverged. Persist the current configuration first, then validate live traffic.",
"attentionTitle": "Fix the aliases needing attention",
"attentionDescription": "Disabled or signed-out aliases stay visible so you can re-auth them or remove them from the pool.",
"quotaTitle": "Fix quota blockers before trusting round robin",
"quotaDescription": "{{count}} signed-in aliases do not currently show usable quota.",
"failoverOnlyTitle": "Inspect aliases that only appear after failover",
"failoverOnlyDescription": "{{count}} aliases have recent traffic only as failover targets, not as direct round-robin selections yet.",
"addMembersTitle": "Add another signed-in alias to unlock real pooling",
"addMembersDescription": "Round robin becomes meaningful once more than one ready alias can participate.",
"verifyTitle": "Send a few more Codex requests and refresh evidence",
"verifyDescription": "The pool is saved and ready, but recent routed calls have not yet shown direct selection across every ready alias.",
"healthyTitle": "No operator action needed right now",
"healthyDescription": "Recent routed calls are directly selecting every ready alias in the saved pool.",
"manualTitle": "Ordered routing is active",
"manualDescription": "This agent is not rotating every request. Repeated use of the primary alias is expected until round robin is enabled."
},
"howItWorksTitle": "How this pool works",
"howItWorks": {
"primary": "The agent Provider field stays pinned to the primary alias.",
"roundRobin": "Round Robin rotates across all ready aliases in the active pool.",
"failover": "Retryable upstream failures can fall through to the next eligible alias in the same request."
},
"quota": {
"checking": "Checking quota",
"checkingDescription": "GoClaw is still fetching the latest quota state for this alias.",
"healthySummary": "{{usable}} of {{total}} quota-ready",
"needsAttention": "{{count}} aliases need quota attention",
"blockersNow": "Blocking right now",
"floorFiveHour": "5h floor {{value}}%",
"floorWeekly": "Wk floor {{value}}%",
"plan": "Plan",
"lastChecked": "Checked {{value}}",
"readyLabel": "Quota ready",
"readyDescription": "This alias currently shows usable quota windows.",
"readinessTitle": "Quota Readiness",
"readinessDescription": "Look here first. Only signed-in aliases with usable quota should be trusted for live traffic.",
"requiresReadyAlias": "This alias must be signed in before GoClaw can verify quota.",
"failure": {
"billing": {
"label": "Billing",
"description": "Workspace billing or entitlement is blocking this account right now."
},
"exhausted": {
"label": "Exhausted",
"description": "This account is signed in, but one of its core quota windows is exhausted."
},
"reauth": {
"label": "Reauth",
"description": "Sign in again to refresh workspace access."
},
"forbidden": {
"label": "Forbidden",
"description": "This account cannot read upstream quota data."
},
"needs_setup": {
"label": "Needs setup",
"description": "Sign in again so GoClaw can restore the ChatGPT account workspace metadata."
},
"retry_later": {
"label": "Retry later",
"description": "The quota endpoint is temporarily unavailable or rate limited."
},
"unavailable": {
"label": "Unavailable",
"description": "Quota data is unavailable for this account right now."
}
}
},
"metrics": {
"poolHealth": "Pool Health",
"policy": "Traffic Policy",
"poolSize": "Pool Size",
"observedSample": "Observed Sample",
"routerActiveAccounts": "Router Active",
"blockedAccounts": "Blocked",
"readyAccounts": "Signed-In Accounts",
"quotaReadyAccounts": "Quota Ready",
"observedRotation": "Used Directly",
"healthy": "Healthy",
"needsAttention": "Needs attention",
"readyOfTotal": "{{ready}} of {{total}} aliases ready",
"failovers": "Failovers",
"noFailovers": "No failovers yet"
},
"verdict": {
"healthy": {
"title": "Round robin looks healthy",
"description": "{{observed}} ready accounts were observed rotating across {{count}} traced spans."
},
"warning": {
"title": "Round robin is configured but not verified yet",
"description": "Only {{observed}} ready accounts have direct routing evidence across {{count}} traced spans so far."
},
"manual": {
"title": "Ordered routing is active",
"description": "This agent is not rotating every request. Repeated use of the primary alias is expected until round robin is enabled."
}
},
"role": {
"preferred": "Primary",
"extra": "Extra"
},
"status": {
"ready": "Ready",
"needs_sign_in": "Needs sign-in",
"disabled": "Disabled"
},
"strategy": {
"manual": "Primary First",
"primaryFirst": "Primary First",
"roundRobin": "Round Robin",
"priorityOrder": "Priority Order"
}
},
"identity": {
"title": "Identity",
"agentKey": "Agent Key",
+115 -17
View File
@@ -22,11 +22,46 @@
"card": {
"apiKeySet": "API key set",
"authenticated": "Authenticated",
"oauthLinked": "OAuth linked"
"oauthAccount": "OAuth account",
"oauthAlias": "Alias: {{name}}",
"connected": "Connected",
"signInNeeded": "Sign in needed",
"disabled": "Disabled"
},
"detail": {
"title": "Provider Details",
"advanced": "Advanced"
"quota": {
"checking": "Checking quota",
"plan": "Plan",
"lastChecked": "Checked {{value}}",
"failure": {
"billing": {
"label": "Billing",
"description": "Workspace billing or entitlement is blocking this account right now."
},
"exhausted": {
"label": "Exhausted",
"description": "This account is signed in, but one of its core quota windows is exhausted."
},
"reauth": {
"label": "Reauth",
"description": "Sign in again to refresh workspace access."
},
"forbidden": {
"label": "Forbidden",
"description": "This account cannot read upstream quota data."
},
"needs_setup": {
"label": "Needs setup",
"description": "Sign in again so GoClaw can restore the ChatGPT account workspace metadata."
},
"retry_later": {
"label": "Retry later",
"description": "The quota endpoint is temporarily unavailable or rate limited."
},
"unavailable": {
"label": "Unavailable",
"description": "Quota data is unavailable for this account right now."
}
}
},
"form": {
"createTitle": "Add Provider",
@@ -53,6 +88,12 @@
"alreadyAdded": "(already added)",
"nameFixed": "Name",
"configure": "Configure an LLM provider connection.",
"configureOauth": "Connect one OpenAI Codex OAuth account.",
"oauthAlias": "Account Alias *",
"oauthAliasPlaceholder": "e.g. primary",
"oauthAliasHint": "Lowercase alias used in routing and pools.",
"oauthDisplayNamePlaceholder": "Primary Codex account",
"oauthDisplayNameHint": "Optional label shown in the dashboard.",
"lowercaseHint": "Lowercase, numbers, hyphens"
},
"delete": {
@@ -72,7 +113,7 @@
"minimax_native": "MiniMax",
"cohere": "Cohere",
"perplexity": "Perplexity",
"chatgpt_oauth": "ChatGPT (OAuth)",
"chatgpt_oauth": "ChatGPT Subscription (OAuth)",
"yescale": "YesScale",
"acp": "ACP"
},
@@ -98,27 +139,32 @@
"oauth": {
"checkingStatus": "Checking authentication status...",
"authSuccessful": "Authentication successful!",
"activeProvider": "Provider",
"closingIn": "is now active. Closing in {{count}}s...",
"authenticated": "Authenticated — provider",
"authSuccessfulDesc": "This named OpenAI/Codex account is ready to use.",
"activeProvider": "Account alias",
"authenticated": "Authenticated — account",
"active": "active",
"poolBadge": "Pool ready",
"roundRobinBadge": "Round robin",
"modelPrefixHint": "Use model prefix",
"modelPrefixExample": "in agent config (e.g. openai-codex/gpt-4o). Token refreshes automatically.",
"removeToken": "Remove Token",
"signInDesc": "Sign in with your ChatGPT account to use your subscription's models without a separate API key.",
"modelPrefixExample": "in agent config (for example {{example}}).",
"multiAccountHint": "Add more aliases later from an agent pool to enable pooling or round robin.",
"aliasRequired": "Enter a valid lowercase account alias before starting OpenAI Codex OAuth.",
"removeToken": "Disconnect Account",
"signInDesc": "Sign in with your ChatGPT/Codex subscription. No separate API key needed.",
"waiting": "Waiting for authentication... Complete sign-in in the opened window.",
"remoteVpsHint": "After signing in, your browser will try to open",
"remoteVpsError": "and show a \"Can't reach this page\" error. That's normal — copy the full URL from the browser address bar and paste it below.",
"pasteUrlPlaceholder": "http://localhost:1455/auth/callback?code=...&state=...",
"submit": "Submit",
"signInWithChatGPT": "Sign in with ChatGPT",
"signInWithChatGPT": "Connect OpenAI Account",
"starting": "Starting...",
"remoteVps": "Remote/VPS?",
"loggedOut": "Logged out",
"loggedOutDesc": "OpenAI OAuth token removed",
"loggedOutDesc": "OpenAI Codex OAuth account disconnected",
"oauthFailed": "OAuth failed",
"exchangeFailed": "Exchange failed",
"logoutFailed": "Logout failed"
"logoutFailed": "Logout failed",
"done": "Done"
},
"cli": {
"description": "Claude CLI uses your local",
@@ -134,6 +180,7 @@
"recheckButton": "Re-check"
},
"detail": {
"title": "Provider Details",
"advanced": "Advanced",
"identity": "Identity",
"identityDesc": "Provider identification",
@@ -143,16 +190,39 @@
"acpConfigDesc": "Agent subprocess settings",
"cliConfig": "Claude CLI",
"cliConfigDesc": "Local CLI authentication",
"oauthConfig": "OAuth",
"oauthConfigDesc": "ChatGPT OAuth authentication",
"oauthConfig": "ChatGPT Subscription (OAuth)",
"oauthConfigDesc": "Manage sign-in for this named OpenAI/Codex account",
"nameReadonly": "Provider identifier, cannot be changed",
"oauthAliasReadonly": "OAuth account alias, cannot be changed",
"providerType": "Provider Type",
"apiKeySection": "API Key",
"apiKeySectionDesc": "Authentication credentials",
"statusSection": "Status",
"statusSectionDesc": "Provider availability",
"enabledDesc": "Provider is active and available for agents",
"embeddingSection": "Embedding"
"embeddingSection": "Embedding",
"oauthAccountUsage": "Account Usage",
"oauthAccountUsageDesc": "How this alias works within provider-owned OpenAI Codex pooling.",
"oauthAccountUsageManagedDesc": "This alias is currently a pool member managed by {{provider}}.",
"oauthAccountUsageOwnerDesc": "This alias currently owns a reusable pool with {{count}} extra accounts.",
"oauthPoolRole": {
"member": "Pool Member",
"owner": "Pool Owner",
"standalone": "Standalone"
},
"oauthAliasLabel": "Account Alias",
"oauthAccountBadge": "ChatGPT Subscription (OAuth)",
"oauthModelPrefix": "Model Prefix",
"oauthQuotaTitle": "Quota Status",
"oauthQuotaDescription": "Latest ChatGPT workspace quota for this account.",
"oauthModelPrefixCopied": "Model prefix copied",
"oauthPreferredHint": "Set an agent's Provider field to this alias to make it the primary OpenAI alias.",
"oauthProviderDefaultHint": "Use the pool defaults below to define the reusable OpenAI Codex routing policy for every agent that points at this alias.",
"oauthRoutingHint": "Agent pages now show the effective pool for that agent, plus whether it inherits these defaults or saves a custom override.",
"oauthManagedByHint": "This alias is managed by provider {{provider}}. Edit pool membership there instead of on this account.",
"codexPoolDefaultsTitle": "Pool Defaults",
"codexPoolDefaultsDescription": "Define the reusable OpenAI Codex pool owned by this alias for agents that use it as their primary provider.",
"oauthDisplayNameRecommendation": "Add a display name if you want clearer labels when multiple OpenAI Codex OAuth accounts appear in the pool."
},
"embedding": {
"enable": "Enable Embedding",
@@ -167,6 +237,34 @@
"verifyFailed": "Embedding verification failed",
"dimensionsMismatch": "{{count}} dimensions — does not match required 1536"
},
"list": {
"poolOwner": "Pool owner",
"poolMember": "Pool member",
"standalone": "Standalone",
"managedBy": "Managed by {{provider}}",
"memberCount_one": "{{count}} member",
"memberCount_other": "{{count}} members",
"memberOverflow": "+{{count}}",
"strategy": {
"roundRobin": "Round robin",
"priorityOrder": "Priority order",
"primaryFirst": "Primary first"
},
"status": {
"needsSignIn": "Sign in needed",
"disabled": "Disabled"
}
},
"pageHint": {
"title": "ChatGPT Subscription (OAuth) accounts",
"description": "Each alias is one Codex account. Pool owners manage member accounts here, and agent pages only show the effective route.",
"connected_one": "{{count}} connected account",
"connected_other": "{{count}} connected accounts",
"owners_one": "{{count}} pool owner",
"owners_other": "{{count}} pool owners",
"members_one": "{{count}} member",
"members_other": "{{count}} members"
},
"toast": {
"created": "Provider created",
"createdDesc": "{{name}} has been added",
+7 -1
View File
@@ -18,12 +18,18 @@
"provider": {
"title": "Configure LLM Provider",
"descriptionCli": "Connect using your local Claude CLI installation. No API key needed.",
"descriptionOauth": "Sign in with your ChatGPT account to use your subscription through OAuth. No API key needed.",
"descriptionOauth": "Sign in to create one named OpenAI Codex OAuth account. Give it an alias now, then add more aliases later for pool members or round robin.",
"description": "Connect to an AI provider to power your agents. You'll need an API key.",
"providerType": "Provider Type",
"providerTypeHint": "The LLM service you want to connect. OpenRouter is recommended for access to multiple models.",
"name": "Name",
"nameHint": "Internal identifier for this provider. Auto-generated from provider type.",
"oauthAlias": "Account Alias",
"oauthAliasPlaceholder": "e.g. primary",
"oauthAliasHint": "Use a unique lowercase alias such as primary, work, or team for this OpenAI/Codex account. The alias becomes the provider/model prefix used by agents.",
"displayName": "Display Name (Optional)",
"displayNameHint": "Friendly label for this OpenAI/Codex account. Helpful when you add more aliases later.",
"displayNamePlaceholder": "Primary Codex account",
"apiKey": "API Key *",
"apiKeyHint": "Your provider's secret key. Encrypted server-side and never exposed in API responses.",
"apiBase": "API Base URL",
+247
View File
@@ -124,6 +124,253 @@
"saved": "Đã lưu",
"failedToSave": "Lưu thất bại"
},
"chatgptOAuthRouting": {
"title": "Pool Codex OAuth",
"description": "Chỉnh pool đã lưu rồi kiểm tra trực tiếp.",
"defaultAccount": "Tài khoản chính",
"defaultHint": "Trường Provider của agent luôn ghim vào bí danh chính.",
"badge": "Pool Codex",
"badgeTooltip": "Agent này có thể định tuyến lưu lượng Codex qua nhiều bí danh OpenAI OAuth.",
"summaryTitle": "Pool tài khoản OpenAI",
"summaryDescription": "Xem pool OpenAI Codex đang hiệu lực cho agent này. Thành viên pool được quản lý ở provider; trang này chỉ điều khiển hành vi routing.",
"strategyLabel": "Chính sách lưu lượng",
"strategyHint": "Primary First luôn ưu tiên bí danh chính. Round Robin xoay vòng qua mọi tài khoản sẵn sàng. Priority Order sẽ đi theo thứ tự đã cấu hình sau bí danh chính.",
"extraAccountsLabel": "Tài khoản phụ",
"availableExtraAccountsLabel": "Tài khoản phụ có thể thêm",
"poolMembershipLabel": "Thành viên pool",
"extraHint": "Chỉ các bí danh OpenAI Codex OAuth đã bật và đã đăng nhập mới được tham gia.",
"emptyExtras": "Đăng nhập thêm một bí danh OpenAI Codex OAuth để bật pooling.",
"noReadyExtras": "Đăng nhập và bật thêm một bí danh OpenAI Codex OAuth để thêm vào đây.",
"extraSelectableHint": "Chỉ các bí danh OpenAI Codex OAuth đã bật và đã đăng nhập mới được vào active pool.",
"selectedAccountsLabel": "Tài khoản trong pool",
"emptySelected": "Chưa chọn pool member bổ sung nào.",
"manageAction": "Mở Pool",
"singleAccountHint": "Hãy cấu hình thêm một provider OpenAI Codex OAuth khác trên trang Providers để tạo pool do provider quản lý.",
"membershipManagedAtProvider": "Thành viên pool được quản lý tại provider {{provider}}. Hãy đổi membership ở đó rồi quay lại đây để chỉnh hành vi routing.",
"membershipConfigureProviderFirst": "Provider {{provider}} chưa có provider-owned pool. Hãy cấu hình pool members ở đó trước khi bật pooled routing cho agent này.",
"readySummary": "{{ready}} / {{total}} tài khoản phụ đã sẵn sàng.",
"preferredNeedsAttention": "Tài khoản chính cần được xử lý trước khi agent dùng ổn định.",
"loadingAccounts": "Đang kiểm tra trạng thái tài khoản OpenAI Codex OAuth...",
"attentionTitle": "Các tài khoản cần chú ý",
"attentionHint": "Các tài khoản đã chọn nhưng bị tắt hoặc đã đăng xuất vẫn hiển thị ở đây cho đến khi bạn gỡ ra hoặc đăng nhập lại.",
"pageTitle": "Pool tài khoản OpenAI",
"pageDescription": "Xem pool OpenAI Codex đang hiệu lực cho {{name}}, rồi chọn agent này sẽ kế thừa mặc định của provider, chỉ dùng tài khoản chính, hay tinh chỉnh chiến lược routing.",
"backToAgent": "Quay lại Agent",
"openProviders": "Mở Providers",
"pageUnsupportedTitle": "Agent này chưa dùng provider OpenAI Codex OAuth",
"pageUnsupportedDescription": "Hãy đặt Provider của agent thành một bí danh OpenAI Codex OAuth trước, rồi quay lại đây để quản lý pool.",
"providerAccessTitle": "Chỉ admin mới quản lý được provider",
"providerAccessDescription": "Bạn vẫn có thể xem pool đang chạy ở đây, nhưng chỉ admin mới có thể đăng nhập tài khoản, thêm bí danh hoặc mở trang Providers.",
"providerAccessInline": "Chỉ admin mới có thể đăng nhập bí danh mới hoặc quản lý trang Providers.",
"unsavedChangesTitle": "Bản nháp chưa được áp dụng",
"unsavedChangesDescription": "Các thẻ xác minh bên dưới đang phản ánh pool đã lưu gần nhất. Hãy lưu bản nháp này trước khi dùng bằng chứng để xác nhận round robin.",
"draftBadge": "Bản nháp chờ lưu",
"savedPoolOnlyHint": "Bằng chứng trực tiếp vẫn đang phản ánh pool đã lưu gần nhất.",
"verificationTitle": "Cách xác minh hoạt động",
"verificationDescription": "Bằng chứng đến từ các LLM span gần đây của agent này. Làm mới trang sau vài request để so sánh pool đã cấu hình với các bí danh thực sự được dùng.",
"addPoolMembersTitle": "Thêm bí danh vào pool này",
"addPoolMembersDescription": "Tạo thêm các bí danh OpenAI Codex OAuth ở trang Providers, rồi quay lại đây để thêm chúng vào agent này.",
"needsAttentionTitle": "Cần chú ý",
"needsAttentionDescription": "{{count}} bí danh trong pool đang bị tắt hoặc cần đăng nhập lại trước khi bạn có thể tin cậy chúng.",
"never": "Chưa bao giờ",
"requestCount": "{{count}} request gần đây",
"lastUsedAt": "Dùng gần nhất {{value}}",
"openProvider": "Mở Provider",
"activityTitle": "Định tuyến trực tiếp",
"activityDescription": "Sức khỏe runtime và bằng chứng route trực tiếp.",
"refreshEvidence": "Làm mới trạng thái trực tiếp",
"recentRequestsCount": "{{count}} request gần đây",
"observedProviders": "{{observed}} / {{total}} tài khoản đã được dùng trực tiếp",
"failoverOnlyProviders": "{{count}} bí danh chỉ mới thấy qua failover",
"selectedCountLabel": "{{count}} lượt dùng trực tiếp",
"failoverServeCount": "{{count}} lượt phục vụ qua failover",
"failoverOnlyLabel": "Chỉ qua failover",
"switchRate": "{{switches}} lần đổi provider trong {{total}} bước request",
"poolMembersTitle": "Tài khoản trong pool",
"poolMembersDescription": "Vai trò, trạng thái route, bằng chứng gần đây và quota.",
"sequenceTitle": "Request gần đây",
"sequenceColumns": {
"step": "#",
"account": "Tài khoản",
"route": "Luồng",
"when": "Lúc",
"duration": "Thời gian"
},
"sequenceDescription": "Bằng chứng gần đây từ các lượt gọi Codex đã route.",
"sequenceEmptyTitle": "Chưa có bằng chứng gần đây",
"loadingEvidence": "Đang tải bằng chứng định tuyến Codex gần đây...",
"unknownModel": "Model không xác định",
"attemptCount": "{{count}} lượt thử",
"selectedProviderBadge": "Chọn {{provider}}",
"servedProviderBadge": "Phục vụ {{provider}}",
"failoverHint": "Lỗi có thể retry đã rơi sang: {{providers}}",
"traceAction": "Trace",
"openTrace": "Mở Trace",
"noEvidence": "Hãy gửi vài request Codex rồi làm mới trang.",
"selectedCount": "{{count}} trong pool",
"productLabel": "ChatGPT Subscription (OAuth)",
"controlTitle": "Thiết lập Pool",
"controlDescription": "Thành viên pool được quản lý ở provider. Trang này điều khiển việc agent kế thừa pool đó, chỉ dùng tài khoản chính, hay override chiến lược routing.",
"mode": {
"label": "Chế độ routing của agent",
"inherit": "Dùng mặc định từ provider",
"custom": "Tùy chỉnh riêng cho agent",
"noProviderDefault": "Chưa có provider-owned pool nào được lưu. Hãy cấu hình reusable pool members ở trang Providers trước, hoặc giữ agent này chỉ dùng tài khoản chính.",
"providerDefaultSummary": "Mặc định từ provider có {{count}} tài khoản",
"summaryInherited": "Agent này hiện đang dùng pool mặc định của provider.",
"summaryCustom": "Agent này hiện đang lưu routing override riêng cho provider-owned pool."
},
"poolStateTitle": "Trạng thái Pool",
"routerActiveTitle": "Đang route",
"fallbackTitle": "Chỉ fallback",
"blockedNowTitle": "Đang chặn",
"checkingTitle": "Đang kiểm tra",
"emptyGroup": "Không có",
"viewerMode": "Chỉ xem",
"consoleSummary": "{{active}} đang route · {{observed}} đã dùng trực tiếp · {{blocked}} đang chặn",
"monitorDirectLabel": "Trực tiếp",
"monitorDirectUseLabel": "Lượt trực tiếp",
"monitorFailoverLabel": "Failover",
"lastSeenLabel": "Lần thấy cuối",
"sampleBadge": "{{count}} span đã ghi nhận",
"noSampleBadge": "Chưa có mẫu gần đây",
"runtimeHealthTitle": "Sức khỏe runtime",
"runtimeHealthSummary": "{{rate}}% thành công · điểm {{score}}",
"noRuntimeSample": "Chưa có mẫu runtime gần đây.",
"failureStreakBadge": "{{count}} lỗi liên tiếp",
"runtimeSuccessCompact": "Thành công {{count}}",
"runtimeFailureCompact": "Thất bại {{count}}",
"lastSuccessLabel": "Lần ok cuối {{value}}",
"lastFailureLabel": "Lần lỗi cuối {{value}}",
"healthState": {
"healthy": "Ổn định",
"degraded": "Suy giảm",
"critical": "Nguy cấp",
"idle": "Chưa có mẫu"
},
"checkpoints": {
"configured": "Đã cấu hình",
"ready": "Sẵn sàng",
"observed": "Được dùng trực tiếp",
"switching": "Đang chuyển"
},
"nextActionTitle": "Hành động tiếp theo",
"nextAction": {
"saveTitle": "Hãy lưu bản nháp trước khi tin vào bằng chứng",
"saveDescription": "Pool đã lưu và bản nháp hiện tại đã khác nhau. Hãy lưu cấu hình trước, rồi mới kiểm tra lưu lượng trực tiếp.",
"attentionTitle": "Xử lý các bí danh đang cần chú ý",
"attentionDescription": "Các bí danh bị tắt hoặc đã đăng xuất vẫn hiển thị để bạn đăng nhập lại hoặc gỡ chúng khỏi pool.",
"quotaTitle": "Gỡ các chặn quota trước khi tin vào round robin",
"quotaDescription": "{{count}} bí danh đã đăng nhập nhưng hiện chưa cho thấy quota dùng được.",
"failoverOnlyTitle": "Kiểm tra các bí danh chỉ xuất hiện sau failover",
"failoverOnlyDescription": "{{count}} bí danh chỉ có lưu lượng gần đây dưới vai trò đích failover, chưa có bằng chứng được chọn trực tiếp.",
"addMembersTitle": "Thêm một bí danh đã đăng nhập để mở khóa pooling thực sự",
"addMembersDescription": "Round robin chỉ có ý nghĩa khi có hơn một bí danh sẵn sàng tham gia.",
"verifyTitle": "Gửi thêm vài request Codex rồi làm mới bằng chứng",
"verifyDescription": "Pool đã lưu và đã sẵn sàng, nhưng các lần gọi gần đây vẫn chưa cho thấy mọi bí danh sẵn sàng đều được chọn trực tiếp.",
"healthyTitle": "Hiện chưa cần thao tác thêm",
"healthyDescription": "Các lần gọi gần đây đang chọn trực tiếp mọi bí danh sẵn sàng trong pool đã lưu.",
"manualTitle": "Routing có thứ tự đang hoạt động",
"manualDescription": "Agent này không xoay vòng ở mọi request. Việc bí danh chính được dùng lặp lại là bình thường cho đến khi bật round robin."
},
"howItWorksTitle": "Pool này hoạt động thế nào",
"howItWorks": {
"primary": "Trường Provider của agent luôn ghim vào bí danh chính.",
"roundRobin": "Round Robin xoay vòng qua tất cả bí danh đã sẵn sàng trong active pool.",
"failover": "Các lỗi upstream có thể retry có thể rơi sang bí danh đủ điều kiện tiếp theo trong cùng request."
},
"quota": {
"checking": "Đang kiểm tra quota",
"checkingDescription": "GoClaw vẫn đang lấy trạng thái quota mới nhất cho bí danh này.",
"healthySummary": "{{usable}} / {{total}} bí danh có quota dùng được",
"needsAttention": "{{count}} bí danh đang cần xử lý quota",
"blockersNow": "Đang chặn lúc này",
"floorFiveHour": "Sàn 5h {{value}}%",
"floorWeekly": "Sàn tuần {{value}}%",
"plan": "Gói",
"lastChecked": "Đã kiểm tra {{value}}",
"readyLabel": "Quota dùng được",
"readyDescription": "Bí danh này hiện đang cho thấy các cửa sổ quota còn dùng được.",
"readinessTitle": "Mức sẵn sàng quota",
"readinessDescription": "Hãy nhìn phần này trước. Chỉ các bí danh đã đăng nhập và còn quota dùng được mới nên được tin cho lưu lượng trực tiếp.",
"requiresReadyAlias": "Bí danh này cần được đăng nhập trước khi GoClaw có thể xác minh quota.",
"failure": {
"billing": {
"label": "Billing",
"description": "Billing hoặc entitlement của workspace đang chặn tài khoản này."
},
"exhausted": {
"label": "Cạn quota",
"description": "Tài khoản này vẫn đăng nhập, nhưng một trong các cửa sổ quota chính đã cạn."
},
"reauth": {
"label": "Đăng nhập lại",
"description": "Hãy đăng nhập lại để làm mới quyền truy cập workspace."
},
"forbidden": {
"label": "Bị chặn",
"description": "Tài khoản này không thể đọc dữ liệu quota từ upstream."
},
"needs_setup": {
"label": "Cần thiết lập",
"description": "Hãy đăng nhập lại để GoClaw khôi phục metadata workspace của tài khoản."
},
"retry_later": {
"label": "Thử lại sau",
"description": "Endpoint quota đang tạm thời không sẵn sàng hoặc bị rate limit."
},
"unavailable": {
"label": "Không khả dụng",
"description": "Hiện không lấy được dữ liệu quota cho tài khoản này."
}
}
},
"metrics": {
"poolHealth": "Sức khỏe pool",
"policy": "Chính sách lưu lượng",
"poolSize": "Số tài khoản",
"observedSample": "Mẫu đã ghi nhận",
"routerActiveAccounts": "Router Active",
"blockedAccounts": "Đang chặn",
"readyAccounts": "Tài khoản đã đăng nhập",
"quotaReadyAccounts": "Quota sẵn sàng",
"observedRotation": "Dùng trực tiếp",
"healthy": "Ổn định",
"needsAttention": "Cần chú ý",
"readyOfTotal": "{{ready}} / {{total}} bí danh sẵn sàng",
"failovers": "Failover",
"noFailovers": "Chưa có failover"
},
"verdict": {
"healthy": {
"title": "Round robin có vẻ khỏe",
"description": "Đã thấy {{observed}} tài khoản sẵn sàng luân phiên qua {{count}} span gần đây."
},
"warning": {
"title": "Round robin đã cấu hình nhưng chưa xác minh đủ",
"description": "Mới chỉ có {{observed}} tài khoản sẵn sàng có bằng chứng dùng trực tiếp trong {{count}} span gần đây."
},
"manual": {
"title": "Routing có thứ tự đang hoạt động",
"description": "Agent này không xoay vòng ở mọi request. Việc bí danh chính được dùng lặp lại là bình thường cho đến khi bật round robin."
}
},
"role": {
"preferred": "Chính",
"extra": "Phụ"
},
"status": {
"ready": "Sẵn sàng",
"needs_sign_in": "Cần đăng nhập",
"disabled": "Đã tắt"
},
"strategy": {
"manual": "Primary First",
"primaryFirst": "Primary First",
"roundRobin": "Round Robin",
"priorityOrder": "Priority Order"
}
},
"identity": {
"title": "Nhận dạng",
"agentKey": "Khóa agent",
+114 -12
View File
@@ -22,7 +22,47 @@
"card": {
"apiKeySet": "Đã đặt API key",
"authenticated": "Đã xác thực",
"oauthLinked": "Đã liên kết OAuth"
"oauthLinked": "Đã liên kết OAuth",
"oauthAccount": "Tài khoản OAuth",
"oauthAlias": "Bí danh: {{name}}",
"connected": "Đã kết nối",
"signInNeeded": "Cần đăng nhập",
"disabled": "Đã tắt"
},
"quota": {
"checking": "Đang kiểm tra quota",
"plan": "Gói",
"lastChecked": "Kiểm tra {{value}}",
"failure": {
"billing": {
"label": "Thanh toán",
"description": "Gói thanh toán hoặc quyền truy cập hiện đang chặn tài khoản này."
},
"exhausted": {
"label": "Hết quota",
"description": "Tài khoản đã đăng nhập nhưng một trong các cửa sổ quota chính đã cạn."
},
"reauth": {
"label": "Đăng nhập lại",
"description": "Đăng nhập lại để làm mới quyền truy cập workspace."
},
"forbidden": {
"label": "Bị chặn",
"description": "Tài khoản này không thể đọc dữ liệu quota từ upstream."
},
"needs_setup": {
"label": "Cần thiết lập",
"description": "Đăng nhập lại để GoClaw khôi phục metadata workspace ChatGPT."
},
"retry_later": {
"label": "Thử lại sau",
"description": "Endpoint quota đang tạm thời không khả dụng hoặc bị giới hạn tốc độ."
},
"unavailable": {
"label": "Không khả dụng",
"description": "Hiện không lấy được dữ liệu quota cho tài khoản này."
}
}
},
"detail": {
"title": "Chi tiết Provider",
@@ -53,6 +93,12 @@
"alreadyAdded": "(đã thêm)",
"nameFixed": "Tên",
"configure": "Cấu hình kết nối provider LLM.",
"configureOauth": "Kết nối một tài khoản OpenAI Codex OAuth.",
"oauthAlias": "Bí danh tài khoản *",
"oauthAliasPlaceholder": "vd: primary",
"oauthAliasHint": "Bí danh chữ thường dùng cho routing và pool.",
"oauthDisplayNamePlaceholder": "Tài khoản Codex chính",
"oauthDisplayNameHint": "Nhãn tùy chọn hiển thị trên giao diện.",
"lowercaseHint": "Chữ thường, số, dấu gạch ngang"
},
"delete": {
@@ -72,7 +118,7 @@
"minimax_native": "MiniMax",
"cohere": "Cohere",
"perplexity": "Perplexity",
"chatgpt_oauth": "ChatGPT (OAuth)",
"chatgpt_oauth": "ChatGPT Subscription (OAuth)",
"yescale": "YesScale",
"acp": "ACP"
},
@@ -98,27 +144,32 @@
"oauth": {
"checkingStatus": "Đang kiểm tra trạng thái xác thực...",
"authSuccessful": "Xác thực thành công!",
"authSuccessfulDesc": "Tài khoản OpenAI/Codex có bí danh này đã sẵn sàng để dùng.",
"activeProvider": "Provider",
"closingIn": "đang hoạt động. Đóng sau {{count}} giây...",
"authenticated": "Đã xác thực — provider",
"active": "đang hoạt động",
"poolBadge": "Sẵn sàng cho pool",
"roundRobinBadge": "Round robin",
"modelPrefixHint": "Dùng tiền tố model",
"modelPrefixExample": "trong cấu hình agent (vd: openai-codex/gpt-4o). Token tự động làm mới.",
"removeToken": "Xóa Token",
"signInDesc": "Đăng nhập bằng tài khoản ChatGPT để sử dụng các model trong gói đăng ký mà không cần API key riêng.",
"modelPrefixExample": "trong cấu hình agent (vd: {{example}}).",
"multiAccountHint": "Sau này bạn có thể thêm bí danh khác từ pool của agent để bật pooling hoặc round robin.",
"aliasRequired": "Nhập một bí danh tài khoản hợp lệ dạng chữ thường trước khi bắt đầu OpenAI Codex OAuth.",
"removeToken": "Ngắt kết nối tài khoản",
"signInDesc": "Đăng nhập bằng gói ChatGPT/Codex của bạn. Không cần API key riêng.",
"waiting": "Đang chờ xác thực... Hoàn tất đăng nhập trong cửa sổ đã mở.",
"remoteVpsHint": "Sau khi đăng nhập, trình duyệt sẽ cố mở",
"remoteVpsError": "và hiển thị lỗi \"Không thể truy cập trang này\". Điều này là bình thường — sao chép URL đầy đủ từ thanh địa chỉ trình duyệt và dán vào bên dưới.",
"pasteUrlPlaceholder": "http://localhost:1455/auth/callback?code=...&state=...",
"submit": "Gửi",
"signInWithChatGPT": "Đăng nhập với ChatGPT",
"signInWithChatGPT": "Kết nối tài khoản OpenAI",
"starting": "Đang bắt đầu...",
"remoteVps": "Remote/VPS?",
"loggedOut": "Đã đăng xuất",
"loggedOutDesc": "Đã xóa OAuth token OpenAI",
"loggedOutDesc": "Đã ngắt kết nối tài khoản OpenAI Codex OAuth",
"oauthFailed": "OAuth thất bại",
"exchangeFailed": "Trao đổi thất bại",
"logoutFailed": "Đăng xuất thất bại"
"logoutFailed": "Đăng xuất thất bại",
"done": "Xong"
},
"cli": {
"description": "Claude CLI sử dụng tệp nhị phân",
@@ -143,16 +194,67 @@
"acpConfigDesc": "Cài đặt tiến trình con agent",
"cliConfig": "Claude CLI",
"cliConfigDesc": "Xác thực CLI cục bộ",
"oauthConfig": "OAuth",
"oauthConfigDesc": "Xác thực OAuth ChatGPT",
"oauthConfig": "ChatGPT Subscription (OAuth)",
"oauthConfigDesc": "Quản lý đăng nhập cho tài khoản OpenAI/Codex có bí danh này",
"nameReadonly": "Định danh provider, không thể thay đổi",
"oauthAliasReadonly": "Bí danh tài khoản OAuth, không thể thay đổi",
"providerType": "Loại provider",
"apiKeySection": "API Key",
"apiKeySectionDesc": "Thông tin xác thực",
"statusSection": "Trạng thái",
"statusSectionDesc": "Tình trạng hoạt động của provider",
"enabledDesc": "Provider đang hoạt động và sẵn sàng cho các agent",
"embeddingSection": "Embedding"
"embeddingSection": "Embedding",
"oauthAccountUsage": "Cách dùng tài khoản",
"oauthAccountUsageDesc": "Cách bí danh này hoạt động trong mô hình OpenAI Codex pool do provider sở hữu.",
"oauthAccountUsageManagedDesc": "Bí danh này hiện là pool member được quản lý bởi {{provider}}.",
"oauthAccountUsageOwnerDesc": "Bí danh này hiện đang sở hữu một pool dùng lại với {{count}} tài khoản phụ.",
"oauthPoolRole": {
"member": "Thành viên pool",
"owner": "Chủ pool",
"standalone": "Độc lập"
},
"oauthAliasLabel": "Bí danh tài khoản",
"oauthAccountBadge": "ChatGPT Subscription (OAuth)",
"oauthModelPrefix": "Tiền tố model",
"oauthQuotaTitle": "Trạng thái quota",
"oauthQuotaDescription": "Quota ChatGPT workspace mới nhất của tài khoản này.",
"oauthModelPrefixCopied": "Đã sao chép tiền tố model",
"oauthPreferredHint": "Đặt trường Provider của agent thành bí danh này để biến nó thành bí danh OpenAI chính.",
"oauthProviderDefaultHint": "Dùng phần thiết lập pool mặc định bên dưới để định nghĩa chính sách routing OpenAI Codex dùng lại cho mọi agent trỏ vào bí danh này.",
"oauthRoutingHint": "Trang agent giờ sẽ hiển thị pool hiệu lực của agent đó, cùng việc nó đang kế thừa mặc định này hay đang dùng override riêng.",
"oauthManagedByHint": "Bí danh này đang được quản lý bởi provider {{provider}}. Hãy chỉnh membership ở provider đó thay vì tại tài khoản này.",
"codexPoolDefaultsTitle": "Thiết lập pool mặc định",
"codexPoolDefaultsDescription": "Định nghĩa pool OpenAI Codex dùng lại do chính bí danh này sở hữu cho các agent dùng nó làm provider chính.",
"oauthDisplayNameRecommendation": "Thêm tên hiển thị nếu bạn muốn nhãn rõ ràng hơn khi nhiều tài khoản OpenAI Codex OAuth xuất hiện trong pool."
},
"list": {
"poolOwner": "Chủ pool",
"poolMember": "Thành viên pool",
"standalone": "Độc lập",
"managedBy": "Được quản lý bởi {{provider}}",
"memberCount_one": "{{count}} tài khoản",
"memberCount_other": "{{count}} tài khoản",
"memberOverflow": "+{{count}}",
"strategy": {
"roundRobin": "Luân phiên",
"priorityOrder": "Theo thứ tự ưu tiên",
"primaryFirst": "Ưu tiên chính"
},
"status": {
"needsSignIn": "Cần đăng nhập",
"disabled": "Đã tắt"
}
},
"pageHint": {
"title": "Tài khoản ChatGPT Subscription (OAuth)",
"description": "Mỗi bí danh là một tài khoản Codex. Pool owner quản lý các member tại đây, còn trang agent chỉ hiển thị route đang có hiệu lực.",
"connected_one": "{{count}} tài khoản đã kết nối",
"connected_other": "{{count}} tài khoản đã kết nối",
"owners_one": "{{count}} pool owner",
"owners_other": "{{count}} pool owner",
"members_one": "{{count}} member",
"members_other": "{{count}} member"
},
"embedding": {
"enable": "Bật Embedding",
+7 -1
View File
@@ -18,12 +18,18 @@
"provider": {
"title": "Cấu hình provider LLM",
"descriptionCli": "Kết nối bằng Claude CLI cục bộ. Không cần API key.",
"descriptionOauth": "Đăng nhập bằng tài khoản ChatGPT để dùng gói thuê bao qua OAuth. Không cần API key.",
"descriptionOauth": "Đăng nhập để tạo một tài khoản OpenAI Codex OAuth có bí danh riêng. Đặt bí danh ngay bây giờ, rồi thêm các bí danh khác sau này cho pool members hoặc round robin.",
"description": "Kết nối với provider AI để cấp nguồn cho agent. Bạn cần có API key.",
"providerType": "Loại provider",
"providerTypeHint": "Dịch vụ LLM bạn muốn kết nối. Khuyến nghị dùng OpenRouter để truy cập nhiều model.",
"name": "Tên",
"nameHint": "Định danh nội bộ cho provider này. Tự động tạo từ loại provider.",
"oauthAlias": "Bí danh tài khoản",
"oauthAliasPlaceholder": "vd: primary",
"oauthAliasHint": "Dùng một bí danh chữ thường duy nhất như primary, work hoặc team cho tài khoản OpenAI/Codex này. Bí danh sẽ trở thành tiền tố provider/model mà agent sử dụng.",
"displayName": "Tên hiển thị (tùy chọn)",
"displayNameHint": "Nhãn thân thiện cho tài khoản OpenAI/Codex này. Hữu ích khi bạn thêm nhiều bí danh sau đó.",
"displayNamePlaceholder": "Tài khoản Codex chính",
"apiKey": "API Key *",
"apiKeyHint": "Khóa bí mật của provider. Được mã hóa phía máy chủ và không bao giờ lộ ra trong phản hồi API.",
"apiBase": "API Base URL",
+247
View File
@@ -124,6 +124,253 @@
"saved": "已保存",
"failedToSave": "保存失败"
},
"chatgptOAuthRouting": {
"title": "Codex OAuth 池",
"description": "编辑已保存的池,然后直接检查。",
"defaultAccount": "主账户",
"defaultHint": "Agent 的 Provider 字段会始终固定在主别名上。",
"badge": "Codex 池",
"badgeTooltip": "这个 agent 可以在多个 OpenAI OAuth 别名之间分发 Codex 流量。",
"summaryTitle": "OpenAI 账户池",
"summaryDescription": "查看这个 agent 当前生效的 OpenAI Codex 账户池。成员由 provider 管理,这个页面只控制路由行为。",
"strategyLabel": "流量策略",
"strategyHint": "Primary First 会始终先使用主别名。Round Robin 会在所有可用账户之间轮换。Priority Order 会在主别名之后按配置顺序依次尝试。",
"extraAccountsLabel": "额外账户",
"availableExtraAccountsLabel": "可添加的额外账户",
"poolMembershipLabel": "池成员关系",
"extraHint": "只有已启用且已登录的 OpenAI Codex OAuth 别名才能参与。",
"emptyExtras": "再登录一个 OpenAI Codex OAuth 别名即可启用 pooling。",
"noReadyExtras": "请先登录并启用另一个 OpenAI Codex OAuth 别名,然后再添加到这里。",
"extraSelectableHint": "只有已启用且已登录的 OpenAI Codex OAuth 别名才能进入 active pool。",
"selectedAccountsLabel": "池中的账户",
"emptySelected": "还没有选择额外的池成员。",
"manageAction": "打开池管理",
"singleAccountHint": "请在 Providers 页面配置另一个具名 OpenAI Codex OAuth provider,以创建由 provider 管理的账户池。",
"membershipManagedAtProvider": "池成员由 provider {{provider}} 管理。请先在那里修改成员关系,再回到这里调整路由行为。",
"membershipConfigureProviderFirst": "provider {{provider}} 还没有保存 provider-owned pool。请先在那里配置池成员,再为这个 agent 启用池化路由。",
"readySummary": "{{ready}} / {{total}} 个额外账户已就绪。",
"preferredNeedsAttention": "主账户需要先处理,agent 才能稳定使用它。",
"loadingAccounts": "正在检查 OpenAI Codex OAuth 账户状态...",
"attentionTitle": "需要关注的账户",
"attentionHint": "已选中但被禁用或已退出登录的账户会继续显示在这里,直到您移除它们或重新登录。",
"pageTitle": "OpenAI 账户池",
"pageDescription": "查看 {{name}} 当前生效的 OpenAI Codex 账户池,然后决定这个 agent 是继承 provider 默认值、只使用主账户,还是调整路由策略。",
"backToAgent": "返回 Agent",
"openProviders": "打开 Providers",
"pageUnsupportedTitle": "这个 agent 当前未使用 OpenAI Codex OAuth provider",
"pageUnsupportedDescription": "请先把 agent 的 Provider 设置为某个 OpenAI Codex OAuth 别名,然后再回来管理这个池。",
"providerAccessTitle": "只有管理员可以管理 provider",
"providerAccessDescription": "您仍然可以在这里查看实时池状态,但只有管理员可以登录账户、添加别名或打开 Providers 页面。",
"providerAccessInline": "只有管理员才能登录新的别名或管理 Providers 页面。",
"unsavedChangesTitle": "草稿尚未生效",
"unsavedChangesDescription": "下方验证卡片反映的是最近一次已保存的池配置。请先保存这个草稿,再使用证据验证 round robin。",
"draftBadge": "草稿待保存",
"savedPoolOnlyHint": "实时证据仍然反映最近一次已保存的池配置。",
"verificationTitle": "如何验证",
"verificationDescription": "证据来自该 agent 最近的 LLM span。发送几次请求后刷新此页,即可对比配置好的池和实际使用的别名。",
"addPoolMembersTitle": "为这个池添加更多别名",
"addPoolMembersDescription": "先到 Providers 页面创建更多 OpenAI Codex OAuth 别名,再回到这里把它们加入这个 agent。",
"needsAttentionTitle": "需要处理",
"needsAttentionDescription": "{{count}} 个池别名当前已禁用或需要重新登录,暂时不能可靠使用。",
"never": "从未",
"requestCount": "{{count}} 次最近请求",
"lastUsedAt": "最近使用于 {{value}}",
"openProvider": "打开 Provider",
"activityTitle": "实时路由",
"activityDescription": "运行健康与实时路由证据。",
"refreshEvidence": "刷新实时状态",
"recentRequestsCount": "{{count}} 次最近请求",
"observedProviders": "{{observed}} / {{total}} 个账户已被直接使用",
"failoverOnlyProviders": "{{count}} 个别名仅通过 failover 出现",
"selectedCountLabel": "{{count}} 次直接使用",
"failoverServeCount": "{{count}} 次 failover 承接",
"failoverOnlyLabel": "仅 failover",
"switchRate": "{{total}} 次请求跳转中发生了 {{switches}} 次 provider 切换",
"poolMembersTitle": "池中账户",
"poolMembersDescription": "角色、路由状态、最近证据与 quota。",
"sequenceTitle": "最近请求",
"sequenceColumns": {
"step": "#",
"account": "账号",
"route": "路由",
"when": "时间",
"duration": "耗时"
},
"sequenceDescription": "来自最近 Codex 路由调用的证据。",
"sequenceEmptyTitle": "还没有最近证据",
"loadingEvidence": "正在加载最近的 Codex 路由证据...",
"unknownModel": "未知模型",
"attemptCount": "尝试 {{count}} 次",
"selectedProviderBadge": "选中 {{provider}}",
"servedProviderBadge": "承接 {{provider}}",
"failoverHint": "可重试的错误已切换到:{{providers}}",
"traceAction": "Trace",
"openTrace": "打开 Trace",
"noEvidence": "请再发送几次 Codex 请求,然后刷新此页。",
"selectedCount": "{{count}} 个在池中",
"productLabel": "ChatGPT Subscription (OAuth)",
"controlTitle": "池设置",
"controlDescription": "池成员由 provider 管理。这个页面只控制 agent 是继承该池、仅使用主账户,还是覆盖路由策略。",
"mode": {
"label": "Agent 路由模式",
"inherit": "使用 Provider 默认值",
"custom": "仅此 Agent 自定义",
"noProviderDefault": "还没有保存 provider-owned pool。请先去 Providers 页面配置可复用的池成员,或者继续让这个 agent 只使用主账户。",
"providerDefaultSummary": "Provider 默认池包含 {{count}} 个账户",
"summaryInherited": "这个 agent 当前正在使用 provider 默认账户池。",
"summaryCustom": "这个 agent 当前保存了针对 provider-owned pool 的路由覆盖配置。"
},
"poolStateTitle": "池状态",
"routerActiveTitle": "路由中",
"fallbackTitle": "仅备用",
"blockedNowTitle": "当前阻塞",
"checkingTitle": "检查中",
"emptyGroup": "无",
"viewerMode": "仅查看",
"consoleSummary": "{{active}} 个可路由 · {{observed}} 个已直接使用 · {{blocked}} 个被阻塞",
"monitorDirectLabel": "直连",
"monitorDirectUseLabel": "直连次数",
"monitorFailoverLabel": "Failover",
"lastSeenLabel": "最近出现",
"sampleBadge": "{{count}} 个追踪 span",
"noSampleBadge": "暂无最近样本",
"runtimeHealthTitle": "运行健康",
"runtimeHealthSummary": "{{rate}}% 成功 · 分数 {{score}}",
"noRuntimeSample": "还没有最近的运行样本。",
"failureStreakBadge": "连续 {{count}} 次失败",
"runtimeSuccessCompact": "成功 {{count}}",
"runtimeFailureCompact": "失败 {{count}}",
"lastSuccessLabel": "上次成功 {{value}}",
"lastFailureLabel": "上次失败 {{value}}",
"healthState": {
"healthy": "健康",
"degraded": "降级",
"critical": "严重",
"idle": "空闲"
},
"checkpoints": {
"configured": "已配置",
"ready": "已就绪",
"observed": "已直接使用",
"switching": "在切换"
},
"nextActionTitle": "下一步",
"nextAction": {
"saveTitle": "先保存草稿,再相信这些证据",
"saveDescription": "已保存的池和当前草稿已经分叉。请先保存配置,再验证实时流量。",
"attentionTitle": "处理需要关注的别名",
"attentionDescription": "已禁用或已退出登录的别名会继续显示,方便您重新登录或将其移出池。",
"quotaTitle": "先处理 quota 阻塞,再相信 round robin",
"quotaDescription": "{{count}} 个已登录别名当前还没有显示可用 quota。",
"failoverOnlyTitle": "检查那些只在 failover 后出现的别名",
"failoverOnlyDescription": "{{count}} 个别名最近只作为 failover 目标出现,还没有直接选择证据。",
"addMembersTitle": "再添加一个已登录别名,真正启用 pooling",
"addMembersDescription": "只有当不止一个就绪别名可以参与时,round robin 才有实际意义。",
"verifyTitle": "再发送几次 Codex 请求并刷新证据",
"verifyDescription": "池已经保存且处于就绪状态,但最近调用还没有显示所有就绪别名都被直接选中。",
"healthyTitle": "当前不需要额外操作",
"healthyDescription": "最近调用已经直接选中了已保存池中的所有就绪别名。",
"manualTitle": "当前启用的是有序路由",
"manualDescription": "这个 agent 并不会在每个请求上轮换。未启用 round robin 时,重复命中主别名是正常现象。"
},
"howItWorksTitle": "这个池如何工作",
"howItWorks": {
"primary": "Agent 的 Provider 字段会始终固定在主别名上。",
"roundRobin": "Round Robin 会在 active pool 中所有就绪别名之间轮换。",
"failover": "可重试的上游错误可以在同一次请求里切换到下一个符合条件的别名。"
},
"quota": {
"checking": "正在检查 quota",
"checkingDescription": "GoClaw 仍在拉取这个别名的最新 quota 状态。",
"healthySummary": "{{usable}} / {{total}} 个别名具备可用 quota",
"needsAttention": "{{count}} 个别名需要处理 quota 问题",
"blockersNow": "当前阻塞",
"floorFiveHour": "5h 下限 {{value}}%",
"floorWeekly": "周下限 {{value}}%",
"plan": "套餐",
"lastChecked": "检查于 {{value}}",
"readyLabel": "Quota 可用",
"readyDescription": "这个别名当前显示有可用的 quota 窗口。",
"readinessTitle": "Quota 就绪状态",
"readinessDescription": "先看这里。只有已登录且 quota 可用的别名,才值得信任用于实时流量。",
"requiresReadyAlias": "这个别名需要先登录,GoClaw 才能验证 quota。",
"failure": {
"billing": {
"label": "Billing",
"description": "当前 workspace 的计费或 entitlement 正在阻止这个账户。"
},
"exhausted": {
"label": "额度耗尽",
"description": "这个账户仍然已登录,但其中一个核心 quota 窗口已经耗尽。"
},
"reauth": {
"label": "重新登录",
"description": "请重新登录以刷新 workspace 访问权限。"
},
"forbidden": {
"label": "被拒绝",
"description": "这个账户无法读取上游 quota 数据。"
},
"needs_setup": {
"label": "需要设置",
"description": "请重新登录,让 GoClaw 恢复这个账户的 workspace 元数据。"
},
"retry_later": {
"label": "稍后重试",
"description": "quota 接口暂时不可用,或当前被限流。"
},
"unavailable": {
"label": "不可用",
"description": "当前无法获取这个账户的 quota 数据。"
}
}
},
"metrics": {
"poolHealth": "池健康度",
"policy": "流量策略",
"poolSize": "池大小",
"observedSample": "已观测样本",
"routerActiveAccounts": "Router Active",
"blockedAccounts": "阻塞中",
"readyAccounts": "已登录账户",
"quotaReadyAccounts": "Quota 就绪",
"observedRotation": "直接使用",
"healthy": "健康",
"needsAttention": "需要关注",
"readyOfTotal": "{{ready}} / {{total}} 个别名已就绪",
"failovers": "Failover",
"noFailovers": "暂无 failover"
},
"verdict": {
"healthy": {
"title": "Round robin 看起来健康",
"description": "最近 {{count}} 个追踪 span 中,已观察到 {{observed}} 个就绪账户参与轮换。"
},
"warning": {
"title": "Round robin 已配置,但还未充分验证",
"description": "最近 {{count}} 个追踪 span 中,只有 {{observed}} 个就绪账户有直接路由证据。"
},
"manual": {
"title": "当前启用的是有序路由",
"description": "这个 agent 并不会在每个请求上轮换。未启用 round robin 时,重复命中主别名是正常现象。"
}
},
"role": {
"preferred": "主",
"extra": "额外"
},
"status": {
"ready": "就绪",
"needs_sign_in": "需要登录",
"disabled": "已禁用"
},
"strategy": {
"manual": "Primary First",
"primaryFirst": "Primary First",
"roundRobin": "Round Robin",
"priorityOrder": "Priority Order"
}
},
"identity": {
"title": "身份",
"agentKey": "Agent标识",
+132 -12
View File
@@ -22,7 +22,47 @@
"card": {
"apiKeySet": "已设置API密钥",
"authenticated": "已认证",
"oauthLinked": "已链接OAuth"
"oauthLinked": "已链接OAuth",
"oauthAccount": "OAuth 账户",
"oauthAlias": "别名:{{name}}",
"connected": "已连接",
"signInNeeded": "需要登录",
"disabled": "已禁用"
},
"quota": {
"checking": "正在检查配额",
"plan": "套餐",
"lastChecked": "检查于 {{value}}",
"failure": {
"billing": {
"label": "账单",
"description": "当前账单状态或权限正在阻止此账户使用。"
},
"exhausted": {
"label": "已耗尽",
"description": "该账户已登录,但其核心配额窗口之一已经耗尽。"
},
"reauth": {
"label": "重新登录",
"description": "请重新登录以刷新工作区访问权限。"
},
"forbidden": {
"label": "无权限",
"description": "此账户无法读取上游配额数据。"
},
"needs_setup": {
"label": "需要设置",
"description": "请重新登录,以便 GoClaw 恢复 ChatGPT 账户工作区元数据。"
},
"retry_later": {
"label": "稍后重试",
"description": "配额接口暂时不可用或已被限流。"
},
"unavailable": {
"label": "不可用",
"description": "当前无法获取此账户的配额数据。"
}
}
},
"detail": {
"title": "提供商详情",
@@ -53,6 +93,21 @@
"alreadyAdded": "(已添加)",
"nameFixed": "名称",
"configure": "配置 LLM Provider连接。",
"configureOauth": "连接一个 OpenAI Codex OAuth 账户。之后可在 agent 账户池中继续添加更多别名。",
"oauthAlias": "账户别名 *",
"oauthAliasPlaceholder": "例如 primary",
"oauthAliasHint": "为这个 OpenAI/Codex 账户使用唯一的小写别名,例如 primary、work 或 team。之后可以再添加更多别名用于池或 round robin。",
"oauthDisplayNamePlaceholder": "主 Codex 账户",
"oauthDisplayNameHint": "界面里显示的友好标签。当您拥有多个 OpenAI Codex OAuth 账户时会更清晰。",
"oauthSetupBadge": "1 个别名 = 1 个账户",
"oauthSetupAliasTitle": "别名",
"oauthSetupAliasDesc": "Agent 池和路由里使用的小写路由键。",
"oauthSetupLabelTitle": "显示名称",
"oauthSetupLabelDesc": "可选标签,多个账户时在面板里更容易分辨。",
"oauthSetupPoolTitle": "后续",
"oauthSetupPoolDesc": "稍后可在 agent 中继续添加已登录别名,用于池成员或 round robin。",
"oauthAliasBadge": "路由键",
"oauthDisplayNameBadge": "可选",
"lowercaseHint": "小写字母、数字、连字符"
},
"delete": {
@@ -72,7 +127,7 @@
"minimax_native": "MiniMax",
"cohere": "Cohere",
"perplexity": "Perplexity",
"chatgpt_oauth": "ChatGPT (OAuth)",
"chatgpt_oauth": "ChatGPT Subscription (OAuth)",
"yescale": "YesScale",
"acp": "ACP"
},
@@ -98,27 +153,41 @@
"oauth": {
"checkingStatus": "正在检查认证状态...",
"authSuccessful": "认证成功!",
"authSuccessfulDesc": "这个具名的 OpenAI/Codex 账户已经可以使用。",
"activeProvider": "Provider",
"closingIn": "现已激活。{{count}} 秒后关闭...",
"authenticated": "已认证 — Provider",
"active": "活跃",
"poolBadge": "可加入账户池",
"roundRobinBadge": "Round robin",
"modelPrefixHint": "在Agent配置中使用模型前缀",
"modelPrefixExample": "(例如 openai-codex/gpt-4o)。令牌自动刷新。",
"removeToken": "移除令牌",
"signInDesc": "使用您的 ChatGPT 账户登录,无需单独的 API 密钥即可使用订阅中的模型。",
"modelPrefixExample": "(例如 {{example}})。令牌会自动刷新。",
"multiAccountHint": "之后可在 agent 账户池中继续添加别名,以启用 pooling 或 round robin。",
"aliasRequired": "开始 OpenAI Codex OAuth 之前,请先输入有效的小写账户别名。",
"removeToken": "断开账户",
"signInDesc": "每个 OpenAI Codex OAuth provider 都代表一个具名账户。登录后即可直接使用您的 ChatGPT/Codex 订阅,无需单独 API 密钥。",
"subscriptionBadge": "订阅登录",
"poolBadge": "可加入账户池",
"aliasBadge": "别名",
"accountCardTitle": "当前账户",
"accountCardDesc": "通过订阅登录连接一个 OpenAI/Codex 账户。",
"routingCardTitle": "池与 round robin",
"routingCardDesc": "稍后可从使用该账户的 agent 中再添加更多已登录别名。",
"prefixCardTitle": "模型前缀",
"prefixCardDesc": "在 agent 配置里把这个别名用作 provider 前缀。",
"waiting": "等待认证中... 请在已打开的窗口中完成登录。",
"remoteVpsHint": "登录后,您的浏览器将尝试打开",
"remoteVpsError": "并显示「无法访问此页面」错误。这是正常的 — 从浏览器地址栏复制完整 URL 并粘贴到下方。",
"pasteUrlPlaceholder": "http://localhost:1455/auth/callback?code=...&state=...",
"submit": "提交",
"signInWithChatGPT": "使用 ChatGPT 登录",
"signInWithChatGPT": "连接 OpenAI 账户",
"starting": "启动中...",
"remoteVps": "远程/VPS?",
"loggedOut": "已登出",
"loggedOutDesc": "OpenAI OAuth 令牌已移除",
"loggedOutDesc": "OpenAI Codex OAuth 账户已断开",
"oauthFailed": "OAuth 失败",
"exchangeFailed": "交换失败",
"logoutFailed": "登出失败"
"logoutFailed": "登出失败",
"done": "完成"
},
"cli": {
"description": "Claude CLI 使用本地",
@@ -143,16 +212,67 @@
"acpConfigDesc": "Agent子进程设置",
"cliConfig": "Claude CLI",
"cliConfigDesc": "本地CLI认证",
"oauthConfig": "OAuth",
"oauthConfigDesc": "ChatGPT OAuth认证",
"oauthConfig": "ChatGPT Subscription (OAuth)",
"oauthConfigDesc": "管理这个具名 OpenAI/Codex 账户的登录状态",
"nameReadonly": "Provider标识符,无法更改",
"oauthAliasReadonly": "OAuth 账户别名无法更改",
"providerType": "Provider类型",
"apiKeySection": "API密钥",
"apiKeySectionDesc": "认证凭证",
"statusSection": "状态",
"statusSectionDesc": "Provider可用性",
"enabledDesc": "Provider已激活并可供Agent使用",
"embeddingSection": "Embedding"
"embeddingSection": "Embedding",
"oauthAccountUsage": "账户用法",
"oauthAccountUsageDesc": "这个别名如何参与由 provider 拥有的 OpenAI Codex 账户池。",
"oauthAccountUsageManagedDesc": "这个别名当前是由 {{provider}} 管理的池成员。",
"oauthAccountUsageOwnerDesc": "这个别名当前拥有一个可复用账户池,包含 {{count}} 个附加账户。",
"oauthPoolRole": {
"member": "池成员",
"owner": "池拥有者",
"standalone": "独立账户"
},
"oauthAliasLabel": "账户别名",
"oauthAccountBadge": "ChatGPT Subscription (OAuth)",
"oauthModelPrefix": "模型前缀",
"oauthQuotaTitle": "配额状态",
"oauthQuotaDescription": "此账户最新的 ChatGPT 工作区配额。",
"oauthModelPrefixCopied": "模型前缀已复制",
"oauthPreferredHint": "把 agent 的 Provider 字段设为这个别名,就能把它作为主 OpenAI 别名。",
"oauthProviderDefaultHint": "使用下方的默认账户池设置,为所有把这个别名设为主 provider 的 agent 定义可复用的 OpenAI Codex 路由策略。",
"oauthRoutingHint": "Agent 页面现在会展示该 agent 的最终生效账户池,以及它是继承这里的默认值还是使用自己的覆盖配置。",
"oauthManagedByHint": "这个别名由 provider {{provider}} 管理。请到那个 provider 上编辑池成员,而不是在这个账户上修改。",
"codexPoolDefaultsTitle": "默认账户池",
"codexPoolDefaultsDescription": "为把这个别名作为主 provider 的 agent 定义由该别名拥有的可复用 OpenAI Codex 账户池。",
"oauthDisplayNameRecommendation": "如果您希望多个 OpenAI Codex OAuth 账户在池里显示得更清楚,可以补充显示名称。"
},
"list": {
"poolOwner": "池拥有者",
"poolMember": "池成员",
"standalone": "独立账户",
"managedBy": "由 {{provider}} 管理",
"memberCount_one": "{{count}} 个成员",
"memberCount_other": "{{count}} 个成员",
"memberOverflow": "+{{count}}",
"strategy": {
"roundRobin": "轮询",
"priorityOrder": "按优先级",
"primaryFirst": "主账号优先"
},
"status": {
"needsSignIn": "需要登录",
"disabled": "已禁用"
}
},
"pageHint": {
"title": "ChatGPT Subscription (OAuth) 账户",
"description": "每个别名就是一个 Codex 账户。池拥有者在这里管理成员账户,而 agent 页面只展示最终生效的路由。",
"connected_one": "{{count}} 个已连接账户",
"connected_other": "{{count}} 个已连接账户",
"owners_one": "{{count}} 个池拥有者",
"owners_other": "{{count}} 个池拥有者",
"members_one": "{{count}} 个成员",
"members_other": "{{count}} 个成员"
},
"embedding": {
"enable": "启用Embedding",
+7 -1
View File
@@ -18,12 +18,18 @@
"provider": {
"title": "配置 LLM Provider",
"descriptionCli": "使用本地 Claude CLI 连接,无需 API 密钥。",
"descriptionOauth": "使用 ChatGPT 账号登录,通过 OAuth 使用您的订阅。无需 API 密钥。",
"descriptionOauth": "登录以创建一个具名的 OpenAI Codex OAuth 账户。现在先设置别名,之后再添加更多别名用于池成员或 round robin。",
"description": "连接 AI Provider为您的Agent提供支持,需要 API 密钥。",
"providerType": "Provider类型",
"providerTypeHint": "您要连接的 LLM 服务。推荐使用 OpenRouter 以访问多种模型。",
"name": "名称",
"nameHint": "此Provider的内部标识符,根据Provider类型自动生成。",
"oauthAlias": "账户别名",
"oauthAliasPlaceholder": "例如 primary",
"oauthAliasHint": "为这个 OpenAI/Codex 账户使用唯一的小写别名,例如 primary、work 或 team。该别名会成为 agent 使用的 provider/model 前缀。",
"displayName": "显示名称(可选)",
"displayNameHint": "这个 OpenAI/Codex 账户的人类可读标签。当您后续添加多个别名时会更清晰。",
"displayNamePlaceholder": "主 Codex 账户",
"apiKey": "API 密钥 *",
"apiKeyHint": "您的Provider密钥,服务端加密存储,不会在 API 响应中暴露。",
"apiBase": "API 基础 URL",
+1
View File
@@ -5,6 +5,7 @@ export const ROUTES = {
CHAT_PATTERN: "/chat/:sessionKey?",
AGENTS: "/agents",
AGENT_DETAIL: "/agents/:id",
AGENT_CODEX_POOL: "/agents/:id/codex-pool",
SESSIONS: "/sessions",
SESSION_DETAIL: "/sessions/:key",
SKILLS: "/skills",
+3
View File
@@ -5,6 +5,8 @@ export const queryKeys = {
providers: {
all: ["providers"] as const,
models: (providerId: string) => ["providers", providerId, "models"] as const,
chatgptOAuthStatuses: (providerKeys: string[]) => ["providers", "chatgpt-oauth-statuses", ...providerKeys] as const,
chatgptOAuthQuotas: (providerNames: string[]) => ["providers", "chatgpt-oauth-quotas", ...providerNames] as const,
},
agents: {
all: ["agents"] as const,
@@ -12,6 +14,7 @@ export const queryKeys = {
files: (agentKey: string) => ["agents", agentKey, "files"] as const,
links: (agentId: string) => ["agents", agentId, "links"] as const,
instances: (agentId: string) => ["agents", agentId, "instances"] as const,
codexPoolActivity: (agentId: string, limit: number) => ["agents", agentId, "codex-pool-activity", limit] as const,
},
sessions: {
all: ["sessions"] as const,
+8 -4
View File
@@ -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 AgentCardProps {
agent: AgentData;
@@ -12,15 +13,13 @@ interface AgentCardProps {
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 AgentCard({ agent, onClick, onResummon, onDelete }: AgentCardProps) {
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<string, unknown>;
const selfEvolve = agent.agent_type === "predefined" && Boolean(otherCfg.self_evolve);
const emoji = typeof otherCfg.emoji === "string" ? otherCfg.emoji : "";
const hasOAuthRouting = hasActiveChatGPTOAuthRouting(agent.other_config);
// Show agent_key as subtitle only if there's a display_name and agent_key is meaningful
const showSubtitle = agent.display_name && !UUID_RE.test(agent.agent_key);
@@ -106,6 +105,11 @@ export function AgentCard({ agent, onClick, onResummon, onDelete }: AgentCardPro
</TooltipContent>
</Tooltip>
)}
{hasOAuthRouting && (
<Badge variant="outline" className="text-[11px]">
{t("chatgptOAuthRouting.badge")}
</Badge>
)}
{agent.context_window > 0 && (
<span className="text-[11px] text-muted-foreground">
{(agent.context_window / 1000).toFixed(0)}K ctx
@@ -7,14 +7,20 @@ import {
} from "@/components/ui/dialog";
import { ConfigGroupHeader } from "@/components/shared/config-group-header";
import type {
AgentData, CompactionConfig, ContextPruningConfig,
AgentData, ChatGPTOAuthRoutingConfig, CompactionConfig, ContextPruningConfig,
SandboxConfig, WorkspaceSharingConfig,
} from "@/types/agent";
import {
ThinkingSection, WorkspaceSharingSection, CompactionSection,
ChatGPTOAuthRoutingSection, ThinkingSection, WorkspaceSharingSection, CompactionSection,
ContextPruningSection, SandboxSection,
} from "./config-sections";
import { WorkspaceSection } from "./general-sections";
import { useProviders } from "@/pages/providers/hooks/use-providers";
import { getChatGPTOAuthProviderRouting } from "@/types/provider";
import {
buildAgentOtherConfigWithChatGPTOAuthRouting,
normalizeChatGPTOAuthRouting,
} from "./agent-display-utils";
interface AgentAdvancedDialogProps {
open: boolean;
@@ -25,11 +31,25 @@ interface AgentAdvancedDialogProps {
export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: AgentAdvancedDialogProps) {
const { t } = useTranslation("agents");
const { providers } = useProviders();
const providerByName = new Map(providers.map((provider) => [provider.name, provider]));
const currentProvider = providerByName.get(agent.provider);
const providerDefaults = getChatGPTOAuthProviderRouting(currentProvider?.settings);
const deriveState = (a: AgentData) => {
const otherObj = (a.other_config ?? {}) as Record<string, unknown>;
const routing = normalizeChatGPTOAuthRouting(a.other_config);
return {
thinkingLevel: typeof otherObj.thinking_level === "string" ? otherObj.thinking_level : "off",
chatgptRouting: {
override_mode: routing.isExplicit
? routing.overrideMode
: providerDefaults
? "inherit"
: "custom",
strategy: routing.strategy,
extra_provider_names: routing.extraProviderNames,
} as ChatGPTOAuthRoutingConfig,
wsSharing: (otherObj.workspace_sharing ?? {}) as WorkspaceSharingConfig,
comp: a.compaction_config ?? {},
pruneEnabled: a.context_pruning != null,
@@ -42,6 +62,7 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
const init = deriveState(agent);
const [wsSharing, setWsSharing] = useState<WorkspaceSharingConfig>(init.wsSharing);
const [thinkingLevel, setThinkingLevel] = useState(init.thinkingLevel);
const [chatgptRouting, setChatgptRouting] = useState<ChatGPTOAuthRoutingConfig>(init.chatgptRouting);
const [comp, setComp] = useState<CompactionConfig>(init.comp);
const [pruneEnabled, setPruneEnabled] = useState(init.pruneEnabled);
const [prune, setPrune] = useState<ContextPruningConfig>(init.prune);
@@ -53,6 +74,7 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
if (!open) return;
const s = deriveState(agent);
setThinkingLevel(s.thinkingLevel);
setChatgptRouting(s.chatgptRouting);
setWsSharing(s.wsSharing);
setComp(s.comp);
setPruneEnabled(s.pruneEnabled);
@@ -70,8 +92,11 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
// Only send the keys this dialog owns to avoid overwriting keys managed by
// the overview tab. The backend does a full column replace, so we must read
// the latest agent data and merge our keys into it.
const existing = (agent.other_config as Record<string, unknown> | null) ?? {};
const otherBase: Record<string, unknown> = { ...existing };
const otherBase = buildAgentOtherConfigWithChatGPTOAuthRouting(
agent,
chatgptRouting,
currentProvider?.settings,
);
delete otherBase.thinking_level;
delete otherBase.workspace_sharing;
if (thinkingLevel && thinkingLevel !== "off") {
@@ -118,6 +143,18 @@ export function AgentAdvancedDialog({ open, onOpenChange, agent, onUpdate }: Age
{/* Thinking */}
<ThinkingSection value={thinkingLevel} onChange={setThinkingLevel} />
<ChatGPTOAuthRoutingSection
currentProvider={agent.provider}
providers={providers}
value={chatgptRouting}
onChange={setChatgptRouting}
defaultRouting={providerDefaults}
membershipEditable={false}
membershipManagedByLabel={
currentProvider?.display_name || agent.provider
}
/>
{/* Performance */}
<ConfigGroupHeader
title={t("configGroups.performance")}
@@ -0,0 +1,508 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ArrowLeft } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DetailPageSkeleton } from "@/components/shared/loading-skeleton";
import { ROUTES } from "@/lib/constants";
import { cn } from "@/lib/utils";
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 {
ChatGPTOAuthRoutingConfig,
EffectiveChatGPTOAuthRoutingStrategy,
} from "@/types/agent";
import {
getChatGPTOAuthProviderRouting,
normalizeChatGPTOAuthStrategy,
} from "@/types/provider";
import { useAgentDetail } from "../hooks/use-agent-detail";
import {
agentDisplayName,
buildAgentOtherConfigWithChatGPTOAuthRouting,
normalizeChatGPTOAuthRouting,
normalizeChatGPTOAuthRoutingInput,
resolveEffectiveChatGPTOAuthRouting,
type NormalizedChatGPTOAuthRouting,
} from "./agent-display-utils";
import { getRouteReadiness } from "./chatgpt-oauth-quota-utils";
import { ChatGPTOAuthRoutingSection } from "./config-sections";
import {
CodexPoolActivityPanel,
type CodexPoolEntry,
} from "./codex-pool-activity-panel";
import { useCodexPoolActivity } from "./hooks/use-codex-pool-activity";
function providerStatus(
providerName: string,
statusByName: Map<string, { availability: ChatGPTOAuthAvailability }>,
enabled?: boolean,
): ChatGPTOAuthAvailability {
return (
statusByName.get(providerName)?.availability ??
(enabled === false ? "disabled" : "needs_sign_in")
);
}
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 buildDraftRouting(
savedRouting: NormalizedChatGPTOAuthRouting,
hasProviderDefaults: boolean,
): ChatGPTOAuthRoutingConfig {
if (savedRouting.isExplicit) {
return {
override_mode: savedRouting.overrideMode,
strategy: savedRouting.strategy,
extra_provider_names: savedRouting.extraProviderNames,
};
}
if (hasProviderDefaults) {
return {
override_mode: "inherit",
strategy: "primary_first",
extra_provider_names: [],
};
}
return {
override_mode: "custom",
strategy: "primary_first",
extra_provider_names: [],
};
}
function routingDraftSignature(
routing: ChatGPTOAuthRoutingConfig,
hasProviderDefaults: boolean,
): string {
const normalized = normalizeChatGPTOAuthRoutingInput(routing);
if (normalized.overrideMode === "inherit" && hasProviderDefaults) {
return JSON.stringify({ override_mode: "inherit" });
}
return JSON.stringify({
override_mode: "custom",
strategy: normalized.strategy,
extra_provider_names: normalized.extraProviderNames,
});
}
export function AgentCodexPoolPage() {
const { id = "" } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useTranslation("agents");
const role = useAuthStore((state) => state.role);
const canManageProviders = role === "admin" || role === "owner";
const { agent, loading, updateAgent } = useAgentDetail(id);
const { providers, loading: providersLoading } = useProviders();
const { statuses } = useChatGPTOAuthProviderStatuses(providers);
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 = agent ? providerByName.get(agent.provider) : undefined;
const providerDefaults = useMemo(
() => getChatGPTOAuthProviderRouting(currentProvider?.settings),
[currentProvider?.settings],
);
const isEligible = Boolean(
agent && currentProvider?.provider_type === "chatgpt_oauth",
);
const savedRouting = useMemo(
() => normalizeChatGPTOAuthRouting(agent?.other_config),
[agent?.other_config],
);
const savedEffectiveRouting = useMemo(
() =>
resolveEffectiveChatGPTOAuthRouting(
agent?.provider ?? "",
currentProvider?.settings,
savedRouting,
),
[agent?.provider, currentProvider?.settings, savedRouting],
);
const savedDraftRouting = useMemo(
() => buildDraftRouting(savedRouting, Boolean(providerDefaults)),
[providerDefaults, savedRouting],
);
const savedDraftSignature = useMemo(
() => routingDraftSignature(savedDraftRouting, Boolean(providerDefaults)),
[providerDefaults, savedDraftRouting],
);
const [routing, setRouting] = useState<ChatGPTOAuthRoutingConfig>(savedDraftRouting);
const [saving, setSaving] = useState(false);
const syncedAgentIDRef = useRef(agent?.id ?? "");
const savedDraftSignatureRef = useRef(savedDraftSignature);
const draftSignature = useMemo(
() => routingDraftSignature(routing, Boolean(providerDefaults)),
[providerDefaults, routing],
);
useEffect(() => {
const nextAgentID = agent?.id ?? "";
if (nextAgentID !== syncedAgentIDRef.current) {
syncedAgentIDRef.current = nextAgentID;
savedDraftSignatureRef.current = savedDraftSignature;
setRouting(savedDraftRouting);
return;
}
const previousSavedSignature = savedDraftSignatureRef.current;
if (savedDraftSignature === previousSavedSignature) {
return;
}
if (draftSignature === previousSavedSignature) {
setRouting(savedDraftRouting);
}
savedDraftSignatureRef.current = savedDraftSignature;
}, [agent?.id, draftSignature, savedDraftRouting, savedDraftSignature]);
const draftRouting = useMemo(
() => normalizeChatGPTOAuthRoutingInput(routing),
[routing],
);
const draftEffectiveRouting = useMemo(
() =>
resolveEffectiveChatGPTOAuthRouting(
agent?.provider ?? "",
currentProvider?.settings,
draftRouting,
),
[agent?.provider, currentProvider?.settings, draftRouting],
);
const quotaProviderNames = useMemo(
() =>
Array.from(
new Set(
[
...savedEffectiveRouting.poolProviderNames,
...draftEffectiveRouting.poolProviderNames,
].filter(
(providerName): providerName is string =>
Boolean(providerName) &&
providerByName.get(providerName)?.provider_type === "chatgpt_oauth",
),
),
),
[draftEffectiveRouting.poolProviderNames, providerByName, savedEffectiveRouting.poolProviderNames],
);
const {
quotaByName,
isLoading: quotasLoading,
isFetching: quotasFetching,
refetch: refreshQuotas,
} = useChatGPTOAuthProviderQuotas(
quotaProviderNames,
Boolean(agent && isEligible),
);
const {
data: activity,
isFetching: activityFetching,
refetch: refreshActivity,
} = useCodexPoolActivity(agent?.id ?? id, 8, Boolean(agent && isEligible));
const buildEntries = (
poolNames: string[],
): CodexPoolEntry[] => {
if (!agent) return [];
const countsByName = new Map(
activity.provider_counts.map((item) => [item.provider_name, item]),
);
return poolNames.map((providerName) => {
const provider = providerByName.get(providerName);
const count = countsByName.get(providerName);
return {
name: providerName,
label: provider?.display_name || providerName,
availability: providerStatus(
providerName,
statusByName,
provider?.enabled,
),
role: providerName === agent.provider ? "preferred" : "extra",
requestCount: count?.request_count ?? 0,
directSelectionCount:
count?.direct_selection_count ?? count?.request_count ?? 0,
failoverServeCount: count?.failover_serve_count ?? 0,
successCount: count?.success_count ?? 0,
failureCount: count?.failure_count ?? 0,
consecutiveFailures: count?.consecutive_failures ?? 0,
successRate: count?.success_rate ?? 0,
healthScore: count?.health_score ?? 0,
healthState: count?.health_state ?? "idle",
lastSelectedAt: count?.last_selected_at,
lastFailoverAt: count?.last_failover_at,
lastUsedAt: count?.last_used_at,
lastSuccessAt: count?.last_success_at,
lastFailureAt: count?.last_failure_at,
providerHref: provider?.id ? `/providers/${provider.id}` : undefined,
quota: quotaByName.get(providerName),
};
});
};
const liveEntries = useMemo(
() => buildEntries(savedEffectiveRouting.poolProviderNames),
[activity.provider_counts, agent, providerByName, quotaByName, savedEffectiveRouting.poolProviderNames, statusByName],
);
const draftEntries = useMemo(
() => buildEntries(draftEffectiveRouting.poolProviderNames),
[activity.provider_counts, agent, draftEffectiveRouting.poolProviderNames, providerByName, quotaByName, statusByName],
);
const routeEntries = useMemo(
() =>
liveEntries.map((entry) => ({
...entry,
routeReadiness: getRouteReadiness(entry.availability, entry.quota),
})),
[liveEntries],
);
const blockedEntries = routeEntries.filter(
(entry) => entry.routeReadiness === "blocked",
);
const readyEntries = liveEntries.filter(
(entry) => entry.availability === "ready",
);
const runtimeHealthyEntries = liveEntries.filter(
(entry) => entry.healthState === "healthy",
);
const runtimeDegradedEntries = liveEntries.filter(
(entry) => entry.healthState === "degraded",
);
const runtimeCriticalEntries = liveEntries.filter(
(entry) => entry.healthState === "critical",
);
const observedRoutableCount = routeEntries.filter(
(entry) =>
entry.routeReadiness !== "blocked" && entry.directSelectionCount > 0,
).length;
const switchCount = activity.recent_requests
.slice(1)
.reduce(
(count, request, index) =>
count +
((request.selected_provider || request.provider_name) !==
(activity.recent_requests[index]?.selected_provider ||
activity.recent_requests[index]?.provider_name)
? 1
: 0),
0,
);
const savedStrategy = normalizeChatGPTOAuthStrategy(
activity.strategy || savedEffectiveRouting.strategy,
);
const isDirty = draftSignature !== savedDraftSignature;
const roundRobinVerified =
savedStrategy === "round_robin" &&
readyEntries.length > 1 &&
observedRoutableCount >= readyEntries.length &&
switchCount >= Math.max(1, readyEntries.length - 1) &&
blockedEntries.length === 0 &&
runtimeCriticalEntries.length === 0;
const recentRequestCount = activity.stats_sample_size ?? 0;
const title = agent ? agentDisplayName(agent, t("card.unnamedAgent")) : "";
if (loading || providersLoading || !agent) {
return <DetailPageSkeleton tabs={0} />;
}
const handleSave = async () => {
setSaving(true);
try {
await updateAgent({
other_config: buildAgentOtherConfigWithChatGPTOAuthRouting(
agent,
routing,
currentProvider?.settings,
),
});
await Promise.all([refreshActivity(), refreshQuotas()]);
} catch {
// toast handled in hook
} finally {
setSaving(false);
}
};
const summaryTone =
savedStrategy !== "round_robin"
? "manual"
: roundRobinVerified
? "healthy"
: "warning";
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden p-3 sm:p-4 xl:p-5 [@media(max-height:760px)]:p-2.5">
<section className="shrink-0 rounded-xl border bg-card/70 px-3 py-2.5 shadow-sm sm:px-4 sm:py-3 [@media(max-height:760px)]:py-2">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between [@media(max-height:760px)]:gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-col items-start gap-1.5 [@media(max-height:760px)]:flex-row [@media(max-height:760px)]:items-center [@media(max-height:760px)]:gap-2">
<Button
variant="ghost"
size="sm"
className="mb-1.5 h-7 gap-1.5 px-2 text-xs sm:h-8 sm:px-2.5 sm:text-sm [@media(max-height:760px)]:mb-0 [@media(max-height:760px)]:h-7 [@media(max-height:760px)]:px-1.5"
onClick={() => navigate(`/agents/${agent.id}`)}
>
<ArrowLeft className="h-3.5 w-3.5" />
{t("chatgptOAuthRouting.backToAgent")}
</Button>
<h1 className="text-xl font-semibold tracking-tight sm:text-2xl [@media(max-height:760px)]:text-lg">
{t("chatgptOAuthRouting.pageTitle")}
</h1>
</div>
<p className="mt-1 max-w-3xl text-xs text-muted-foreground sm:text-sm [@media(max-height:760px)]:hidden">
{t("chatgptOAuthRouting.pageDescription", { name: title })}
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5 [@media(max-height:760px)]:mt-1.5">
<Badge
variant="outline"
className={cn(
"px-2.5 py-1 font-semibold",
summaryTone === "healthy" &&
"border-emerald-500/30 bg-emerald-500/[0.07] text-emerald-700 dark:text-emerald-200",
summaryTone === "warning" &&
"border-amber-500/30 bg-amber-500/[0.08] text-amber-800 dark:text-amber-200",
summaryTone === "manual" && "border-border/70 bg-muted/20",
)}
>
{t(`chatgptOAuthRouting.verdict.${summaryTone}.title`)}
</Badge>
<Badge variant="outline">
{t(strategyLabelKey(savedStrategy))}
</Badge>
<Badge variant="outline">
{savedEffectiveRouting.overrideMode === "inherit"
? t("chatgptOAuthRouting.mode.inherit")
: t("chatgptOAuthRouting.mode.custom")}
</Badge>
<Badge variant="outline" className="[@media(max-height:760px)]:hidden">
{recentRequestCount > 0
? t("chatgptOAuthRouting.sampleBadge", {
count: recentRequestCount,
})
: t("chatgptOAuthRouting.noSampleBadge")}
</Badge>
<Badge variant="success">
{t("chatgptOAuthRouting.healthState.healthy")}{" "}
{runtimeHealthyEntries.length}
</Badge>
{runtimeDegradedEntries.length > 0 ? (
<Badge variant="warning">
{t("chatgptOAuthRouting.healthState.degraded")}{" "}
{runtimeDegradedEntries.length}
</Badge>
) : null}
{runtimeCriticalEntries.length > 0 ? (
<Badge variant="destructive">
{t("chatgptOAuthRouting.healthState.critical")}{" "}
{runtimeCriticalEntries.length}
</Badge>
) : null}
{isDirty ? (
<Badge variant="warning">
{t("chatgptOAuthRouting.draftBadge")}
</Badge>
) : null}
</div>
</div>
{canManageProviders ? (
<Button
asChild
variant="outline"
size="sm"
className="h-8 shrink-0 self-start px-3 [@media(max-height:760px)]:h-7"
>
<Link to={ROUTES.PROVIDERS}>
{t("chatgptOAuthRouting.openProviders")}
</Link>
</Button>
) : null}
</div>
</section>
{!isEligible ? (
<Alert className="mt-3 shrink-0">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
{t("chatgptOAuthRouting.pageUnsupportedTitle")}
</AlertTitle>
<AlertDescription>
{t("chatgptOAuthRouting.pageUnsupportedDescription")}
</AlertDescription>
</Alert>
) : (
<div className="mt-2 flex min-h-0 flex-1 flex-col gap-3 overflow-hidden [@media(max-height:760px)]:gap-2">
<div className="grid min-h-0 flex-1 gap-3 overflow-y-auto overscroll-contain lg:grid-cols-[minmax(0,1.45fr)_minmax(320px,0.95fr)] lg:items-start lg:overflow-hidden [@media(max-height:760px)]:gap-2">
<CodexPoolActivityPanel
entries={liveEntries}
strategy={savedStrategy}
recentRequests={activity.recent_requests}
statsSampleSize={activity.stats_sample_size ?? 0}
fetching={activityFetching}
showProviderLinks={canManageProviders}
onRefresh={() => {
void Promise.all([refreshActivity(), refreshQuotas()]);
}}
className="h-full min-h-0"
/>
<div className="flex min-h-0 flex-col gap-4 overflow-hidden lg:h-full lg:self-stretch">
<ChatGPTOAuthRoutingSection
currentProvider={agent.provider}
providers={providers}
value={routing}
onChange={setRouting}
defaultRouting={
providerDefaults
? {
strategy: providerDefaults.strategy,
extraProviderNames: providerDefaults.extraProviderNames,
}
: null
}
canManageProviders={canManageProviders}
membershipEditable={false}
membershipManagedByLabel={
currentProvider?.display_name || agent.provider
}
quotaByName={quotaByName}
quotaLoading={quotasLoading || quotasFetching}
entries={draftEntries}
isDirty={isDirty}
saving={saving}
onSave={handleSave}
contentScrollable
className="h-full min-h-0"
/>
</div>
</div>
</div>
)}
</div>
);
}
@@ -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) {
</TabsList>
<TabsContent value="agent" className="mt-4">
<AgentOverviewTab key={agent.id + "-" + agent.updated_at} agent={agent} onUpdate={updateAgent} heartbeat={hb} />
<AgentOverviewTab
key={agent.id + "-" + agent.updated_at}
agent={agent}
onUpdate={updateAgent}
heartbeat={hb}
onManageCodexPool={() => navigate(`/agents/${agent.id}/codex-pool`)}
/>
</TabsContent>
<TabsContent value="files" className="mt-4">
@@ -105,13 +113,15 @@ export function AgentDetailPage({ agentId, onBack }: AgentDetailPageProps) {
</div>
</div>
<AgentAdvancedDialog
key={agent.id}
open={advancedOpen}
onOpenChange={setAdvancedOpen}
agent={agent}
onUpdate={updateAgent}
/>
{advancedOpen ? (
<AgentAdvancedDialog
key={agent.id}
open={advancedOpen}
onOpenChange={setAdvancedOpen}
agent={agent}
onUpdate={updateAgent}
/>
) : null}
<SummoningModal
open={summoningOpen}
@@ -1,6 +1,32 @@
import type {
AgentData,
ChatGPTOAuthRoutingConfig,
ChatGPTOAuthRoutingOverrideMode,
EffectiveChatGPTOAuthRoutingStrategy,
} from "@/types/agent";
import {
getChatGPTOAuthProviderRouting,
normalizeChatGPTOAuthStrategy,
} from "@/types/provider";
/** Matches a standard UUID v4 string. */
export 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 interface NormalizedChatGPTOAuthRouting {
isExplicit: boolean;
overrideMode: ChatGPTOAuthRoutingOverrideMode;
strategy: EffectiveChatGPTOAuthRoutingStrategy;
extraProviderNames: string[];
}
export interface EffectiveChatGPTOAuthRouting {
source: "single" | "provider_default" | "agent_custom";
overrideMode: ChatGPTOAuthRoutingOverrideMode;
strategy: EffectiveChatGPTOAuthRoutingStrategy;
extraProviderNames: string[];
poolProviderNames: string[];
}
/** Returns the display name for an agent, falling back to agent_key or unnamedLabel. */
export function agentDisplayName(
agent: { display_name?: string; agent_key: string },
@@ -15,3 +41,165 @@ export function agentDisplayName(
export function agentKeyDisplay(agentKey: string): string {
return UUID_RE.test(agentKey) ? agentKey.slice(0, 8) + "…" : agentKey;
}
/** Returns normalized ChatGPT OAuth routing config from agent other_config. */
export function normalizeChatGPTOAuthRouting(otherConfig?: Record<string, unknown> | 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<string, unknown>;
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<string, unknown> | 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<string, unknown>,
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<string, unknown>,
): Record<string, unknown> {
const existing = (agent.other_config as Record<string, unknown> | null) ?? {};
const otherBase: Record<string, unknown> = { ...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<string, unknown> = {
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;
}
@@ -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
</TooltipContent>
</Tooltip>
)}
{hasOAuthRouting && (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-[10px]">
{t("chatgptOAuthRouting.badge")}
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[240px] text-xs">
{t("chatgptOAuthRouting.badgeTooltip")}
</TooltipContent>
</Tooltip>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<span className="font-mono text-[11px]">{keyDisplay}</span>
@@ -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<string, unknown>) => Promise<void>;
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<string, unknown>;
@@ -126,6 +128,8 @@ export function AgentOverviewTab({ agent, onUpdate, heartbeat }: AgentOverviewTa
onSaveBlockedChange={setLlmSaveBlocked}
/>
<ChatGPTOAuthRoutingSummarySection agent={agent} onManage={onManageCodexPool} />
{/* Memory — always visible, per-agent overrides */}
<MemorySection
value={mem}
@@ -0,0 +1,125 @@
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 ChatGPTOAuthQuotaBadgesProps {
quota?: ChatGPTOAuthProviderQuota | null;
loading?: boolean;
translationNamespace?: "agents" | "providers";
translationKeyPrefix?: string;
className?: string;
}
const failureVariantByKind = {
billing: "destructive",
exhausted: "destructive",
reauth: "warning",
forbidden: "destructive",
needs_setup: "warning",
retry_later: "outline",
unavailable: "outline",
} as const;
export function ChatGPTOAuthQuotaBadges({
quota,
loading = false,
translationNamespace = "agents",
translationKeyPrefix = "chatgptOAuthRouting.quota",
className,
}: ChatGPTOAuthQuotaBadgesProps) {
const { t } = useTranslation(translationNamespace);
if (loading && !quota) {
return (
<Badge variant="outline">{t(`${translationKeyPrefix}.checking`)}</Badge>
);
}
if (!quota) return null;
const failureKind = getQuotaFailureKind(quota);
const signals = getQuotaSignals(quota);
const planLabel = getQuotaPlanLabel(quota.plan_type);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className={cn("flex flex-wrap items-center gap-1.5", className)}>
{failureKind ? (
<Badge variant={failureVariantByKind[failureKind]}>
{t(`${translationKeyPrefix}.failure.${failureKind}.label`)}
</Badge>
) : (
<>
{planLabel && <Badge variant="outline">{planLabel}</Badge>}
{signals.map((signal) => (
<Badge
key={signal.shortLabel}
variant={getQuotaBadgeVariant(signal.remaining)}
>
{signal.shortLabel} {signal.remaining}%
</Badge>
))}
</>
)}
</div>
</TooltipTrigger>
<TooltipContent sideOffset={6} className="max-w-64 px-3 py-2">
{failureKind ? (
<div className="space-y-1.5">
<p className="font-medium">
{t(`${translationKeyPrefix}.failure.${failureKind}.label`)}
</p>
<p className="text-muted-foreground">
{t(`${translationKeyPrefix}.failure.${failureKind}.description`)}
</p>
</div>
) : (
<div className="space-y-1.5">
{planLabel && (
<div className="flex justify-between gap-3">
<span className="text-muted-foreground">
{t(`${translationKeyPrefix}.plan`)}
</span>
<span>{planLabel}</span>
</div>
)}
{signals.map((signal) => (
<div
key={signal.shortLabel}
className="flex justify-between gap-3"
>
<span>{signal.shortLabel}</span>
<span>
{signal.remaining}%
{signal.resetAt ? ` · ${formatDate(signal.resetAt)}` : ""}
</span>
</div>
))}
<p className="text-muted-foreground">
{t(`${translationKeyPrefix}.lastChecked`, {
value: formatRelativeTime(quota.last_updated),
})}
</p>
</div>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -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, unknown>) => 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, unknown>) => 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<number | null>((min, signal) => (min == null ? signal.remaining : Math.min(min, signal.remaining)), null);
return (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t("chatgptOAuthRouting.quota.readinessTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("chatgptOAuthRouting.quota.readinessDescription")}
</p>
</div>
<div className="flex flex-wrap gap-2">
{loading ? (
<Badge variant="outline">{t("chatgptOAuthRouting.quota.checking")}</Badge>
) : (
<>
<Badge variant={usableCount === readyEntries.length && readyEntries.length > 0 ? "success" : "warning"}>
{t("chatgptOAuthRouting.quota.healthySummary", { usable: usableCount, total: readyEntries.length })}
</Badge>
{blockerCount > 0 && (
<Badge variant="warning">
{t("chatgptOAuthRouting.quota.needsAttention", { count: blockerCount })}
</Badge>
)}
{floorFiveHour != null && (
<Badge variant={getQuotaBadgeVariant(floorFiveHour)}>
{t("chatgptOAuthRouting.quota.floorFiveHour", { value: floorFiveHour })}
</Badge>
)}
</>
)}
</div>
</div>
<div className="space-y-2.5">
{entries.map((entry) => (
<div key={entry.name} className="rounded-lg border bg-muted/10 p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{entry.label}</span>
<Badge variant={entry.role === "preferred" ? "secondary" : "outline"}>
{t(`chatgptOAuthRouting.role.${entry.role}`)}
</Badge>
<Badge variant={quotaStateVariant(entry)}>
{quotaStateLabel(entry, t)}
</Badge>
</div>
<p className="font-mono text-xs text-muted-foreground">{entry.name}</p>
</div>
{showProviderLinks && entry.providerHref && (
<Button asChild variant="ghost" size="sm" className="h-7 px-0 text-xs shrink-0">
<Link to={entry.providerHref}>
{t("chatgptOAuthRouting.openProvider")}
<ArrowUpRight className="ml-1 h-3.5 w-3.5" />
</Link>
</Button>
)}
</div>
<div className="mt-3">
<ChatGPTOAuthQuotaStrip quota={entry.quota} />
</div>
<div className="mt-3 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{quotaStateDescription(entry, t)}</span>
{entry.quota?.last_updated && (
<span>{t("chatgptOAuthRouting.quota.lastChecked", { value: formatRelativeTime(entry.quota.last_updated) })}</span>
)}
</div>
</div>
))}
</div>
</section>
);
}
@@ -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 (
<Badge variant="outline" className={cn("h-5 px-1.5 text-[10px]", className)}>
{t(`${translationKeyPrefix}.checking`)}
</Badge>
);
}
if (!quota) return null;
const failureKind = getQuotaFailureKind(quota);
const signals = getQuotaSignals(quota);
const planLabel = getQuotaPlanLabel(quota.plan_type);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
layout === "inline"
? "flex min-w-0 items-center gap-1.5"
: compact
? cn(
"space-y-1 rounded-md px-2.5 py-1.5",
embedded ? "bg-transparent px-0 py-0" : "border bg-background/70",
)
: cn(
"space-y-1.5 rounded-md px-2.5 py-2",
embedded ? "bg-transparent px-0 py-0" : "border bg-background/70",
),
className,
)}
>
{failureKind ? (
<Badge
variant={failureVariantByKind[failureKind]}
className="h-5 px-1.5 text-[10px]"
>
{t(`${translationKeyPrefix}.failure.${failureKind}.label`)}
</Badge>
) : layout === "inline" ? (
<>
{planLabel && (
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{planLabel}
</Badge>
)}
{signals.map((signal) => (
<div
key={signal.shortLabel}
className="flex items-center gap-1 rounded-full border bg-background/70 px-1.5 py-1"
>
<span className="text-[9px] font-medium uppercase tracking-wide text-muted-foreground">
{signal.shortLabel}
</span>
<div className="h-1.5 w-11 overflow-hidden rounded-full bg-muted">
<div
className={cn(
"h-full rounded-full transition-all",
quotaBarClass(signal.remaining),
)}
style={{
width: `${Math.max(6, Math.min(100, signal.remaining))}%`,
}}
/>
</div>
</div>
))}
</>
) : (
<>
<div className="flex flex-wrap items-center gap-1.5">
{planLabel && <Badge variant="outline">{planLabel}</Badge>}
{showSignalBadges &&
signals.map((signal) => (
<Badge
key={signal.shortLabel}
variant={getQuotaBadgeVariant(signal.remaining)}
>
{signal.shortLabel} {signal.remaining}%
</Badge>
))}
</div>
{signals.length > 0 && (
<div className="grid gap-1">
{signals.map((signal) => (
<div
key={signal.shortLabel}
className={cn(
"flex items-center gap-2",
compact && "gap-1.5",
)}
>
<span className="w-7 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{signal.shortLabel}
</span>
<div className="flex-1 overflow-hidden rounded-full bg-muted h-1.5">
<div
className={cn(
"h-full rounded-full transition-all",
quotaBarClass(signal.remaining),
)}
style={{
width: `${Math.max(6, Math.min(100, signal.remaining))}%`,
}}
/>
</div>
{!compact && (
<span className="w-10 text-right text-[11px] font-medium">
{signal.remaining}%
</span>
)}
</div>
))}
</div>
)}
</>
)}
</div>
</TooltipTrigger>
<TooltipContent sideOffset={6} className="max-w-72 px-3 py-2">
{failureKind ? (
<div className="space-y-1.5">
<p className="font-medium">
{t(`${translationKeyPrefix}.failure.${failureKind}.label`)}
</p>
<p className="text-muted-foreground">
{t(`${translationKeyPrefix}.failure.${failureKind}.description`)}
</p>
{quota.action_hint && (
<p className="text-muted-foreground">{quota.action_hint}</p>
)}
</div>
) : (
<div className="space-y-1.5">
{planLabel && (
<div className="flex justify-between gap-3">
<span className="text-muted-foreground">
{t(`${translationKeyPrefix}.plan`)}
</span>
<span>{planLabel}</span>
</div>
)}
{signals.map((signal) => (
<div
key={signal.shortLabel}
className="flex justify-between gap-3"
>
<span>{signal.shortLabel}</span>
<span>
{signal.remaining}%
{signal.resetAt ? ` · ${formatDate(signal.resetAt)}` : ""}
</span>
</div>
))}
<p className="text-muted-foreground">
{t(`${translationKeyPrefix}.lastChecked`, {
value: formatRelativeTime(quota.last_updated),
})}
</p>
</div>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -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,
};
}
@@ -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<typeof getRouteReadiness>,
): "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<typeof getRouteReadiness>): 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 (
<div className="h-full rounded-md border bg-background/70 px-2 py-1 xl:px-2.5 xl:py-1.5">
<p className="text-[9px] font-medium uppercase tracking-wide text-muted-foreground xl:text-[10px]">
{label}
</p>
<p className="mt-0.5 text-[13px] font-semibold leading-tight tabular-nums xl:mt-1 xl:text-sm">
{value}
</p>
</div>
);
}
function MemberMetric({ label, value }: { label: string; value: string }) {
return (
<div className="flex h-full items-center justify-between gap-1 rounded-md border bg-background/70 px-2 py-1 xl:px-2.5">
<p className="truncate text-[9px] font-medium text-muted-foreground xl:text-[10px]">
{label}
</p>
<p className="shrink-0 text-[12px] font-semibold leading-tight tabular-nums xl:text-[13px]">
{value}
</p>
</div>
);
}
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 (
<div
className={cn(
"rounded-lg border border-dashed text-muted-foreground",
compact ? "px-3 py-3 text-xs" : "px-4 py-4 text-sm",
className,
)}
>
{t("chatgptOAuthRouting.loadingEvidence")}
</div>
);
}
if (recentRequests.length === 0) {
return compact ? (
<div
className={cn(
"rounded-lg border border-dashed bg-muted/5 px-3 py-3 text-xs text-muted-foreground",
className,
)}
>
{t("chatgptOAuthRouting.noEvidence")}
</div>
) : (
<div className={cn("rounded-lg border border-dashed bg-muted/5", className)}>
<EmptyState
icon={Route}
title={t("chatgptOAuthRouting.sequenceEmptyTitle")}
description={t("chatgptOAuthRouting.noEvidence")}
className="py-6"
/>
</div>
);
}
if (compact) {
return (
<div
className={cn(
"overflow-x-auto overflow-y-hidden overscroll-contain pb-1",
className,
)}
>
<div className="flex min-w-max gap-2">
{recentRequests.map((request, index) => {
const providerSummary =
requestProviderSummary(request) ||
request.model ||
t("chatgptOAuthRouting.unknownModel");
const accent = requestAccentClasses(requestAccentSeed(request));
return (
<div
key={request.span_id}
className={cn(
"relative isolate flex min-h-[4.85rem] w-[12.75rem] shrink-0 snap-start flex-col overflow-hidden rounded-lg border bg-background/80 p-2.5",
"lg:min-h-[5.35rem] lg:w-[13.75rem] xl:min-h-[5.85rem] xl:w-[14.75rem] sm:xl:w-[15rem]",
"[@media(max-height:760px)]:min-h-[4.35rem] [@media(max-height:760px)]:w-[11.75rem] [@media(max-height:760px)]:p-1.5",
"transition-colors hover:bg-background",
accent.card,
)}
>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 top-0 h-1 bg-gradient-to-r",
accent.stripe,
)}
/>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 bg-gradient-to-br",
accent.glow,
)}
/>
<Button
asChild
variant="ghost"
size="icon"
className={cn(
"absolute right-1.5 top-1.5 z-10 h-5 w-5 shrink-0 rounded-full border border-transparent xl:right-2 xl:top-2 xl:h-6 xl:w-6",
accent.trace,
)}
>
<Link
to={`/traces/${request.trace_id}`}
aria-label={t("chatgptOAuthRouting.openTrace")}
title={t("chatgptOAuthRouting.openTrace")}
>
<ArrowUpRight className="h-2.5 w-2.5 xl:h-3 xl:w-3" />
</Link>
</Button>
<div className="relative z-10 flex min-w-0 items-start gap-2 pr-6 xl:pr-7">
<div className="flex min-w-0 flex-1 items-start gap-2">
<div
className={cn(
"flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[9px] font-semibold tabular-nums",
"xl:h-6 xl:w-6 xl:text-[10px]",
accent.index,
)}
>
{index + 1}
</div>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden
className={cn(
"h-2 w-2 shrink-0 rounded-full",
accent.marker,
)}
/>
<p className="truncate text-[13px] font-semibold leading-tight xl:text-sm">
{providerSummary}
</p>
</div>
<p className="truncate text-[10px] text-muted-foreground xl:text-[11px]">
{request.model || t("chatgptOAuthRouting.unknownModel")}
</p>
</div>
</div>
</div>
<div className="relative z-10 mt-1 flex items-center gap-1.5 overflow-hidden whitespace-nowrap text-[10px] text-muted-foreground xl:mt-2 xl:gap-2 xl:text-[11px]">
<Badge
variant={request.used_failover ? "warning" : "outline"}
className={cn(
"h-5 shrink-0 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs",
request.used_failover ? undefined : accent.directBadge,
)}
>
{request.used_failover
? t("chatgptOAuthRouting.monitorFailoverLabel")
: t("chatgptOAuthRouting.monitorDirectLabel")}
</Badge>
<span className="shrink-0 font-medium tabular-nums">
{formatRelativeTime(request.started_at)}
</span>
<span aria-hidden className="shrink-0 text-muted-foreground/70">
·
</span>
<span className="shrink-0 font-medium tabular-nums">
{formatDuration(request.duration_ms)}
</span>
{request.attempt_count > 1 && (
<>
<span
aria-hidden
className="shrink-0 text-muted-foreground/70"
>
·
</span>
<span className="shrink-0 font-medium tabular-nums">
{request.attempt_count}x
</span>
</>
)}
{request.status !== "completed" && (
<Badge
variant={requestStatusVariant(request.status)}
className="h-5 shrink-0 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{request.status}
</Badge>
)}
</div>
{request.used_failover &&
request.failover_providers &&
request.failover_providers.length > 0 && (
<p className="relative z-10 mt-0.5 truncate text-[10px] text-muted-foreground xl:text-[11px]">
{t("chatgptOAuthRouting.failoverHint", {
providers: request.failover_providers.join(", "),
})}
</p>
)}
</div>
);
})}
</div>
</div>
);
}
return (
<div className={cn("min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1", className)}>
<div className="space-y-2">
{recentRequests.map((request) => {
const providerSummary = requestProviderSummary(request);
return (
<div
key={request.span_id}
className={cn(
"rounded-lg border bg-muted/10",
"px-3 py-2.5",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={request.used_failover ? "warning" : "info"}>
{request.used_failover
? t("chatgptOAuthRouting.monitorFailoverLabel")
: t("chatgptOAuthRouting.monitorDirectLabel")}
</Badge>
{providerSummary ? (
<Badge variant="outline">{providerSummary}</Badge>
) : (
<Badge variant="secondary">{request.status}</Badge>
)}
</div>
<p className="truncate text-sm font-medium">
{request.model || t("chatgptOAuthRouting.unknownModel")}
</p>
<div
className={cn(
"flex flex-wrap gap-x-3 gap-y-1 text-muted-foreground",
"text-xs",
)}
>
<span>{formatRelativeTime(request.started_at)}</span>
<span>{formatDuration(request.duration_ms)}</span>
<span>
{t("chatgptOAuthRouting.attemptCount", {
count: request.attempt_count,
})}
</span>
</div>
{request.used_failover &&
request.failover_providers &&
request.failover_providers.length > 0 && (
<p
className={cn(
"text-muted-foreground",
"text-xs",
)}
>
{t("chatgptOAuthRouting.failoverHint", {
providers: request.failover_providers.join(", "),
})}
</p>
)}
</div>
<div className="flex items-center gap-2">
<Badge variant={requestStatusVariant(request.status)}>
{request.status}
</Badge>
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
>
<Link
to={`/traces/${request.trace_id}`}
aria-label={t("chatgptOAuthRouting.openTrace")}
title={t("chatgptOAuthRouting.openTrace")}
>
<ArrowUpRight className="h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
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 (
<Card className={cn("flex h-full min-h-0 flex-col gap-0 overflow-hidden", className)}>
<CardHeader className="border-b bg-muted/20 px-3 py-2.5 lg:px-4 lg:py-3 [@media(max-height:760px)]:py-1.5">
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-start sm:justify-between [@media(max-height:760px)]:gap-1">
<div>
<CardTitle className="text-sm sm:text-base [@media(max-height:760px)]:text-[15px]">
{t("chatgptOAuthRouting.activityTitle")}
</CardTitle>
<p className="hidden text-xs text-muted-foreground xl:block [@media(max-height:760px)]:hidden">
{t("chatgptOAuthRouting.activityDescription")}
</p>
</div>
<div className="flex flex-wrap items-center gap-2 [@media(max-height:760px)]:gap-1.5">
<Badge variant="outline" className="h-6 px-2 text-[11px] [@media(max-height:760px)]:h-5">
{t(strategyLabelKey(strategy))}
</Badge>
{blockedEntries.length > 0 && (
<Badge variant="warning" className="h-6 px-2 text-[11px] [@media(max-height:760px)]:h-5">
{t("chatgptOAuthRouting.blockedNowTitle")} {blockedEntries.length}
</Badge>
)}
{failoverOnlyProviders > 0 && (
<Badge variant="warning" className="h-6 px-2 text-[11px] [@media(max-height:760px)]:h-5">
{t("chatgptOAuthRouting.failoverOnlyProviders", {
count: failoverOnlyProviders,
})}
</Badge>
)}
<Button
type="button"
variant="outline"
size="sm"
className="h-8 gap-1.5 px-2.5 [@media(max-height:760px)]:h-7 [@media(max-height:760px)]:px-2"
onClick={onRefresh}
disabled={fetching}
>
<RefreshCw
className={`h-4 w-4${fetching ? " animate-spin" : ""}`}
/>
{t("chatgptOAuthRouting.refreshEvidence")}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-hidden px-3 py-2.5 lg:px-4 lg:py-3 [@media(max-height:760px)]:gap-2 [@media(max-height:760px)]:py-2">
<div className="grid gap-1.5 sm:grid-cols-2 xl:grid-cols-4 [@media(max-height:760px)]:gap-1">
<MonitorStat
label={t("chatgptOAuthRouting.metrics.poolSize")}
value={String(entries.length)}
/>
<MonitorStat
label={t("chatgptOAuthRouting.metrics.observedSample")}
value={String(statsSampleSize)}
/>
<MonitorStat
label={t("chatgptOAuthRouting.metrics.observedRotation")}
value={`${directObservedProviders}/${entries.length}`}
/>
<MonitorStat
label={t("chatgptOAuthRouting.metrics.failovers")}
value={String(
recentRequests.filter((request) => request.used_failover)
.length,
)}
/>
</div>
<section className="shrink-0 rounded-lg border bg-muted/5 p-2 [@media(max-height:760px)]:p-1.5">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium [@media(max-height:760px)]:text-[13px]">
{t("chatgptOAuthRouting.sequenceTitle")}
</h3>
<Badge variant="outline">
{t("chatgptOAuthRouting.recentRequestsCount", {
count: recentRequests.length,
})}
</Badge>
</div>
<CodexPoolRecentRequestsList
recentRequests={recentRequests}
loading={fetching && recentRequests.length === 0}
compact
className="mt-1.5 [@media(max-height:760px)]:mt-1"
/>
</section>
<section className="flex min-h-0 flex-1 flex-col gap-2.5 [@media(max-height:760px)]:gap-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-medium [@media(max-height:760px)]:text-[13px]">
{t("chatgptOAuthRouting.poolMembersTitle")}
</h3>
<Badge variant="outline">
{t("chatgptOAuthRouting.selectedCount", {
count: entries.length,
})}
</Badge>
</div>
{entries.length === 0 ? (
<div className="rounded-lg border border-dashed bg-muted/5">
<EmptyState
icon={Route}
title={t("chatgptOAuthRouting.noReadyExtras")}
description={t("chatgptOAuthRouting.extraSelectableHint")}
className="py-6"
/>
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1">
<div className="grid auto-rows-min content-start gap-2.5 [grid-template-columns:repeat(auto-fit,minmax(min(100%,12.25rem),1fr))] lg:[grid-template-columns:repeat(auto-fit,minmax(min(100%,14rem),1fr))] xl:[grid-template-columns:repeat(auto-fit,minmax(min(100%,15rem),1fr))] [@media(max-height:760px)]:gap-2 [@media(max-height:760px)]:[grid-template-columns:repeat(auto-fit,minmax(min(100%,12.25rem),1fr))]">
{routeEntries.map((entry) => {
const accent = requestAccentClasses(entry.name);
return (
<div
key={entry.name}
className={cn(
"relative isolate overflow-hidden rounded-lg border bg-background/80 p-2 lg:min-h-[10.5rem] lg:p-2.5 xl:min-h-[11rem]",
"[@media(max-height:760px)]:min-h-0 [@media(max-height:760px)]:p-1.5",
accent.card,
)}
>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-x-0 top-0 h-1 bg-gradient-to-r",
accent.stripe,
)}
/>
<div
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 bg-gradient-to-br",
accent.glow,
)}
/>
<div className="relative z-10">
{(() => {
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 (
<>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-1.5 xl:gap-2">
<span className="inline-flex min-w-0 items-center gap-1 truncate text-[13px] font-medium xl:gap-1.5 xl:text-sm">
<span
aria-hidden
className={cn(
"h-2 w-2 shrink-0 rounded-full",
accent.marker,
)}
/>
<span className="truncate">{entry.label}</span>
</span>
<Badge
variant="outline"
className={cn(
"h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs",
poolRoleBadgeClass(entry.role),
)}
>
{t(`chatgptOAuthRouting.role.${entry.role}`)}
</Badge>
{showAvailabilityBadge && (
<Badge
variant={availabilityVariant(entry.availability)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(
`chatgptOAuthRouting.status.${entry.availability}`,
)}
</Badge>
)}
{showHealthBadge && (
<Badge
variant={runtimeHealthVariant(entry.healthState)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(
`chatgptOAuthRouting.healthState.${entry.healthState}`,
)}
</Badge>
)}
{showRouteBadge && (
<Badge
variant={routeBadgeVariant(entry.routeReadiness)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(routeLabelKey(entry.routeReadiness))}
</Badge>
)}
</div>
{entry.label !== entry.name && (
<p className="truncate font-mono text-[10px] text-muted-foreground xl:text-xs">
{entry.name}
</p>
)}
</div>
{showProviderLinks && entry.providerHref && (
<Button
asChild
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 shrink-0 rounded-full xl:h-8 xl:w-8",
accent.trace,
)}
>
<Link
to={entry.providerHref}
aria-label={t("chatgptOAuthRouting.openProvider")}
title={t("chatgptOAuthRouting.openProvider")}
>
<ArrowUpRight className="h-3.5 w-3.5 xl:h-4 xl:w-4" />
</Link>
</Button>
)}
</div>
<ChatGPTOAuthQuotaStrip
quota={entry.quota}
className="mt-1 xl:mt-1.5"
compact
/>
<div className="mt-1 rounded-md border bg-background/75 px-2 py-1.5 xl:mt-1.5 xl:px-2.5">
<div className="flex items-center justify-between gap-2">
{totalOutcomes > 0 ? (
<p className="truncate text-[11px] font-medium text-foreground xl:text-xs">
{t(
"chatgptOAuthRouting.runtimeHealthSummary",
{
rate: entry.successRate,
score: entry.healthScore,
},
)}
</p>
) : (
<p className="truncate text-[11px] text-muted-foreground xl:text-xs">
{t("chatgptOAuthRouting.noRuntimeSample")}
</p>
)}
{entry.consecutiveFailures > 0 && (
<Badge
variant={
entry.consecutiveFailures >= 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,
})}
</Badge>
)}
</div>
<div className="mt-1 flex h-2 overflow-hidden rounded-full bg-muted xl:mt-1.5">
{totalOutcomes > 0 ? (
<>
<div
className="h-full bg-emerald-500 transition-all"
style={{ width: `${barWidths.success}%` }}
/>
{barWidths.failure > 0 && (
<div
className="h-full bg-rose-500/80 transition-all"
style={{ width: `${barWidths.failure}%` }}
/>
)}
</>
) : (
<div className="h-full w-full bg-muted" />
)}
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-[10px] text-muted-foreground xl:mt-1.5 xl:gap-3 xl:text-[11px]">
<span>
{t("chatgptOAuthRouting.runtimeSuccessCompact", {
count: entry.successCount,
})}
</span>
<span>
{t("chatgptOAuthRouting.runtimeFailureCompact", {
count: entry.failureCount,
})}
</span>
{entry.lastFailureAt && (
<span className="truncate">
{t("chatgptOAuthRouting.lastFailureLabel", {
value: formatRelativeTime(entry.lastFailureAt),
})}
</span>
)}
</div>
</div>
<div className="mt-1 grid gap-1 sm:grid-cols-3 xl:mt-1.5 xl:gap-1.5">
<MemberMetric
label={t("chatgptOAuthRouting.monitorDirectLabel")}
value={String(entry.directSelectionCount)}
/>
<MemberMetric
label={t("chatgptOAuthRouting.monitorFailoverLabel")}
value={String(entry.failoverServeCount)}
/>
<MemberMetric
label={t("chatgptOAuthRouting.lastSeenLabel")}
value={
entry.lastUsedAt
? formatRelativeTime(entry.lastUsedAt)
: t("chatgptOAuthRouting.never")
}
/>
</div>
</>
);
})()}
</div>
</div>
);
})}
</div>
</div>
)}
</section>
</CardContent>
</Card>
);
}
interface CodexPoolRecentRequestsPanelProps {
recentRequests: CodexPoolRecentRequest[];
loading: boolean;
className?: string;
}
export function CodexPoolRecentRequestsPanel({
recentRequests,
loading,
className,
}: CodexPoolRecentRequestsPanelProps) {
const { t } = useTranslation("agents");
return (
<Card className={cn("flex min-h-0 flex-col gap-0 overflow-hidden", className)}>
<CardHeader className="border-b bg-muted/20 px-4 py-3">
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle className="text-base">
{t("chatgptOAuthRouting.sequenceTitle")}
</CardTitle>
<p className="text-sm text-muted-foreground">
{t("chatgptOAuthRouting.sequenceDescription")}
</p>
</div>
<Badge variant="outline">
{t("chatgptOAuthRouting.recentRequestsCount", {
count: recentRequests.length,
})}
</Badge>
</div>
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3">
<CodexPoolRecentRequestsList
recentRequests={recentRequests}
loading={loading}
/>
</CardContent>
</Card>
);
}
@@ -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<string, ChatGPTOAuthProviderQuota>;
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<typeof getRouteReadiness>,
): "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<typeof getRouteReadiness>,
): 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 (
<div className="self-start rounded-lg border bg-muted/10 px-2.5 py-2 [@media(max-height:760px)]:px-2 [@media(max-height:760px)]:py-1.5">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{title}
</p>
<Badge variant={variant}>{count}</Badge>
</div>
{entries.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-1.5">
{entries.map((entry) => (
<div
key={entry.name}
className="rounded-md border bg-background/80 px-2 py-1 text-xs"
>
<span className="font-medium">{entry.label}</span>
{entry.detail ? (
<span className="text-muted-foreground"> · {entry.detail}</span>
) : null}
</div>
))}
</div>
) : (
<p className="mt-2 text-xs text-muted-foreground">{emptyLabel}</p>
)}
</div>
);
}
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 (
<Card className={cn("flex min-h-0 flex-col gap-0 overflow-hidden", className)}>
<CardHeader className="border-b bg-muted/20 px-3 py-2 lg:px-4 lg:py-2.5 [@media(max-height:860px)]:py-1.5">
<div className="flex items-start justify-between gap-1.5">
<div className="min-w-0">
<CardTitle className="text-sm sm:text-[15px] lg:text-base [@media(max-height:860px)]:text-[14px]">
{title ?? t("chatgptOAuthRouting.controlTitle")}
</CardTitle>
<CardDescription className="mt-0.5 hidden text-xs text-muted-foreground 2xl:block 2xl:line-clamp-2 [@media(min-width:1800px)]:line-clamp-none [@media(max-height:860px)]:hidden">
{description ?? t("chatgptOAuthRouting.controlDescription")}
</CardDescription>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-1.5">
{showOverrideMode ? (
<Badge
variant={mode === "inherit" ? "secondary" : "outline"}
className="h-6 px-2 text-[11px] [@media(max-height:860px)]:h-5"
>
{mode === "inherit"
? t("chatgptOAuthRouting.mode.inherit")
: t("chatgptOAuthRouting.mode.custom")}
</Badge>
) : null}
{!canManageProviders ? (
<Badge variant="outline" className="h-6 px-2 text-[11px] [@media(max-height:860px)]:h-5">
{t("chatgptOAuthRouting.viewerMode")}
</Badge>
) : null}
{isDirty ? (
<Badge variant="warning" className="h-6 px-2 text-[11px] [@media(max-height:860px)]:h-5">
{t("chatgptOAuthRouting.draftBadge")}
</Badge>
) : null}
{(quotaLoading || isLoading) ? (
<Badge variant="outline" className="h-6 px-2 text-[11px] [@media(max-height:860px)]:h-5">
{t("chatgptOAuthRouting.quota.checking")}
</Badge>
) : null}
</div>
</div>
</CardHeader>
<CardContent
className={cn(
"min-h-0 flex-1 space-y-3 px-3 py-2.5 lg:px-4 lg:py-3 [@media(max-height:760px)]:space-y-2 [@media(max-height:760px)]:py-2",
contentScrollable ? "overflow-y-auto" : "overflow-visible",
)}
>
{showOverrideMode ? (
<section className="space-y-2.5 [@media(max-height:760px)]:space-y-2">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("chatgptOAuthRouting.mode.label")}
</p>
</div>
<div className="grid gap-1.5 xl:grid-cols-2">
<Button
type="button"
variant={mode === "inherit" ? "default" : "outline"}
onClick={() => setMode("inherit")}
disabled={!canManageProviders || !providerDefaultsAvailable}
className="h-9 [@media(max-height:760px)]:h-8"
>
{t("chatgptOAuthRouting.mode.inherit")}
</Button>
<Button
type="button"
variant={mode === "custom" ? "default" : "outline"}
onClick={() => setMode("custom")}
disabled={!canManageProviders}
className="h-9 [@media(max-height:760px)]:h-8"
>
{t("chatgptOAuthRouting.mode.custom")}
</Button>
</div>
{!providerDefaultsAvailable ? (
<div className="rounded-lg border border-dashed px-3 py-3 text-sm text-muted-foreground">
{t("chatgptOAuthRouting.mode.noProviderDefault")}
</div>
) : null}
</section>
) : null}
<section className="space-y-2.5 [@media(max-height:760px)]:space-y-2">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("chatgptOAuthRouting.strategyLabel")}
</p>
</div>
<div className="grid gap-2 sm:grid-cols-3">
<Button
type="button"
variant={selectedStrategy === "primary_first" ? "default" : "outline"}
onClick={() => setStrategy("primary_first")}
disabled={!canManageProviders || mode === "inherit"}
className="h-9 text-xs sm:text-sm [@media(max-height:760px)]:h-8"
>
{t("chatgptOAuthRouting.strategy.primaryFirst")}
</Button>
<Button
type="button"
variant={selectedStrategy === "round_robin" ? "default" : "outline"}
onClick={() => setStrategy("round_robin")}
disabled={!canUsePoolStrategies}
className="h-9 text-xs sm:text-sm [@media(max-height:760px)]:h-8"
>
{t("chatgptOAuthRouting.strategy.roundRobin")}
</Button>
<Button
type="button"
variant={selectedStrategy === "priority_order" ? "default" : "outline"}
onClick={() => setStrategy("priority_order")}
disabled={!canUsePoolStrategies}
className="h-9 text-xs sm:text-sm [@media(max-height:760px)]:h-8"
>
{t("chatgptOAuthRouting.strategy.priorityOrder")}
</Button>
</div>
</section>
<section className="space-y-2.5 [@media(max-height:760px)]:space-y-2">
<div className="flex flex-wrap items-center gap-1.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{membershipEditable
? t("chatgptOAuthRouting.availableExtraAccountsLabel")
: t("chatgptOAuthRouting.poolMembershipLabel")}
</p>
</div>
{!membershipEditable ? (
<div className="rounded-lg border border-dashed px-3 py-3 text-sm text-muted-foreground">
{selectedEntries.length > 1
? t("chatgptOAuthRouting.membershipManagedAtProvider", {
provider: membershipManagedByLabel || currentProvider,
})
: t("chatgptOAuthRouting.membershipConfigureProviderFirst", {
provider: membershipManagedByLabel || currentProvider,
})}
</div>
) : isLoading ? (
<div className="rounded-lg border border-dashed px-3 py-3 text-sm text-muted-foreground">
{t("chatgptOAuthRouting.loadingAccounts")}
</div>
) : readyExtraProviders.length > 0 ? (
<div className="grid gap-2 xl:grid-cols-2">
{readyExtraProviders.map((provider) => {
const selected = selectedExtras.has(provider.name);
const failureKind = getQuotaFailureKind(
quotaByName?.get(provider.name),
);
return (
<Button
key={provider.name}
type="button"
variant="outline"
size="sm"
className={cn(
"h-8 justify-start gap-1.5 rounded-lg px-3 text-left text-[13px] xl:h-9 xl:text-sm [@media(max-height:760px)]:h-8",
selected &&
"border-primary/40 bg-primary/10 text-foreground hover:bg-primary/15 dark:border-primary/30 dark:bg-primary/10",
!selected &&
failureKind &&
"border-amber-500/40 text-amber-700 dark:text-amber-300",
selected &&
failureKind &&
"border-amber-500/40 bg-amber-500/10 text-amber-900 hover:bg-amber-500/15 dark:text-amber-200",
)}
onClick={() => toggleProvider(provider.name)}
disabled={!canEditMembership || mode === "inherit"}
>
{selected ? <Check className="h-3.5 w-3.5" /> : null}
{provider.display_name || provider.name}
</Button>
);
})}
</div>
) : (
<div className="rounded-lg border border-dashed px-3 py-3 text-sm text-muted-foreground">
{t("chatgptOAuthRouting.noReadyExtras")}
</div>
)}
</section>
<section className="space-y-3 [@media(max-height:760px)]:space-y-2">
<div className="flex flex-wrap items-center justify-between gap-1.5">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("chatgptOAuthRouting.selectedAccountsLabel")}
</p>
<Badge variant="outline" className="h-6 px-2 text-[11px]">
{t("chatgptOAuthRouting.selectedCount", {
count: selectedEntries.length,
})}
</Badge>
</div>
{selectedEntries.length > 0 ? (
<div className="rounded-lg border bg-muted/10 p-3 [@media(max-height:760px)]:p-2.5">
<div className="grid gap-1.5 xl:grid-cols-2 [@media(max-height:760px)]:gap-1">
{selectedEntries.map((entry) => (
<div
key={entry.name}
className="rounded-lg border bg-background/80 px-2.5 py-2 [@media(max-height:760px)]:px-2 [@media(max-height:760px)]:py-1.5"
>
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-0 truncate text-sm font-medium">
{entry.label}
</span>
<Badge
variant="outline"
className={cn(
"h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs",
roleBadgeClass(entry.role),
)}
>
{t(`chatgptOAuthRouting.role.${entry.role}`)}
</Badge>
{entry.availability !== "ready" && (
<Badge
variant={statusBadgeVariant(entry.availability)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(`chatgptOAuthRouting.status.${entry.availability}`)}
</Badge>
)}
{entry.routeReadiness !== "healthy" && (
<Badge
variant={routeBadgeVariant(entry.routeReadiness)}
className="h-5 px-1.5 text-[10px] xl:h-6 xl:px-2 xl:text-xs"
>
{t(routeLabelKey(entry.routeReadiness))}
</Badge>
)}
</div>
</div>
))}
</div>
</div>
) : (
<div className="rounded-lg border border-dashed px-3 py-3 text-sm text-muted-foreground">
{t("chatgptOAuthRouting.emptySelected")}
</div>
)}
</section>
<section className="space-y-2.5 [@media(max-height:760px)]:space-y-2">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{t("chatgptOAuthRouting.poolStateTitle")}
</p>
<div className="grid items-start gap-1.5 xl:grid-cols-3 [@media(max-height:760px)]:gap-1">
<StateGroup
title={t("chatgptOAuthRouting.routerActiveTitle")}
count={routerActiveEntries.length}
variant="success"
entries={routerActiveEntries.map((entry) => ({
name: entry.name,
label: entry.label,
detail: routeDetail(entry),
}))}
emptyLabel={t("chatgptOAuthRouting.emptyGroup")}
/>
<StateGroup
title={t("chatgptOAuthRouting.fallbackTitle")}
count={standbyEntries.length}
variant="warning"
entries={standbyEntries.map((entry) => ({
name: entry.name,
label: entry.label,
detail: routeDetail(entry),
}))}
emptyLabel={t("chatgptOAuthRouting.emptyGroup")}
/>
<StateGroup
title={t("chatgptOAuthRouting.blockedNowTitle")}
count={blockedEntries.length}
variant="destructive"
entries={blockedEntries.map((entry) => ({
name: entry.name,
label: entry.label,
detail: routeDetail(entry),
}))}
emptyLabel={t("chatgptOAuthRouting.emptyGroup")}
/>
</div>
</section>
</CardContent>
{canManageProviders && onSave && (isDirty || saving) ? (
<div className="border-t bg-background/70 px-3 py-2 lg:px-4 [@media(max-height:760px)]:py-1.5">
<div className="flex items-center justify-end">
<Button
type="button"
size="sm"
onClick={onSave}
disabled={!isDirty || saving}
>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{saving ? tc("saving") : tc("save")}
</Button>
</div>
</div>
) : null}
</Card>
);
}
@@ -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";
@@ -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<CodexPoolActivityResponse>(`/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: [],
},
};
}
@@ -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 (
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
<div className="space-y-2.5">
<div className="flex flex-wrap items-start gap-3">
<div className="min-w-0 flex-1 space-y-0.5">
<h3 className="text-sm font-medium">{t("chatgptOAuthRouting.summaryTitle")}</h3>
<p className="text-xs text-muted-foreground">{t("chatgptOAuthRouting.summaryDescription")}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="ml-auto shrink-0 gap-1.5 self-start"
onClick={onManage}
>
<Settings2 className="h-4 w-4" />
{t("chatgptOAuthRouting.manageAction")}
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{t(strategyLabelKey(effectiveRouting.strategy))}</Badge>
<Badge variant={effectiveRouting.overrideMode === "inherit" ? "secondary" : "outline"}>
{effectiveRouting.overrideMode === "inherit"
? t("chatgptOAuthRouting.mode.inherit")
: t("chatgptOAuthRouting.mode.custom")}
</Badge>
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="space-y-2 rounded-lg border bg-muted/10 p-3">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t("chatgptOAuthRouting.defaultAccount")}
</p>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">{preferredLabel}</Badge>
<Badge variant="outline" className={statusBadgeClass(preferredAvailability)}>
{t(`chatgptOAuthRouting.status.${preferredAvailability}`)}
</Badge>
<ChatGPTOAuthQuotaBadges quota={quotaByName.get(agent.provider)} />
</div>
</div>
<div className="space-y-2 rounded-lg border bg-muted/10 p-3">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t("chatgptOAuthRouting.selectedAccountsLabel")}
</p>
<p className="text-sm font-semibold">
{t("chatgptOAuthRouting.selectedCount", {
count: effectiveRouting.poolProviderNames.length,
})}
</p>
<p className="text-xs text-muted-foreground">
{effectiveRouting.overrideMode === "inherit" && providerDefaults
? t("chatgptOAuthRouting.mode.summaryInherited")
: t("chatgptOAuthRouting.mode.summaryCustom")}
</p>
</div>
<div className="space-y-2 rounded-lg border bg-muted/10 p-3">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t("chatgptOAuthRouting.metrics.quotaReadyAccounts")}
</p>
<p className="text-sm font-semibold">
{t("chatgptOAuthRouting.readySummary", {
ready: readyExtraCount,
total: extraEntries.length,
})}
</p>
<p className="text-xs text-muted-foreground">
{quotaSummary.attention > 0
? t("chatgptOAuthRouting.quota.needsAttention", { count: quotaSummary.attention })
: t("chatgptOAuthRouting.quota.healthySummary", {
usable: quotaSummary.usable,
total: quotaSummaryTotal,
})}
</p>
</div>
</div>
<div className="space-y-2">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t("chatgptOAuthRouting.extraAccountsLabel")}
</p>
{extraEntries.length > 0 ? (
<div className="flex flex-wrap gap-2">
{extraEntries.map((entry) => (
<div key={entry.providerName} className="flex flex-wrap items-center gap-1.5 rounded-md border px-2 py-1">
<span className="text-xs font-medium">{entry.label}</span>
<Badge variant="outline" className={statusBadgeClass(entry.availability)}>
{t(`chatgptOAuthRouting.status.${entry.availability}`)}
</Badge>
<ChatGPTOAuthQuotaBadges quota={entry.quota} />
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">{t("chatgptOAuthRouting.emptySelected")}</p>
)}
</div>
<Alert>
<AlertDescription>
<p>
{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 })}
</p>
{isLoading ? <p>{t("chatgptOAuthRouting.loadingAccounts")}</p> : null}
</AlertDescription>
</Alert>
</section>
);
}
+8 -4
View File
@@ -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<string, unknown>;
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 (
<button
@@ -79,6 +78,11 @@ export function AgentListRow({ agent, ownerName, onClick, onResummon, onDelete }
</TooltipContent>
</Tooltip>
)}
{hasOAuthRouting && (
<Badge variant="outline" className="text-[11px]">
{t("chatgptOAuthRouting.badge")}
</Badge>
)}
</div>
{/* Owner */}
@@ -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<ChatGPTOAuthProviderQuota>(
`/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,
};
}
@@ -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<ChatGPTOAuthStatusResponse>(
`/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 ?? [],
};
}
@@ -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" },
@@ -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 ?? [];
@@ -126,13 +126,15 @@ export function ProviderAdvancedDialog({
description={t("detail.identityDesc")}
/>
<div className="space-y-2">
<Label>{t("form.name")}</Label>
<Label>{isOAuth ? t("form.oauthAlias") : t("form.name")}</Label>
<Input
value={provider.name}
disabled
className="text-base md:text-sm font-mono"
/>
<p className="text-xs text-muted-foreground">{t("detail.nameReadonly")}</p>
<p className="text-xs text-muted-foreground">
{isOAuth ? t("detail.oauthAliasReadonly") : t("detail.nameReadonly")}
</p>
</div>
{/* Connection — standard providers only */}
@@ -246,7 +248,13 @@ export function ProviderAdvancedDialog({
title={t("detail.oauthConfig")}
description={t("detail.oauthConfigDesc")}
/>
<OAuthSection onSuccess={() => onOpenChange(false)} />
<OAuthSection
providerName={provider.name}
displayName={provider.display_name}
apiBase={provider.api_base}
authenticatedActionLabel={t("form.close")}
onSuccess={() => onOpenChange(false)}
/>
</>
)}
</div>
@@ -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 (
<TooltipProvider>
@@ -55,7 +58,7 @@ export function ProviderHeader({ provider, onBack, onAdvanced, onDelete }: Provi
</Badge>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<span className="font-mono text-[11px]">{provider.name}</span>
<span className="font-mono text-[11px]">{subtitle}</span>
<span className="text-border">·</span>
<span>{typeLabel}</span>
</div>
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more