diff --git a/.gitignore b/.gitignore index 9d5ebae4ba..0c3a4d1526 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ dist/ **/.codexclaw/ .omo/ **/.omo/ +.playwright-cli/ # Local development worktrees .worktrees/ diff --git a/docs-site/src/components/Landing.astro b/docs-site/src/components/Landing.astro index 06d280366c..43e8ff1d41 100644 --- a/docs-site/src/components/Landing.astro +++ b/docs-site/src/components/Landing.astro @@ -214,7 +214,7 @@ const docsMap = [

{'Sub-agents'}

-

{'Pin up to five routed or native models for Codex spawn_agent, and switch the v1 / base / v2 surface globally.'}

+

{'Pin up to five routed or native models for Codex spawn_agent, and switch the V1 / base / V2 surface globally.'}

Claude Code

diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 0ca894ffb8..a903eb016a 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -122,14 +122,14 @@ forces Codex's model cache stale after a toggle. ## Multi-agent surface mode -The Models page labels the three collaboration choices **Reliable v1**, **Codex native** (the -base/upstream behavior), and **Concurrent v2**. This control changes which Codex collaboration surface each picker +The Models page labels the three collaboration choices **Reliable V1**, **Codex native** (the +base/upstream behavior), and **Concurrent V2**. This control changes which Codex collaboration surface each picker entry uses; see [Sub-agent Surface](/guides/sub-agent-surface/) for the canonical mode, delegation, inheritance, fallback, and encrypted-task behavior. ## Reasoning top tiers -Reasoning-tier visibility is independent of the v1/base/v2 surface mode. Generated reasoning-capable +Reasoning-tier visibility is independent of the V1/base/V2 surface mode. Generated reasoning-capable entries advertise `max` so direct sub-agent effort overrides validate; current generated routed entries and older native GPT entries also advertise `ultra`. Exact upstream GPT-5.6 ladders are preserved, so Luna has `max` but no `ultra`. diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index e9cc154f59..dd425d766a 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -293,7 +293,7 @@ it is not; `ccx doctor` reports restart safety (service/shim coverage). ## The subagent picker -Catalog sync makes the selected sub-agent models available to Codex; see [Codex App model picker](/guides/codex-app-models/#subagent-selection) for picker ordering and [Sub-agent Surface](/guides/sub-agent-surface/) for v1/base/v2 delegation and fallback behavior. +Catalog sync makes the selected sub-agent models available to Codex; see [Codex App model picker](/guides/codex-app-models/#subagent-selection) for picker ordering and [Sub-agent Surface](/guides/sub-agent-surface/) for V1/base/V2 delegation and fallback behavior. ## Codex account warmup diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index a5c5b50bfa..099c02b253 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -213,9 +213,9 @@ default and leaves the target's own behavior unchanged. Supported values are `lo `high`, `xhigh`, `max`, and `ultra`; omit the field or set it to `null` to leave effort entirely to the caller and target. -## Encrypted v2 sub-agent tasks +## Encrypted V2 sub-agent tasks -There is one important limitation for Codex v2 sub-agents ([issue #92](https://github.com/pavelhov/CodexCommander/issues/92)). +There is one important limitation for Codex V2 sub-agents ([issue #92](https://github.com/pavelhov/CodexCommander/issues/92)). A native parent can send a newly spawned worker's task only as ciphertext minted for the native ChatGPT backend. An external provider cannot read that payload. @@ -239,10 +239,10 @@ You have four recovery options: 1. Select a native ChatGPT model for the child. 2. Add a canonical native ChatGPT target to the combo. -3. Use the v1 surface for delegation across different providers. +3. Use the V1 surface for delegation across different providers. 4. Enable **Plaintext compatibility** under **Subagents → Run Policy**, then start a new session. -See [Sub-agent Surface](/guides/sub-agent-surface/) for the v1/base/v2 modes and the full encrypted +See [Sub-agent Surface](/guides/sub-agent-surface/) for the V1/base/V2 modes and the full encrypted task workflow. ## Manage combos @@ -321,7 +321,7 @@ running CodexCommander instance that receives model requests. ### Why do I get `combo_unavailable`? Every target is currently ineligible: for example, its provider is disabled, it is cooling down, -it has already been attempted for this request, or an encrypted v2 task excludes it. Check target +it has already been attempted for this request, or an encrypted V2 task excludes it. Check target provider state and recent upstream errors. For cooldowns, wait for the 60-second default or the upstream `Retry-After` period (never more than 10 minutes), then retry. diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index bfd405e80a..8593a33c40 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -1,5 +1,5 @@ --- -title: Sub-agent Surface (v1 / base / v2) +title: Sub-agent Surface (V1 / base / V2) description: Control how Codex spawns and manages sub-agents across all models. --- @@ -16,12 +16,12 @@ Choose the mode for **new sessions**. Existing sessions keep the surface they st | Mode | What Codex gets | Who should pick it | | --- | --- | --- | -| **v1** | Classic namespaced `spawn_agent`, `send_input`, `resume_agent`, and `close_agent` tools. A spawn can select another model directly. | Beginners who need reliable delegation across different providers, especially native-to-routed children. | -| **base** (default; **Codex native** in the GUI) | Upstream model pins: GPT-5.6 Sol/Terra use v2, Luna uses v1, and unpinned models follow Codex's `multi_agent_v2` feature flag. | Most users. It follows Codex's intended surface for each model without forcing one globally. | -| **v2** | Flat `spawn_agent`, `send_message`, `followup_task`, `interrupt_agent`, and agent-list tools, with concurrent sessions. | Users who want the newer concurrent workflow. Mixed-provider parents must also choose the plaintext compatibility delivery policy described below. | +| **V1** | Classic namespaced `spawn_agent`, `send_input`, `resume_agent`, and `close_agent` tools. A spawn can select another model directly. | Beginners who need reliable delegation across different providers, especially native-to-routed children. | +| **base** (default; **Codex native** in the GUI) | Upstream model pins: GPT-5.6 Sol/Terra use V2, Luna uses V1, and unpinned models follow Codex's `multi_agent_v2` feature flag. | Most users. It follows Codex's intended surface for each model without forcing one globally. | +| **V2** | Flat `spawn_agent`, `send_message`, `followup_task`, `interrupt_agent`, and agent-list tools, with concurrent sessions. | Users who want the newer concurrent workflow. Mixed-provider parents must also choose the plaintext compatibility delivery policy described below. | :::tip[Not sure?] -Start with **base**. Choose **v1** for the established cross-provider path. Force **v2** only when +Start with **base**. Choose **V1** for the established cross-provider path. Force **V2** only when you specifically want its newer session model; enable plaintext compatibility when that V2 parent must delegate to Kimi, Grok, DeepSeek, or another external provider. ::: @@ -30,16 +30,16 @@ must delegate to Kimi, Grok, DeepSeek, or another external provider. The selected mode controls the `multi_agent_version` field in every catalog entry Codex reads: -- **v1** stamps `multi_agent_version = "v1"` on every model. +- **V1** stamps `multi_agent_version = "v1"` on every model. - **base** restores upstream pins. Unpinned entries follow the native `multi_agent_v2` feature flag. -- **v2** stamps `multi_agent_version = "v2"` on every model. +- **V2** stamps `multi_agent_version = "v2"` on every model. CodexCommander applies this as the final pass to both the live `/v1/models` catalog and the catalog synced to disk. That is why a mode change affects newly created App, CLI, and TUI sessions consistently. ### A mode is not a worker reload -Changing to **v2** makes Luna *eligible for the V2 collaboration surface* because the generated +Changing to **V2** makes Luna *eligible for the V2 collaboration surface* because the generated catalog stamps it as V2. It does not, by itself, make Luna (or any other model) available to a currently running Codex worker. For a model to be usable by `spawn_agent`, all of these must hold: @@ -52,7 +52,7 @@ This separation is deliberate: protocol selection controls catalog semantics; ca controls what an already-running Codex worker has loaded. In particular, opening a **new task** or forking a task does **not** reload an existing app-server's model catalog. -For a v2 roster, eligibility has three states: an entry stamped `"v2"`, explicitly set to `null`, or +For a V2 roster, eligibility has three states: an entry stamped `"v2"`, explicitly set to `null`, or with no `multi_agent_version` field is eligible. A genuine `"v1"` pin is excluded because it states that the model belongs to the other collaboration surface. @@ -62,12 +62,12 @@ The dashboard's **Sub-agent delegation** controls three related settings: - `injectionModel` is the preferred worker model named in CodexCommander guidance. - `injectionEffort` is the optional `reasoning_effort` to request for that model. -- `injectionPrompt` replaces the built-in v2 guidance text. +- `injectionPrompt` replaces the built-in V2 guidance text. `multiAgentGuidanceEnabled` defaults to on and is the master switch for CodexCommander-authored guidance -on both surfaces. Turning it off suppresses both the v2 designation block and v1 proactive text. +on both surfaces. Turning it off suppresses both the V2 designation block and V1 proactive text. -These are instructions to the main agent, not a proxy-side spawn router. On v2, a full-history fork +These are instructions to the main agent, not a proxy-side spawn router. On V2, a full-history fork inherits the parent model and rejects model or effort overrides. Guidance therefore tells Codex to use `fork_turns: "none"` (or a positive partial turn count such as `"3"`) when passing `model` or `reasoning_effort`, and to make the task message self-contained. @@ -81,14 +81,14 @@ Custom `injectionPrompt` text can use all four placeholders: | `{{roster}}` | The resolved picker-visible, surface-compatible roster | | `{{fallback}}` | The configured global fallback guidance | -The built-in v2 guidance has a 700-character budget. If it would exceed the budget, CodexCommander drops +The built-in V2 guidance has a 700-character budget. If it would exceed the budget, CodexCommander drops the roster first rather than truncating the core spawn instructions. Built-in guidance fires only when a preferred model, eligible roster, or fallback chain resolves. A configured `injectionModel` is sufficient to render a custom prompt; if a bare value cannot resolve uniquely, `{{model}}` expands to an empty string. -On v1, CodexCommander injects only the upstream-style proactive delegation guidance at `max` or `ultra` -effort. It does not add a preferred model, roster, fallback list, or custom prompt on v1. +On V1, CodexCommander injects only the upstream-style proactive delegation guidance at `max` or `ultra` +effort. It does not add a preferred model, roster, fallback list, or custom prompt on V1. The default-off `syncCodexSubagentDefaults` option is separate from guidance. When CodexCommander owns active Codex routing, sync or restart can write the selected values as marker-owned @@ -117,7 +117,7 @@ normal heterogeneous fallback chain. ## V2 task delivery -Codex may send a v2 native-to-routed child task only as backend-encrypted `encrypted_content`. That +Codex may send a V2 native-to-routed child task only as backend-encrypted `encrypted_content`. That payload can be read by the native ChatGPT backend, but not by an external provider. This is the known [#92 limitation](https://github.com/pavelhov/CodexCommander/issues/92). @@ -133,7 +133,7 @@ CodexCommander fails safely instead of forwarding an empty or unreadable task: | Policy | Behavior | | --- | --- | -| `"encrypted"` (default) | Preserves ChatGPT's reserved encrypted collaboration schema and the fail-closed behavior above. Use native ChatGPT workers or v1 for external workers. | +| `"encrypted"` (default) | Preserves ChatGPT's reserved encrypted collaboration schema and the fail-closed behavior above. Use native ChatGPT workers or V1 for external workers. | | `"plaintext"` | Experimental mixed-provider V2 compatibility. It changes only V2 **task-message delivery** so a routed provider can read the delegated task; it is not a general key or credential setting. For ChatGPT parents, CodexCommander presents a non-reserved plaintext collaboration namespace and restores the canonical namespace on the client-facing response. For routed parents, it marks only completed V2 message calls as plaintext. Both paths activate Codex's plaintext V2 handler, while its graph, mailbox, wait, follow-up, and completion lifecycle remain native. | The plaintext decision is made when the parent tool schema is created, before the worker model is @@ -153,8 +153,8 @@ switching an active conversation in place. ### GUI -- **Dashboard** → first stat cell: choose **v1**, **base**, or **v2**. -- **Models** → **Current behavior** → **Collaboration**: choose **Reliable v1**, **Codex native** (base/default semantics), or **Concurrent v2**. +- **Dashboard** → first stat cell: choose **V1**, **base**, or **V2**. +- **Models** → **Current behavior** → **Collaboration**: choose **Reliable V1**, **Codex native** (base/default semantics), or **Concurrent V2**. - **Subagents** → **Agent Command Center**: - **Configured Roster** chooses and orders the five model overrides advertised first to `spawn_agent`. Drag rows, use the arrow buttons, or press Alt + /. The card @@ -237,15 +237,15 @@ curl -X PUT http://localhost:10100/api/injection-model \ No. Guidance can recommend a model, and native-default sync can provide a Codex default, but the main agent still decides whether to delegate. -### Why did my v2 child use the parent model? +### Why did my V2 child use the parent model? -A full-history v2 fork inherits the parent model. Use a spawn that sets `fork_turns` to `"none"` or +A full-history V2 fork inherits the parent model. Use a spawn that sets `fork_turns` to `"none"` or a positive partial count before passing a model or effort override. -### Why is a configured model missing from the v2 roster? +### Why is a configured model missing from the V2 roster? It may be picker-hidden, outside the five-model display limit, missing from the catalog, or pinned -to v1. A `"v2"`, `null`, or absent surface value is eligible; a real `"v1"` pin is not. +to V1. A `"v2"`, `null`, or absent surface value is eligible; a real `"v1"` pin is not. ### Does V2 make Luna available immediately? @@ -271,7 +271,7 @@ make ChatGPT show **stopped unexpectedly**. A pending catalog or uninjected mana Yes, with **V2 message delivery → Plaintext compatibility** and a fresh session. The policy keeps the V2 lifecycle but makes that parent's delegated messages plaintext. Leave delivery encrypted for -the native-only confidentiality contract, or use Reliable v1 for the established cross-provider surface. +the native-only confidentiality contract, or use Reliable V1 for the established cross-provider surface. ### Reasoning effort diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 4830332c91..2fbbac0421 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -61,7 +61,7 @@ the browser or password manager's decision. | Area | What it does | | --- | --- | -| **Dashboard summary** | Multi-agent mode, online state, version, uptime, provider count, 30-day token total, active providers, and available native/routed models. | +| **Dashboard summary** | Multi-agent mode, online state, version, uptime, provider count, 30-day token total and estimated list-price cost, active providers, and available native/routed models. | | **Sub-agent delegation** | Choose a native or routed model and optional reasoning effort shared by CodexCommander delegation guidance and the separate native-default opt-in. This is not a proxy-side per-spawn router; see below. | | **Sidecars** | Choose the web-search model and effort plus the vision-description model. Changes apply on the next request. | | **Maintenance** | Resync the Codex model catalog and inspect project-local config bypass warnings. | @@ -72,7 +72,7 @@ the browser or password manager's decision. | **Add provider** | Search registry-backed presets for account login, API-key services, local servers, or a custom endpoint. A query searches Accounts, Free and Paid together while the tabs remain useful for browsing. | | **Codex Auth** | Add ChatGPT/Codex pool accounts, select the next-session account, refresh 5h / weekly / 30d quotas, enable or disable quota auto-switch, set its 1–100% threshold, and configure transient-failure failover. | | **Subagents** | Open the **Agent Command Center** to choose and order the five models advertised to `spawn_agent`, search the current catalog, and configure Run Policy for protocol, V2 delivery, guidance, fallback, and thread limits. Saved entries that are not advertised are reported explicitly. Its status distinguishes saved configuration, the generated on-disk catalog, and the roster loaded by current Codex workers. | -| **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose **Reliable v1**, **Codex native**, or **Concurrent v2**, and configure the v2 thread limit. The Current behavior card reports context as **Uncapped**, **Limited**, or **Mixed limits**. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. Each routed-provider row reports **Auto-discovery on** or **Static catalog only** and links to the owning Provider setting. | +| **Models** | Toggle native GPT and routed models, set provider allowlists and context caps, choose **Reliable V1**, **Codex native**, or **Concurrent V2**, and configure the V2 thread limit. The Current behavior card reports context as **Uncapped**, **Limited**, or **Mixed limits**. Configured providers stay visible as zero-model groups when discovery is off or returns no rows. Each routed-provider row reports **Auto-discovery on** or **Static catalog only** and links to the owning Provider setting. | | **Client Apps** | Inspect configured and available local clients, apply or remove managed config where supported, review backups, and reach Codex, Claude Code/Desktop, Grok Build, OpenCode and the file-managed clients without treating providers as clients. | | **API Access** | Issue and manage keys that authenticate other apps to the CodexCommander proxy. Provider credentials remain under Providers. | | **Logs** | Auto-refresh recent requests with tokens, requested → sent outbound effort, resolved model, provider, status, request id, duration, and error details. The detail view includes the exact sent reasoning wire field when the adapter emits one. “Sent” is what CodexCommander serialized; it does not prove that the provider accepted, honored, or applied that effort. Filter by opaque conversation/session id (when the client sends one) to total tokens and estimated list-price cost for the currently loaded Logs ring. | @@ -87,9 +87,12 @@ addressable instead: `#dashboard` opens Overview, and `#dashboard/providers` and `#dashboard/models` open the other two. Reload, bookmark, and Back all keep the section you were on. **Logs** works the same way with `#logs` and `#logs/debug`. -Cost values in **Logs** and **Usage** are API list-price equivalents calculated from reported tokens. -They are not billing receipts or evidence of an actual charge; subscription usage or provider credits -may apply instead. +Cost values in **Dashboard**, **Logs**, and **Usage** are API list-price equivalents calculated from +reported tokens. They are not billing receipts or evidence of an actual charge; subscription usage +or provider credits may apply instead. The Dashboard's **Plan & quota** section shows +provider-reported limits (5-hour / weekly / monthly windows), the provider plan, and observed +reference spend versus published caps — always labeled as provider-reported estimates, never billed +spend. ## Model visibility @@ -139,7 +142,7 @@ available with the same worker-interruption caveat as the dashboard fallback. The Dashboard's **Sub-agent delegation** picker stores `injectionModel` and, optionally, `injectionEffort`. **CodexCommander multi-agent guidance** independently controls the delegation -instructions that use those values. On eligible v2 turns, that guidance tells the parent +instructions that use those values. On eligible V2 turns, that guidance tells the parent agent which exact model and reasoning effort to pass to `spawn_agent`; clearing the model also clears the stored effort. @@ -153,10 +156,10 @@ than overwritten, so they may continue to override the requested defaults. Neither control is a proxy-side cross-model spawn router. CodexCommander guidance asks Codex to pass overrides to `spawn_agent`; native `[agents]` defaults apply only when Codex creates a new task after they have been synchronized. See -[Sub-agent Surface](/guides/sub-agent-surface/) for the canonical v1/base/v2 behavior. +[Sub-agent Surface](/guides/sub-agent-surface/) for the canonical V1/base/V2 behavior. ::: -The spawn override guarantee applies to the **built-in** v2 guidance text. A custom +The spawn override guarantee applies to the **built-in** V2 guidance text. A custom `injectionPrompt` replaces that text entirely and must include `{{model}}` and `{{effort}}` placeholders (and optionally `{{roster}}`) or those values will not appear in the injected guidance. @@ -232,7 +235,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | `GET /api/codex-catalog/status` · `POST /api/codex-catalog/apply` | Read catalog, routing, and worker activation evidence. The guarded Apply endpoint reconciles pending catalog or managed-routing state, then may force-restart only verified stale workers behind a desired-revision fence and explicit interruption confirmation. For an already-converged stale worker it is an advanced fallback that may make ChatGPT show **stopped unexpectedly**. A browser GUI session also needs the one-time launch authorization described above. | | `GET` / `PUT /api/sidecar-settings` | Read or set search/vision sidecar model settings. | | `GET` / `PUT /api/injection-model` | Read or set the shared sub-agent model/effort selection and the independent guidance/native-default switches. | -| `GET` / `PUT /api/v2` | Read or set the surface mode, Codex feature flag, and v2 thread limit. | +| `GET` / `PUT /api/v2` | Read or set the collaboration protocol, Codex feature flag, and V2 thread limit. | | `GET /api/providers` · `POST /api/providers` · `PATCH /api/providers?name=...` · `DELETE /api/providers?name=...` | List, add/replace, enable/disable, set the default, or remove providers. `PATCH` uses standalone `{ "setDefault": true }` on an enabled provider; `POST` may include `setDefault` when creating/replacing (also enabled-only). Deleting the current default reassigns to the first remaining enabled provider when one exists; otherwise the API returns `409` with `code: "last_provider"` and keeps the current default. | | `GET /api/models` · `PUT /api/disabled-models` | List native/routed model rows and update the shared disabled-model set. | | `GET /api/selected-models` · `PUT /api/model-visibility` | Read provider allowlists and atomically change the final visibility of one model or provider group. | diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 24aa111b04..220adf013f 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -11,7 +11,7 @@ These commands control agent policy and routing, inspect the live proxy, and con Manage the headless multi-agent roster, effort caps, prompt injection, fallback, and sidecar settings. Use `status` for the current policy. See [Sub-agent surfaces](/guides/sub-agent-surface/) for how -surface modes, delegation, effort, and fallback behavior fit together. +the collaboration protocol, delegation, effort, and fallback behavior fit together. ```bash ccx agent subagents set ark/model-a,openai/gpt-5.5 @@ -19,17 +19,17 @@ ccx agent subagents set ark/model-a,openai/gpt-5.5 ### `ccx v2 |threads >` -Manage the Codex `multi_agent_v2` feature flag and the three-state multi-agent surface mode. +Manage the Codex `multi_agent_v2` feature flag and the three-state collaboration protocol. | Subcommand | Action | | --- | --- | -| `status` (default) | Report the current v2 flag, multi-agent mode, and thread concurrency. | +| `status` (default) | Report the current V2 flag, multi-agent mode, and thread concurrency. | | `on` | Enable the `multi_agent_v2` feature and resync the catalog. | | `off` | Disable the `multi_agent_v2` feature and resync the catalog. | -| `mode v1` | Force all models to v1, disable native v2, and preserve the active thread limit. | +| `mode v1` | Force all models to V1, disable native V2, and preserve the active thread limit. | | `mode default` | Respect upstream model surface pins. | -| `mode v2` | Force all models to v2, enable native v2, and preserve the active thread limit. | -| `threads ` | Set the active v1/v2 thread limit to an integer of at least 1. | +| `mode v2` | Force all models to V2, enable native V2, and preserve the active thread limit. | +| `threads ` | Set the active V1/V2 thread limit to an integer of at least 1. | ```bash ccx v2 status @@ -84,6 +84,12 @@ Inspect proxy requests, usage, storage, memory, and debug data. The direct alias ccx observe usage --range 30d --json ``` +If the proxy cannot read its usage log (a genuine read, stat, or schema +failure), the underlying `GET /api/usage` responds `503` with +`{ "error": "read_failed", "range", "surface" }` and `ccx usage` reports the +error instead of printing a zeroed report. A missing usage log is not an +error — it still prints an empty (zeroed) report. + ### `ccx debug ` Read or change runtime debug overrides through the running proxy's management API. diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 03eb2b7752..a2db6c72fb 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -10,24 +10,24 @@ routes, and limits delegated work. | Field | Type | Default | Meaning | | --- | --- | --- | --- | -| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` stamps every catalog model as v1; `v2` stamps every model as v2. `default` restores upstream pins (Sol/Terra v2, Luna v1) and otherwise follows the native `multi_agent_v2` flag. After changing it, Apply replaces a running worker; then start a new task for the session-bound tool shape. | +| `multiAgentMode?` | `"v1" \| "default" \| "v2"` | `"default"` | `v1` stamps every catalog model as V1; `v2` stamps every model as V2. `default` restores upstream pins (Sol/Terra V2, Luna V1) and otherwise follows the native `multi_agent_v2` flag. After changing it, Apply replaces a running worker; then start a new task for the session-bound tool shape. | | `multiAgentV2MessageDelivery?` | `"encrypted" \| "plaintext"` | `"encrypted"` | V2 task-message delivery only, not credential encryption. `encrypted` preserves ChatGPT's reserved backend contract and native-only ciphertext guard. `plaintext` opts subsequent V2 parent requests into experimental mixed-provider compatibility; all delegated messages from that parent become plaintext, and routed parents receive the stock Codex plaintext marker on message-bearing collaboration calls. Start a new task after changing it; it does not dirty the catalog or need Apply. | | `subagentModels?` | `string[]` | `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.4-mini` | Up to five bare native, account-qualified `/`, or routed `provider/model` ids advertised first in the sub-agent picker. The dashboard preserves configured exact selectors, including account-qualified choices, and reports which saved entries are advertised or excluded. Use `ccx agent subagents set` or edit the configuration for choices that are not in the current catalog. An explicit empty list is preserved. | -| `injectionModel?` | `string` | — | Preferred native or routed sub-agent model used in proxy-authored v2 delegation guidance. | +| `injectionModel?` | `string` | — | Preferred native or routed sub-agent model used in proxy-authored V2 delegation guidance. | | `injectionEffort?` | `string` | — | Preferred effort (`low` through `ultra`), meaningful only with `injectionModel`. | -| `injectionPrompt?` | `string` | — | Replaces the built-in v2 guidance body. Supports `{{model}}`, `{{effort}}`, `{{roster}}`, and `{{fallback}}`. A configured `injectionModel` is sufficient to render the custom prompt. | -| `multiAgentGuidanceEnabled` | `boolean` | `true` | Controls only CodexCommander-authored v1/v2 developer guidance; it does not change native agent defaults, tools, routing, rosters, or effort caps. | +| `injectionPrompt?` | `string` | — | Replaces the built-in V2 guidance body. Supports `{{model}}`, `{{effort}}`, `{{roster}}`, and `{{fallback}}`. A configured `injectionModel` is sufficient to render the custom prompt. | +| `multiAgentGuidanceEnabled` | `boolean` | `true` | Controls only CodexCommander-authored V1/V2 developer guidance; it does not change native agent defaults, tools, routing, rosters, or effort caps. | | `syncCodexSubagentDefaults?` | `boolean` | `false` | Opt into writing `injectionModel` and optional `injectionEffort` as Codex's native defaults during sync/restart. Requires `injectionModel`. | | `subagentModelFallback?` | `string[]` | `[]` | Priority-ordered global fallback models for spawned child turns. | | `subagentModelFallbackPollMs?` | `number` | `60000` | Availability-probe cache interval. Values below 1000 ms fall back to the default. | -| `effortCap?` | `string` | — | Hard ceiling for qualifying v2 main turns and marked spawned-child turns. Accepts `low` through `ultra`. | +| `effortCap?` | `string` | — | Hard ceiling for qualifying V2 main turns and marked spawned-child turns. Accepts `low` through `ultra`. | | `subagentEffortCap?` | `string` | — | Additional ceiling for spawned-child turns only. When both caps apply, the lower wins. | Manage the surface with the dashboard or `ccx v2 status|on|off|mode |threads `. Mode, protocol, and thread changes update managed Codex boot configuration. If a Codex worker is already running, choose **Apply agent catalog** to replace it, then start a new task. `maxConcurrentThreadsPerSession` is a `PUT /api/v2` field, not a `config.json` key; `ccx v2 threads ` writes `max_concurrent_threads_per_session` under -`[features.multi_agent_v2]` in Codex's `$CODEX_HOME/config.toml` after v2 is enabled. +`[features.multi_agent_v2]` in Codex's `$CODEX_HOME/config.toml` after V2 is enabled. The management API exposes `GET`/`PUT /api/v2`, `/api/injection-model`, `/api/effort-caps`, `/api/subagent-models`, and `/api/subagent-model-fallback`. Injection-model updates are partial; @@ -44,22 +44,22 @@ loudly when the installed Codex build does not know the flag yet. ## Roster and guidance -The effective v2 roster is the configured, picker-visible, priority-sorted first five models that -are compatible with v2 and present in the injected catalog. V2 eligibility treats an explicit `"v2"`, +The effective V2 roster is the configured, picker-visible, priority-sorted first five models that +are compatible with V2 and present in the injected catalog. V2 eligibility treats an explicit `"v2"`, `null`, or absent upstream pin as eligible; a real `"v1"` pin is excluded. Excluded entries remain in configuration so they can become eligible later. Surface detection uses tool shape. A namespaced `spawn_agent` with `send_input`, `resume_agent`, or -`close_agent` is v1. A flat `spawn_agent` with `send_message`, `followup_task`, `interrupt_agent`, or -`list_agents` is v2. +`close_agent` is V1. A flat `spawn_agent` with `send_message`, `followup_task`, `interrupt_agent`, or +`list_agents` is V2. V1 guidance is proactive text only at `max` or `ultra`. V2 receives a proxy-authored developer -message only when a preferred model, eligible roster, or fallback chain exists. Built-in v2 guidance +message only when a preferred model, eligible roster, or fallback chain exists. Built-in V2 guidance has a 700-character budget and drops the roster first if necessary. Guidance is deduplicated across replay prefixes and inserted before a trailing `compaction_trigger`. `injectionModel` and `injectionEffort` are advisory unless native-default sync is enabled. The built-in -v2 text asks Codex to pass supported model/effort overrides to `spawn_agent` with +V2 text asks Codex to pass supported model/effort overrides to `spawn_agent` with `fork_turns: "none"`. A custom `injectionPrompt` substitutes missing values with an empty string. ## Native Codex default sync @@ -100,7 +100,7 @@ session uses the ordinary heterogeneous chain while preserving V2 lifecycle sema ## Effort caps -Caps apply only to the v2 collaboration feature: a main turn qualifies when its tools expose v2, +Caps apply only to the V2 collaboration feature: a main turn qualifies when its tools expose V2, while a child qualifies when it carries exact codex-rs `x-openai-subagent: collab_spawn` or `"subagent_kind": "thread_spawn"` markers in `x-codex-turn-metadata`, even if leaf tools no longer expose collaboration. V1 main turns, `multiAgentMode: "v1"`, compaction, @@ -110,5 +110,5 @@ Caps only lower effort. They snap to the highest advertised rung at or below the no effort control or no supported rung fits, CodexCommander removes the effort and lets the provider default apply. `max` and `ultra` are accepted, while the dashboard offers `low` through `xhigh`. -For a beginner-oriented explanation of v1, default, and v2 behavior, see +For a beginner-oriented explanation of V1, default, and V2 behavior, see [Sub-agent surfaces](/guides/sub-agent-surface/). diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 4c5b63911c..eb002ac290 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -84,7 +84,7 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`, } ``` -For strategy behavior, retryable failures, cooldowns, encrypted v2 task limits, and management +For strategy behavior, retryable failures, cooldowns, encrypted V2 task limits, and management commands, see [Combos](/guides/combos/). ## Routing policy profiles (`config.routingProfiles`) diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 6da492129a..288273c226 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -91,7 +91,7 @@ route-specific results rather than repeating this table. | Method and path | Purpose | Notable errors | | --- | --- | --- | -| `GET, PUT /api/v2` | Read or change the agent protocol, V2 task-message delivery, and thread settings. A protocol/mode/thread boot-config change needs **Apply agent catalog** to replace a running worker, then a new task for its session-bound tool shape. `multiAgentV2MessageDelivery` accepts `plaintext` or the `encrypted` default; sending `encrypted` or `null` removes the explicit plaintext override. Delivery changes need only a new task and do not dirty the catalog. `maxConcurrentThreadsPerSession: null` restores the Codex default | 400 invalid settings; 502 transition or persistence failure | +| `GET, PUT /api/v2` | Read or change the agent protocol, V2 task-message delivery, and thread settings. A protocol/thread boot-config change needs **Apply agent catalog** to replace a running worker, then a new task for its session-bound tool shape. `multiAgentV2MessageDelivery` accepts `plaintext` or the `encrypted` default; sending `encrypted` or `null` removes the explicit plaintext override. Delivery changes need only a new task and do not dirty the catalog. `maxConcurrentThreadsPerSession: null` restores the Codex default | 400 invalid settings; 502 transition or persistence failure | | `GET, PUT /api/injection-model` | Read or set the preferred guidance model, effort, prompt, and guidance settings; this is advisory unless native-default sync is enabled | 400 invalid model, effort, or body | | `GET, PUT /api/effort-caps` | Read or set global and sub-agent reasoning-effort ceilings | 400 invalid ladder value | | `GET, PUT /api/subagent-models` | Read or order up to five requested `spawn_agent` quick picks; this does not force routing. Responses keep the persisted `chosen` list separate from the effective `advertised` list, report any `excluded` choices, and include additive `activation` evidence for the desired config, on-disk catalog, and running Codex worker | 400 invalid list or more than five models | @@ -166,7 +166,7 @@ See [Combos](/guides/combos/) for target strategies, cooldowns, aliases, and rou | `GET /api/debug/usage-logs` | Read bounded usage-debug entries | — | | `GET /api/debug/injection-logs` | Read bounded guidance-injection debug entries | — | | `GET /api/claude/inbound-debug` | Read Claude inbound debug state and entries | — | -| `GET /api/usage` | Summarize usage by range and client surface | Returns an `error: "read_failed"` summary if storage cannot be read | +| `GET /api/usage` | Summarize usage by range and client surface | 503 `{ error: "read_failed", range, surface }` if the usage log cannot be read; a missing log file still returns 200 with a zeroed summary | | `GET /api/storage` | Scan Codex storage usage by bucket | Returns an `error: "scan_failed"` payload on scan failure | | `POST /api/storage/cleanup/preview` | Preview archived-session cleanup and return a binding digest | 400 `invalid_json` or `invalid_percent` | | `POST /api/storage/cleanup` | Quarantine or permanently remove the previewed archived set | 400 invalid input; 409 stale/busy/referenced state; 500 filesystem/database failure | diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 24d831eb46..dc51e48764 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -208,7 +208,7 @@ conversation. | Route type | Behavior | | --- | --- | | Canonical ChatGPT or official OpenAI route | Forwards the request to the native `/responses/compact` endpoint with the resolved account and model authentication | -| Other routed model | Runs an internal, non-streaming, no-tools compaction turn with a `compaction_trigger`; requires exactly one synthetic `compaction` item whose `encrypted_content` is a `ccx1:` envelope; decodes that summary into v1 replacement history | +| Other routed model | Runs an internal, non-streaming, no-tools compaction turn with a `compaction_trigger`; requires exactly one synthetic `compaction` item whose `encrypted_content` is a `ccx1:` envelope; decodes that summary into V1 replacement history | Native compact responses are buffered with a 32 MiB maximum, including responses whose declared `Content-Length` already exceeds the limit. The compact-specific failures include: @@ -255,7 +255,7 @@ Errors use the client dialect's envelope where needed, but these status/code mea | 401 | `authentication_error` | A required proxy admission credential is missing or invalid | | 403 | `origin_rejected` | A Responses/OpenAI data-plane request or WebSocket upgrade came from a disallowed origin | | 503 | `combo_unavailable` | Every target in the selected combo is unavailable, in cooldown, disabled, or otherwise ineligible | -| 400 | `unreadable_encrypted_agent_task` | An encrypted v2 worker task has no eligible native ChatGPT target that can consume it | +| 400 | `unreadable_encrypted_agent_task` | An encrypted V2 worker task has no eligible native ChatGPT target that can consume it | | 426 | `upgrade_required` | The Responses WebSocket transport is disabled or the upgrade failed; use HTTP | Anthropic-origin failures are rendered in Anthropic's error envelope, so the origin rejection is a @@ -270,7 +270,7 @@ provider. Some agent hooks place plaintext control text in an `encrypted_content` slot. For compatibility, the proxy separates that plaintext into text parts while retaining any structurally valid Fernet runs unchanged. If an `agent_message` loses all encrypted parts during -that repair, it becomes a normal user message. If a current v2 task remains genuinely encrypted +that repair, it becomes a normal user message. If a current V2 task remains genuinely encrypted but the selected routed target cannot read native ChatGPT ciphertext, CodexCommander fails with `unreadable_encrypted_agent_task` instead of sending unreadable bytes to that provider. See [Sub-agent Surface](/guides/sub-agent-surface/) for the client behavior around worker tasks. diff --git a/gui/bun.lock b/gui/bun.lock index a59c072eee..6c1b0060e1 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -8,6 +8,7 @@ "@tanstack/react-virtual": "^3.14.5", "react": "^19.2.7", "react-dom": "^19.2.7", + "zustand": "^5.0.15", }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -410,6 +411,8 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "zustand": ["zustand@5.0.15", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], diff --git a/gui/package.json b/gui/package.json index 9c9d2a03ff..e9977793f4 100644 --- a/gui/package.json +++ b/gui/package.json @@ -16,7 +16,8 @@ "dependencies": { "@tanstack/react-virtual": "^3.14.5", "react": "^19.2.7", - "react-dom": "^19.2.7" + "react-dom": "^19.2.7", + "zustand": "^5.0.15" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/gui/src/components/ComboWorkspace.tsx b/gui/src/components/ComboWorkspace.tsx index 66cd751f64..e8a5ac383c 100644 --- a/gui/src/components/ComboWorkspace.tsx +++ b/gui/src/components/ComboWorkspace.tsx @@ -147,7 +147,7 @@ export default function ComboWorkspace({ - {item.model} + {item.model} {item.targets.length === 1 ? t("cws.targetCountOne") diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index 66c230d5f5..f5f6b9894a 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -22,7 +22,7 @@ import { UnsavedLeaveDialog } from "./ProviderDialogs"; import type { ProviderQuotaReportView } from "../../provider-workspace/report"; import type { AccountLoadState, ProviderModelUsageRow, ProviderUsageTotals, OAuthAccountRow, ApiKeyRow, LoginHint, ProviderAuthHandlers, ProviderUpdatePatch } from "./types"; import type { ProviderRouteTab } from "../../provider-route"; -import { accountNeedsReauth } from "../../oauth-health-display"; +import { accountNeedsReauth as accountNeedsReauthHealth } from "../../oauth-health-display"; type Tab = ProviderRouteTab; @@ -31,6 +31,9 @@ export default function ProviderDetails({ usageTotals, modelUsage, quotaReport, + quotaUnavailableReason, + onRetryQuota, + accountNeedsReauth, availableModels, hasLiveModels, selectedModels, @@ -62,6 +65,10 @@ export default function ProviderDetails({ usageTotals?: ProviderUsageTotals; modelUsage?: ProviderModelUsageRow[]; quotaReport?: ProviderQuotaReportView; + quotaUnavailableReason?: string; + onRetryQuota?: () => void; + /** True only for a genuine active-account reauth need (never quota-derived). */ + accountNeedsReauth?: boolean; availableModels: string[]; /** Server-reported live-catalog provenance; see filterModels(). */ hasLiveModels: boolean; @@ -257,6 +264,9 @@ export default function ProviderDetails({ connectionIdentity={connectionIdentity} usageTotals={usageTotals} quotaReport={quotaReport} + quotaUnavailableReason={quotaUnavailableReason} + onRetryQuota={onRetryQuota} + accountNeedsReauth={accountNeedsReauth} oauthEmail={oauthEmail} oauth={oauth} onEditSettings={() => switchTab("settings")} @@ -269,8 +279,8 @@ export default function ProviderDetails({ ? () => { if (item.authMode === "oauth") { const rows = accounts ?? []; - const active = rows.find(a => a.active && accountNeedsReauth(a)) - ?? rows.find(a => accountNeedsReauth(a)); + const active = rows.find(a => a.active && accountNeedsReauthHealth(a)) + ?? rows.find(a => accountNeedsReauthHealth(a)); void authHandlers?.onReauth(item.name, active?.id); return; } @@ -291,7 +301,7 @@ export default function ProviderDetails({ selectedModels={selectedModels} modelsLoading={modelsLoading} modelsLoadFailed={modelsLoadFailed} - reauthRequired={(accounts ?? []).some(account => account.active && accountNeedsReauth(account))} + reauthRequired={(accounts ?? []).some(account => account.active && accountNeedsReauthHealth(account))} onRetryModels={onRetryModels} onOpenAccounts={authSurface ? () => switchTab("accounts") : undefined} /> diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index b7a048a6a4..e0f38525df 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -9,6 +9,7 @@ import { IconAlert, IconCheck } from "../../icons"; import { binProviderStatus, type WorkspaceItem } from "../../provider-workspace/catalog"; import { formatRelativeTime, relativeTimeLabelsFromT, formatRequestCount, formatTokenCount } from "../../provider-workspace/usage"; import { accountQuotaFromReport, formatQuotaSourceLabel, type ProviderQuotaReportView } from "../../provider-workspace/report"; +import { quotaUnavailableReasonKey } from "../../quota-unavailable"; import type { ProviderUsageTotals } from "./types"; import { authModeLabel } from "./ProviderRail"; import type { ProviderUpdatePatch } from "./types"; @@ -30,7 +31,8 @@ type ConnectionTestState = { }; export default function ProviderOverview({ - item, usageTotals, quotaReport, oauthEmail, oauth, + item, usageTotals, quotaReport, quotaUnavailableReason, onRetryQuota, oauthEmail, oauth, + accountNeedsReauth = false, apiBase, connectionIdentity, onEditSettings, onViewUsage, onUpdateProvider, onReauthenticate, onCancelLogin, reauthBusy = false, @@ -38,6 +40,11 @@ export default function ProviderOverview({ item: WorkspaceItem; usageTotals?: ProviderUsageTotals; quotaReport?: ProviderQuotaReportView; + /** Provider quota is unavailable (no report); reason drives the notice copy. */ + quotaUnavailableReason?: string; + onRetryQuota?: () => void; + /** True only for a genuine active-account reauth need (never quota-derived). */ + accountNeedsReauth?: boolean; oauthEmail?: string; /** Login state for OAuth summaries that carry no email (e.g. Cursor/Kimi). */ oauth?: { loggedIn?: boolean }; @@ -56,6 +63,22 @@ export default function ProviderOverview({ const timeLabels = relativeTimeLabelsFromT(t); const status = binProviderStatus(item); const needsAttention = Boolean(item.activeNeedsReauth); + /** Genuine account-health reauth need, excluding quota-derived attention. */ + const genuineAccountNeedsReauth = accountNeedsReauth === true; + + // One owner per problem: quota-unavailable reasons never masquerade as auth needs, + // and a genuine browser-reauth need is never hidden by quota state. When a local-CLI + // refresh and a real reauth need coexist, both warnings render (distinct actions). + const localCliQuotaOwnsAttention = + quotaUnavailableReason === "local_cli_refresh_required" + && !genuineAccountNeedsReauth; + const showAuthWarning = needsAttention && !localCliQuotaOwnsAttention; + const reauthQuotaOwnedByAuth = + quotaUnavailableReason === "reauth_required" + && showAuthWarning; + const showQuotaUnavailableNotice = + Boolean(quotaUnavailableReason) + && !reauthQuotaOwnedByAuth; const statusText = status === "ready" ? t("pws.status.connected") : status === "needs-setup" @@ -201,20 +224,35 @@ export default function ProviderOverview({ )} - {quotaReport && ( + {quotaReport ? (

{t("pws.rateLimits")}

- )} + ) : showQuotaUnavailableNotice ? ( +
+

{t("pws.quota.unavailableTitle")}

+
+
+
+ ) : null}

{t("pws.authSummary")}

- {needsAttention ? ( -
+ {showAuthWarning ? ( +
@@ -217,7 +215,6 @@ export default function SubagentRunPolicySection({ })} disabled={saving} label={t("sub.policy.preferred")} - style={{ width: "100%" }} /> {t("sub.delegation.modelHint")}
@@ -236,7 +233,6 @@ export default function SubagentRunPolicySection({ }} disabled={saving} label={t("sub.policy.fallback")} - style={{ width: "100%" }} /> {t("sub.policy.fallbackHint")} @@ -312,7 +308,7 @@ export default function SubagentRunPolicySection({ {policy.fallbackModels.map((model, index) => (
  • {index + 1} - {formatNamespacedModelId(model, t)} + {formatNamespacedModelId(model, t)} diff --git a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx index 69ae3732e7..00d2f203a6 100644 --- a/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx +++ b/gui/src/components/subagents-workspace/SubagentsWorkspace.tsx @@ -337,7 +337,7 @@ export default function SubagentsWorkspace({ - {formatNamespacedModelId(selector, t)} + {formatNamespacedModelId(selector, t)} {/* No map entry (e.g. unsaved draft row) => no claim at all; the sr-only "both" line requires an explicit "both" entry from the server. */} @@ -417,7 +417,7 @@ export default function SubagentsWorkspace({ if (!surface) return null; return (
  • - {formatNamespacedModelId(selector, t)} + {formatNamespacedModelId(selector, t)} {surfaceLabel(surface, t)}
  • ); @@ -469,7 +469,7 @@ export default function SubagentsWorkspace({
  • - {formatNamespacedModelId(selector, t)} + {formatNamespacedModelId(selector, t)} {priority && #{priority}} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index bfe1d16891..a83325f2fc 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -205,6 +205,18 @@ export const de: Record = { "dash.providers": "Anbieter", "dash.tokens30d": "Tokens (30d)", "dash.coverage": "{pct} Abdeckung", + "dash.cost30d": "Geschätzte Kosten (30 Tage)", + "dash.cost30dHint": "API-Listenpreis-Äquivalent — niemals abgerechnete Ausgaben.", + "dash.cost30dCoverage": "{priced} bepreist · {unpriced} unbepreist · {unmetered} ohne Verbrauchswerte", + "dash.planQuota.title": "Tarif & Kontingent", + "dash.planQuota.hint": "Vom Anbieter gemeldete Limits und Schätzungen — niemals abgerechnete Ausgaben.", + "dash.planQuota.loading": "Anbieterkontingent wird geladen…", + "dash.planQuota.empty": "Noch keine Anbieter-Kontingentdaten.", + "dash.planQuota.disclaimer": "Vom Anbieter gemeldete Obergrenzen und lokale Schätzungen — keine Abrechnung.", + "dash.planQuota.referenceIntro": "Veröffentlichte Obergrenzen vs. über diesen Proxy beobachteter Datenverkehr", + "dash.planQuota.unavailable": "Kontingent nicht verfügbar", + "dash.planQuota.manageProvider": "{provider} verwalten", + "dash.planQuota.retry": "Kontingentprüfung wiederholen", "dash.mem.title": "Speicherbeobachtung", "dash.mem.hint": "Schreibgeschützte Laufzeitdiagnose. Beobachteter Speicher ist max(RSS, external, ArrayBuffers), damit Windows-Working-Set-Trimming gebundenen Speicher nicht versteckt.", "dash.mem.rss": "Resident Set (RSS)", @@ -283,7 +295,7 @@ export const de: Record = { "dash.syncCodexSubagentDefaults": "Auch als Codex-Standard speichern", "dash.syncCodexSubagentDefaultsHint": "Eingeschaltet wird die Wahl von oben in Codex' eigene Konfiguration geschrieben, sodass auch neue Aufgaben mit diesem Modell starten. Ausgeschaltet wird sie nur hier gemerkt. Wirksam beim nächsten Sync oder Neustart; deine selbst geschriebenen [agents]-Einstellungen bleiben unberührt.", "dash.multiAgentGuidance": "Roster als Worker-Anleitung verwenden", - "dash.multiAgentGuidanceHint": "Nennt geeignete Roster-Modelle in der CodexCommander-Anleitung. Erzwingt keine Delegierung und routet nicht jeden Kindlauf; v1 zeigt die Anleitung nur bei max oder ultra.", + "dash.multiAgentGuidanceHint": "Nennt geeignete Roster-Modelle in der CodexCommander-Anleitung. Erzwingt keine Delegierung und routet nicht jeden Kindlauf; V1 zeigt die Anleitung nur bei max oder ultra.", "dash.injectionNone": "Keine", "dash.injectionEffortLabel": "Reasoning-Aufwand", "dash.injectionEffortNone": "Modell-Standard", @@ -427,9 +439,9 @@ export const de: Record = { "models.collaborationTitle": "Zusammenarbeit", "models.change": "Ändern", "models.newSessionsOnly": "Speichern, anwenden, dann neue Aufgabe starten", - "models.modeLabel_v1": "Zuverlässiges v1", + "models.modeLabel_v1": "Zuverlässiges V1", "models.modeLabel_default": "Codex-nativ", - "models.modeLabel_v2": "Parallel v2", + "models.modeLabel_v2": "Parallel V2", "models.modeStatus_v1": "Flexible Modellauswahl", "models.modeStatus_default": "Codex-Standards", "models.modeStatus_v2": "Parallele Agentensitzungen", @@ -438,7 +450,7 @@ export const de: Record = { "models.modeDesc_v2": "Den neueren parallelen Sub-Agent-Workflow ausführen.", "models.modeOptionDesc_v1": "Klassische Werkzeuge mit direkter Modellwahl bei jedem Start.", "models.modeOptionDesc_default": "Den vorgelagerten Modell-Pins und dem nativen Codex-Feature folgen.", - "models.modeOptionDesc_v2": "Den neueren parallelen Workflow für jedes Modell erzwingen.", + "models.modeOptionDesc_v2": "Den neueren parallelen Workflow für jedes Modell verwenden.", "models.contextTitle": "Kontext", "models.contextStateUncapped": "Unbegrenzt", "models.contextStateLimited": "Begrenzt auf {value}", @@ -489,14 +501,14 @@ export const de: Record = { "models.contextCapLabel": "Kontext-Limit", "models.v2Label": "Sub-Agent", "models.shadowCallOriginal": "⚠ {models} →", - "models.v2DocsLink": "Was ist v1 / v2?", - "models.v2Mode_v1": "v1", + "models.v2DocsLink": "Was ist V1 / V2?", + "models.v2Mode_v1": "V1", "models.v2Mode_default": "base", - "models.v2Mode_v2": "v2", - "models.v2ModeDesc_v1": "Alle Modelle → v1-Oberfläche", - "models.v2ModeDesc_default": "Upstream-Standard (sol/terra=v2, luna=v1)", - "models.v2ModeDesc_v2": "Alle Modelle → v2-Oberfläche", - "models.v2Help": "Steuert die Multi-Agent-Oberfläche für alle Modelle.\n\nv1: Klassischer Single-Thread-Agent. Jedes Modell nutzt die v1-Collab-Oberfläche.\nbase: Upstream-Standard — sol/terra nutzen v2, luna v1, andere folgen dem Codex-Feature-Flag.\nv2: Multi-Thread-Agent mit spawn_agent. Jedes Modell nutzt die v2-Collab-Oberfläche.\n\nZuerst speichern. Läuft ein Codex-Worker, mit Anwenden ersetzen und danach für sitzungsgebundene Tool-Schemas eine neue Aufgabe starten. Eine neue Aufgabe allein lädt keinen bestehenden Worker neu.", + "models.v2Mode_v2": "V2", + "models.v2ModeDesc_v1": "Alle Modelle → V1-Protokoll", + "models.v2ModeDesc_default": "Upstream-Standard (sol/terra=V2, luna=V1)", + "models.v2ModeDesc_v2": "Alle Modelle → V2-Protokoll", + "models.v2Help": "Steuert das Multi-Agent-Protokoll für alle Modelle.\n\nV1: Klassischer Single-Thread-Agent. Jedes Modell nutzt das V1-Protokoll.\nbase: Upstream-Standard — sol/terra nutzen V2, luna V1, andere folgen dem Codex-Feature-Flag.\nV2: Multi-Thread-Agent mit spawn_agent. Jedes Modell nutzt das V2-Protokoll.\n\nZuerst speichern. Läuft ein Codex-Worker, mit Anwenden ersetzen und danach für sitzungsgebundene Tool-Schemas eine neue Aufgabe starten. Eine neue Aufgabe allein lädt keinen bestehenden Worker neu.", "dash.multiAgent": "Sub-Agent", "models.v2Conflict": "[agents] max_threads ist gesetzt — codex verweigert den Start; entferne es aus config.toml", "models.v2Applied": "Sub-Agent-Modus gespeichert. Laufenden Worker mit Anwenden ersetzen, dann für sitzungsgebundene Änderungen eine neue Aufgabe starten.", @@ -568,7 +580,7 @@ export const de: Record = { "models.allowlistHint": "Nur geprüfte Modelle gehen in den Katalog (leer = alle). Nützlich für Anbieter mit tausenden Modellen.", "models.selectedCount": "{n} ausgewählt", "sub.pageTitle": "Agenten-Kommandozentrale", - "sub.pageSubtitle": "Wähle beworbene Worker-Modelle, Anleitung und Fallbacks für Kindläufe.", + "sub.pageSubtitle": "Wähle, welche Modelle Sub-Agents verwenden dürfen, welches Modell sie anleitet und die Fallback-Reihenfolge.", "sub.desktopPickerLimit": "Diese Liste steuert spawn_agent, nicht den Modell-Picker von Codex Desktop. Desktop kann geroutete Provider-IDs per Remote-Allowlist ausblenden; verwende Combos → Natives OpenAI-Alias für eine explizite Kompatibilitätszuordnung.", "sub.featured": "Konfigurierter Kader", "sub.noneSelected": "Noch keine Schnellauswahl.", @@ -619,25 +631,25 @@ export const de: Record = { "sub.libraryCount": "{n} Katalogmodelle", "sub.libraryHint": "Jedes Katalogmodell bleibt über seine exakte ID aufrufbar. Der Roster steuert die fünf an spawn_agent beworbenen Modelle, nicht Worker- oder Fallback-Auswahl.", "sub.noMatchingModels": "Keine Modelle passen zu dieser Suche und diesem Filter.", - "sub.policyHint": "Wähle Agentenprotokoll, Worker-Anleitung und Fallbacks für gestartete Kindläufe. Die Richtlinie wird unabhängig vom Roster gespeichert.", + "sub.policyHint": "Lege Agentenprotokoll, Worker-Anleitung und Kind-Fallbacks fest. Wird unabhängig vom Roster gespeichert.", "sub.policy.mode": "Agentenprotokoll", "sub.policy.messageDelivery": "V2-Nachrichtenzustellung", "sub.policy.messageDelivery_encrypted": "Verschlüsselt (nativ)", "sub.policy.messageDelivery_plaintext": "Klartext-Kompatibilität", "sub.policy.messageDeliveryHint_encrypted": "Behält ChatGPTs nativen verschlüsselten Vertrag bei; externe V2-Worker können nicht verfügbar sein.", - "sub.policy.messageDeliveryHint_plaintext": "Experimentell. Aktiviert V2 über mehrere Anbieter; die V2-Aufgabennachrichtenzustellung dieses Elternagenten ist Klartext. Speichern und dann eine neue Aufgabe starten. Für diese reine Zustellungsänderung ist kein Anwenden nötig.", + "sub.policy.messageDeliveryHint_plaintext": "Experimentell. Aktiviert V2 über mehrere Anbieter; die V2-Aufgabennachrichtenzustellung dieses Elternagenten ist Klartext. Für diese reine Zustellungsänderung ist kein Anwenden nötig.", "sub.policy.preferred": "Bevorzugtes Anleitungsmodell", - "sub.policy.noPreferred": "Kein bevorzugtes Modell — Codex wählt aus dem Roster", + "sub.policy.noPreferred": "Kein bevorzugtes Modell", "sub.policy.fallback": "Globaler Kind-Fallback", - "sub.policy.fallbackHint": "Für gestartete Kindläufe nach dem angeforderten Modell und einem Rollen-Fallback; nicht verfügbare oder kontingentierte Kandidaten werden übersprungen.", + "sub.policy.fallbackHint": "Wird probiert, wenn das angeforderte Modell eines Kindes nicht verfügbar ist; kontingentierte Modelle werden übersprungen.", "sub.policy.noFallback": "Kein Fallback", "sub.policy.concurrency": "Thread-Limit", - "sub.policy.concurrencyHint": "V2 zählt Threads inklusive Root, V1 zählt Kind-Threads. Leer setzt den Codex-Standard wieder her. Speichern, bei laufendem Worker anwenden und dann eine neue Aufgabe starten.", + "sub.policy.concurrencyHint": "V2 zählt Threads inklusive Root, V1 zählt Kind-Threads. Leer setzt den Codex-Standard wieder her.", "sub.policy.codexDefault": "Codex-Standard", "sub.policy.increaseConcurrency": "Subagent-Parallelität erhöhen", "sub.policy.decreaseConcurrency": "Subagent-Parallelität verringern", "sub.policy.save": "Änderungen speichern", - "sub.policy.saved": "Ausführungsrichtlinie gespeichert. Laufenden Worker mit Anwenden ersetzen und dann für Protokoll, Thread-Limit und sitzungsgebundene Tool-Schemas eine neue Aufgabe starten. V2-Aufgabennachrichtenzustellung wirkt auf spätere Anfragen; Anleitung und Fallback auf künftige Kindläufe.", + "sub.policy.saved": "Ausführungsrichtlinie gespeichert.", "sub.policy.saveFailed": "Einige Richtlinien-Änderungen konnten nicht gespeichert werden. Deine ungespeicherten Auswahlen werden weiterhin angezeigt.", "sub.policy.loading": "Ausführungsrichtlinie wird geladen…", "sub.policy.retry": "Richtlinie neu laden", @@ -656,8 +668,8 @@ export const de: Record = { "sub.policy.preferredEffortHint": "Reasoning-Stufe, die in der Anleitung genannt wird, wenn das bevorzugte Modell verfügbar ist.", "sub.policy.guidance": "Roster als Worker-Anleitung verwenden", "sub.policy.guidanceHint": "Nennt geeignete Roster-Modelle in der Anleitung. Erzwingt keine Delegierung und routet nicht jeden Kindlauf.", - "sub.policy.compatibilityV2": "Codex-nativ und Parallel v2 können native verschlüsselte V2-Aufgaben senden, die externe Anbieter nicht lesen können (#92). Für V2 über mehrere Anbieter Klartext-Kompatibilität oder Zuverlässiges v1 wählen. Die Protokollwahl aktiviert keinen veralteten Worker.", - "sub.policy.compatibilityV2Plaintext": "Experimentelles V2 über mehrere Anbieter ist aktiviert. Die V2-Aufgabennachrichtenzustellung dieses Elternagenten ist Klartext, auch an native Worker. V2 selbst aktiviert keinen veralteten Codex-Worker; unbekannte Schemas schlagen weiter sicher fehl.", + "sub.policy.compatibilityV2": "V2-Aufgaben sind verschlüsselt; externe Anbieter können sie nicht lesen. Für V2 über mehrere Anbieter Klartext-Kompatibilität oder Zuverlässiges V1 für den etablierten Pfad wählen.", + "sub.policy.compatibilityV2Plaintext": "Experimentelles V2 über mehrere Anbieter ist aktiviert. Die Aufgabennachrichtenzustellung dieses Elternagenten ist Klartext, auch an native Worker.", "sub.policy.subagentCap": "Effort-Obergrenze für Subagenten", "sub.policy.subagentCapHint": "Begrenzt den Effort von Child-Agenten, ohne niedrigere Anforderungen anzuheben.", "sub.filter.label": "Modelle nach Fähigkeit filtern", @@ -751,6 +763,7 @@ export const de: Record = { "logs.metric.effortTitle": "Angefragter Aufwand → exakter von CodexCommander gesendeter Wert; der Anbieter kann ihn dennoch ignorieren", "logs.metric.estimatedCostTitle": "API-Listenpreis-Äquivalent, keine tatsächliche Belastung; bei fehlendem Preisabgleich nicht verfügbar", "usage.cost.total": "API-Listenpreis-Äquivalent (dieser Zeitraum)", + "usage.cost.unavailable": "Nicht verfügbar", "usage.cost.disclaimer": "Kein Abrechnungsbeleg. Stattdessen können Abonnementnutzung oder Anbieter-Guthaben gelten.", "usage.cost.unpricedNote": "{count} Anfragen ohne Preis oder Nutzung ausgeschlossen", "logs.detail.section.basic": "Grundinformationen", @@ -848,7 +861,7 @@ export const de: Record = { "debug.noLines.usage": "Nutzungserfassung ist an, aber es wurde noch nichts erfasst. Sende einen Chat/eine Anfrage über Codex, dann erscheint es hier.", "debug.noLines.injection": "Injektions-Log ist an, aber es wurde noch nichts erfasst. Es erfasst Multi-Agent-Guidance-Injektion und Effort-Cap-Entscheidungen bei Collab- und Sub-Agent-Turns.", "usage.title": "Nutzung", - "usage.subtitle": "Lokale Token-Buchhaltung deines Proxys. Fehlende Nutzung wird nie als Null angezeigt.", + "usage.subtitle": "Anfragen und Tokens, die dein Proxy erfasst hat. Ein fehlgeschlagener Lesevorgang wird nie als Null angezeigt.", "usage.loading": "Lade Nutzungsdaten…", "usage.empty": "Noch keine Nutzung erfasst. Sende eine Anfrage über den Proxy, um Aktivität hier zu sehen.", "usage.loadError": "Nutzungsdaten konnten nicht geladen werden.", @@ -1757,6 +1770,11 @@ export const de: Record = { "pws.metricTokens": "Tokens", "pws.usageUnavailable": "Noch keine Nutzung erfasst.", "pws.rateLimits": "Limits", + "pws.quota.unavailableTitle": "Kontingent nicht verfügbar", + "pws.quota.signInRequired": "Anmeldung erforderlich", + "pws.quota.loginNeedsRefresh": "Anmeldung muss aktualisiert werden", + "pws.quota.upstreamUnavailable": "Vorübergehend nicht verfügbar", + "pws.quota.retry": "Erneut versuchen", "pws.quotaUnavailable": "Keine Kontingentdaten für diesen Provider.", "pws.planLimits": "Plangrenzen und lokale Beobachtungen", "pws.reference.intro": "OpenCode Go bietet keine Live-API für das verbleibende Guthaben. Diese veröffentlichten Obergrenzen stehen neben dem Datenverkehr, den dieser CodexCommander-Proxy beobachtet hat.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d1c63544cd..ac0d866d1d 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -219,6 +219,18 @@ export const en = { "dash.providers": "Providers", "dash.tokens30d": "Tokens (30d)", "dash.coverage": "{pct} coverage", + "dash.cost30d": "Est. cost (30d)", + "dash.cost30dHint": "API list-price equivalent — never billed spend.", + "dash.cost30dCoverage": "{priced} priced · {unpriced} unpriced · {unmetered} unmetered requests", + "dash.planQuota.title": "Plan & quota", + "dash.planQuota.hint": "Provider-reported limits and estimates — never billed spend.", + "dash.planQuota.loading": "Loading provider quota…", + "dash.planQuota.empty": "No provider quota data yet.", + "dash.planQuota.disclaimer": "Provider-reported caps and local estimates — not a billing receipt.", + "dash.planQuota.referenceIntro": "Published caps vs. traffic observed through this proxy", + "dash.planQuota.unavailable": "Quota unavailable", + "dash.planQuota.manageProvider": "Manage {provider}", + "dash.planQuota.retry": "Retry quota check", // memory observability card (read-only /api/system/memory) "dash.mem.title": "Memory observability", "dash.mem.hint": "Read-only runtime diagnostics. Observed memory is max(RSS, external, ArrayBuffers) so Windows working-set trimming does not hide committed retention.", @@ -299,7 +311,7 @@ export const en = { "dash.syncCodexSubagentDefaults": "Also save as a Codex default", "dash.syncCodexSubagentDefaultsHint": "On, the pick above is written into Codex's own config, so new tasks start with that model too. Off, it is remembered only here. It takes effect on the next sync or restart, and your hand-written [agents] settings are left alone.", "dash.multiAgentGuidance": "Use roster as worker guidance", - "dash.multiAgentGuidanceHint": "Names eligible roster models in CodexCommander guidance. It does not force delegation or route every child. On v1, guidance appears only at max or ultra effort.", + "dash.multiAgentGuidanceHint": "Names eligible roster models in CodexCommander guidance. It does not force delegation or route every child. On V1, guidance appears only at max or ultra effort.", "dash.injectionNone": "None", "dash.injectionEffortLabel": "Reasoning effort", "dash.injectionEffortNone": "Model default", @@ -448,9 +460,9 @@ export const en = { "models.collaborationTitle": "Collaboration", "models.change": "Change", "models.newSessionsOnly": "Save, Apply, then start a new task", - "models.modeLabel_v1": "Reliable v1", + "models.modeLabel_v1": "Reliable V1", "models.modeLabel_default": "Codex native", - "models.modeLabel_v2": "Concurrent v2", + "models.modeLabel_v2": "Concurrent V2", "models.modeStatus_v1": "Flexible model selection", "models.modeStatus_default": "Codex defaults", "models.modeStatus_v2": "Concurrent agent sessions", @@ -459,7 +471,7 @@ export const en = { "models.modeDesc_v2": "Run the newer concurrent sub-agent workflow.", "models.modeOptionDesc_v1": "Classic tools with direct model choice for every spawn.", "models.modeOptionDesc_default": "Follow Codex's upstream model pins and native feature flag.", - "models.modeOptionDesc_v2": "Force the newer concurrent workflow for every model.", + "models.modeOptionDesc_v2": "Use the newer concurrent workflow for every model.", "models.contextTitle": "Context", "models.contextStateUncapped": "Uncapped", "models.contextStateLimited": "Limited to {value}", @@ -510,14 +522,14 @@ export const en = { "models.contextCapLabel": "Context cap", "models.v2Label": "Sub-agent", "models.shadowCallOriginal": "⚠ {models} →", - "models.v2DocsLink": "What is v1 / v2?", - "models.v2Mode_v1": "v1", + "models.v2DocsLink": "What is V1 / V2?", + "models.v2Mode_v1": "V1", "models.v2Mode_default": "base", - "models.v2Mode_v2": "v2", - "models.v2ModeDesc_v1": "All models → v1 surface", - "models.v2ModeDesc_default": "Upstream defaults (sol/terra=v2, luna=v1)", - "models.v2ModeDesc_v2": "All models → v2 surface", - "models.v2Help": "Controls the multi-agent surface for all models.\n\nv1: Classic single-thread agent. Every model uses the v1 collab surface.\nbase: Upstream defaults — sol/terra use v2, luna uses v1, others follow the codex feature flag.\nv2: Multi-thread agent with spawn_agent. Every model uses the v2 collab surface.\n\nSave first. If Codex has a running worker, use Apply to replace it, then start a new task for session-bound tool schemas. A new task alone never reloads an existing worker.", + "models.v2Mode_v2": "V2", + "models.v2ModeDesc_v1": "All models → V1 protocol", + "models.v2ModeDesc_default": "Upstream defaults (sol/terra=V2, luna=V1)", + "models.v2ModeDesc_v2": "All models → V2 protocol", + "models.v2Help": "Controls the multi-agent protocol for all models.\n\nV1: Classic single-thread agent. Every model uses the V1 protocol.\nbase: Upstream defaults — sol/terra use V2, luna uses V1, others follow the codex feature flag.\nV2: Multi-thread agent with spawn_agent. Every model uses the V2 protocol.\n\nSave first. If Codex has a running worker, use Apply to replace it, then start a new task for session-bound tool schemas. A new task alone never reloads an existing worker.", "dash.multiAgent": "Sub-agent", "models.v2Conflict": "[agents] max_threads is set — codex will refuse to start; remove it from config.toml", "models.v2Applied": "Sub-agent mode saved. Apply to replace a running worker, then start a new task for session-bound changes.", @@ -591,7 +603,7 @@ export const en = { // subagents "sub.pageTitle": "Agent Command Center", - "sub.pageSubtitle": "Choose advertised worker models, guidance, and child fallback behavior.", + "sub.pageSubtitle": "Choose which models sub-agents may use, which model guides them, and the fallback order.", "sub.desktopPickerLimit": "This roster controls spawn_agent, not the top-level Codex Desktop picker. Desktop may hide routed provider ids with its remote allowlist; use Combos → Native OpenAI alias for an explicit compatibility mapping.", "sub.featured": "Configured Roster", "sub.noneSelected": "No quick picks yet.", @@ -599,7 +611,7 @@ export const en = { "sub.search": "Search models", "sub.settings": "Run Policy", "sub.delegation.model": "Preferred delegate", - "sub.delegation.modelHint": "The model CodexCommander names first when guiding delegated work. The active roster remains available for explicit overrides.", + "sub.delegation.modelHint": "The model CodexCommander names first when guiding delegated work. The configured roster remains available for explicit overrides.", "sub.saved": "Saved {n} quick picks. Disk and the generated catalog are up to date; no Codex worker was restarted.", "sub.savedExcluded": "Saved {n} quick picks, but {missing} are not currently advertised on the selected agent surface.", "sub.savedRefreshFailed": "Saved {n} quick picks to disk, but the catalog could not be refreshed cleanly. Existing workers were not restarted.", @@ -642,25 +654,25 @@ export const en = { "sub.libraryCount": "{n} catalog models", "sub.libraryHint": "Any catalog model remains callable by exact ID. The roster controls the five models advertised to spawn_agent; it does not force a worker or fallback.", "sub.noMatchingModels": "No models match this search and filter.", - "sub.policyHint": "Choose the agent protocol, worker guidance, and spawned-child fallback behavior. Policy saves independently from the roster.", + "sub.policyHint": "Set the agent protocol, worker guidance, and child fallbacks. Saves separately from the roster.", "sub.policy.mode": "Agent protocol", "sub.policy.messageDelivery": "V2 message delivery", "sub.policy.messageDelivery_encrypted": "Encrypted (native)", "sub.policy.messageDelivery_plaintext": "Plaintext compatibility", "sub.policy.messageDeliveryHint_encrypted": "Keeps ChatGPT's native encrypted contract; external V2 workers may be unavailable.", - "sub.policy.messageDeliveryHint_plaintext": "Experimental. Enables mixed-provider V2; V2 task-message delivery from this parent is plaintext. Save, then start a new task. This delivery-only change does not require Apply.", + "sub.policy.messageDeliveryHint_plaintext": "Experimental. Enables mixed-provider V2; V2 task-message delivery from this parent is plaintext. This delivery-only change does not require Apply.", "sub.policy.preferred": "Preferred guidance model", - "sub.policy.noPreferred": "No preferred model — Codex chooses from roster", + "sub.policy.noPreferred": "No preferred model", "sub.policy.fallback": "Global child fallback", - "sub.policy.fallbackHint": "Used for spawned child turns after the requested model and any role fallback; unavailable or quota-limited candidates are skipped.", + "sub.policy.fallbackHint": "Tried when a child's requested model is unavailable; quota-limited models are skipped.", "sub.policy.noFallback": "No fallback", "sub.policy.concurrency": "Thread limit", - "sub.policy.concurrencyHint": "V2 counts total threads including the root; V1 counts child threads. Blank restores the Codex default. Save, Apply if a worker is running, then start a new task.", + "sub.policy.concurrencyHint": "V2 counts total threads including the root; V1 counts child threads. Blank restores the Codex default.", "sub.policy.codexDefault": "Codex default", "sub.policy.increaseConcurrency": "Increase sub-agent concurrency", "sub.policy.decreaseConcurrency": "Decrease sub-agent concurrency", "sub.policy.save": "Save changes", - "sub.policy.saved": "Run policy saved. Apply to replace a running worker, then start a new task for protocol, thread-limit, and session-bound tool-schema changes. V2 task-message delivery affects later requests; guidance and fallback affect future spawned turns.", + "sub.policy.saved": "Run policy saved.", "sub.policy.saveFailed": "Some run-policy changes could not be saved. Your unsaved choices are still shown.", "sub.policy.loading": "Loading run policy…", "sub.policy.retry": "Reload policy", @@ -679,8 +691,8 @@ export const en = { "sub.policy.preferredEffortHint": "Reasoning level named in guidance when the preferred model is available.", "sub.policy.guidance": "Use roster as worker guidance", "sub.policy.guidanceHint": "Names eligible roster models in guidance. It does not force delegation or route every child.", - "sub.policy.compatibilityV2": "Codex native and Concurrent v2 can send native encrypted V2 tasks, which external providers cannot read (#92). Choose Plaintext compatibility for mixed-provider V2, or Reliable v1 for the established cross-provider path. Protocol selection does not activate a stale worker.", - "sub.policy.compatibilityV2Plaintext": "Experimental mixed-provider V2 is enabled. V2 task-message delivery from this parent is plaintext, including messages to native workers. V2 itself does not activate a stale Codex worker; unrecognized schemas still fail closed.", + "sub.policy.compatibilityV2": "V2 tasks are encrypted; external providers cannot read them. Use Plaintext compatibility for mixed-provider V2, or Reliable V1 for the established path.", + "sub.policy.compatibilityV2Plaintext": "Experimental mixed-provider V2 is enabled. Task-message delivery from this parent is plaintext, including messages to native workers.", "sub.policy.subagentCap": "Sub-agent effort ceiling", "sub.policy.subagentCapHint": "Limits child-agent effort without raising lower requests.", "sub.filter.label": "Filter models by capability", @@ -776,6 +788,7 @@ export const en = { "logs.metric.effortTitle": "Requested effort → exact value sent by CodexCommander; the provider may still ignore it", "logs.metric.estimatedCostTitle": "API list-price equivalent, not an actual charge; unmatched pricing is unavailable", "usage.cost.total": "API list-price equivalent (this range)", + "usage.cost.unavailable": "Unavailable", "usage.cost.disclaimer": "Not a billing receipt. Subscription usage or provider credits may apply instead.", "usage.cost.unpricedNote": "{count} requests excluded (no price or usage)", "logs.detail.section.basic": "Basic information", @@ -877,7 +890,7 @@ export const en = { // usage page "usage.title": "Usage", - "usage.subtitle": "Local token accounting from your proxy. Missing usage is never shown as zero.", + "usage.subtitle": "Requests and tokens tracked by your proxy. A failed read is never shown as zero.", "usage.loading": "Loading usage data…", "usage.empty": "No usage recorded yet. Send a request through the proxy to see activity here.", "usage.loadError": "Could not load usage data.", @@ -1213,6 +1226,11 @@ export const en = { "pws.usageUnavailable": "No usage recorded yet.", "pws.rateLimits": "Rate limits", "pws.quotaUnavailable": "No quota data for this provider.", + "pws.quota.unavailableTitle": "Quota unavailable", + "pws.quota.signInRequired": "Sign in required", + "pws.quota.loginNeedsRefresh": "Login needs refresh", + "pws.quota.upstreamUnavailable": "Temporarily unavailable", + "pws.quota.retry": "Retry", "pws.planLimits": "Plan limits and local observations", "pws.reference.intro": "OpenCode Go does not expose a live remaining-balance API. These published caps are shown beside traffic observed through this CodexCommander proxy.", "pws.reference.limitReported": "OpenCode Go reported the {window} limit.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3e00ce47a9..727448dfb9 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -217,6 +217,18 @@ export const ja: Record = { "dash.providers": "プロバイダー", "dash.tokens30d": "トークン (30日)", "dash.coverage": "{pct} カバレッジ", + "dash.cost30d": "推定コスト (30日)", + "dash.cost30dHint": "API 定価相当額 — 請求される金額ではありません。", + "dash.cost30dCoverage": "{priced} 件計上 · {unpriced} 件価格不明 · {unmetered} 件使用量なし", + "dash.planQuota.title": "プランと割り当て", + "dash.planQuota.hint": "プロバイダー報告の上限と推定値 — 請求される金額ではありません。", + "dash.planQuota.loading": "プロバイダー割り当てを読み込み中…", + "dash.planQuota.empty": "プロバイダー割り当てデータはまだありません。", + "dash.planQuota.disclaimer": "プロバイダー報告の上限とローカル推定値 — 請求書ではありません。", + "dash.planQuota.referenceIntro": "公開上限とこのプロキシ経由で観測されたトラフィックの比較", + "dash.planQuota.unavailable": "クォータを利用できません", + "dash.planQuota.manageProvider": "{provider} を管理", + "dash.planQuota.retry": "クォータ確認を再試行", "dash.mem.title": "メモリ可観測性", "dash.mem.hint": "読み取り専用のランタイム診断。観測メモリは max(RSS, external, ArrayBuffers) で、Windows の working set trimming がコミット済み保持を隠さないようにします。", "dash.mem.rss": "常駐メモリ (RSS)", @@ -295,7 +307,7 @@ export const ja: Record = { "dash.syncCodexSubagentDefaults": "Codex の既定値としても保存", "dash.syncCodexSubagentDefaultsHint": "オンにすると、上で選んだモデルが Codex 自身の設定にも保存され、新しいタスクも最初からそのモデルを使います。オフならここだけで記憶します。反映は次回の同期または再起動時で、自分で書いた [agents] 設定はそのまま残ります。", "dash.multiAgentGuidance": "ロースターをワーカーガイダンスに使う", - "dash.multiAgentGuidanceHint": "利用可能なロースターモデルを CodexCommander のガイダンスで示します。委任や全子タスクのルーティングを強制しません。v1 では max または ultra のときだけ表示します。", + "dash.multiAgentGuidanceHint": "利用可能なロースターモデルを CodexCommander のガイダンスで示します。委任や全子タスクのルーティングを強制しません。V1 では max または ultra のときだけ表示します。", "dash.injectionNone": "なし", "dash.injectionEffortLabel": "推論負荷", "dash.injectionEffortNone": "モデル既定", @@ -437,9 +449,9 @@ export const ja: Record = { "models.collaborationTitle": "コラボレーション", "models.change": "変更", "models.newSessionsOnly": "保存、適用、新しいタスクの開始", - "models.modeLabel_v1": "高信頼 v1", + "models.modeLabel_v1": "高信頼 V1", "models.modeLabel_default": "Codex ネイティブ", - "models.modeLabel_v2": "並行 v2", + "models.modeLabel_v2": "並行 V2", "models.modeStatus_v1": "柔軟なモデル選択", "models.modeStatus_default": "Codex の既定値", "models.modeStatus_v2": "並行エージェントセッション", @@ -448,7 +460,7 @@ export const ja: Record = { "models.modeDesc_v2": "新しい並行サブエージェントワークフローを実行します。", "models.modeOptionDesc_v1": "各起動でモデルを直接選べるクラシックツールです。", "models.modeOptionDesc_default": "Codex のアップストリーム指定とネイティブ機能フラグに従います。", - "models.modeOptionDesc_v2": "すべてのモデルに新しい並行ワークフローを強制します。", + "models.modeOptionDesc_v2": "すべてのモデルに新しい並行ワークフローを使用します。", "models.contextTitle": "コンテキスト", "models.contextStateUncapped": "上限なし", "models.contextStateLimited": "{value} に制限", @@ -499,14 +511,14 @@ export const ja: Record = { "models.contextCapLabel": "コンテキスト上限", "models.v2Label": "サブエージェント", "models.shadowCallOriginal": "⚠ {models} →", - "models.v2DocsLink": "v1 / v2 とは?", - "models.v2Mode_v1": "v1", + "models.v2DocsLink": "V1 / V2 とは?", + "models.v2Mode_v1": "V1", "models.v2Mode_default": "ベース", - "models.v2Mode_v2": "v2", - "models.v2ModeDesc_v1": "すべてのモデル → v1 サーフェス", - "models.v2ModeDesc_default": "上流のデフォルト(sol/terra=v2、luna=v1)", - "models.v2ModeDesc_v2": "すべてのモデル → v2 サーフェス", - "models.v2Help": "すべてのモデルのマルチエージェントサーフェスを制御します。\n\nv1: クラシックな単一スレッドエージェント。すべてのモデルが v1 コラボサーフェスを使います。\nベース: 上流のデフォルト — sol/terra は v2、luna は v1、それ以外は codex のフィーチャーフラグに従います。\nv2: spawn_agent を備えたマルチスレッドエージェント。すべてのモデルが v2 コラボサーフェスを使います。\n\nまず保存します。Codex ワーカーが動作中なら適用で置き換え、その後セッションに紐付くツールスキーマのため新しいタスクを開始します。新しいタスクだけでは既存ワーカーは再読み込みされません。", + "models.v2Mode_v2": "V2", + "models.v2ModeDesc_v1": "すべてのモデル → V1 プロトコル", + "models.v2ModeDesc_default": "上流のデフォルト(sol/terra=V2、luna=V1)", + "models.v2ModeDesc_v2": "すべてのモデル → V2 プロトコル", + "models.v2Help": "すべてのモデルのマルチエージェントプロトコルを制御します。\n\nV1: クラシックな単一スレッドエージェント。すべてのモデルが V1 プロトコルを使います。\nベース: 上流のデフォルト — sol/terra は V2、luna は V1、それ以外は codex のフィーチャーフラグに従います。\nV2: spawn_agent を備えたマルチスレッドエージェント。すべてのモデルが V2 プロトコルを使います。\n\nまず保存します。Codex ワーカーが動作中なら適用で置き換え、その後セッションに紐付くツールスキーマのため新しいタスクを開始します。新しいタスクだけでは既存ワーカーは再読み込みされません。", "dash.multiAgent": "サブエージェント", "models.v2Conflict": "[agents] max_threads が設定されています — codex は起動を拒否します; config.toml から削除してください", "models.v2Applied": "サブエージェントモードを保存しました。実行中のワーカーに適用し、セッションに紐付く変更のため新しいタスクを開始してください。", @@ -553,7 +565,7 @@ export const ja: Record = { // subagents "sub.pageTitle": "エージェント・コマンドセンター", - "sub.pageSubtitle": "ワーカーとして案内するモデル、ガイダンス、子タスクのフォールバックを選びます。", + "sub.pageSubtitle": "サブエージェントが使えるモデル、案内に使うモデル、フォールバックの順序を選びます。", "sub.desktopPickerLimit": "このロスターは spawn_agent を制御し、Codex Desktop のトップレベルモデルピッカーは制御しません。Desktop が remote allowlist で routed provider id を隠す場合は、Combos → ネイティブ OpenAI エイリアスで明示的な互換マッピングを作成してください。", "sub.featured": "設定済みロースター", "sub.noneSelected": "クイックピックはまだありません。", @@ -604,25 +616,25 @@ export const ja: Record = { "sub.libraryCount": "{n} 件のカタログモデル", "sub.libraryHint": "カタログ内のモデルは正確な ID で引き続き呼び出せます。ロースターは spawn_agent に通知される 5 モデルを制御しますが、ワーカーやフォールバックを強制しません。", "sub.noMatchingModels": "この検索とフィルターに一致するモデルがありません。", - "sub.policyHint": "エージェントプロトコル、ワーカーガイダンス、子タスクのフォールバックを選びます。ポリシーはロースターとは独立して保存されます。", + "sub.policyHint": "エージェントプロトコル、ワーカーガイダンス、子タスクのフォールバックを設定します。ロースターとは別に保存されます。", "sub.policy.mode": "エージェントプロトコル", "sub.policy.messageDelivery": "V2 メッセージ配信", "sub.policy.messageDelivery_encrypted": "暗号化(ネイティブ)", "sub.policy.messageDelivery_plaintext": "平文互換モード", "sub.policy.messageDeliveryHint_encrypted": "ChatGPT のネイティブ暗号化契約を維持します。外部 V2 ワーカーは利用できない場合があります。", - "sub.policy.messageDeliveryHint_plaintext": "実験的機能。複数プロバイダーの V2 を有効にし、この親からの V2 タスクメッセージ配信は平文です。保存してから新しいタスクを開始してください。配信のみの変更に適用は不要です。", + "sub.policy.messageDeliveryHint_plaintext": "実験的機能。複数プロバイダーの V2 を有効にし、この親からの V2 タスクメッセージ配信は平文です。配信のみの変更に適用は不要です。", "sub.policy.preferred": "優先ガイダンスモデル", - "sub.policy.noPreferred": "優先モデルなし — Codex がロースターから選択", + "sub.policy.noPreferred": "優先モデルなし", "sub.policy.fallback": "グローバル子タスクフォールバック", - "sub.policy.fallbackHint": "要求モデルとロールのフォールバックの後、生成された子タスクで使います。利用不可またはクォータ制限の候補はスキップします。", + "sub.policy.fallbackHint": "子タスクの要求モデルが利用できないときに試されます。クォータ制限のモデルはスキップされます。", "sub.policy.noFallback": "フォールバックなし", "sub.policy.concurrency": "スレッド上限", - "sub.policy.concurrencyHint": "V2 はルートを含む総スレッド数、V1 は子スレッド数です。空欄で Codex の既定値に戻ります。保存し、ワーカーが動作中なら適用してから新しいタスクを開始してください。", + "sub.policy.concurrencyHint": "V2 はルートを含む総スレッド数、V1 は子スレッド数です。空欄で Codex の既定値に戻ります。", "sub.policy.codexDefault": "Codex デフォルト", "sub.policy.increaseConcurrency": "サブエージェントの同時実行数を増やす", "sub.policy.decreaseConcurrency": "サブエージェントの同時実行数を減らす", "sub.policy.save": "変更を保存", - "sub.policy.saved": "実行ポリシーを保存しました。実行中のワーカーに適用してから、プロトコル、スレッド上限、セッションに紐付くツールスキーマのため新しいタスクを開始してください。V2 タスクメッセージ配信は後続リクエストに、ガイダンスとフォールバックは今後の子タスクに適用されます。", + "sub.policy.saved": "実行ポリシーを保存しました。", "sub.policy.saveFailed": "一部の実行ポリシー変更を保存できませんでした。未保存の選択内容は引き続き表示されています。", "sub.policy.loading": "実行ポリシーを読み込み中…", "sub.policy.retry": "ポリシーを再読み込み", @@ -641,8 +653,8 @@ export const ja: Record = { "sub.policy.preferredEffortHint": "優先モデルが利用可能な場合にガイダンスで示す推論レベル。", "sub.policy.guidance": "ロースターをワーカーガイダンスに使う", "sub.policy.guidanceHint": "利用可能なロースターモデルをガイダンスで示します。委任や全子タスクのルーティングを強制しません。", - "sub.policy.compatibilityV2": "Codex ネイティブと並行 v2 は、外部プロバイダーが読めないネイティブ暗号化 V2 タスクを送信できます (#92)。複数プロバイダー V2 には平文互換モード、または高信頼 v1 を選んでください。プロトコルの選択だけでは古いワーカーは更新されません。", - "sub.policy.compatibilityV2Plaintext": "実験的な複数プロバイダー V2 が有効です。この親からの V2 タスクメッセージ配信は、ネイティブワーカー宛ても含めて平文です。V2 自体は古い Codex ワーカーを有効化しません。未知のスキーマは引き続き安全に失敗します。", + "sub.policy.compatibilityV2": "V2 タスクは暗号化されており、外部プロバイダーは読めません。複数プロバイダー V2 には平文互換モード、または確立された経路には高信頼 V1 を選んでください。", + "sub.policy.compatibilityV2Plaintext": "実験的な複数プロバイダー V2 が有効です。この親からのタスクメッセージ配信は、ネイティブワーカー宛ても含めて平文です。", "sub.policy.subagentCap": "サブエージェントの effort 上限", "sub.policy.subagentCapHint": "低い要求を引き上げることなく、子エージェントの effort を制限します。", "sub.filter.label": "機能でモデルをフィルター", @@ -738,6 +750,7 @@ export const ja: Record = { "logs.metric.effortTitle": "要求された負荷 → CodexCommander が送信した正確な値。プロバイダーが適用したことは確認できません", "logs.metric.estimatedCostTitle": "API 定価相当額(実際の請求ではありません); 未対応の価格は利用できません", "usage.cost.total": "API 定価相当額(この期間)", + "usage.cost.unavailable": "利用不可", "usage.cost.disclaimer": "請求明細ではありません。サブスクリプション利用量やプロバイダークレジットが代わりに適用される場合があります。", "usage.cost.unpricedNote": "{count} 件のリクエストを除外(価格または使用量なし)", "logs.detail.section.basic": "基本情報", @@ -839,7 +852,7 @@ export const ja: Record = { // usage page "usage.title": "使用量", - "usage.subtitle": "プロキシからのローカルトークン会計です。欠損した使用量はゼロとして表示されることはありません。", + "usage.subtitle": "プロキシが追跡したリクエストとトークンです。読み取りに失敗してもゼロとして表示されることはありません。", "usage.loading": "使用量データを読み込み中…", "usage.empty": "まだ使用量が記録されていません。プロキシ経由でリクエストを送信するとここにアクティビティが表示されます。", "usage.loadError": "使用量データを読み込めませんでした。", @@ -1164,6 +1177,11 @@ export const ja: Record = { "pws.metricTokens": "トークン", "pws.usageUnavailable": "まだ使用量が記録されていません。", "pws.rateLimits": "レート制限", + "pws.quota.unavailableTitle": "クォータを利用できません", + "pws.quota.signInRequired": "サインインが必要です", + "pws.quota.loginNeedsRefresh": "ログインの更新が必要です", + "pws.quota.upstreamUnavailable": "一時的に利用できません", + "pws.quota.retry": "再試行", "pws.quotaUnavailable": "このプロバイダーのクォータデータがありません。", "pws.planLimits": "プラン上限とローカル観測", "pws.reference.intro": "OpenCode Go は残量を取得するライブ API を公開していません。公開上限と、この CodexCommander プロキシを通過した通信の観測値を並べて表示します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index c67cb6ea48..33bdacc82a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -209,6 +209,18 @@ export const ko: Record = { "dash.providers": "프로바이더", "dash.tokens30d": "토큰 (30일)", "dash.coverage": "커버리지 {pct}", + "dash.cost30d": "예상 비용 (30일)", + "dash.cost30dHint": "API 정가 환산치 — 청구 금액이 아닙니다.", + "dash.cost30dCoverage": "과금 {priced}건 · 미과금 {unpriced}건 · 미측정 {unmetered}건", + "dash.planQuota.title": "요금제 및 할당량", + "dash.planQuota.hint": "프로바이더 보고 한도 및 추정치 — 청구 금액이 아닙니다.", + "dash.planQuota.loading": "프로바이더 할당량 불러오는 중…", + "dash.planQuota.empty": "아직 프로바이더 할당량 데이터가 없습니다.", + "dash.planQuota.disclaimer": "프로바이더 보고 상한과 로컬 추정치 — 청구서가 아닙니다.", + "dash.planQuota.referenceIntro": "게시된 상한과 이 프록시를 통해 관측된 트래픽 비교", + "dash.planQuota.unavailable": "할당량을 사용할 수 없음", + "dash.planQuota.manageProvider": "{provider} 관리", + "dash.planQuota.retry": "할당량 확인 다시 시도", "dash.mem.title": "메모리 관찰", "dash.mem.hint": "읽기 전용 런타임 진단. 관측 메모리는 max(RSS, external, ArrayBuffers)라 Windows working set trimming이 커밋된 보존 메모리를 숨기지 못합니다.", "dash.mem.rss": "상주 메모리 (RSS)", @@ -288,7 +300,7 @@ export const ko: Record = { "dash.syncCodexSubagentDefaults": "Codex 설정에도 기본값으로 저장", "dash.syncCodexSubagentDefaultsHint": "켜면 위에서 고른 모델이 Codex 설정 파일에 저장돼, 새로 시작하는 작업도 처음부터 그 모델을 씁니다. 끄면 여기서만 기억합니다. 반영은 다음 동기화나 재시작 때이고, 직접 적어둔 [agents] 설정은 그대로 둡니다.", "dash.multiAgentGuidance": "로스터를 워커 안내에 사용", - "dash.multiAgentGuidanceHint": "사용 가능한 로스터 모델을 CodexCommander 안내에 이름으로 넣습니다. 위임이나 모든 하위 작업의 라우팅을 강제하지 않으며, v1에서는 max 또는 ultra일 때만 안내합니다.", + "dash.multiAgentGuidanceHint": "사용 가능한 로스터 모델을 CodexCommander 안내에 이름으로 넣습니다. 위임이나 모든 하위 작업의 라우팅을 강제하지 않으며, V1에서는 max 또는 ultra일 때만 안내합니다.", "dash.injectionNone": "없음", "dash.injectionEffortLabel": "추론 강도", "dash.injectionEffortNone": "모델 기본값", @@ -437,9 +449,9 @@ export const ko: Record = { "models.collaborationTitle": "협업", "models.change": "변경", "models.newSessionsOnly": "저장, 적용, 새 작업 시작", - "models.modeLabel_v1": "안정적 v1", + "models.modeLabel_v1": "안정적 V1", "models.modeLabel_default": "Codex 네이티브", - "models.modeLabel_v2": "동시 v2", + "models.modeLabel_v2": "동시 V2", "models.modeStatus_v1": "유연한 모델 선택", "models.modeStatus_default": "Codex 기본값", "models.modeStatus_v2": "동시 에이전트 세션", @@ -448,7 +460,7 @@ export const ko: Record = { "models.modeDesc_v2": "새로운 동시 서브에이전트 워크플로를 실행합니다.", "models.modeOptionDesc_v1": "생성할 때마다 모델을 직접 선택하는 클래식 도구입니다.", "models.modeOptionDesc_default": "Codex의 업스트림 지정과 네이티브 기능 플래그를 따릅니다.", - "models.modeOptionDesc_v2": "모든 모델에 새로운 동시 워크플로를 강제합니다.", + "models.modeOptionDesc_v2": "모든 모델에 새로운 동시 워크플로를 사용합니다.", "models.contextTitle": "컨텍스트", "models.contextStateUncapped": "제한 없음", "models.contextStateLimited": "{value}로 제한", @@ -499,14 +511,14 @@ export const ko: Record = { "models.contextCapLabel": "컨텍스트 제한", "models.v2Label": "서브에이전트", "models.shadowCallOriginal": "⚠ {models} →", - "models.v2Mode_v1": "v1", + "models.v2Mode_v1": "V1", "models.v2Mode_default": "base", - "models.v2Mode_v2": "v2", - "models.v2ModeDesc_v1": "전 모델 → v1 서피스", - "models.v2ModeDesc_default": "업스트림 기본값 (sol/terra=v2, luna=v1)", - "models.v2ModeDesc_v2": "전 모델 → v2 서피스", - "models.v2Help": "모든 모델의 멀티에이전트 서피스를 제어합니다.\n\nv1: 단일 스레드 에이전트. 모든 모델이 v1 서피스를 사용합니다.\nbase: 업스트림 기본값 — sol/terra는 v2, luna는 v1, 나머지는 codex 플래그를 따릅니다.\nv2: 멀티 스레드 에이전트(spawn_agent). 모든 모델이 v2 서피스를 사용합니다.\n\n먼저 저장하세요. Codex 워커가 실행 중이면 적용으로 교체한 뒤 세션 종속 도구 스키마를 위해 새 작업을 시작하세요. 새 작업만으로 기존 워커가 다시 로드되지는 않습니다.", - "models.v2DocsLink": "v1 / v2가 뭔가요?", + "models.v2Mode_v2": "V2", + "models.v2ModeDesc_v1": "전 모델 → V1 프로토콜", + "models.v2ModeDesc_default": "업스트림 기본값 (sol/terra=V2, luna=V1)", + "models.v2ModeDesc_v2": "전 모델 → V2 프로토콜", + "models.v2Help": "모든 모델의 멀티에이전트 프로토콜을 제어합니다.\n\nV1: 단일 스레드 에이전트. 모든 모델이 V1 프로토콜을 사용합니다.\nbase: 업스트림 기본값 — sol/terra는 V2, luna는 V1, 나머지는 codex 플래그를 따릅니다.\nV2: 멀티 스레드 에이전트(spawn_agent). 모든 모델이 V2 프로토콜을 사용합니다.\n\n먼저 저장하세요. Codex 워커가 실행 중이면 적용으로 교체한 뒤 세션 종속 도구 스키마를 위해 새 작업을 시작하세요. 새 작업만으로 기존 워커가 다시 로드되지는 않습니다.", + "models.v2DocsLink": "V1 / V2가 뭔가요?", "dash.multiAgent": "서브에이전트", "models.v2Conflict": "[agents] max_threads가 남아 있어 codex가 부팅을 거부합니다 — config.toml에서 제거하세요", "models.v2Applied": "서브에이전트 모드가 저장되었습니다. 실행 중인 워커에 적용한 뒤 세션 종속 변경을 위해 새 작업을 시작하세요.", @@ -580,7 +592,7 @@ export const ko: Record = { // subagents "sub.pageTitle": "에이전트 커맨드 센터", - "sub.pageSubtitle": "워커로 안내할 모델, 안내 방식, 하위 작업 폴백을 선택하세요.", + "sub.pageSubtitle": "하위 에이전트가 사용할 모델, 안내에 쓸 모델, 폴백 순서를 선택하세요.", "sub.desktopPickerLimit": "이 roster는 spawn_agent를 제어하며 Codex Desktop의 최상위 모델 선택기를 제어하지 않습니다. Desktop이 remote allowlist로 routed provider id를 숨기면 Combos → 네이티브 OpenAI 별칭에서 명시적 호환 매핑을 만드세요.", "sub.featured": "구성된 로스터", "sub.noneSelected": "아직 빠른 선택 항목이 없습니다.", @@ -631,25 +643,25 @@ export const ko: Record = { "sub.libraryCount": "카탈로그 모델 {n}개", "sub.libraryHint": "카탈로그 모델은 정확한 ID로 계속 호출할 수 있습니다. 로스터는 spawn_agent에 알리는 5개 모델을 제어하지만 워커나 폴백을 강제하지 않습니다.", "sub.noMatchingModels": "이 검색 및 필터와 일치하는 모델이 없습니다.", - "sub.policyHint": "에이전트 프로토콜, 워커 안내, 하위 작업 폴백을 선택하세요. 정책은 로스터와 별개로 저장됩니다.", + "sub.policyHint": "에이전트 프로토콜, 워커 안내, 하위 작업 폴백을 설정하세요. 로스터와 별개로 저장됩니다.", "sub.policy.mode": "에이전트 프로토콜", "sub.policy.messageDelivery": "V2 메시지 전달", "sub.policy.messageDelivery_encrypted": "암호화(네이티브)", "sub.policy.messageDelivery_plaintext": "평문 호환 모드", "sub.policy.messageDeliveryHint_encrypted": "ChatGPT의 네이티브 암호화 계약을 유지합니다. 외부 V2 워커를 사용할 수 없을 수 있습니다.", - "sub.policy.messageDeliveryHint_plaintext": "실험적 기능입니다. 다중 프로바이더 V2를 활성화하며 이 부모의 V2 작업 메시지 전달은 평문입니다. 저장한 뒤 새 작업을 시작하세요. 전달 전용 변경에는 적용이 필요하지 않습니다.", + "sub.policy.messageDeliveryHint_plaintext": "실험적 기능입니다. 다중 프로바이더 V2를 활성화하며 이 부모의 V2 작업 메시지 전달은 평문입니다. 전달 전용 변경에는 적용이 필요하지 않습니다.", "sub.policy.preferred": "선호 안내 모델", - "sub.policy.noPreferred": "선호 모델 없음 — Codex가 로스터에서 선택", + "sub.policy.noPreferred": "선호 모델 없음", "sub.policy.fallback": "전역 하위 작업 폴백", - "sub.policy.fallbackHint": "요청 모델과 역할 폴백 다음의 생성된 하위 작업에 사용하며, 사용할 수 없거나 할당량 제한인 후보는 건너뜁니다.", + "sub.policy.fallbackHint": "하위 작업의 요청 모델을 사용할 수 없을 때 시도됩니다. 할당량 제한 모델은 건너뜁니다.", "sub.policy.noFallback": "폴백 없음", "sub.policy.concurrency": "스레드 한도", - "sub.policy.concurrencyHint": "V2는 루트를 포함한 전체 스레드 수, V1은 하위 스레드 수를 셉니다. 비워 두면 Codex 기본값으로 돌아갑니다. 저장하고 워커가 실행 중이면 적용한 뒤 새 작업을 시작하세요.", + "sub.policy.concurrencyHint": "V2는 루트를 포함한 전체 스레드 수, V1은 하위 스레드 수를 셉니다. 비워 두면 Codex 기본값으로 돌아갑니다.", "sub.policy.codexDefault": "Codex 기본값", "sub.policy.increaseConcurrency": "서브에이전트 동시 실행 수 늘리기", "sub.policy.decreaseConcurrency": "서브에이전트 동시 실행 수 줄이기", "sub.policy.save": "변경 사항 저장", - "sub.policy.saved": "실행 정책이 저장되었습니다. 실행 중인 워커에 적용한 뒤 프로토콜, 스레드 한도, 세션 종속 도구 스키마를 위해 새 작업을 시작하세요. V2 작업 메시지 전달은 이후 요청에, 안내와 폴백은 이후 하위 작업에 적용됩니다.", + "sub.policy.saved": "실행 정책이 저장되었습니다.", "sub.policy.saveFailed": "일부 실행 정책 변경 사항을 저장할 수 없었습니다. 저장되지 않은 선택은 계속 표시됩니다.", "sub.policy.loading": "실행 정책을 불러오는 중…", "sub.policy.retry": "정책 다시 불러오기", @@ -668,8 +680,8 @@ export const ko: Record = { "sub.policy.preferredEffortHint": "선호 모델이 사용 가능할 때 안내에서 지정하는 추론 수준입니다.", "sub.policy.guidance": "로스터를 워커 안내에 사용", "sub.policy.guidanceHint": "사용 가능한 로스터 모델을 안내에 이름으로 넣습니다. 위임이나 모든 하위 작업의 라우팅을 강제하지 않습니다.", - "sub.policy.compatibilityV2": "Codex 네이티브와 동시 v2는 외부 프로바이더가 읽을 수 없는 네이티브 암호화 V2 작업을 보낼 수 있습니다 (#92). 다중 프로바이더 V2에는 평문 호환 모드 또는 안정적 v1을 선택하세요. 프로토콜 선택만으로 오래된 워커가 활성화되지는 않습니다.", - "sub.policy.compatibilityV2Plaintext": "실험적 다중 프로바이더 V2가 활성화되었습니다. 이 부모의 V2 작업 메시지 전달은 네이티브 워커를 포함하여 평문입니다. V2 자체는 오래된 Codex 워커를 활성화하지 않으며 알 수 없는 스키마는 계속 안전하게 실패합니다.", + "sub.policy.compatibilityV2": "V2 작업은 암호화되어 외부 프로바이더가 읽을 수 없습니다. 다중 프로바이더 V2에는 평문 호환 모드, 기존 경로에는 안정적 V1을 선택하세요.", + "sub.policy.compatibilityV2Plaintext": "실험적 다중 프로바이더 V2가 활성화되었습니다. 이 부모의 태스크 메시지 전달은 네이티브 워커를 포함하여 평문입니다.", "sub.policy.subagentCap": "서브에이전트 effort 상한", "sub.policy.subagentCapHint": "더 낮은 요청을 올리지 않으면서 하위 에이전트의 effort를 제한합니다.", "sub.filter.label": "기능별로 모델 필터링", @@ -765,6 +777,7 @@ export const ko: Record = { "logs.metric.effortTitle": "요청한 추론 강도 → CodexCommander가 전송한 정확한 값이며, 제공자가 적용했는지는 확인되지 않습니다", "logs.metric.estimatedCostTitle": "API 정가 환산치이며 실제 청구액이 아닙니다. 가격 미매칭은 표시하지 않습니다.", "usage.cost.total": "API 정가 환산치 (이 기간)", + "usage.cost.unavailable": "사용할 수 없음", "usage.cost.disclaimer": "결제 영수증이 아닙니다. 구독 사용량 또는 프로바이더 크레딧이 대신 적용될 수 있습니다.", "usage.cost.unpricedNote": "비용 산정 불가 {count}건 제외", "logs.detail.section.basic": "기본 정보", @@ -865,7 +878,7 @@ export const ko: Record = { // usage page "usage.title": "사용량", - "usage.subtitle": "프록시의 로컬 토큰 집계입니다. 누락된 사용량은 0으로 표시하지 않습니다.", + "usage.subtitle": "프록시가 추적한 요청과 토큰입니다. 읽기에 실패해도 0으로 표시되지 않습니다.", "usage.loading": "사용량 데이터를 불러오는 중…", "usage.empty": "아직 기록된 사용량이 없습니다. 프록시로 요청을 보내면 여기에 표시됩니다.", "usage.loadError": "사용량 데이터를 불러오지 못했습니다.", @@ -1779,6 +1792,11 @@ export const ko: Record = { "pws.metricTokens": "토큰", "pws.usageUnavailable": "아직 기록된 사용량이 없습니다.", "pws.rateLimits": "요청 한도", + "pws.quota.unavailableTitle": "할당량을 사용할 수 없음", + "pws.quota.signInRequired": "로그인이 필요합니다", + "pws.quota.loginNeedsRefresh": "로그인 새로고침 필요", + "pws.quota.upstreamUnavailable": "일시적으로 사용할 수 없음", + "pws.quota.retry": "다시 시도", "pws.quotaUnavailable": "이 프로바이더의 쿼터 데이터가 없습니다.", "pws.planLimits": "플랜 한도 및 로컬 관측", "pws.reference.intro": "OpenCode Go는 실시간 잔여 한도 API를 제공하지 않습니다. 공개된 한도와 이 CodexCommander 프록시를 통해 관측된 트래픽을 함께 표시합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 70d675c0cd..bd91f38395 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -217,6 +217,18 @@ export const ru: Record = { "dash.providers": "Провайдеры", "dash.tokens30d": "Токены (30 дн.)", "dash.coverage": "{pct} покрытия", + "dash.cost30d": "Оценка затрат (30 дн.)", + "dash.cost30dHint": "Эквивалент по прайс-листу API — не счёт к оплате.", + "dash.cost30dCoverage": "{priced} оценено · {unpriced} без цены · {unmetered} без данных об использовании", + "dash.planQuota.title": "Тариф и квота", + "dash.planQuota.hint": "Лимиты и оценки от провайдера — не счёт к оплате.", + "dash.planQuota.loading": "Загрузка квоты провайдера…", + "dash.planQuota.empty": "Данных о квоте провайдера пока нет.", + "dash.planQuota.disclaimer": "Лимиты от провайдера и локальные оценки — не платёжный документ.", + "dash.planQuota.referenceIntro": "Опубликованные лимиты и трафик, наблюдаемый через этот прокси", + "dash.planQuota.unavailable": "Квота недоступна", + "dash.planQuota.manageProvider": "Управлять {provider}", + "dash.planQuota.retry": "Повторить проверку квоты", "dash.mem.title": "Наблюдение за памятью", "dash.mem.hint": "Диагностика среды выполнения только для чтения. Наблюдаемая память — max(RSS, external, ArrayBuffers), чтобы trimming рабочего набора Windows не скрывал удержанную память.", "dash.mem.rss": "Резидентная память (RSS)", @@ -295,7 +307,7 @@ export const ru: Record = { "dash.syncCodexSubagentDefaults": "Сохранить и как значение по умолчанию в Codex", "dash.syncCodexSubagentDefaultsHint": "Если включено, выбранная выше модель записывается в собственную конфигурацию Codex, и новые задачи тоже начинаются с неё. Если выключено, выбор запоминается только здесь. Применится при следующей синхронизации или перезапуске, а ваши настройки [agents] останутся нетронутыми.", "dash.multiAgentGuidance": "Использовать состав в подсказке воркера", - "dash.multiAgentGuidanceHint": "Называет подходящие модели состава в подсказке CodexCommander. Не принуждает к делегированию и не маршрутизирует каждого ребёнка; в v1 подсказка появляется только при max или ultra.", + "dash.multiAgentGuidanceHint": "Называет подходящие модели состава в подсказке CodexCommander. Не принуждает к делегированию и не маршрутизирует каждого ребёнка; в V1 подсказка появляется только при max или ultra.", "dash.injectionNone": "Нет", "dash.injectionEffortLabel": "Уровень рассуждений", "dash.injectionEffortNone": "По умолчанию для модели", @@ -442,9 +454,9 @@ export const ru: Record = { "models.collaborationTitle": "Совместная работа", "models.change": "Изменить", "models.newSessionsOnly": "Сохранить, применить, начать новую задачу", - "models.modeLabel_v1": "Надёжный v1", + "models.modeLabel_v1": "Надёжный V1", "models.modeLabel_default": "Нативный Codex", - "models.modeLabel_v2": "Параллельный v2", + "models.modeLabel_v2": "Параллельный V2", "models.modeStatus_v1": "Гибкий выбор модели", "models.modeStatus_default": "Настройки Codex", "models.modeStatus_v2": "Параллельные сессии агентов", @@ -453,7 +465,7 @@ export const ru: Record = { "models.modeDesc_v2": "Запускайте новый параллельный процесс подагентов.", "models.modeOptionDesc_v1": "Классические инструменты с прямым выбором модели при каждом запуске.", "models.modeOptionDesc_default": "Следовать исходным закреплениям моделей и нативному флагу Codex.", - "models.modeOptionDesc_v2": "Принудительно включить новый параллельный процесс для всех моделей.", + "models.modeOptionDesc_v2": "Использовать новый параллельный процесс для всех моделей.", "models.contextTitle": "Контекст", "models.contextStateUncapped": "Без лимита", "models.contextStateLimited": "Лимит {value}", @@ -504,14 +516,14 @@ export const ru: Record = { "models.contextCapLabel": "Лимит контекста", "models.v2Label": "Подагент", "models.shadowCallOriginal": "⚠ {models} →", - "models.v2DocsLink": "Что такое v1 / v2?", - "models.v2Mode_v1": "v1", + "models.v2DocsLink": "Что такое V1 / V2?", + "models.v2Mode_v1": "V1", "models.v2Mode_default": "base", - "models.v2Mode_v2": "v2", - "models.v2ModeDesc_v1": "Все модели → поверхность v1", - "models.v2ModeDesc_default": "Вышестоящие значения по умолчанию (sol/terra=v2, luna=v1)", - "models.v2ModeDesc_v2": "Все модели → поверхность v2", - "models.v2Help": "Управляет мультиагентной поверхностью для всех моделей.\n\nv1: Классический однопоточный агент. Каждая модель использует поверхность взаимодействия v1.\nbase: Вышестоящие значения по умолчанию — sol/terra используют v2, luna использует v1, остальные следуют функциональному флагу codex.\nv2: Многопоточный агент со spawn_agent. Каждая модель использует поверхность взаимодействия v2.\n\nСначала сохраните. Если воркер Codex уже запущен, замените его через Применить, затем начните новую задачу для сессионных схем инструментов. Одна новая задача не перезагружает существующий воркер.", + "models.v2Mode_v2": "V2", + "models.v2ModeDesc_v1": "Все модели → протокол V1", + "models.v2ModeDesc_default": "Вышестоящие значения по умолчанию (sol/terra=V2, luna=V1)", + "models.v2ModeDesc_v2": "Все модели → протокол V2", + "models.v2Help": "Управляет мультиагентным протоколом для всех моделей.\n\nV1: Классический однопоточный агент. Каждая модель использует протокол V1.\nbase: Вышестоящие значения по умолчанию — sol/terra используют V2, luna использует V1, остальные следуют функциональному флагу codex.\nV2: Многопоточный агент со spawn_agent. Каждая модель использует протокол V2.\n\nСначала сохраните. Если воркер Codex уже запущен, замените его через Применить, затем начните новую задачу для сессионных схем инструментов. Одна новая задача не перезагружает существующий воркер.", "dash.multiAgent": "Подагент", "models.v2Conflict": "Задан [agents] max_threads — codex откажется запускаться; удалите его из config.toml", "models.v2Applied": "Режим подагента сохранён. Примените к работающему воркеру, затем начните новую задачу для сессионных изменений.", @@ -585,7 +597,7 @@ export const ru: Record = { // subagents "sub.pageTitle": "Командный центр агентов", - "sub.pageSubtitle": "Выберите модели для подсказок воркерам, сами подсказки и цепочку запасных вариантов дочерних задач.", + "sub.pageSubtitle": "Выберите, какие модели могут использовать дочерние агенты, какая модель ведёт подсказки и порядок запасных вариантов.", "sub.desktopPickerLimit": "Этот roster управляет spawn_agent, а не верхнеуровневым picker Codex Desktop. Desktop может скрывать routed id провайдеров через remote allowlist; используйте Combos → Нативный псевдоним OpenAI для явного совместимого сопоставления.", "sub.featured": "Настроенный состав", "sub.noneSelected": "Быстрый выбор пока пуст.", @@ -636,25 +648,25 @@ export const ru: Record = { "sub.libraryCount": "Моделей в каталоге: {n}", "sub.libraryHint": "Любую модель каталога по-прежнему можно вызвать по точному ID. Состав управляет пятью моделями, объявляемыми spawn_agent, но не принуждает выбор воркера или fallback.", "sub.noMatchingModels": "Нет моделей, соответствующих этому поиску и фильтру.", - "sub.policyHint": "Выберите протокол агента, подсказки воркерам и fallback для созданных дочерних задач. Политика сохраняется независимо от состава.", + "sub.policyHint": "Настройте протокол агента, подсказки воркерам и fallback дочерних задач. Сохраняется отдельно от состава.", "sub.policy.mode": "Протокол агента", "sub.policy.messageDelivery": "Доставка сообщений V2", "sub.policy.messageDelivery_encrypted": "Шифрование (нативное)", "sub.policy.messageDelivery_plaintext": "Совместимость в открытом виде", "sub.policy.messageDeliveryHint_encrypted": "Сохраняет нативный зашифрованный контракт ChatGPT; внешние V2-воркеры могут быть недоступны.", - "sub.policy.messageDeliveryHint_plaintext": "Экспериментально. Включает V2 между провайдерами; доставка сообщений V2-задач от этого родителя идёт открытым текстом. Сохраните и начните новую задачу. Для изменения только доставки применять каталог не нужно.", + "sub.policy.messageDeliveryHint_plaintext": "Экспериментально. Включает V2 между провайдерами; доставка сообщений V2-задач от этого родителя идёт открытым текстом. Для изменения только доставки применять каталог не нужно.", "sub.policy.preferred": "Предпочтительная модель подсказки", - "sub.policy.noPreferred": "Нет предпочтительной модели — Codex выберет из состава", + "sub.policy.noPreferred": "Нет предпочтительной модели", "sub.policy.fallback": "Глобальный fallback дочерних задач", - "sub.policy.fallbackHint": "Используется для созданных дочерних задач после запрошенной модели и fallback роли; недоступные или квотные кандидаты пропускаются.", + "sub.policy.fallbackHint": "Используется, когда запрошенная модель дочерней задачи недоступна; квотные модели пропускаются.", "sub.policy.noFallback": "Без запасного варианта", "sub.policy.concurrency": "Лимит потоков", - "sub.policy.concurrencyHint": "V2 считает все потоки вместе с корневым, V1 — дочерние потоки. Пусто возвращает значение Codex по умолчанию. Сохраните, примените при работающем воркере, затем начните новую задачу.", + "sub.policy.concurrencyHint": "V2 считает все потоки вместе с корневым, V1 — дочерние потоки. Пусто возвращает значение Codex по умолчанию.", "sub.policy.codexDefault": "По умолчанию в Codex", "sub.policy.increaseConcurrency": "Увеличить параллелизм субагентов", "sub.policy.decreaseConcurrency": "Уменьшить параллелизм субагентов", "sub.policy.save": "Сохранить изменения", - "sub.policy.saved": "Политика запуска сохранена. Примените к работающему воркеру, затем начните новую задачу для протокола, лимита потоков и сессионных схем инструментов. Доставка сообщений V2-задач влияет на последующие запросы; подсказки и fallback — на будущие дочерние задачи.", + "sub.policy.saved": "Политика запуска сохранена.", "sub.policy.saveFailed": "Некоторые изменения политики запуска сохранить не удалось. Ваши несохранённые варианты по-прежнему отображаются.", "sub.policy.loading": "Загрузка политики запуска…", "sub.policy.retry": "Перезагрузить политику", @@ -673,8 +685,8 @@ export const ru: Record = { "sub.policy.preferredEffortHint": "Уровень рассуждения, который указывается в подсказке, если предпочтительная модель доступна.", "sub.policy.guidance": "Использовать состав в подсказке воркера", "sub.policy.guidanceHint": "Называет подходящие модели состава в подсказке. Не принуждает к делегированию и не маршрутизирует каждого ребёнка.", - "sub.policy.compatibilityV2": "Нативный Codex и Параллельный v2 могут отправлять нативные зашифрованные V2-задачи, недоступные внешним провайдерам (#92). Для V2 между провайдерами выберите открытый режим либо Надёжный v1. Выбор протокола сам по себе не обновляет устаревший воркер.", - "sub.policy.compatibilityV2Plaintext": "Экспериментальный V2 между провайдерами включён. Доставка сообщений V2-задач от этого родителя идёт открытым текстом, включая нативные воркеры. Сам V2 не активирует устаревший воркер Codex; неизвестные схемы по-прежнему завершаются безопасной ошибкой.", + "sub.policy.compatibilityV2": "V2-задачи шифруются; внешние провайдеры не могут их прочитать. Для V2 между провайдерами выберите открытый режим, для устоявшегося пути — Надёжный V1.", + "sub.policy.compatibilityV2Plaintext": "Экспериментальный V2 между провайдерами включён. Доставка сообщений задач от этого родителя идёт открытым текстом, включая нативные воркеры.", "sub.policy.subagentCap": "Потолок effort для субагентов", "sub.policy.subagentCapHint": "Ограничивает effort дочерних агентов, не повышая более низкие запросы.", "sub.filter.label": "Фильтровать модели по возможностям", @@ -770,6 +782,7 @@ export const ru: Record = { "logs.metric.effortTitle": "Запрошенный уровень → точное значение, отправленное CodexCommander; применение провайдером не подтверждено", "logs.metric.estimatedCostTitle": "Эквивалент стоимости по прайс-листу API, а не фактическое списание; если цену не удалось сопоставить, значение недоступно", "usage.cost.total": "Эквивалент стоимости по прайс-листу API (за этот период)", + "usage.cost.unavailable": "Недоступно", "usage.cost.disclaimer": "Не является счётом. Расходы могут покрываться подпиской или кредитами провайдера.", "usage.cost.unpricedNote": "Исключено {count} запросов (нет цены или данных использования)", "logs.detail.section.basic": "Основная информация", @@ -871,7 +884,7 @@ export const ru: Record = { // usage page "usage.title": "Использование", - "usage.subtitle": "Локальный учёт токенов вашего прокси. Отсутствующие данные никогда не показываются как ноль.", + "usage.subtitle": "Запросы и токены, отслеживаемые вашим прокси. Сбой чтения никогда не показывается как ноль.", "usage.loading": "Загрузка данных об использовании…", "usage.empty": "Данных об использовании пока нет. Отправьте запрос через прокси, чтобы увидеть здесь активность.", "usage.loadError": "Не удалось загрузить данные об использовании.", @@ -1206,6 +1219,11 @@ export const ru: Record = { "pws.metricTokens": "токенов", "pws.usageUnavailable": "Использование пока не зафиксировано.", "pws.rateLimits": "Лимиты запросов", + "pws.quota.unavailableTitle": "Квота недоступна", + "pws.quota.signInRequired": "Требуется вход", + "pws.quota.loginNeedsRefresh": "Нужно обновить вход", + "pws.quota.upstreamUnavailable": "Временно недоступно", + "pws.quota.retry": "Повторить", "pws.quotaUnavailable": "Нет данных о квоте для этого провайдера.", "pws.planLimits": "Лимиты плана и локальные наблюдения", "pws.reference.intro": "OpenCode Go не предоставляет API с актуальным остатком. Опубликованные лимиты показаны рядом с трафиком, который прошёл через этот прокси CodexCommander.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index d7e12fbb11..cac576b913 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -209,6 +209,18 @@ export const zh: Record = { "dash.providers": "提供方", "dash.tokens30d": "Token (30 天)", "dash.coverage": "覆盖率 {pct}", + "dash.cost30d": "预估成本(30 天)", + "dash.cost30dHint": "API 标价折算 — 绝不代表实际扣费。", + "dash.cost30dCoverage": "{priced} 已计价 · {unpriced} 未计价 · {unmetered} 无用量", + "dash.planQuota.title": "套餐与配额", + "dash.planQuota.hint": "服务商报告的限额与估算 — 绝不代表实际扣费。", + "dash.planQuota.loading": "正在加载服务商配额…", + "dash.planQuota.empty": "暂无服务商配额数据。", + "dash.planQuota.disclaimer": "服务商报告的上限与本地估算 — 并非账单。", + "dash.planQuota.referenceIntro": "公布上限与本代理观察到的流量对比", + "dash.planQuota.unavailable": "配额不可用", + "dash.planQuota.manageProvider": "管理 {provider}", + "dash.planQuota.retry": "重试配额检查", "dash.mem.title": "内存可观测性", "dash.mem.hint": "只读运行时诊断。观测内存为 max(RSS, external, ArrayBuffers),避免 Windows working set trimming 隐藏已提交的保留内存。", "dash.mem.rss": "常驻内存 (RSS)", @@ -287,7 +299,7 @@ export const zh: Record = { "dash.syncCodexSubagentDefaults": "同时保存为 Codex 默认值", "dash.syncCodexSubagentDefaultsHint": "打开后,上面选的模型会写进 Codex 自己的配置,新任务一开始也用它。关闭则只在这里记住。下次同步或重启后生效,你手写的 [agents] 设置不会被改动。", "dash.multiAgentGuidance": "将名单用于工作者指导", - "dash.multiAgentGuidanceHint": "在 CodexCommander 指导中点名可用的名单模型。不强制委派,也不为每个子任务路由;v1 仅在 max 或 ultra 时显示指导。", + "dash.multiAgentGuidanceHint": "在 CodexCommander 指导中点名可用的名单模型。不强制委派,也不为每个子任务路由;V1 仅在 max 或 ultra 时显示指导。", "dash.injectionNone": "无", "dash.injectionEffortLabel": "推理强度", "dash.injectionEffortNone": "模型默认", @@ -434,9 +446,9 @@ export const zh: Record = { "models.collaborationTitle": "协作", "models.change": "更改", "models.newSessionsOnly": "保存、应用、启动新任务", - "models.modeLabel_v1": "可靠 v1", + "models.modeLabel_v1": "可靠 V1", "models.modeLabel_default": "Codex 原生", - "models.modeLabel_v2": "并发 v2", + "models.modeLabel_v2": "并发 V2", "models.modeStatus_v1": "灵活选择模型", "models.modeStatus_default": "Codex 默认值", "models.modeStatus_v2": "并发代理会话", @@ -445,7 +457,7 @@ export const zh: Record = { "models.modeDesc_v2": "运行新版并发子代理工作流。", "models.modeOptionDesc_v1": "经典工具,每次生成都可直接选择模型。", "models.modeOptionDesc_default": "遵循 Codex 的上游模型固定设置和原生功能标志。", - "models.modeOptionDesc_v2": "为所有模型强制使用新版并发工作流。", + "models.modeOptionDesc_v2": "为所有模型使用新版并发工作流。", "models.contextTitle": "上下文", "models.contextStateUncapped": "不设上限", "models.contextStateLimited": "限制为 {value}", @@ -496,14 +508,14 @@ export const zh: Record = { "models.contextCapLabel": "上下文限制", "models.v2Label": "子代理", "models.shadowCallOriginal": "⚠ {models} →", - "models.v2Mode_v1": "v1", + "models.v2Mode_v1": "V1", "models.v2Mode_default": "base", - "models.v2Mode_v2": "v2", - "models.v2ModeDesc_v1": "所有模型 → v1 界面", - "models.v2ModeDesc_default": "上游默认值 (sol/terra=v2, luna=v1)", - "models.v2ModeDesc_v2": "所有模型 → v2 界面", - "models.v2Help": "控制所有模型的多代理界面。\n\nv1: 经典单线程代理。所有模型使用 v1 协作界面。\nbase: 上游默认值 — sol/terra 使用 v2,luna 使用 v1,其余跟随 codex 功能标志。\nv2: 多线程代理(spawn_agent)。所有模型使用 v2 协作界面。\n\n先保存。如果 Codex 工作者正在运行,请用应用替换它,然后为会话绑定的工具架构启动新任务。仅启动新任务不会重新加载现有工作器。", - "models.v2DocsLink": "v1 / v2 是什么?", + "models.v2Mode_v2": "V2", + "models.v2ModeDesc_v1": "所有模型 → V1 协议", + "models.v2ModeDesc_default": "上游默认值 (sol/terra=V2, luna=V1)", + "models.v2ModeDesc_v2": "所有模型 → V2 协议", + "models.v2Help": "控制所有模型的多代理协议。\n\nV1: 经典单线程代理。所有模型使用 V1 协议。\nbase: 上游默认值 — sol/terra 使用 V2,luna 使用 V1,其余跟随 codex 功能标志。\nV2: 多线程代理(spawn_agent)。所有模型使用 V2 协议。\n\n先保存。如果 Codex 工作者正在运行,请用应用替换它,然后为会话绑定的工具架构启动新任务。仅启动新任务不会重新加载现有工作器。", + "models.v2DocsLink": "V1 / V2 是什么?", "dash.multiAgent": "子代理", "models.v2Conflict": "[agents] max_threads 仍存在 — codex 将拒绝启动,请从 config.toml 移除", "models.v2Applied": "子代理模式已保存。对运行中的工作器应用后,再为会话绑定的更改启动新任务。", @@ -577,7 +589,7 @@ export const zh: Record = { // subagents "sub.pageTitle": "代理指挥中心", - "sub.pageSubtitle": "选择工作者指导模型、指导方式和子任务回退行为。", + "sub.pageSubtitle": "选择子代理可用的模型、指导所用的模型以及回退顺序。", "sub.desktopPickerLimit": "此 roster 控制 spawn_agent,而不是 Codex Desktop 顶层模型选择器。Desktop 可能通过远程 allowlist 隐藏路由提供商 id;请使用“组合 → 原生 OpenAI 别名”创建显式兼容映射。", "sub.featured": "已配置名单", "sub.noneSelected": "尚无快捷选择。", @@ -585,7 +597,7 @@ export const zh: Record = { "sub.search": "搜索模型", "sub.settings": "运行策略", "sub.delegation.model": "首选指导模型", - "sub.delegation.modelHint": "CodexCommander 在指导中首先点名的模型。活跃名单仍可用于显式覆盖。", + "sub.delegation.modelHint": "CodexCommander 在指导中首先点名的模型。配置名单仍可用于显式覆盖。", "sub.saved": "已保存 {n} 个快捷选择。磁盘和生成的目录已是最新,但没有重启任何 Codex 工作者。", "sub.savedExcluded": "已保存 {n} 个快捷选择,但其中 {missing} 个当前未在所选代理界面中展示。", "sub.savedRefreshFailed": "已将 {n} 个快捷选择保存到磁盘,但目录未能正常刷新。未重启现有工作器。", @@ -628,25 +640,25 @@ export const zh: Record = { "sub.libraryCount": "{n} 个目录模型", "sub.libraryHint": "目录中的模型仍可按确切 ID 调用。名单控制向 spawn_agent 公布的五个模型,但不会强制工作者或回退选择。", "sub.noMatchingModels": "没有符合此搜索和筛选条件的模型。", - "sub.policyHint": "选择代理协议、工作者指导和已生成子任务的回退行为。策略独立于名单保存。", + "sub.policyHint": "设置代理协议、工作者指导和子任务回退。与名单分开保存。", "sub.policy.mode": "代理协议", "sub.policy.messageDelivery": "V2 消息传递", "sub.policy.messageDelivery_encrypted": "加密(原生)", "sub.policy.messageDelivery_plaintext": "明文兼容模式", "sub.policy.messageDeliveryHint_encrypted": "保留 ChatGPT 原生加密协议;外部 V2 工作者可能不可用。", - "sub.policy.messageDeliveryHint_plaintext": "实验功能。启用跨提供方 V2;此父级发送的 V2 任务消息均为明文。保存后启动新任务。仅更改消息传递时无需应用。", + "sub.policy.messageDeliveryHint_plaintext": "实验功能。启用跨提供方 V2;此父级发送的 V2 任务消息均为明文。仅更改消息传递时无需应用。", "sub.policy.preferred": "首选指导模型", - "sub.policy.noPreferred": "无首选模型 — Codex 从名单中选择", + "sub.policy.noPreferred": "无首选模型", "sub.policy.fallback": "全局子任务回退", - "sub.policy.fallbackHint": "在请求模型和角色回退之后用于已生成的子任务;不可用或受配额限制的候选项会被跳过。", + "sub.policy.fallbackHint": "当子任务请求的模型不可用时尝试;受配额限制的模型会被跳过。", "sub.policy.noFallback": "无回退", "sub.policy.concurrency": "线程上限", - "sub.policy.concurrencyHint": "V2 计算包含根代理的总线程数;V1 计算子线程数。留空恢复 Codex 默认值。保存;若工作器正在运行则应用,然后启动新任务。", + "sub.policy.concurrencyHint": "V2 计算包含根代理的总线程数;V1 计算子线程数。留空恢复 Codex 默认值。", "sub.policy.codexDefault": "Codex 默认值", "sub.policy.increaseConcurrency": "增加子代理并发数", "sub.policy.decreaseConcurrency": "减少子代理并发数", "sub.policy.save": "保存更改", - "sub.policy.saved": "运行策略已保存。对运行中的工作器应用后,再为协议、线程上限和会话绑定的工具架构启动新任务。V2 任务消息传递影响后续请求;指导和回退影响之后生成的子任务。", + "sub.policy.saved": "运行策略已保存。", "sub.policy.saveFailed": "部分运行策略更改无法保存。您未保存的选择仍会显示。", "sub.policy.loading": "正在加载运行策略…", "sub.policy.retry": "重新加载策略", @@ -665,8 +677,8 @@ export const zh: Record = { "sub.policy.preferredEffortHint": "首选模型可用时,在指导中指定的推理级别。", "sub.policy.guidance": "将名单用于工作者指导", "sub.policy.guidanceHint": "在指导中点名可用的名单模型。不强制委派,也不为每个子任务路由。", - "sub.policy.compatibilityV2": "Codex 原生和并发 v2 可以发送外部提供方无法读取的原生加密 V2 任务(#92)。跨提供方 V2 请选择明文兼容模式,或使用可靠 v1。仅选择协议不会更新过期工作器。", - "sub.policy.compatibilityV2Plaintext": "已启用实验性的跨提供方 V2。此父级的 V2 任务消息传递均为明文,包括发给原生工作者的消息。V2 本身不会激活过期 Codex 工作者;未知协议仍会安全失败。", + "sub.policy.compatibilityV2": "V2 任务已加密;外部提供方无法读取。跨提供方 V2 请选择明文兼容模式,既有路径请使用可靠 V1。", + "sub.policy.compatibilityV2Plaintext": "已启用实验性的跨提供方 V2。此父级的任务消息传递均为明文,包括发给原生工作者的消息。", "sub.policy.subagentCap": "子代理 effort 上限", "sub.policy.subagentCapHint": "限制子代理的 effort,但不会提升更低的请求。", "sub.filter.label": "按能力筛选模型", @@ -762,6 +774,7 @@ export const zh: Record = { "logs.metric.effortTitle": "请求的推理强度 → CodexCommander 发送的确切值;不代表提供商已应用", "logs.metric.estimatedCostTitle": "按 API 标价估算,并非实际扣费;价格无法匹配时不显示", "usage.cost.total": "API 标价折算(当前范围)", + "usage.cost.unavailable": "不可用", "usage.cost.disclaimer": "这不是账单或扣费凭证。实际可能计入订阅用量或消耗服务商额度。", "usage.cost.unpricedNote": "已排除 {count} 个无法计费的请求", "logs.detail.section.basic": "基本信息", @@ -862,7 +875,7 @@ export const zh: Record = { // usage page "usage.title": "用量", - "usage.subtitle": "代理本地的 Token 用量统计。缺失的用量不会显示为零。", + "usage.subtitle": "代理跟踪的请求和 Token。读取失败时不会显示为零。", "usage.loading": "正在加载用量数据…", "usage.empty": "尚无用量记录。通过代理发送请求后将在此显示。", "usage.loadError": "无法加载用量数据。", @@ -1776,6 +1789,11 @@ export const zh: Record = { "pws.metricTokens": "令牌", "pws.usageUnavailable": "尚无用量记录。", "pws.rateLimits": "速率限制", + "pws.quota.unavailableTitle": "配额不可用", + "pws.quota.signInRequired": "需要登录", + "pws.quota.loginNeedsRefresh": "需要刷新登录", + "pws.quota.upstreamUnavailable": "暂时不可用", + "pws.quota.retry": "重试", "pws.quotaUnavailable": "此提供商暂无配额数据。", "pws.planLimits": "套餐上限与本地观测", "pws.reference.intro": "OpenCode Go 不提供实时剩余额度 API。这里将公开上限与通过此 CodexCommander 代理观测到的流量并列显示。", diff --git a/gui/src/intl-formatters.ts b/gui/src/intl-formatters.ts index e096931b79..d9d65e953a 100644 --- a/gui/src/intl-formatters.ts +++ b/gui/src/intl-formatters.ts @@ -68,3 +68,17 @@ export function formatEstimatedUsdValue(value: number, locale?: string): string }).format(value); return `~${formatted}`; } + +/** + * Format a DEFINED zero USD estimate for display (locale-aware "$0.00"-equivalent, 2 + * fraction digits). A defined zero must read as a zeroed amount — never "Unavailable" + * and never the ~$0.0000 estimate rendering. + */ +export function formatEstimatedUsdZero(locale?: string): string { + return cachedNumberFormat(locale, { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(0); +} diff --git a/gui/src/pages/ClaudeCode.tsx b/gui/src/pages/ClaudeCode.tsx index 92447180ef..643567755d 100644 --- a/gui/src/pages/ClaudeCode.tsx +++ b/gui/src/pages/ClaudeCode.tsx @@ -272,7 +272,7 @@ export default function ClaudeCode({ apiBase, active = true }: { apiBase: string onClick={() => setSelectedSection(s.id)} aria-current={selectedSection === s.id ? "true" : undefined} > - {s.label} + {s.label} ))} diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 4d7d7e0621..dc6259012d 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1640,7 +1640,7 @@ export default function Models({ apiBase }: { apiBase: string }) { onClick={() => setSelectedProvider(null)} aria-current={selectedProvider === null ? "true" : undefined} > - {t("models.workspace.allProviders")} + {t("models.workspace.allProviders")} {t("models.active", { active: effectiveVisibleCount, total: models.length })} {groups.map(group => { @@ -1661,7 +1661,7 @@ export default function Models({ apiBase }: { apiBase: string }) { onClick={() => setSelectedProvider(provider)} aria-current={selectedProvider === provider ? "true" : undefined} > - {formatProviderDisplayName(provider, t)} + {formatProviderDisplayName(provider, t)} {t("models.active", { active: activeCount, total: rows.length })} ); diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index b7f179d7bc..70b9aa7519 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -403,6 +403,9 @@ export default function Providers({ apiBase }: { apiBase: string }) { usageTotals={data.usageTotals} modelUsage={data.modelUsage} quotaReport={data.quotaReport} + quotaUnavailableReason={data.quotaUnavailableReason} + onRetryQuota={data.onRetryQuota} + accountNeedsReauth={data.accountNeedsReauth} availableModels={data.availableModels} hasLiveModels={data.hasLiveModels} selectedModels={data.selectedModels} diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 345a419b7a..f0b2a538a6 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -1,95 +1,26 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; import { formatProviderDisplayName } from "../provider-icons"; import { formatTokens } from "../format-tokens"; -import { formatEstimatedUsdValue as formatUsdEstimate } from "../intl-formatters"; -import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; +import { formatEstimatedUsdValue as formatUsdEstimate, formatEstimatedUsdZero } from "../intl-formatters"; import { EmptyState, Notice } from "../ui"; import { modelLabel } from "../model-display"; -import { useDataSurface } from "../data-surface"; +import { classifyDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; import { SectionTabs } from "../components/section-tabs"; import { sectionAnchorId } from "../section-anchors"; - -type Range = "all" | "30d" | "7d"; -type UsageSurface = "all" | "codex" | "claude" | "grok"; - -interface UsageSummaryTotals { - requests: number; - measuredRequests: number; - reportedRequests: number; - unreportedRequests: number; - unsupportedRequests: number; - estimatedRequests: number; - inputTokens: number; - outputTokens: number; - cachedInputTokens: number; - cacheReadInputTokens?: number; - cacheCreationInputTokens?: number; - reasoningOutputTokens: number; - totalTokens: number; - coverageRatio: number; - estimatedCostUsd?: number; - pricedRequests?: number; - unpricedRequests?: number; - unmeteredRequests?: number; -} - -interface UsageDay { - date: string; - requests: number; - measuredRequests: number; - reportedRequests: number; - totalTokens: number; - models: UsageDayModel[]; -} - -interface UsageDayModel { - model: string; - provider: string; - requests: number; - totalTokens: number; -} - -interface UsageModel { - provider: string; - model: string; - resolvedModel?: string; - requests: number; - measuredRequests: number; - reportedRequests: number; - estimatedRequests: number; - totalTokens: number; - inputTokens: number; - outputTokens: number; - shareRatio: number; -} - -interface UsageProvider { - provider: string; - requests: number; - measuredRequests: number; - reportedRequests: number; - estimatedRequests: number; - totalTokens: number; - shareRatio: number; -} - -interface UsageResponse { - range: Range; - surface: UsageSurface; - since: number | null; - generatedAt: number; - summary: UsageSummaryTotals; - days: UsageDay[]; - models: UsageModel[]; - providers: UsageProvider[]; - historyTruncated: boolean; - truncatedPrefixBytes: number; - entriesTruncated: boolean; - entriesDropped: number; - error?: string; -} +import { useUsageReport } from "../usage-report-store"; +import { + type UsageDay, + type UsageModel, + type UsageProvider, + type UsageRange, + type UsageReport, + type UsageSummaryTotals, + type UsageSurface, +} from "../usage-report-validation"; + +type Range = UsageRange; function formatPct(ratio: number): string { return `${Math.round(ratio * 100)}%`; @@ -294,11 +225,21 @@ function UsageSummaryCards({
    {t("usage.card.coverage")}
    {formatPct(summary.coverageRatio)}
    {t("usage.card.activeDays")}
    {activeDays}
    - {summary.estimatedCostUsd !== undefined && ( + {summary.estimatedCostUsd === undefined ? ( + // "Unavailable" is reachable ONLY through legacy (unvalidated) seeds: the DTO + // requires the cost fields and every fetched report is validated before caching, + // so a validated report can never take this branch. +
    + {t("usage.cost.total")} + {t("usage.cost.unavailable")} +
    + ) : (
    {t("usage.cost.total")} - {formatUsdEstimate(summary.estimatedCostUsd, locale)} + {summary.estimatedCostUsd === 0 + ? formatEstimatedUsdZero(locale) + : formatUsdEstimate(summary.estimatedCostUsd, locale)} {t("usage.cost.disclaimer")} {((summary.unpricedRequests ?? 0) + (summary.unmeteredRequests ?? 0)) > 0 && ( @@ -658,7 +599,7 @@ function UsageWorkspaceBody({ locale, t, }: { - data: UsageResponse | null; + data: UsageReport | null; heatmap: ReturnType; weekBars: UsageDay[]; activeDays: number; @@ -731,50 +672,19 @@ function UsageWorkspaceBody({ ); } -/** Held usage payloads so provider/surface tab switches skip a cold ~5s refetch. */ -const usageMemoryCache = new Map(); - -function usageCacheKey(apiBase: string, range: Range, surface: UsageSurface): string { - return `ccx.usage.v1:${apiBase}:${range}:${surface}`; -} - -function readHeldUsage(apiBase: string, range: Range, surface: UsageSurface): UsageResponse | null { - const key = usageCacheKey(apiBase, range, surface); - return usageMemoryCache.get(key) ?? readSessionListCache(key); -} - -function writeHeldUsage(apiBase: string, range: Range, surface: UsageSurface, value: UsageResponse) { - const key = usageCacheKey(apiBase, range, surface); - usageMemoryCache.set(key, value); - writeSessionListCache(key, value); -} - export default function Usage({ apiBase }: { apiBase: string }) { const { t, locale } = useI18n(); const [range, setRange] = useState("30d"); const [surface, setSurface] = useState("all"); const [modelQuery, setModelQuery] = useState(""); - const loadUsage = useCallback(async (signal: AbortSignal): Promise => { - const response = await fetch(`${apiBase}/api/usage?range=${range}&surface=${surface}`, { signal }); - if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim()); - const next = await response.json() as UsageResponse; - writeHeldUsage(apiBase, range, surface, next); - return next; - }, [apiBase, range, surface]); - - const resourceKey = usageCacheKey(apiBase, range, surface); - const cached = readHeldUsage(apiBase, range, surface); - // Range and surface identify different reports, so the key changes with both. That prevents - // a force-loading dependency revalidation from ever showing a previous report as this one. - const resource = useDataSurface( - resourceKey, - [apiBase, range, surface], - loadUsage, - { isEmpty: () => false, initialData: cached ?? undefined }, - ); - const { state } = resource; - const data = state.data ?? cached ?? null; + // The usage-report domain store owns fetching, singleflight dedupe, and persistence. + // Range and surface identify different reports, so the key changes with both and a + // previous report is never shown as this one. The Dashboard's 30d/all selector shares + // this exact entry, collapsing what used to be three independent fetches into one. + const report = useUsageReport(apiBase, range, surface); + const state = classifyDataSurface(report, () => false, true); + const data = state.data ?? null; const heatmap = useMemo(() => buildHeatmap(data?.days ?? []), [data?.days]); const weekBars = useMemo(() => lastSevenDays(data?.days ?? []), [data?.days]); @@ -808,8 +718,13 @@ export default function Usage({ apiBase }: { apiBase: string }) { ) : state.kind === "failed-cold" ? ( - {state.error instanceof Error ? `${t("usage.loadError")} ${state.error.message}` : t("usage.loadError")}{" "} - diff --git a/gui/src/pages/dashboard-core-poll.ts b/gui/src/pages/dashboard-core-poll.ts index d2314030f8..e4db864d1b 100644 --- a/gui/src/pages/dashboard-core-poll.ts +++ b/gui/src/pages/dashboard-core-poll.ts @@ -14,7 +14,6 @@ import { type SettingsData, type ShadowCallData, type SidecarData, - type UsageSummary30d, } from "./dashboard-shared"; import { parseShadowCallData } from "./shadow-call-source"; @@ -134,14 +133,6 @@ export async function fetchDashboardModels(apiBase: string, signal: AbortSignal) return requireJson(response); } -export async function fetchDashboardUsage(apiBase: string, signal: AbortSignal): Promise { - const response = await fetch(`${apiBase}/api/usage?range=30d`, { signal }); - // Usage can be expensive on an older server. Keeping it in its own resource means - // it cannot delay health/provider/settings commits, and a failed refresh retains - // the last good usage snapshot. - return requireJson(response); -} - /** Web-search / vision sidecar + shadow-call — config reads, typically sub-10ms. */ export async function fetchDashboardSidecars( apiBase: string, diff --git a/gui/src/pages/dashboard-overview-head.tsx b/gui/src/pages/dashboard-overview-head.tsx index 31e85eb1a1..9fa0f5b314 100644 --- a/gui/src/pages/dashboard-overview-head.tsx +++ b/gui/src/pages/dashboard-overview-head.tsx @@ -1,6 +1,7 @@ import { IconAlert, IconInfo } from "../icons"; import { type TKey, useT } from "../i18n/shared"; import { formatTokens } from "../format-tokens"; +import { formatEstimatedUsdValue, formatEstimatedUsdZero } from "../intl-formatters"; import { formatUptime } from "../formatUptime"; import type { useDashboardData } from "./use-dashboard-data"; @@ -81,6 +82,28 @@ export function DashboardOverviewHead({ : "\u00a0"}
    +
    +
    {t("dash.cost30d")}
    +
    + {usage30d && usage30d.summary.estimatedCostUsd !== undefined + ? usage30d.summary.estimatedCostUsd === 0 + ? formatEstimatedUsdZero(locale) + : formatEstimatedUsdValue(usage30d.summary.estimatedCostUsd, locale) + : "—"} +
    +
    + {usage30d && usage30d.summary.estimatedCostUsd !== undefined + && (usage30d.summary.pricedRequests + + usage30d.summary.unpricedRequests + + usage30d.summary.unmeteredRequests) > 0 + ? t("dash.cost30dCoverage", { + priced: usage30d.summary.pricedRequests, + unpriced: usage30d.summary.unpricedRequests, + unmetered: usage30d.summary.unmeteredRequests, + }) + : "\u00a0"} +
    +
    diff --git a/gui/src/pages/dashboard-overview-panels.tsx b/gui/src/pages/dashboard-overview-panels.tsx index 8009d04423..0518c2030a 100644 --- a/gui/src/pages/dashboard-overview-panels.tsx +++ b/gui/src/pages/dashboard-overview-panels.tsx @@ -1,5 +1,6 @@ import MemoryObservabilityCard from "../components/MemoryObservabilityCard"; import type { useDashboardData } from "./use-dashboard-data"; +import { DashboardPlanQuotaSection } from "./dashboard-plan-quota-section"; import { DashboardEffortCapPanel, DashboardInjectionPanel, @@ -18,6 +19,7 @@ export function DashboardOverviewPanels(props: Dash) {
    + ); diff --git a/gui/src/pages/dashboard-plan-quota-section.tsx b/gui/src/pages/dashboard-plan-quota-section.tsx new file mode 100644 index 0000000000..33a7bd92bd --- /dev/null +++ b/gui/src/pages/dashboard-plan-quota-section.tsx @@ -0,0 +1,162 @@ +/** + * Dashboard "Plan & quota" section. + * + * Fed by the shared provider-quota store (keyed by apiBase) — the same entry the + * Providers workspace shell selects, so both surfaces dedupe into one fetch and share + * last-known-good data. Reuses the workspace parsing (accountQuotaFromReport / + * capacityAggregationFromReport / referenceQuotaFromReport) and the + * ProviderCapacityQuota presentation so both surfaces share semantics: per-provider + * plan, 5h/week/month windows, and observed reference spend vs published caps — + * always labeled provider-reported / estimates, never billed spend. + * + * Styling reuses existing dashboard tokens/classes (panel, dash-sidecar-grid, + * pws-capacity-*, muted/text-caption/mono); no new stylesheet is introduced. + */ +import { useEffect } from "react"; +import { useI18n, useT, type TFn, type TKey } from "../i18n/shared"; +import { useProviderQuota } from "../provider-quota-store"; +import { formatProviderDisplayName } from "../provider-icons"; +import { quotaUnavailableReasonKey } from "../quota-unavailable"; +import { providerRouteHash } from "../provider-route"; +import { + formatQuotaSourceLabel, + referenceQuotaFromReport, + type ProviderQuotaReferenceWindowView, + type ProviderQuotaReportView, +} from "../provider-workspace/report"; +import { formatRequestCount, formatTokenCount } from "../provider-workspace/usage"; +import { ProviderCapacityQuota } from "../components/provider-workspace/ProviderCapacityQuota"; + +const REFERENCE_WINDOW_KEYS: Record = { + five_hour: "pws.reference.fiveHour", + weekly: "pws.reference.weekly", + monthly: "pws.reference.monthly", +}; + +function referenceObservedLabel( + window: ProviderQuotaReferenceWindowView, + locale: string, + t: TFn, +): string { + if (window.observedRequests === 0) return t("pws.reference.noTraffic"); + if (window.observedSpendUsd !== undefined) { + const amount = new Intl.NumberFormat(locale, { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: window.observedSpendUsd < 0.01 ? 4 : 2, + }).format(window.observedSpendUsd); + return t("pws.reference.spendObserved", { amount }); + } + if (window.observedTokens > 0) { + return t("pws.reference.tokensObserved", { tokens: formatTokenCount(window.observedTokens, locale) }); + } + return t("pws.reference.requestsObserved", { requests: formatRequestCount(window.observedRequests, locale) }); +} + +function PlanQuotaReference({ report, locale }: { report: ProviderQuotaReportView; locale: string }) { + const t = useT(); + const reference = referenceQuotaFromReport(report); + if (!reference) return null; + const money = (amount: number) => new Intl.NumberFormat(locale, { + style: "currency", + currency: "USD", + maximumFractionDigits: amount >= 10 ? 0 : 2, + }).format(amount); + return ( +
    +
    {t("dash.planQuota.referenceIntro")}
    + {reference.windows.map(window => ( +
    + {t(REFERENCE_WINDOW_KEYS[window.id])} + + {t("pws.reference.publishedCap", { amount: money(window.publishedLimitUsd) })} + + {referenceObservedLabel(window, locale, t)} +
    + ))} +
    + ); +} + +export function DashboardPlanQuotaSection({ apiBase }: { apiBase: string }) { + const t = useT(); + const { locale } = useI18n(); + const quota = useProviderQuota(apiBase); + // First subscriber on this surface: fetch (singleflight dedupes against the + // Providers workspace shell; a rehydrated session seed quiet-revalidates). + const ensure = quota.ensure; + useEffect(() => { + ensure(); + }, [ensure]); + + const entries = Object.entries(quota.reports); + const unavailable = quota.unavailableProviders; + const hasRetryableUnavailable = unavailable.some( + ({ reason }) => reason !== "reauth_required", + ); + return ( +
    +
    +

    {t("dash.planQuota.title")}

    + {t("dash.planQuota.hint")} +
    + {entries.length === 0 && unavailable.length === 0 ? ( +

    + {quota.loading ? t("dash.planQuota.loading") : t("dash.planQuota.empty")} +

    + ) : ( + entries.length > 0 && ( +
    + {entries.map(([provider, report]) => ( +
    +
    + {formatProviderDisplayName(provider, t)} + {report.source?.trim() && ( + {formatQuotaSourceLabel(report.source)} + )} +
    + + +
    + ))} +
    + ) + )} + {unavailable.length > 0 && ( +
    + {t("dash.planQuota.unavailable")} + {unavailable.map(({ provider, reason }) => { + const display = formatProviderDisplayName(provider, t); + return ( + + + {display} + {" — "} + {t(quotaUnavailableReasonKey(reason))} + + {reason === "reauth_required" && ( + + + + {t("dash.planQuota.manageProvider", { provider: display })} + + + )} + + ); + })} + {hasRetryableUnavailable && ( + + )} +
    + )} +

    {t("dash.planQuota.disclaimer")}

    +
    + ); +} diff --git a/gui/src/pages/dashboard-shared.ts b/gui/src/pages/dashboard-shared.ts index bfa44cbc21..b77f883767 100644 --- a/gui/src/pages/dashboard-shared.ts +++ b/gui/src/pages/dashboard-shared.ts @@ -50,7 +50,17 @@ export interface SidecarPatch { vision?: { backend?: SidecarBackend | null; model?: string }; } export type { ShadowCallData } from "./shadow-call-source"; -export interface UsageSummary30d { summary: { requests: number; totalTokens: number; coverageRatio: number } } +export interface UsageSummary30d { + summary: { + requests: number; + totalTokens: number; + coverageRatio: number; + estimatedCostUsd: number; + pricedRequests: number; + unpricedRequests: number; + unmeteredRequests: number; + }; +} export interface SyncResult { ok: boolean; added: number; diff --git a/gui/src/pages/integrations/OpenCodeIntegrationPage.tsx b/gui/src/pages/integrations/OpenCodeIntegrationPage.tsx index e784e640da..8290b28ea0 100644 --- a/gui/src/pages/integrations/OpenCodeIntegrationPage.tsx +++ b/gui/src/pages/integrations/OpenCodeIntegrationPage.tsx @@ -252,7 +252,7 @@ export default function OpenCodeIntegrationPage({
    {t("integrations.destination")}
    -
    {homeDisplayPath(integration.targetPath)}
    +
    {homeDisplayPath(integration.targetPath)}
    {t("integrations.models")}
    diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 8203403218..941b4fd261 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -16,12 +16,12 @@ import { fetchDashboardOverview, fetchDashboardSettings, fetchDashboardSidecars, - fetchDashboardUsage, fetchProjectConfigDiagnostics, fetchStartupHealth, normalizeInjectionSelection, type DashboardEpochRefs, } from "./dashboard-core-poll"; +import { useUsageReport } from "../usage-report-store"; import { type DashboardSection, type HealthData, @@ -43,7 +43,6 @@ import { const CONTROLS_CACHE_PREFIX = "ccx.dash.controls.v1:"; const OVERVIEW_CACHE_PREFIX = "ccx.dash.overview.v1:"; -const USAGE_CACHE_PREFIX = "ccx.dash.usage30d.v1:"; const STARTUP_CACHE_PREFIX = "ccx.dash.startup.v1:"; const MA_MODE_CACHE_PREFIX = "ccx.dash.maMode.v1:"; @@ -78,10 +77,6 @@ export function useDashboardData(apiBase: string) { () => readSessionListCache(`${OVERVIEW_CACHE_PREFIX}${apiBase}`), [apiBase], ); - const cachedUsage = useMemo( - () => readSessionListCache(`${USAGE_CACHE_PREFIX}${apiBase}`), - [apiBase], - ); const cachedStartup = useMemo(() => { const cached = readSessionListCache(`${STARTUP_CACHE_PREFIX}${apiBase}`); return cached === "error" ? null : cached; @@ -97,7 +92,6 @@ export function useDashboardData(apiBase: string) { const [settings, setSettings] = useState(() => cachedControls?.settings ?? null); const [sidecar, setSidecar] = useState(() => cachedControls?.sidecar ?? null); const [shadowCall, setShadowCall] = useState(() => cachedControls?.shadowCall ?? null); - const [usage30d, setUsage30d] = useState(() => cachedUsage); const [sidecarSaving, setSidecarSaving] = useState(false); const [shadowCallSaving, setShadowCallSaving] = useState(false); const [modelsLoading, setModelsLoading] = useState(false); @@ -221,12 +215,41 @@ export function useDashboardData(apiBase: string) { { pollMs: 5000, enabled: overviewReady }, ); - const usagePoll = useKeyedClientResource( - `dashboard-usage:${apiBase}`, - [apiBase], - (signal) => fetchDashboardUsage(apiBase, signal), - { pollMs: 60_000, enabled: overviewReady }, - ); + // Usage is owned by the shared usage-report store (key `${apiBase}:30d:all`). The Usage + // page selects the same entry, so both surfaces dedupe into one in-flight fetch. Keeping + // it out of the client-resource polls means usage can never delay health/settings commits. + const usageReport = useUsageReport(apiBase, "30d", "all"); + const usage30d = useMemo(() => { + const report = usageReport.data; + if (!report) return null; + return { + summary: { + requests: report.summary.requests, + totalTokens: report.summary.totalTokens, + coverageRatio: report.summary.coverageRatio, + estimatedCostUsd: report.summary.estimatedCostUsd, + pricedRequests: report.summary.pricedRequests, + unpricedRequests: report.summary.unpricedRequests, + unmeteredRequests: report.summary.unmeteredRequests, + }, + }; + }, [usageReport.data]); + + // The dashboard used to poll /api/usage?range=30d every 60s. Usage is now owned by the + // usage-report store, so while the Dashboard is mounted we quiet-revalidate the 30d/all + // entry on the same cadence. replace:false keeps singleflight semantics: a poll skips + // when another fetch is in flight and never aborts it, and quiet refreshes retain the + // last-good data (no skeleton flash). + const refreshUsage30d = usageReport.refresh; + useEffect(() => { + const timer = window.setInterval(() => { + // Same pause-when-hidden behavior the old client-resource poll had: a background + // tab has nobody reading the paint, so skip the quiet revalidation until visible. + if (typeof document !== "undefined" && document.visibilityState === "hidden") return; + refreshUsage30d({ replace: false }); + }, 60_000); + return () => window.clearInterval(timer); + }, [refreshUsage30d]); const diagnosticsPoll = useKeyedClientResource( `dashboard-diagnostics:${apiBase}`, @@ -336,13 +359,6 @@ export function useDashboardData(apiBase: string) { } }, [settingsPoll.data, apiBase]); - useEffect(() => { - if (usagePoll.data !== undefined) { - setUsage30d(usagePoll.data); - writeSessionListCache(`${USAGE_CACHE_PREFIX}${apiBase}`, usagePoll.data); - } - }, [usagePoll.data, apiBase]); - useEffect(() => { if (diagnosticsPoll.data) setProjectConfigWarnings(diagnosticsPoll.data); }, [diagnosticsPoll.data]); @@ -544,7 +560,7 @@ export function useDashboardData(apiBase: string) { modelQuery, setModelQuery, expandedProviders, setExpandedProviders, health, startupHealth, providers, models, settings, sidecar, shadowCall, usage30d, - usageLoading: usagePoll.loading && !usage30d, + usageLoading: usageReport.loading && !usage30d, healthLoading: overviewPoll.loading && !health, sidecarSaving, shadowCallSaving, modelsLoading, settingsSaving, syncing, maMode, maModeResolved, maBusy, setMaHelpOpen, maHelpOpen, diff --git a/gui/src/provider-quota-store.ts b/gui/src/provider-quota-store.ts new file mode 100644 index 0000000000..5bb108e845 --- /dev/null +++ b/gui/src/provider-quota-store.ts @@ -0,0 +1,615 @@ +/** + * Domain store for GET /api/provider-quotas. + * + * Keyed by apiBase and shared by the Providers workspace shell and the Dashboard + * "Plan & quota" section, so both surfaces dedupe into one in-flight fetch and + * share the same last-known-good data. The shell's quotaRefreshEpoch / + * quotaForceRefresh semantics map to a refresh({ force }) action (force adds the + * server-side ?refresh=1 TTL bypass). + * + * Privacy invariant: only quota reports (provider/label/source/quota/updatedAt + + * aggregation) and a timestamp are persisted — never account emails or ids. The + * wire shape from src/providers/quota.ts already avoids identities; this store + * does not add any. + */ + +import { create } from "zustand"; +import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; +import { useCallback, useMemo } from "react"; +import { + capacityAggregationFromReport, + type ProviderQuotaReportView, +} from "./provider-workspace/report"; + +export const PROVIDER_QUOTA_STORAGE_NAME = "ccx.provider-quotas.v1"; +/** Same freshness bound the workspace shell applied to its session cache. */ +const QUOTA_REPORT_MAX_AGE_MS = 30 * 60_000; + +export interface ProviderQuotaData { + reports: Record; + authAttention: Record; + /** + * In-memory quota availability per provider (status/reason/checkedAt), projected + * from the wire. Deliberately NOT persisted: the persisted slice stays reports + + * timestamp, so a hostile availability row can never reach sessionStorage. + */ + availability: Record; + updatedAt?: number; +} + +export interface ProviderQuotaAvailability { + status: string; + reason?: string; + checkedAt: number; +} + +export interface ProviderQuotaEntry extends ProviderQuotaData { + loading: boolean; + refreshing: boolean; + hasSucceeded: boolean; + lastAttemptOk: boolean; + error?: unknown; + /** True for a rehydrated seed: the first subscriber must quiet-revalidate. */ + seedNeedsRevalidate: boolean; +} + +export type ProviderQuotaResource = ProviderQuotaData & { + key: string; + error: unknown; + loading: boolean; + refreshing: boolean; + hasSucceeded: boolean; + lastAttemptOk: boolean; + /** Providers whose quota is unavailable and have no report, sorted by name. */ + unavailableProviders: Array<{ provider: string; reason?: string }>; + ensure: (opts?: { force?: boolean }) => void; + refresh: (opts?: { force?: boolean }) => void; +}; + +/** The persisted slice — quota reports + timestamp only. */ +interface PersistedQuotaSlice { + entries: Record; updatedAt: number }>; +} + +interface ProviderQuotaStoreState { + entries: Record; + /** One in-flight controller per apiBase; singleflight + cancellation. */ + inflight: Record; + ensure: (apiBase: string, opts?: { force?: boolean }) => void; + refresh: (apiBase: string, opts?: { force?: boolean }) => void; + clearForTests: () => void; +} + +/** + * Per-row ingest validation: keep a report row when it is fresh (updatedAt within + * QUOTA_REPORT_MAX_AGE_MS), has a quota object, and carries no malformed optional + * fields. Deep shape validation is left to the consumers' parsers + * (capacityAggregationFromReport / accountQuotaFromReport / referenceQuotaFromReport), + * which return null for unusable payloads. + */ +function quotaReportFromRow(value: unknown, now: number): ProviderQuotaReportView | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const row = value as Record; + if (typeof row.updatedAt !== "number" || !Number.isFinite(row.updatedAt)) return null; + if (now - row.updatedAt >= QUOTA_REPORT_MAX_AGE_MS) return null; + const quota = projectQuota(row.quota); + if (!quota) return null; + if (row.label !== undefined && typeof row.label !== "string") return null; + if (row.source !== undefined && typeof row.source !== "string") return null; + const aggregation = "aggregation" in row ? projectAggregation(row.aggregation) : null; + return { + ...(typeof row.label === "string" ? { label: row.label } : {}), + ...(typeof row.source === "string" ? { source: row.source } : {}), + updatedAt: row.updatedAt, + quota, + // ProviderQuotaReportView declares aggregation as required; consumers treat + // undefined as "no capacity aggregation" (capacityAggregationFromReport returns + // null), so reference-window-only reports stay representable. + aggregation: aggregation ?? undefined, + }; +} + +/** Strict display filter matching the workspace shell's prior session-cache behavior. */ +export function freshQuotaReport(value: unknown, now: number): ProviderQuotaReportView | null { + const report = quotaReportFromRow(value, now); + if (!report || report.aggregation === undefined) return null; + return capacityAggregationFromReport(report) ? report : null; +} + +export function freshQuotaReportRecord(value: unknown, now = Date.now()): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const out: Record = {}; + for (const [provider, raw] of Object.entries(value)) { + const report = freshQuotaReport(raw, now); + if (provider.trim() && report) out[provider] = report; + } + return out; +} + +function freshQuotaReportsFromResponse(value: unknown, now = Date.now()): Record { + if (!Array.isArray(value)) return {}; + const out: Record = {}; + for (const raw of value) { + const row = raw as Record | null; + const provider = row?.provider; + const report = row ? quotaReportFromRow(row, now) : null; + if (typeof provider === "string" && provider.trim() && report) out[provider] = report; + } + return out; +} + +/** + * The quota endpoint may discover an auth problem after the account list was read. + * Project only fixed, privacy-safe reason codes so an open surface cannot keep + * saying Connected until its next account refresh. + */ +export function quotaAuthAttentionFromResponse(value: unknown): Record { + if (!Array.isArray(value)) return {}; + const out: Record = {}; + for (const raw of value) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; + const row = raw as Record; + if (typeof row.provider !== "string" || !row.provider.trim()) continue; + if (row.reason === "reauth_required" || row.reason === "local_cli_refresh_required") { + out[row.provider] = true; + } + } + return out; +} + +/** Known, privacy-safe reason codes the UI maps to copy. Anything else is dropped. */ +const KNOWN_QUOTA_UNAVAILABLE_REASONS = new Set([ + "reauth_required", + "local_cli_refresh_required", + "upstream_unavailable", +]); +/** Wire availability statuses; anything else is dropped at ingest. */ +const KNOWN_QUOTA_AVAILABILITY_STATUSES = new Set(["available", "stale", "unavailable"]); + +/** + * Project availability rows onto { provider, status, reason?, checkedAt } only — + * provider + status + reason + checkedAt, nothing else (no identities, no raw + * provider errors). Mirrors the Mac app's ProviderQuotaAvailability decoding. + */ +export function quotaAvailabilityFromResponse(value: unknown): Record { + if (!Array.isArray(value)) return {}; + const out: Record = {}; + for (const raw of value) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; + const row = raw as Record; + if (typeof row.provider !== "string" || !row.provider.trim()) continue; + if (typeof row.status !== "string" || !KNOWN_QUOTA_AVAILABILITY_STATUSES.has(row.status)) continue; + const checkedAt = finiteNumber(row.checkedAt) ?? Date.now(); + const reason = + typeof row.reason === "string" && KNOWN_QUOTA_UNAVAILABLE_REASONS.has(row.reason) + ? row.reason + : undefined; + out[row.provider] = { + status: row.status, + ...(reason ? { reason } : {}), + checkedAt, + }; + } + return out; +} + +/** + * Providers with a non-available quota status AND no report entry, sorted by provider + * name. A provider with a report (even a stale last-known-good one) is not listed. + */ +export function unavailableQuotaProviders( + availability: Record, + reports: Record, +): Array<{ provider: string; reason?: string }> { + return Object.entries(availability) + .filter(([provider, row]) => row.status !== "available" && !(provider in reports)) + .map(([provider, row]) => ({ provider, ...(row.reason ? { reason: row.reason } : {}) })) + .sort((a, b) => a.provider.localeCompare(b.provider)); +} + +const sessionStorageLazy: PersistStorage = { + getItem: (name) => { + try { + const raw = sessionStorage.getItem(name); + if (!raw) return null; + return JSON.parse(raw) as StorageValue; + } catch { + return null; + } + }, + setItem: (name, value) => { + try { + sessionStorage.setItem(name, JSON.stringify(value)); + } catch { + /* private mode / no sessionStorage in this runtime */ + } + }, + removeItem: (name) => { + try { + sessionStorage.removeItem(name); + } catch { + /* ignore */ + } + }, +}; + +function emptyEntry(): ProviderQuotaEntry { + return { + reports: {}, + authAttention: {}, + availability: {}, + loading: false, + refreshing: false, + hasSucceeded: false, + lastAttemptOk: false, + seedNeedsRevalidate: false, + }; +} + +function finiteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +/** + * Project a ProviderQuota row onto the known keys only (percentages, resets, windows, + * referenceWindows, observedLimitEvent, updatedAt). A hostile or legacy server that + * stashes identity fields inside `quota` can never get them persisted: anything not on + * this allowlist is dropped at ingest. + */ +function projectQuota(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const row = value as Record; + const out: Record = {}; + for (const key of ["fiveHourPercent", "fiveHourResetAt", "weeklyPercent", "weeklyResetAt", "monthlyPercent", "monthlyResetAt"] as const) { + const n = finiteNumber(row[key]); + if (n !== undefined) out[key] = n; + } + const updatedAt = finiteNumber(row.updatedAt); + if (updatedAt !== undefined) out.updatedAt = updatedAt; + if (Array.isArray(row.customWindows)) { + const windows = row.customWindows.flatMap(raw => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const window = raw as Record; + const label = typeof window.label === "string" && window.label.trim() ? window.label : null; + const percent = finiteNumber(window.percent); + if (!label || percent === undefined) return []; + const resetAt = finiteNumber(window.resetAt); + return [{ label, percent, ...(resetAt !== undefined ? { resetAt } : {}) }]; + }); + if (windows.length > 0) out.customWindows = windows; + } + if (Array.isArray(row.referenceWindows)) { + const windows = row.referenceWindows.flatMap(raw => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const window = raw as Record; + const id = window.id; + const coverage = window.coverage; + const label = typeof window.label === "string" && window.label.trim() ? window.label : null; + const windowSeconds = finiteNumber(window.windowSeconds); + const publishedLimitUsd = finiteNumber(window.publishedLimitUsd); + const observedTokens = finiteNumber(window.observedTokens); + const observedRequests = finiteNumber(window.observedRequests); + const pricedRequests = finiteNumber(window.pricedRequests); + const unpricedRequests = finiteNumber(window.unpricedRequests); + const unmeasuredRequests = finiteNumber(window.unmeasuredRequests); + const validId = id === "five_hour" || id === "weekly" || id === "monthly"; + const validCoverage = coverage === "none" || coverage === "complete" || coverage === "partial" || coverage === "unpriced"; + if (!validId || !validCoverage || !label || windowSeconds === undefined || publishedLimitUsd === undefined + || observedTokens === undefined || observedRequests === undefined + || pricedRequests === undefined || unpricedRequests === undefined || unmeasuredRequests === undefined) return []; + const observedSpendUsd = finiteNumber(window.observedSpendUsd); + return [{ + id, + label, + windowSeconds, + publishedLimitUsd, + observedTokens, + observedRequests, + pricedRequests, + unpricedRequests, + unmeasuredRequests, + coverage, + ...(observedSpendUsd !== undefined ? { observedSpendUsd } : {}), + }]; + }); + if (windows.length > 0) out.referenceWindows = windows; + } + if (row.observedLimitEvent && typeof row.observedLimitEvent === "object" && !Array.isArray(row.observedLimitEvent)) { + const event = row.observedLimitEvent as Record; + const limitName = event.limitName; + const observedAt = finiteNumber(event.observedAt); + if ((limitName === "5 hour" || limitName === "weekly" || limitName === "monthly") && observedAt !== undefined) { + const resetAt = finiteNumber(event.resetAt); + out.observedLimitEvent = { + limitName, + observedAt, + ...(resetAt !== undefined ? { resetAt } : {}), + }; + } + } + return Object.keys(out).length > 0 ? out : null; +} + +function projectCapacityWindow(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const row = value as Record; + const usedPercent = finiteNumber(row.usedPercent); + if (usedPercent === undefined) return null; + const out: Record = { usedPercent }; + for (const key of ["includedAccounts", "excludedAccounts", "nextRecoveryAt", "nextRecoveryPercent"] as const) { + const n = finiteNumber(row[key]); + if (n !== undefined) out[key] = n; + } + if (typeof row.incomplete === "boolean") out.incomplete = row.incomplete; + const updatedAt = finiteNumber(row.updatedAt); + if (updatedAt !== undefined) out.updatedAt = updatedAt; + return out; +} + +/** + * Project a CodexCapacityAggregation onto its known keys so identity-like fields cannot + * ride along into the persisted slice (aggregation carries currentAccount with + * plan/quota only; anything else is dropped). + */ +function projectAggregation(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const row = value as Record; + if (row.kind !== "capacity-weighted-v1" || row.scope !== "routable-known") return null; + const presentation = row.presentation; + if (presentation !== "aggregate" && presentation !== "effective-account-fallback" && presentation !== "coverage-only") return null; + const out: Record = { kind: row.kind, scope: row.scope, presentation }; + for (const key of ["excludedAccounts", "unknownPlanAccounts", "partialWindowAccounts", "includedAccounts"] as const) { + const n = finiteNumber(row[key]); + if (n !== undefined) out[key] = n; + } + if (typeof row.incomplete === "boolean") out.incomplete = row.incomplete; + for (const key of ["fiveHour", "weekly", "monthly"] as const) { + const window = projectCapacityWindow(row[key]); + if (window) out[key] = window; + } + if (Array.isArray(row.customWindows)) { + const windows = row.customWindows.flatMap(raw => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const entry = raw as Record; + const label = typeof entry.label === "string" && entry.label.trim() ? entry.label : null; + const window = projectCapacityWindow(entry); + return label && window ? [{ label, ...window }] : []; + }); + if (windows.length > 0) out.customWindows = windows; + } + if (row.currentAccount && typeof row.currentAccount === "object" && !Array.isArray(row.currentAccount)) { + const account = row.currentAccount as Record; + const projected: Record = {}; + if (typeof account.plan === "string" || account.plan === null) projected.plan = account.plan; + if (typeof account.isMain === "boolean") projected.isMain = account.isMain; + if (account.quota === null) { + projected.quota = null; + } else { + const quota = projectQuota(account.quota); + if (quota) projected.quota = quota; + } + if (Object.keys(projected).length > 0) out.currentAccount = projected; + } + return out; +} + +function fetchQuotas( + set: (partial: Partial | ((state: ProviderQuotaStoreState) => Partial)) => void, + get: () => ProviderQuotaStoreState, + apiBase: string, + options?: { force?: boolean; replace?: boolean }, +): void { + const key = apiBase; + const inflight = get().inflight[key]; + // Singleflight: concurrent subscribers dedupe onto the in-flight request. + if (inflight && options?.replace !== true) return; + inflight?.abort(); + const controller = new AbortController(); + set(state => { + const existing = state.entries[key]; + return { + inflight: { ...state.inflight, [key]: controller }, + entries: { + ...state.entries, + [key]: { + ...(existing ?? emptyEntry()), + loading: existing?.reports === undefined || Object.keys(existing.reports).length === 0 + ? true + : false, + refreshing: true, + error: undefined, + seedNeedsRevalidate: false, + }, + }, + }; + }); + + void (async () => { + try { + const response = await fetch(`${apiBase}/api/provider-quotas${options?.force ? "?refresh=1" : ""}`, { + signal: controller.signal, + }); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim()); + const data = (await response.json()) as { reports?: unknown; availability?: unknown } | null; + if (get().inflight[key] !== controller) return; + const reports = freshQuotaReportsFromResponse(data?.reports); + const authAttention = quotaAuthAttentionFromResponse(data?.availability); + const availability = quotaAvailabilityFromResponse(data?.availability); + set(state => ({ + inflight: { ...state.inflight, [key]: null }, + entries: { + ...state.entries, + [key]: { + reports, + authAttention, + availability, + updatedAt: Date.now(), + error: undefined, + loading: false, + refreshing: false, + hasSucceeded: true, + lastAttemptOk: true, + seedNeedsRevalidate: false, + }, + }, + })); + } catch (error) { + if (controller.signal.aborted || get().inflight[key] !== controller) return; + set(state => ({ + inflight: { ...state.inflight, [key]: null }, + entries: { + ...state.entries, + [key]: { + ...(state.entries[key] ?? emptyEntry()), + error: error === undefined ? new Error("provider quota load failed") : error, + loading: false, + refreshing: false, + lastAttemptOk: false, + }, + }, + })); + } + })(); +} + +export const useProviderQuotaStore = create()( + persist( + (set, get) => ({ + entries: {}, + inflight: {}, + ensure: (apiBase, opts) => { + const key = apiBase; + // A forced ensure always re-reads (and replaces any in-flight request); it is the + // singleflight-visible variant of refresh for callers that only have ensure. + if (opts?.force === true) { + fetchQuotas(set, get, apiBase, { force: true, replace: true }); + return; + } + if (get().inflight[key]) return; + const entry = get().entries[key]; + // Cold start, rehydrated seed, or a previously cold-failed key: fetch + // (quiet for a seed, cold otherwise). Singleflight dedupes subscribers. + if (!entry || entry.seedNeedsRevalidate || !entry.hasSucceeded) { + fetchQuotas(set, get, apiBase, {}); + return; + } + // Healthy cached data — nothing to do. + }, + refresh: (apiBase, opts) => { + fetchQuotas(set, get, apiBase, { ...opts, replace: true }); + }, + clearForTests: () => { + for (const controller of Object.values(get().inflight)) controller?.abort(); + set({ entries: {}, inflight: {} }); + }, + }), + { + name: PROVIDER_QUOTA_STORAGE_NAME, + storage: sessionStorageLazy, + partialize: (state): PersistedQuotaSlice => ({ + entries: Object.fromEntries( + Object.entries(state.entries) + .filter(([, entry]) => entry.updatedAt !== undefined && Object.keys(entry.reports).length > 0) + .map(([key, entry]) => [ + key, + { reports: entry.reports, updatedAt: entry.updatedAt as number }, + ]), + ), + }), + merge: (persisted, current) => { + const persistedEntries = + (persisted as Partial | undefined)?.entries ?? {}; + const now = Date.now(); + const entries: Record = { ...current.entries }; + for (const [key, value] of Object.entries(persistedEntries)) { + if (!value || !value.reports) continue; + const freshReports = freshQuotaReportRecord(value.reports, now) ?? {}; + if (Object.keys(freshReports).length === 0) continue; + entries[key] = { + ...(entries[key] ?? emptyEntry()), + reports: freshReports, + updatedAt: value.updatedAt, + seedNeedsRevalidate: true, + }; + } + return { ...current, entries }; + }, + }, + ), +); + +/** + * Select the provider-quota entry for an apiBase. The caller decides when to fetch: + * `ensure` starts a cold/quiet fetch (singleflight), `refresh` always re-fetches and + * replaces any in-flight request (used for quotaRefreshEpoch/quotaForceRefresh). + */ +export function useProviderQuota(apiBase: string): ProviderQuotaResource { + const key = apiBase; + const entry = useProviderQuotaStore(state => state.entries[key]); + const ensureAction = useProviderQuotaStore(state => state.ensure); + const refreshAction = useProviderQuotaStore(state => state.refresh); + // Stable fallbacks so derived memos don't recompute while the entry is absent + // (a fresh `{}` per render would churn unavailableProviders and trip lint). + const availability = entry?.availability ?? EMPTY_AVAILABILITY; + const reports = entry?.reports ?? EMPTY_REPORTS; + const unavailableProviders = useMemo( + () => unavailableQuotaProviders(availability, reports), + [availability, reports], + ); + + const ensure = useCallback( + (opts?: { force?: boolean }) => ensureAction(key, opts), + [key, ensureAction], + ); + const refresh = useCallback( + (opts?: { force?: boolean }) => refreshAction(key, opts), + [key, refreshAction], + ); + + return { + key, + reports, + authAttention: entry?.authAttention ?? {}, + availability, + unavailableProviders, + updatedAt: entry?.updatedAt, + error: entry?.error, + loading: entry?.loading ?? false, + refreshing: entry?.refreshing ?? false, + hasSucceeded: entry?.hasSucceeded ?? false, + lastAttemptOk: entry?.lastAttemptOk ?? false, + ensure, + refresh, + }; +} + +const EMPTY_AVAILABILITY: Record = {}; +const EMPTY_REPORTS: Record = {}; + +/** Test-only: drop every entry and abort in-flight work so suite order cannot reuse data. */ +export function clearProviderQuotaStoresForTests(): void { + useProviderQuotaStore.getState().clearForTests(); +} + +/** Test-only: seed an entry as if it were rehydrated from sessionStorage. */ +export function seedProviderQuotaForTests( + apiBase: string, + data: { reports: Record; updatedAt: number }, +): void { + useProviderQuotaStore.setState(state => ({ + entries: { + ...state.entries, + [apiBase]: { + ...emptyEntry(), + reports: data.reports, + updatedAt: data.updatedAt, + seedNeedsRevalidate: true, + }, + }, + })); +} + +/** Test-only: re-run persist rehydration against the current sessionStorage. */ +export function rehydrateProviderQuotaForTests(): void { + void useProviderQuotaStore.persist.rehydrate(); +} diff --git a/gui/src/quota-unavailable.ts b/gui/src/quota-unavailable.ts new file mode 100644 index 0000000000..afad8d1675 --- /dev/null +++ b/gui/src/quota-unavailable.ts @@ -0,0 +1,12 @@ +import type { TKey } from "./i18n/shared"; + +/** + * Map a quota-unavailable reason code to its localized copy, matching the Mac app's + * ProviderListView summary. Any unknown or missing reason falls back to the generic + * "Temporarily unavailable" line (raw reason strings never reach the DOM). + */ +export function quotaUnavailableReasonKey(reason: string | undefined): TKey { + if (reason === "reauth_required") return "pws.quota.signInRequired"; + if (reason === "local_cli_refresh_required") return "pws.quota.loginNeedsRefresh"; + return "pws.quota.upstreamUnavailable"; +} diff --git a/gui/src/styles-apikeys-workspace.css b/gui/src/styles-apikeys-workspace.css index f59b3c60d6..2f02725bb8 100644 --- a/gui/src/styles-apikeys-workspace.css +++ b/gui/src/styles-apikeys-workspace.css @@ -161,8 +161,8 @@ } .awi-overview .api-panel { - padding: 18px; - gap: 10px; + padding: var(--space-4); + gap: var(--space-2); } .awi-overview .api-auth-list { diff --git a/gui/src/styles-claudecode-workspace.css b/gui/src/styles-claudecode-workspace.css index ebf34279b7..af41082377 100644 --- a/gui/src/styles-claudecode-workspace.css +++ b/gui/src/styles-claudecode-workspace.css @@ -29,7 +29,7 @@ padding: 0 16px 12px; margin-top: -4px; font-size: var(--text-caption); - line-height: 1.45; + line-height: 1.45; /* deliberate: between --leading-ui and --leading-body */ color: var(--muted); border-bottom: 1px solid var(--border-soft); } @@ -43,7 +43,7 @@ color: var(--faint); text-transform: uppercase; letter-spacing: 0.04em; - font-size: 10.5px; + font-size: 10.5px; /* deliberate: micro-caption tuning */ } /* ── Rail ─────────────────────────────────────────────── */ @@ -183,7 +183,7 @@ align-items: baseline; gap: 6px; margin: 0 0 5px; - font-size: 10.5px; + font-size: 10.5px; /* deliberate: micro-caption tuning */ font-weight: var(--weight-semibold); letter-spacing: 0.04em; text-transform: uppercase; @@ -223,7 +223,7 @@ .claude-aliases-chip-id { font-family: var(--font-code); - font-size: 11px; + font-size: var(--text-caption); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/gui/src/styles-combos-workspace.css b/gui/src/styles-combos-workspace.css index f73aa3b8a0..f19a55af25 100644 --- a/gui/src/styles-combos-workspace.css +++ b/gui/src/styles-combos-workspace.css @@ -87,7 +87,7 @@ .combos-workspace-rail-group-head { display: flex; align-items: center; - gap: 7px; + gap: var(--space-1-5); padding: 8px 16px 6px; color: var(--muted); font-size: var(--text-caption); @@ -232,7 +232,7 @@ font-size: var(--text-control); font-weight: 500; color: var(--muted); - padding: 8px 12px; + padding: var(--space-2) var(--space-3); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; @@ -295,8 +295,8 @@ .cwi-count-pill { display: inline-flex; align-items: baseline; - gap: 6px; - padding: 8px 12px; + gap: var(--space-1-5); + padding: var(--space-2) var(--space-3); border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--raised); @@ -508,7 +508,7 @@ .pwi-json-unsaved-title, .pwi-remove-confirm-title { margin: 0 0 8px; font-size: var(--text-subtitle); font-weight: 650; color: var(--text); } .pwi-json-unsaved-desc, -.pwi-remove-confirm-desc { margin: 0 0 18px; font-size: var(--text-control); line-height: 1.45; } +.pwi-remove-confirm-desc { margin: 0 0 var(--space-4); font-size: var(--text-control); line-height: 1.45; /* deliberate: between --leading-ui and --leading-body */ } .pwi-json-unsaved-actions, .pwi-remove-confirm-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; } .pwi-remove-confirm-danger { background: var(--red) !important; color: #fff !important; border-color: transparent !important; } diff --git a/gui/src/styles-dashboard-workspace.css b/gui/src/styles-dashboard-workspace.css index a7a990d320..ce4511ed3f 100644 --- a/gui/src/styles-dashboard-workspace.css +++ b/gui/src/styles-dashboard-workspace.css @@ -97,6 +97,40 @@ min-width: 0; } +/* Quota-unavailable strip: one full-width row under the Plan & quota grid, never a + grid card — providers with no report entry get a reason line; reauth rows deep-link + to the provider (Manage), non-auth rows share one Retry quota check. */ +.dash-plan-quota-unavailable { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2) var(--space-3); + margin-top: var(--space-3); + padding: var(--space-2) var(--space-3); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--surface); +} + +.dash-plan-quota-unavailable-label { + font-weight: var(--weight-semibold); +} + +.dash-plan-quota-unavailable-item { + display: inline-flex; + align-items: baseline; + flex-wrap: wrap; + gap: var(--space-2); + color: var(--muted); +} + +.dash-plan-quota-unavailable-manage { + display: inline-flex; + align-items: baseline; + gap: var(--space-1); + white-space: nowrap; +} + .dash-sidecar-card__row .font-semibold { min-width: 0; } diff --git a/gui/src/styles-integrations.css b/gui/src/styles-integrations.css index 6fc1d8762c..984d6016f2 100644 --- a/gui/src/styles-integrations.css +++ b/gui/src/styles-integrations.css @@ -8,7 +8,7 @@ .integration-badge--danger { background: var(--red-soft); color: var(--red); } .integration-badge--danger-outline { background: transparent; color: var(--red); border-color: var(--red); } -.integration-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; padding: 14px 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--raised); margin-bottom: 14px; } +.integration-summary { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-4); padding: var(--space-3) var(--space-4); border: 1px solid var(--border); border-radius: var(--radius); background: var(--raised); margin-bottom: 14px; } .integration-summary-cell { display: flex; flex-direction: column; gap: 2px; } .integration-summary-label { font-size: var(--text-caption); color: var(--muted); } .integration-summary .btn { margin-left: auto; } @@ -91,8 +91,8 @@ } .client-apps-page-head h2 { margin: 0; font-size: var(--text-display); letter-spacing: -0.025em; } .client-apps-page-head p { margin: 5px 0 0; color: var(--muted); font-size: var(--text-body); } -.client-apps-page-actions { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; } -.client-apps-page-actions .btn { display: inline-flex; align-items: center; gap: 7px; } +.client-apps-page-actions { display: flex; align-items: center; gap: var(--space-2); flex: 0 0 auto; } +.client-apps-page-actions .btn { display: inline-flex; align-items: center; gap: var(--space-1-5); } .client-apps-page-actions svg { width: 15px; height: 15px; } .client-apps-flow { @@ -134,15 +134,15 @@ .client-apps-flow-stage small { color: var(--green); font-size: var(--text-caption); font-weight: var(--weight-semibold); } .client-apps-flow-stage em { flex: 1 0 100%; color: var(--muted); font-size: var(--text-caption); font-style: normal; line-height: var(--leading-body); } .client-apps-flow-arrow { color: var(--faint); text-align: center; font-size: 19px; } -.client-apps-flow-chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 12px; min-width: 0; } +.client-apps-flow-chips { display: flex; flex-wrap: wrap; gap: var(--space-1-5); margin-top: var(--space-3); min-width: 0; } .client-apps-flow-chips--providers { grid-column: 1; } .client-apps-flow-chips--clients { grid-column: 5; } .client-apps-flow-chip { display: inline-flex; align-items: center; - gap: 6px; - min-height: 28px; - padding: 4px 9px; + gap: var(--space-1-5); + min-height: var(--control-sm); + padding: var(--space-1) var(--space-2); border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--raised); @@ -228,10 +228,10 @@ button.client-apps-flow-chip:hover { border-color: var(--accent-ring); color: va .client-apps-mark svg { width: 19px; height: 19px; color: var(--muted); } .client-apps-row-copy { display: flex; flex-direction: column; gap: 6px; min-width: 0; } .client-apps-row-title { font-size: var(--text-control); font-weight: var(--weight-semibold); } -.client-apps-row-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; color: var(--muted); font-size: var(--text-caption); } +.client-apps-row-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1-5) var(--space-2); color: var(--muted); font-size: var(--text-caption); } .client-apps-row-meta .badge { font-size: var(--text-caption); } .client-apps-row-actions { display: flex; align-items: center; justify-content: flex-end; } -.client-apps-row-actions .btn { min-height: 34px; white-space: nowrap; } +.client-apps-row-actions .btn { min-height: var(--control-md); white-space: nowrap; } .client-apps-row > .notice, .client-apps-row-refusal { grid-column: 1 / -1; } .client-apps-row-refusal { margin: 0; color: var(--red); font-size: var(--text-caption); line-height: var(--leading-body); } @@ -253,7 +253,7 @@ button.client-apps-flow-chip:hover { border-color: var(--accent-ring); color: va .client-apps-available-main > span:last-child { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .client-apps-available-main strong { font-size: var(--text-control); } .client-apps-available-main small { color: var(--muted); font-size: var(--text-caption); } -.client-apps-available-row .btn { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; min-height: 34px; padding-inline: 10px; } +.client-apps-available-row .btn { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; min-height: var(--control-md); padding-inline: var(--space-2); } .client-apps-available-row .btn svg { width: 13px; height: 13px; } .client-apps-detail { position: sticky; top: 20px; min-width: 0; overflow: hidden; } diff --git a/gui/src/styles-subagents-workspace.css b/gui/src/styles-subagents-workspace.css index 97a53f5b39..660879f879 100644 --- a/gui/src/styles-subagents-workspace.css +++ b/gui/src/styles-subagents-workspace.css @@ -256,13 +256,13 @@ .swi-roster-name, .swi-library-name { - overflow: hidden; color: var(--text); font-family: var(--mono); font-size: var(--text-control); line-height: var(--leading-ui); - text-overflow: ellipsis; - white-space: nowrap; + /* Model IDs must never be clipped mid-token: let them wrap anywhere instead. */ + white-space: normal; + overflow-wrap: anywhere; } .swi-model-chips { @@ -420,10 +420,9 @@ .swi-roster-matrix-name { min-width: 0; - overflow: hidden; font-family: var(--mono); - text-overflow: ellipsis; - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; } .swi-roster-matrix-surface { @@ -480,10 +479,10 @@ .swi-library-filters { display: flex; align-items: center; + /* Wrap instead of clipping: chips must never be cut off at the right edge. */ + flex-wrap: wrap; gap: var(--space-2); padding: 0 var(--space-4) var(--space-3); - overflow-x: auto; - scrollbar-width: thin; } .swi-filter { @@ -606,9 +605,10 @@ .swi-policy-grid { display: grid; - grid-template-columns: minmax(150px, 0.8fr) minmax(220px, 1.2fr) minmax(200px, 1.05fr) minmax(150px, 0.72fr) auto; + grid-template-columns: minmax(170px, 0.8fr) minmax(220px, 1.2fr) minmax(200px, 1.05fr) minmax(170px, 0.72fr) auto; gap: var(--space-4); - align-items: end; + /* Top-align so stacked labels/helpers never form the 30px "label staircase". */ + align-items: start; padding: var(--space-4); border: 1px solid var(--border); border-radius: var(--radius-sm); @@ -628,7 +628,7 @@ } .swi-policy-guidance-field { - grid-column: span 2; + grid-column: span 4; } .swi-policy-toggle-row { @@ -649,14 +649,16 @@ .swi-policy-help { display: block; color: var(--muted); - font-size: var(--text-micro); + font-size: var(--text-label); line-height: var(--leading-body); } .swi-policy-save-cell { display: flex; align-items: flex-end; - min-height: calc(var(--control-lg) + 34px); + justify-content: flex-end; + grid-column: 5; + min-height: 0; } .swi-policy-save-cell .btn { @@ -830,11 +832,10 @@ .swi-fallback-name { min-width: 0; - overflow: hidden; font-family: var(--mono); font-size: var(--text-label); - text-overflow: ellipsis; - white-space: nowrap; + white-space: normal; + overflow-wrap: anywhere; } .swi-fallback-actions { diff --git a/gui/src/styles.css b/gui/src/styles.css index 9453a1d8d6..9b74f009e8 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -245,8 +245,8 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } .sidebar { position: sticky; top: 0; align-self: start; height: 100dvh; z-index: var(--z-overlay); - display: flex; flex-direction: column; gap: 4px; - padding: 18px 14px; + display: flex; flex-direction: column; gap: var(--space-1); + padding: var(--space-4) var(--space-3); border-right: 1px solid var(--border); background: var(--glass-rail); backdrop-filter: var(--glass-blur); @@ -393,7 +393,7 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } /* Page-level underline tabs (Logs & Debug / Dashboard). Distinct from pill .segmented filters. */ /* Let the row take the height it needs — no horizontal scrollbar on a short tab strip. */ .page-tabs { display: flex; flex-wrap: wrap; gap: 2px; border-bottom: 1px solid var(--border); margin: 2px 0 14px; overflow: visible; } -.page-tab { flex: 0 0 auto; white-space: nowrap; appearance: none; background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; padding: 8px 12px; color: var(--muted); cursor: pointer; font: inherit; font-size: var(--text-control); } +.page-tab { flex: 0 0 auto; white-space: nowrap; appearance: none; background: none; border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; padding: var(--space-2) var(--space-3); color: var(--muted); cursor: pointer; font: inherit; font-size: var(--text-control); } .page-tab:hover { color: var(--text); } .page-tab--active { color: var(--text); border-bottom-color: var(--accent); font-weight: var(--weight-semibold); } .page-tab:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: -2px; } @@ -551,7 +551,7 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } /* ---- buttons ---- */ .btn { - display: inline-flex; align-items: center; justify-content: center; gap: 7px; + display: inline-flex; align-items: center; justify-content: center; gap: var(--space-1-5); padding: 8px 16px; border-radius: var(--radius-pill); font: inherit; font-size: var(--text-control); font-weight: var(--weight-medium); line-height: var(--leading-ui); cursor: pointer; border: 1px solid transparent; transition: background var(--motion-fast), border-color var(--motion-fast), opacity var(--motion-fast); white-space: nowrap; @@ -589,7 +589,7 @@ a.btn, a.btn:hover { text-decoration: none; } /* ---- cards / panels ---- */ .card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); min-width: 0; } -.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; } +.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--space-4); } /* Flat accent tint + token border (FE-GRADIENT-02: no gradient wash on opaque functional panels) */ .panel-accent { border-color: color-mix(in srgb, var(--accent) 28%, var(--border)); background: color-mix(in srgb, var(--accent) 5%, var(--surface)); } .api-panel { display: flex; flex-direction: column; gap: 10px; overflow: hidden; } @@ -905,7 +905,7 @@ a.btn, a.btn:hover { text-decoration: none; } /* ---- tables ---- */ .tbl { width: 100%; border-collapse: collapse; font-size: var(--text-control); } -.tbl thead th { text-align: left; padding: 9px 12px; color: var(--muted); font-weight: var(--weight-medium); font-size: var(--text-label); border-bottom: 1px solid var(--border); } +.tbl thead th { text-align: left; padding: var(--space-2) var(--space-3); color: var(--muted); font-weight: var(--weight-medium); font-size: var(--text-label); border-bottom: 1px solid var(--border); } .tbl tbody td { padding: 10px 12px; border-bottom: 1px solid var(--border-soft); } .tbl tbody tr:last-child td { border-bottom: none; } .tbl tbody tr:hover td { background: var(--hover); } @@ -962,6 +962,7 @@ select.input { appearance: none; } backdrop-filter: blur(12px) saturate(1.3); -webkit-backdrop-filter: blur(12px) saturate(1.3); border: 1px solid var(--border); + /* deliberate: pill elevation distinct from --shadow-sm */ box-shadow: 0 2px 8px rgb(0 0 0 / 0.06); color: var(--text); font: inherit; font-size: var(--text-control); line-height: var(--leading-ui); cursor: pointer; transition: border-color var(--motion-fast), box-shadow var(--motion-fast); @@ -985,6 +986,14 @@ select.input { appearance: none; } } .select-trigger:hover:not(:disabled) { border-color: var(--faint); box-shadow: 0 2px 12px rgb(0 0 0 / 0.10); } .select-trigger:disabled { opacity: 0.5; cursor: default; } +/* Long select labels must ellipsize inside the pill instead of spilling past it + (matches the sidecar pattern in styles-dashboard-workspace.css). */ +.select-trigger > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .select-dropdown { position: absolute; top: calc(100% + 4px); left: 0; z-index: var(--z-popover); min-width: 100%; max-height: 280px; overflow-y: auto; @@ -1012,7 +1021,7 @@ select.input { appearance: none; } .select-dropdown-beside { top: auto; bottom: 0; left: calc(100% + 12px); right: auto; min-width: 10rem; max-height: min(60vh, 20rem); overflow-y: auto; } .select-option { display: block; width: 100%; text-align: left; - padding: 7px 12px; border: none; border-radius: var(--radius-sm); + padding: var(--space-1-5) var(--space-3); border: none; border-radius: var(--radius-sm); background: transparent; color: var(--text); font: inherit; font-size: var(--text-control); line-height: var(--leading-ui); cursor: pointer; transition: background var(--motion-fast), box-shadow var(--motion-fast); white-space: nowrap; @@ -1076,7 +1085,7 @@ select.input { appearance: none; } .faint { color: var(--faint); } .row { display: flex; align-items: center; gap: 10px; } .spread { display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.setting-hint { font-size: var(--text-control); line-height: var(--leading-body); margin-top: 3px; max-width: var(--prose-measure); } +.setting-hint { font-size: var(--text-control); line-height: var(--leading-body); margin-top: var(--space-1); max-width: var(--prose-measure); } .stack { display: flex; flex-direction: column; } .chip { font-family: var(--font-code); font-size: var(--text-label); line-height: var(--leading-ui); background: var(--raised); border: 1px solid var(--border); padding: 1px 7px; border-radius: var(--radius-xs); color: var(--text); } @@ -1084,7 +1093,7 @@ select.input { appearance: none; } .empty svg { width: 30px; height: 30px; color: var(--faint); margin-bottom: 12px; } .empty .title { color: var(--text); font-weight: var(--weight-semibold); margin-bottom: 6px; } -.notice { font-size: var(--text-control); line-height: var(--leading-body); padding: 9px 12px; border-radius: var(--radius-sm); margin-bottom: 14px; display: flex; align-items: center; gap: 8px; max-width: var(--prose-measure); } +.notice { font-size: var(--text-control); line-height: var(--leading-body); padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm); margin-bottom: var(--space-3); display: flex; align-items: center; gap: var(--space-2); max-width: var(--prose-measure); } .notice svg { width: 15px; height: 15px; flex-shrink: 0; } /* Tone pairs: ink is a shade of the tint (not gray-on-color), AA on both themes. */ .notice-ok { @@ -1660,7 +1669,7 @@ dialog.modal-overlay::backdrop { .startup-hero-icon svg { width: 34px; height: 34px; } .startup-hero-copy p { margin: 0; color: var(--muted); font-size: 17px; line-height: var(--leading-body); max-width: 66ch; } .startup-primary, .startup-advanced, .startup-actions { margin-bottom: 38px; } -.startup-primary { padding: 18px 31px; } +.startup-primary { padding: var(--space-4) var(--space-8); } .startup-primary-row { display: flex; align-items: center; gap: 28px; min-height: 112px; padding: 12px 0; } .startup-primary-row + .startup-primary-row { border-top: 1px solid var(--border-soft); } .startup-primary-row-icon { width: 58px; height: 58px; border-radius: var(--radius); display: grid; place-items: center; background: var(--raised); flex: 0 0 auto; } @@ -1708,7 +1717,7 @@ dialog.modal-overlay::backdrop { .startup-hero-copy p { font-size: var(--text-body); } .startup-primary, .startup-advanced, .startup-actions { margin-bottom: 20px; } .startup-primary { padding: 10px 20px; } - .startup-primary-row { min-height: 0; gap: 14px; padding: 18px 0; } + .startup-primary-row { min-height: 0; gap: var(--space-3); padding: var(--space-4) 0; } .startup-primary-row-icon { width: 46px; height: 46px; } .startup-primary-row-label, .startup-primary-row-value { font-size: var(--text-body); } .startup-primary-row-actions .btn { min-height: 40px; padding: 8px 16px; font-size: var(--text-control); } @@ -1720,7 +1729,7 @@ dialog.modal-overlay::backdrop { .startup-detail-row { align-items: flex-start; } .startup-detail-row > .startup-detail-actions { flex-direction: column; align-items: flex-end; } } -.modal-desc { font-size: var(--text-control); line-height: var(--leading-body); color: var(--muted); margin-bottom: 14px; max-width: var(--prose-measure); } +.modal-desc { font-size: var(--text-control); line-height: var(--leading-body); color: var(--muted); margin-bottom: var(--space-3); max-width: var(--prose-measure); } .modal-actions { display: flex; gap: 8px; margin-top: 16px; } .modal-actions .btn { flex: 1; } @@ -1881,7 +1890,7 @@ table.logs-table { overflow: auto; max-height: 40vh; white-space: pre-wrap; word-break: break-all; margin: 0; } -.setup-guide { font-size: var(--text-control); line-height: var(--leading-body); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 12px; margin-bottom: 4px; } +.setup-guide { font-size: var(--text-control); line-height: var(--leading-body); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--space-2) var(--space-3); margin-bottom: var(--space-1); } .setup-guide summary { cursor: pointer; color: var(--accent-hover); font-weight: var(--weight-medium); } .setup-guide summary:hover { text-decoration: underline; } .setup-guide a { color: var(--accent-hover); } @@ -2254,7 +2263,7 @@ button.prov-account-row.active { cursor: default; } margin-bottom: 12px; } .claude-tabs button { - min-width: 88px; min-height: 34px; padding: 6px 14px; border: 0; border-radius: var(--radius-pill); + min-width: 88px; min-height: var(--control-md); padding: var(--space-1-5) var(--space-3); border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--muted); font: inherit; font-size: 13px; font-weight: 550; cursor: pointer; line-height: 1.2; } @@ -2305,12 +2314,12 @@ button.prov-account-row.active { cursor: default; } } .claude-lane-default { min-width: 0; overflow: hidden; color: var(--faint); - font-family: var(--font-code); font-size: 11px; font-weight: var(--weight-semibold); + font-family: var(--font-code); font-size: var(--text-caption); font-weight: var(--weight-semibold); text-overflow: ellipsis; white-space: nowrap; } .claude-default-radio { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 11.5px; cursor: pointer; } .claude-default-needed { color: var(--amber); font-size: 11.5px; font-weight: 550; } -.claude-effective-default { display: inline-block; margin-top: 6px; color: var(--amber); font-size: 11px; font-weight: 550; } +.claude-effective-default { display: inline-block; margin-top: var(--space-1-5); color: var(--amber); font-size: var(--text-caption); font-weight: 550; /* deliberate: variable weight between 500/600 */ } .claude-lane-models { display: flex; flex-direction: column; gap: 9px; min-height: 104px; padding: 10px; } /* Lane density controls: a filter that only appears once a lane is long enough to need one, and a pager for the tail. Same idiom as the Models page so the two dense surfaces match. */ @@ -2333,7 +2342,7 @@ button.prov-account-row.active { cursor: default; } .grok-model-row:first-child { border-top: 0; } .grok-model-names { display: flex; flex: 1; min-width: 0; flex-direction: column; gap: 1px; } .grok-model-names strong { overflow: hidden; color: var(--text); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; } -.grok-model-names code { overflow: hidden; color: var(--muted); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } +.grok-model-names code { overflow: hidden; color: var(--muted); font-size: 10.5px; /* deliberate: between --text-micro and --text-caption */ text-overflow: ellipsis; white-space: nowrap; } .claude-lane-empty { display: grid; place-items: center; min-height: 82px; padding: 12px; border: 1px dashed var(--border); border-radius: var(--radius-sm); color: var(--muted); font-size: 12px; text-align: center; @@ -2344,13 +2353,13 @@ button.prov-account-row.active { cursor: default; } .claude-model-card[draggable="true"] { cursor: grab; } .claude-model-card[draggable="true"]:active { cursor: grabbing; } .claude-model-summary { - display: flex; width: 100%; align-items: center; gap: 10px; padding: 9px 12px; + display: flex; width: 100%; align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); border: 0; background: transparent; color: inherit; cursor: pointer; text-align: left; } .claude-model-summary:hover { background: var(--hover); } -.claude-model-context { flex-shrink: 0; color: var(--muted); font-size: 11px; } +.claude-model-context { flex-shrink: 0; color: var(--muted); font-size: var(--text-caption); } .claude-model-context-unknown { color: var(--faint); font-style: italic; } -.claude-row-default { flex-shrink: 0; color: var(--green); font-size: 10.5px; font-weight: 600; } +.claude-row-default { flex-shrink: 0; color: var(--green); font-size: 10.5px; /* deliberate: compact badge row */ font-weight: 600; } .claude-1m-chip { flex-shrink: 0; padding: 1px 6px; border-radius: var(--radius-xs); background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); @@ -2361,7 +2370,7 @@ button.prov-account-row.active { cursor: default; } .claude-model-body > .claude-field:first-child { margin-top: 0; } .claude-field > span, .claude-move-row > label { display: block; margin-bottom: 4px; color: var(--muted); font-size: 11.5px; font-weight: 550; } .claude-alias { - display: block; width: 100%; min-height: 34px; padding: 7px 10px; overflow: hidden; + display: block; width: 100%; min-height: var(--control-md); padding: var(--space-1-5) var(--space-2); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-xs); background: var(--raised); color: var(--text); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; } @@ -2388,7 +2397,7 @@ button.prov-account-row.active { cursor: default; } /* ── Desktop status bar + effort badges (hardening WP3) ── */ .claude-status-bar { - display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 8px 14px; + display: flex; align-items: center; gap: var(--space-2); margin-bottom: var(--space-3); padding: var(--space-2) var(--space-3); border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); font-size: 12.5px; color: var(--muted); } diff --git a/gui/src/ui.tsx b/gui/src/ui.tsx index 188d8f37be..ab297a8346 100644 --- a/gui/src/ui.tsx +++ b/gui/src/ui.tsx @@ -18,8 +18,10 @@ export function Notice({ tone, children }: { tone: "ok" | "err" | "warn"; childr // `warn` is degraded-but-not-failed: the action happened, something adjacent // did not. It must not render as the clean success the user did not get. const toneClass = tone === "ok" ? "notice-ok" : tone === "warn" ? "notice-warn" : "notice-err"; + // Errors must be announced immediately (alert); ok/warn stay polite status. + const liveRole = tone === "err" ? "alert" : "status"; return ( -
    +
    {tone === "ok" ? : } {children}
    diff --git a/gui/src/usage-report-store.ts b/gui/src/usage-report-store.ts new file mode 100644 index 0000000000..9de210c2f3 --- /dev/null +++ b/gui/src/usage-report-store.ts @@ -0,0 +1,323 @@ +/** + * Domain store for GET /api/usage reports. + * + * Replaces the Usage page's private memory cache and the dashboard's usage poll + * with one keyed store shared by every surface: Usage page and Dashboard select + * the same entry for `${apiBase}:30d:all`, so concurrent subscribers dedupe into + * a single in-flight fetch (singleflight) and share the same AbortController + * cancellation semantics as `client-resource`. + * + * Persistence: zustand `persist` with sessionStorage stores ONLY validated + * successful reports plus a timestamp — never errors, never in-flight state. + * On rehydrate, seeded entries are marked `seedNeedsRevalidate` so the first + * subscriber quiet-revalidates instead of trusting the seed forever. + */ + +import { create } from "zustand"; +import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; +import { useCallback, useEffect } from "react"; +import { + parseUsageReport, + type UsageRange, + type UsageReport, + type UsageSurface, +} from "./usage-report-validation"; + +export type UsageReportResource = { + key: string; + data: UsageReport | undefined; + error: unknown; + loading: boolean; + refreshing: boolean; + hasSucceeded: boolean; + lastAttemptOk: boolean; + refresh: (opts?: { forceLoading?: boolean; replace?: boolean }) => void; +}; + +export interface UsageReportEntry { + data?: UsageReport; + error?: unknown; + loading: boolean; + refreshing: boolean; + hasSucceeded: boolean; + lastAttemptOk: boolean; + /** Epoch ms when the report was persisted. */ + persistedAt?: number; + /** True for a rehydrated seed: the first subscriber must quiet-revalidate. */ + seedNeedsRevalidate: boolean; +} + +/** The persisted slice — validated reports + timestamp only. */ +interface PersistedUsageSlice { + entries: Record; +} + +interface UsageReportStoreState { + entries: Record; + /** One in-flight controller per key; singleflight + cancellation. */ + inflight: Record; + ensure: (key: string, apiBase: string, range: UsageRange, surface: UsageSurface) => void; + refresh: ( + key: string, + apiBase: string, + range: UsageRange, + surface: UsageSurface, + opts?: { forceLoading?: boolean; replace?: boolean }, + ) => void; + clearForTests: () => void; +} + +export const USAGE_REPORT_STORAGE_NAME = "ccx.usage-reports.v1"; + +/** + * sessionStorage resolved lazily at each call instead of captured at module load: + * GUI tests install the happy-dom sessionStorage per file/per test, and the store + * must read whatever storage is current when a read/write happens. + */ +const sessionStorageLazy: PersistStorage = { + getItem: (name) => { + try { + const raw = sessionStorage.getItem(name); + if (!raw) return null; + return JSON.parse(raw) as StorageValue; + } catch { + return null; + } + }, + setItem: (name, value) => { + try { + sessionStorage.setItem(name, JSON.stringify(value)); + } catch { + /* private mode / no sessionStorage in this runtime */ + } + }, + removeItem: (name) => { + try { + sessionStorage.removeItem(name); + } catch { + /* ignore */ + } + }, +}; + +export function usageReportKey(apiBase: string, range: UsageRange, surface: UsageSurface): string { + return `${apiBase}:${range}:${surface}`; +} + +function emptyEntry(): UsageReportEntry { + return { + loading: false, + refreshing: false, + hasSucceeded: false, + lastAttemptOk: false, + seedNeedsRevalidate: false, + }; +} + +function fetchReport( + set: (partial: Partial | ((state: UsageReportStoreState) => Partial)) => void, + get: () => UsageReportStoreState, + key: string, + apiBase: string, + range: UsageRange, + surface: UsageSurface, + options?: { forceLoading?: boolean; replace?: boolean }, +): void { + const inflight = get().inflight[key]; + // Singleflight: concurrent subscribers dedupe onto the in-flight request. + if (inflight && options?.replace !== true) return; + // Remember whether this request was a quiet revalidation of a rehydrated seed so a + // failed revalidation can restore the retry flag for the next subscriber. + const existingEntry = get().entries[key]; + const wasSeed = existingEntry?.seedNeedsRevalidate === true && existingEntry.data !== undefined; + inflight?.abort(); + const controller = new AbortController(); + set(state => { + const existing = state.entries[key]; + return { + inflight: { ...state.inflight, [key]: controller }, + entries: { + ...state.entries, + [key]: { + ...(existing ?? emptyEntry()), + loading: options?.forceLoading === true || existing?.data === undefined, + refreshing: true, + error: undefined, + seedNeedsRevalidate: false, + }, + }, + }; + }); + + void (async () => { + try { + const response = await fetch(`${apiBase}/api/usage?range=${range}&surface=${surface}`, { + signal: controller.signal, + }); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}`.trim()); + const data = parseUsageReport(await response.json()); + if (get().inflight[key] !== controller) return; + set(state => ({ + inflight: { ...state.inflight, [key]: null }, + entries: { + ...state.entries, + [key]: { + data, + error: undefined, + loading: false, + refreshing: false, + hasSucceeded: true, + lastAttemptOk: true, + persistedAt: Date.now(), + seedNeedsRevalidate: false, + }, + }, + })); + } catch (error) { + if (controller.signal.aborted || get().inflight[key] !== controller) return; + set(state => ({ + inflight: { ...state.inflight, [key]: null }, + entries: { + ...state.entries, + [key]: { + ...(state.entries[key] ?? emptyEntry()), + error: error === undefined ? new Error("usage report load failed") : error, + loading: false, + refreshing: false, + lastAttemptOk: false, + // A failed revalidation of a rehydrated seed keeps the seed as last-known-good + // (hasSucceeded) and re-arms the quiet retry for the next subscriber. + hasSucceeded: state.entries[key]?.hasSucceeded === true || wasSeed, + seedNeedsRevalidate: wasSeed, + }, + }, + })); + } + })(); +} + +export const useUsageReportStore = create()( + persist( + (set, get) => ({ + entries: {}, + inflight: {}, + ensure: (key, apiBase, range, surface) => { + if (get().inflight[key]) return; + const entry = get().entries[key]; + if (!entry) { + fetchReport(set, get, key, apiBase, range, surface, { forceLoading: true }); + return; + } + if (entry.seedNeedsRevalidate) { + // Quiet revalidation of a rehydrated seed: keep the seed visible, no skeleton. + fetchReport(set, get, key, apiBase, range, surface, {}); + return; + } + if (entry.data === undefined && !entry.hasSucceeded) { + // A previously cold-failed key retries on the next subscriber. + fetchReport(set, get, key, apiBase, range, surface, { forceLoading: true }); + return; + } + // Healthy cached data — nothing to do. + }, + refresh: (key, apiBase, range, surface, opts) => { + fetchReport(set, get, key, apiBase, range, surface, { ...opts, replace: opts?.replace !== false }); + }, + clearForTests: () => { + for (const controller of Object.values(get().inflight)) controller?.abort(); + set({ entries: {}, inflight: {} }); + }, + }), + { + name: USAGE_REPORT_STORAGE_NAME, + storage: sessionStorageLazy, + partialize: (state): PersistedUsageSlice => ({ + entries: Object.fromEntries( + Object.entries(state.entries) + .filter(([, entry]) => entry.data !== undefined) + .map(([key, entry]) => [ + key, + { data: entry.data as UsageReport, persistedAt: entry.persistedAt ?? Date.now() }, + ]), + ), + }), + merge: (persisted, current) => { + const persistedEntries = + (persisted as Partial | undefined)?.entries ?? {}; + const entries: Record = { ...current.entries }; + for (const [key, value] of Object.entries(persistedEntries)) { + if (value && value.data !== undefined) { + entries[key] = { + ...(entries[key] ?? emptyEntry()), + data: value.data, + persistedAt: value.persistedAt, + seedNeedsRevalidate: true, + // A rehydrated seed is last-known-good data: it reads as succeeded so the + // UI does not mistake "showing a seed" for "never succeeded". + hasSucceeded: true, + }; + } + } + return { ...current, entries }; + }, + }, + ), +); + +/** + * Select a usage report and keep it fresh. The key derives from + * apiBase/range/surface, so the Usage page's 30d/all and the Dashboard's 30d/all + * share one store entry and one in-flight fetch. + */ +export function useUsageReport( + apiBase: string, + range: UsageRange, + surface: UsageSurface, +): UsageReportResource { + const key = usageReportKey(apiBase, range, surface); + const entry = useUsageReportStore(state => state.entries[key]); + const ensure = useUsageReportStore(state => state.ensure); + const refreshAction = useUsageReportStore(state => state.refresh); + + useEffect(() => { + ensure(key, apiBase, range, surface); + }, [key, apiBase, range, surface, ensure]); + + const refresh = useCallback( + (opts?: { forceLoading?: boolean; replace?: boolean }) => { + refreshAction(key, apiBase, range, surface, opts); + }, + [key, apiBase, range, surface, refreshAction], + ); + + return { + key, + data: entry?.data, + error: entry?.error, + loading: entry?.loading ?? false, + refreshing: entry?.refreshing ?? false, + hasSucceeded: entry?.hasSucceeded ?? false, + lastAttemptOk: entry?.lastAttemptOk ?? false, + refresh, + }; +} + +/** Test-only: drop every entry and abort in-flight work so suite order cannot reuse data. */ +export function clearUsageReportStoresForTests(): void { + useUsageReportStore.getState().clearForTests(); +} + +/** Test-only: seed an entry as if it were rehydrated from sessionStorage. */ +export function seedUsageReportForTests(key: string, data: UsageReport, persistedAt = Date.now()): void { + useUsageReportStore.setState(state => ({ + entries: { + ...state.entries, + [key]: { ...emptyEntry(), data, persistedAt, seedNeedsRevalidate: true, hasSucceeded: true }, + }, + })); +} + +/** Test-only: re-run persist rehydration against the current sessionStorage. */ +export function rehydrateUsageReportForTests(): void { + void useUsageReportStore.persist.rehydrate(); +} diff --git a/gui/src/usage-report-validation.ts b/gui/src/usage-report-validation.ts new file mode 100644 index 0000000000..89876aef0a --- /dev/null +++ b/gui/src/usage-report-validation.ts @@ -0,0 +1,276 @@ +/** + * Validation for GET /api/usage reports. + * + * The Usage page and the usage-report domain store both consume this contract: + * only validated successful reports may be cached or persisted. Error envelopes + * (HTTP-level or body-level `error`) and malformed summaries are rejected here + * before any cache write, so a transient read failure can never shadow + * last-known-good data. + */ + +export type UsageRange = "all" | "30d" | "7d"; +export type UsageSurface = "all" | "codex" | "claude" | "grok"; + +export interface UsageSummaryTotals { + requests: number; + measuredRequests: number; + reportedRequests: number; + unreportedRequests: number; + unsupportedRequests: number; + estimatedRequests: number; + inputTokens: number; + outputTokens: number; + cachedInputTokens: number; + cacheReadInputTokens?: number; + cacheCreationInputTokens?: number; + reasoningOutputTokens: number; + totalTokens: number; + coverageRatio: number; + /** Required in the success DTO — the management API always emits these. */ + estimatedCostUsd: number; + pricedRequests: number; + unpricedRequests: number; + unmeteredRequests: number; +} + +export interface UsageDayModel { + model: string; + provider: string; + requests: number; + totalTokens: number; +} + +export interface UsageDay { + date: string; + requests: number; + measuredRequests: number; + reportedRequests: number; + totalTokens: number; + models: UsageDayModel[]; +} + +export interface UsageModel { + provider: string; + model: string; + resolvedModel?: string; + requests: number; + measuredRequests: number; + reportedRequests: number; + estimatedRequests: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; + shareRatio: number; +} + +export interface UsageProvider { + provider: string; + requests: number; + measuredRequests: number; + reportedRequests: number; + estimatedRequests: number; + totalTokens: number; + shareRatio: number; +} + +export interface UsageReport { + range: UsageRange; + surface: UsageSurface; + since: number | null; + generatedAt: number; + summary: UsageSummaryTotals; + days: UsageDay[]; + models: UsageModel[]; + providers: UsageProvider[]; + historyTruncated: boolean; + truncatedPrefixBytes: number; + entriesTruncated: boolean; + entriesDropped: number; +} + +/** Typed validation failure — callers must not persist the rejected payload. */ +export class UsageReportValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "UsageReportValidationError"; + } +} + +const REQUIRED_SUMMARY_FIELDS: (keyof UsageSummaryTotals)[] = [ + "requests", + "measuredRequests", + "reportedRequests", + "unreportedRequests", + "unsupportedRequests", + "estimatedRequests", + "inputTokens", + "outputTokens", + "cachedInputTokens", + "reasoningOutputTokens", + "totalTokens", + "coverageRatio", + "estimatedCostUsd", + "pricedRequests", + "unpricedRequests", + "unmeteredRequests", +]; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseSummary(value: unknown): UsageSummaryTotals { + if (!isRecord(value)) { + throw new UsageReportValidationError("usage report summary is not an object"); + } + for (const key of REQUIRED_SUMMARY_FIELDS) { + if (!isFiniteNumber(value[key])) { + throw new UsageReportValidationError( + `usage report summary field "${key}" is missing or not a finite number`, + ); + } + } + return { + ...(value as unknown as UsageSummaryTotals), + cacheReadInputTokens: isFiniteNumber(value.cacheReadInputTokens) ? value.cacheReadInputTokens : undefined, + cacheCreationInputTokens: isFiniteNumber(value.cacheCreationInputTokens) ? value.cacheCreationInputTokens : undefined, + }; +} + +function requireString(value: unknown, field: string): string { + if (typeof value !== "string" || !value) { + throw new UsageReportValidationError(`usage report ${field} is missing or not a non-empty string`); + } + return value; +} + +function requireNumber(value: unknown, field: string): number { + if (!isFiniteNumber(value)) { + throw new UsageReportValidationError(`usage report ${field} is missing or not a finite number`); + } + return value; +} + +function parseDayModels(value: unknown): UsageDayModel[] { + if (!Array.isArray(value)) { + throw new UsageReportValidationError("usage report day models must be an array"); + } + return value.map((raw, index) => { + if (!isRecord(raw)) { + throw new UsageReportValidationError(`usage report day models[${index}] is not an object`); + } + return { + model: requireString(raw.model, `day models[${index}].model`), + provider: requireString(raw.provider, `day models[${index}].provider`), + requests: requireNumber(raw.requests, `day models[${index}].requests`), + totalTokens: requireNumber(raw.totalTokens, `day models[${index}].totalTokens`), + }; + }); +} + +function parseDays(value: unknown): UsageDay[] { + if (!Array.isArray(value)) { + throw new UsageReportValidationError("usage report days must be an array"); + } + return value.map((raw, index) => { + if (!isRecord(raw)) { + throw new UsageReportValidationError(`usage report days[${index}] is not an object`); + } + return { + date: requireString(raw.date, `days[${index}].date`), + requests: requireNumber(raw.requests, `days[${index}].requests`), + measuredRequests: requireNumber(raw.measuredRequests, `days[${index}].measuredRequests`), + reportedRequests: requireNumber(raw.reportedRequests, `days[${index}].reportedRequests`), + totalTokens: requireNumber(raw.totalTokens, `days[${index}].totalTokens`), + models: parseDayModels(raw.models), + }; + }); +} + +function parseModels(value: unknown): UsageModel[] { + if (!Array.isArray(value)) { + throw new UsageReportValidationError("usage report models must be an array"); + } + return value.map((raw, index) => { + if (!isRecord(raw)) { + throw new UsageReportValidationError(`usage report models[${index}] is not an object`); + } + return { + provider: requireString(raw.provider, `models[${index}].provider`), + model: requireString(raw.model, `models[${index}].model`), + ...(typeof raw.resolvedModel === "string" ? { resolvedModel: raw.resolvedModel } : {}), + requests: requireNumber(raw.requests, `models[${index}].requests`), + measuredRequests: requireNumber(raw.measuredRequests, `models[${index}].measuredRequests`), + reportedRequests: requireNumber(raw.reportedRequests, `models[${index}].reportedRequests`), + estimatedRequests: requireNumber(raw.estimatedRequests, `models[${index}].estimatedRequests`), + totalTokens: requireNumber(raw.totalTokens, `models[${index}].totalTokens`), + inputTokens: requireNumber(raw.inputTokens, `models[${index}].inputTokens`), + outputTokens: requireNumber(raw.outputTokens, `models[${index}].outputTokens`), + shareRatio: requireNumber(raw.shareRatio, `models[${index}].shareRatio`), + }; + }); +} + +function parseProviders(value: unknown): UsageProvider[] { + if (!Array.isArray(value)) { + throw new UsageReportValidationError("usage report providers must be an array"); + } + return value.map((raw, index) => { + if (!isRecord(raw)) { + throw new UsageReportValidationError(`usage report providers[${index}] is not an object`); + } + return { + provider: requireString(raw.provider, `providers[${index}].provider`), + requests: requireNumber(raw.requests, `providers[${index}].requests`), + measuredRequests: requireNumber(raw.measuredRequests, `providers[${index}].measuredRequests`), + reportedRequests: requireNumber(raw.reportedRequests, `providers[${index}].reportedRequests`), + estimatedRequests: requireNumber(raw.estimatedRequests, `providers[${index}].estimatedRequests`), + totalTokens: requireNumber(raw.totalTokens, `providers[${index}].totalTokens`), + shareRatio: requireNumber(raw.shareRatio, `providers[${index}].shareRatio`), + }; + }); +} + +/** + * Parse and validate a GET /api/usage success body. Throws + * `UsageReportValidationError` for error envelopes, non-object bodies, invalid + * range/surface, missing collection fields, and missing or malformed required + * summary fields. Callers must only persist the returned value. + */ +export function parseUsageReport(body: unknown): UsageReport { + if (!isRecord(body)) { + throw new UsageReportValidationError("usage report is not an object"); + } + if (body.error !== undefined) { + throw new UsageReportValidationError(`usage report rejected (${String(body.error)})`); + } + const range = body.range; + if (range !== "all" && range !== "30d" && range !== "7d") { + throw new UsageReportValidationError("usage report range is missing or invalid"); + } + const surface = body.surface; + if (surface !== "all" && surface !== "codex" && surface !== "claude" && surface !== "grok") { + throw new UsageReportValidationError("usage report surface is missing or invalid"); + } + if (!isFiniteNumber(body.generatedAt)) { + throw new UsageReportValidationError("usage report generatedAt is missing or not a finite number"); + } + return { + range, + surface, + since: isFiniteNumber(body.since) ? body.since : null, + generatedAt: body.generatedAt, + summary: parseSummary(body.summary), + days: parseDays(body.days), + models: parseModels(body.models), + providers: parseProviders(body.providers), + historyTruncated: body.historyTruncated === true, + truncatedPrefixBytes: isFiniteNumber(body.truncatedPrefixBytes) ? body.truncatedPrefixBytes : 0, + entriesTruncated: body.entriesTruncated === true, + entriesDropped: isFiniteNumber(body.entriesDropped) ? body.entriesDropped : 0, + }; +} diff --git a/gui/tests/dashboard-contracts.test.ts b/gui/tests/dashboard-contracts.test.ts index 61e8391495..df05ce2fbf 100644 --- a/gui/tests/dashboard-contracts.test.ts +++ b/gui/tests/dashboard-contracts.test.ts @@ -34,26 +34,29 @@ test("Dashboard wires a single project-config diagnostics owner outside the sett expect(overviewBody).not.toContain("diagnostics/project-config"); }); -test("Dashboard usage polling cannot delay core health and settings", async () => { +test("Dashboard usage is owned by the shared usage-report store, not a dashboard poll", async () => { const core = await Bun.file(new URL("../src/pages/dashboard-core-poll.ts", import.meta.url)).text(); const hook = await Bun.file(new URL("../src/pages/use-dashboard-data.ts", import.meta.url)).text(); const overviewFnStart = core.indexOf("export async function fetchDashboardOverview"); - const usageFnStart = core.indexOf("export async function fetchDashboardUsage"); const sidecarsFnStart = core.indexOf("export async function fetchDashboardSidecars"); expect(overviewFnStart).toBeGreaterThan(-1); - expect(usageFnStart).toBeGreaterThan(-1); expect(sidecarsFnStart).toBeGreaterThan(-1); + // The overview poll must never own the usage endpoint. expect(core.slice(overviewFnStart, core.indexOf("export async function fetchDashboardMultiAgent"))).not.toContain("/api/usage?range=30d"); expect(core.slice(overviewFnStart, core.indexOf("export async function fetchDashboardMultiAgent"))).not.toContain("/api/sidecar-settings"); expect(core.slice(overviewFnStart, core.indexOf("export async function fetchDashboardMultiAgent"))).not.toContain("/api/shadow-call-settings"); expect(core.slice(sidecarsFnStart)).toContain("/api/sidecar-settings"); - expect(hook).toContain("dashboard-usage:${apiBase}"); + // Usage lives in the domain store: the dashboard selects the same `${apiBase}:30d:all` + // entry the Usage page uses, so both surfaces dedupe into one in-flight fetch. No + // client-resource poll and no dashboard session-cache remain for usage. + expect(hook).toContain('useUsageReport(apiBase, "30d", "all")'); + expect(hook).not.toContain("dashboard-usage:${apiBase}"); + expect(hook).not.toContain("USAGE_CACHE_PREFIX"); + expect(hook).not.toContain("fetchDashboardUsage"); expect(hook).toContain("dashboard-sidecars:${apiBase}"); expect(hook).toContain("dashboard-overview:${apiBase}"); - expect(hook).toContain("fetchDashboardUsage(apiBase, signal)"); expect(hook).toContain("fetchDashboardSidecars"); expect(hook).toContain("fetchDashboardOverview"); - expect(hook).toMatch(/dashboard-usage:\$\{apiBase\}[\s\S]*pollMs: 60_000/); }); test("Dashboard interactive controls load independently of health/providers", async () => { diff --git a/gui/tests/dashboard-plan-quota.test.tsx b/gui/tests/dashboard-plan-quota.test.tsx new file mode 100644 index 0000000000..1e06932699 --- /dev/null +++ b/gui/tests/dashboard-plan-quota.test.tsx @@ -0,0 +1,510 @@ +import { afterAll, afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { + clearProviderQuotaStoresForTests, + PROVIDER_QUOTA_STORAGE_NAME, + useProviderQuotaStore, +} from "../src/provider-quota-store"; +import { DashboardOverviewHead } from "../src/pages/dashboard-overview-head"; +import { DashboardPlanQuotaSection } from "../src/pages/dashboard-plan-quota-section"; +import type { UsageSummary30d } from "../src/pages/dashboard-shared"; + +/** + * Phase 1c contract: the Dashboard overview shows a 30-day estimated cost stat with a + * request-coverage line ($0.00 for a defined zero, "—" when absent), and the Plan & + * quota section renders per-provider plans / quota windows / reference spend from the + * provider-quota store — never persisting account identities. + */ + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + clearClientResourceStoresForTests(); + clearProviderQuotaStoresForTests(); + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + testWindow.sessionStorage.clear(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearClientResourceStoresForTests(); + clearProviderQuotaStoresForTests(); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +afterAll(() => { + globalThis.fetch = originalFetch; +}); + +async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 10)); + }); + } +} + +const NOW = Date.now(); +const aggregation = { + kind: "capacity-weighted-v1", + scope: "routable-known", + presentation: "aggregate", + incomplete: false, + excludedAccounts: 0, + unknownPlanAccounts: 0, + partialWindowAccounts: 0, + weekly: { usedPercent: 31, includedAccounts: 2, excludedAccounts: 0, incomplete: false, updatedAt: NOW }, +}; + +test("Dashboard cost stat renders the 30-day estimate and coverage line", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + + const usage30d: UsageSummary30d = { + summary: { + requests: 5, + totalTokens: 9000, + coverageRatio: 0.8, + estimatedCostUsd: 1.25, + pricedRequests: 4, + unpricedRequests: 1, + unmeteredRequests: 0, + }, + }; + const props = { + locale: "en" as const, + health: { status: "ok", version: "1.0", uptime: 100 }, + providers: [], + usage30d, + usageLoading: false, + healthLoading: false, + startupHealth: null, + projectConfigWarnings: [], + maMode: "default" as const, + maBusy: false, + maHelpTriggerRef: { current: null }, + maHelpOpen: false, + setMaHelpOpen: () => {}, + switchMaMode: async () => {}, + }; + await act(async () => { + root.render( + + + , + ); + }); + try { + const text = container.textContent ?? ""; + expect(text).toContain("Est. cost (30d)"); + expect(text).toContain("1.25"); + expect(text).toContain("4 priced · 1 unpriced · 0 unmetered requests"); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("Dashboard cost stat renders $0.00 for a defined zero and — when absent", async () => { + const { createRoot } = await import("react-dom/client"); + const base = { + locale: "en" as const, + health: { status: "ok", version: "1.0", uptime: 100 }, + providers: [], + usageLoading: false, + healthLoading: false, + startupHealth: null, + projectConfigWarnings: [], + maMode: "default" as const, + maBusy: false, + maHelpTriggerRef: { current: null }, + maHelpOpen: false, + setMaHelpOpen: () => {}, + switchMaMode: async () => {}, + }; + const zeroContainer = document.createElement("div"); + document.body.append(zeroContainer); + const zeroRoot = createRoot(zeroContainer); + await act(async () => { + zeroRoot.render( + + + , + ); + }); + try { + expect(zeroContainer.textContent ?? "").toContain("$0.00"); + // A defined zero must never fall back to the ~$0.0000 estimate rendering. + expect(zeroContainer.textContent ?? "").not.toContain("~"); + } finally { + await act(async () => { zeroRoot.unmount(); }); + zeroContainer.remove(); + } + const absentContainer = document.createElement("div"); + document.body.append(absentContainer); + const absentRoot = createRoot(absentContainer); + await act(async () => { + absentRoot.render( + + + , + ); + }); + try { + expect(absentContainer.textContent ?? "").toContain("—"); + } finally { + await act(async () => { absentRoot.unmount(); }); + absentContainer.remove(); + } +}); + +test("Plan & quota section renders provider plan, windows, and reference spend", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ + reports: [ + { + provider: "openai", + label: "OpenAI (Codex login)", + source: "chatgpt:wham", + updatedAt: NOW, + quota: { weeklyPercent: 31, updatedAt: NOW }, + aggregation, + }, + { + provider: "opencode-go", + label: "OpenCode Go", + source: "opencode-go:published-caps-2026-08-05+local-estimate", + updatedAt: NOW, + quota: { + referenceWindows: [{ + id: "five_hour", + label: "5-hour", + windowSeconds: 18_000, + publishedLimitUsd: 12, + observedSpendUsd: 1.25, + observedTokens: 42_000, + observedRequests: 3, + pricedRequests: 2, + unpricedRequests: 1, + unmeasuredRequests: 0, + coverage: "partial", + }], + updatedAt: NOW, + }, + aggregation: undefined, + }, + ], + availability: [], + }), { status: 200, headers: { "Content-Type": "application/json" } })) as typeof fetch; + + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + try { + await waitFor(() => (container.textContent ?? "").includes("Plan & quota")); + await waitFor(() => (container.textContent ?? "").includes("Configured-weight pool estimate")); + const text = container.textContent ?? ""; + expect(text).toContain("OpenAI (Codex login)"); + expect(text).toContain("31% used"); + expect(text).toContain("OpenCode Go"); + expect(text).toContain("$12 published cap"); + expect(text).toContain("$1.25 observed through CodexCommander"); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("Plan & quota section never persists account identities to sessionStorage", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ + reports: [ + { + provider: "openai", + label: "OpenAI (Codex login)", + source: "chatgpt:wham", + updatedAt: NOW, + quota: { weeklyPercent: 31, updatedAt: NOW }, + aggregation, + // Stray identity fields the real server never emits. + accountId: "acct_12345", + account: { email: "acct@example.com" }, + quota: { + weeklyPercent: 31, + updatedAt: NOW, + accountId: "acct_12345", + account: { email: "acct@example.com" }, + }, + }, + ], + availability: [], + }), { status: 200, headers: { "Content-Type": "application/json" } })) as typeof fetch; + + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + try { + await waitFor(() => (container.textContent ?? "").includes("OpenAI (Codex login)")); + const persisted = testWindow.sessionStorage.getItem(PROVIDER_QUOTA_STORAGE_NAME) ?? ""; + expect(persisted).not.toContain("acct@example.com"); + expect(persisted).not.toContain("acct_12345"); + expect(persisted).not.toContain("accountId"); + expect(useProviderQuotaStore.getState().entries["http://plan-quota-privacy"]?.reports.openai?.accountId).toBeUndefined(); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +function openaiReport(): Record { + return { + provider: "openai", + label: "OpenAI (Codex login)", + source: "chatgpt:wham", + updatedAt: NOW, + quota: { weeklyPercent: 31, updatedAt: NOW }, + aggregation, + }; +} + +test("Plan & quota renders a full-width strip for unavailable providers with per-reason actions", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ + reports: [openaiReport()], + availability: [ + { provider: "xai", status: "unavailable", reason: "upstream_unavailable", checkedAt: NOW }, + { provider: "anthropic", status: "unavailable", reason: "reauth_required", checkedAt: NOW }, + { provider: "kimi", status: "unavailable", reason: "local_cli_refresh_required", checkedAt: NOW }, + ], + }), { status: 200, headers: { "Content-Type": "application/json" } })) as typeof fetch; + + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + try { + await waitFor(() => (container.textContent ?? "").includes("Quota unavailable")); + const strip = container.querySelector(".dash-plan-quota-unavailable"); + expect(strip).toBeTruthy(); + const text = strip?.textContent ?? ""; + expect(text).toContain("xAI Grok"); + expect(text).toContain("Temporarily unavailable"); + expect(text).toContain("Anthropic"); + expect(text).toContain("Sign in required"); + expect(text).toContain("Kimi"); + expect(text).toContain("Login needs refresh"); + // Reauth rows deep-link to the provider; retryable rows share one Retry. + expect(text).toContain("Manage Anthropic"); + expect(text).toContain("Retry quota check"); + const manageLink = Array.from(strip?.querySelectorAll("a") ?? []) + .find(anchor => anchor.textContent?.includes("Manage Anthropic")); + expect(manageLink?.getAttribute("href")).toBe("#providers/anthropic/overview"); + expect(Array.from(strip?.querySelectorAll("button") ?? []).length).toBe(1); + // F6: the strip has no live-region role; each message span is the live region + // and contains no interactive descendants. + expect(strip?.getAttribute("role")).toBeNull(); + const statusSpans = strip?.querySelectorAll('[role="status"]') ?? []; + expect(statusSpans.length).toBe(3); + for (const span of Array.from(statusSpans)) { + expect(span.querySelector("button, a")).toBeNull(); + } + // F4: grid precedes the strip, strip precedes the disclaimer. + expect(strip?.previousElementSibling?.classList.contains("dash-sidecar-grid")).toBe(true); + expect(strip?.nextElementSibling?.textContent).toContain("Provider-reported caps and local estimates"); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("reauth-only strip deep-links to the provider and offers no quota retry", async () => { + const urls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + urls.push(String(input)); + return new Response(JSON.stringify({ + reports: [], + availability: [ + { provider: "anthropic", status: "unavailable", reason: "reauth_required", checkedAt: NOW }, + ], + }), { status: 200, headers: { "Content-Type": "application/json" } }); + }) as typeof fetch; + + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + try { + await waitFor(() => (container.textContent ?? "").includes("Sign in required")); + expect(container.textContent).toContain("Manage Anthropic"); + expect(Array.from(container.querySelectorAll("button")) + .some(button => button.textContent?.includes("Retry"))).toBe(false); + expect(urls.every(url => !url.includes("refresh=1"))).toBe(true); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("Plan & quota strip Retry forces refresh and disappears once the provider reports", async () => { + const urls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + urls.push(String(input)); + if (String(input).includes("refresh=1")) { + return new Response(JSON.stringify({ + reports: [openaiReport(), { ...openaiReport(), provider: "xai", label: "xAI Grok" }], + availability: [{ provider: "xai", status: "available", checkedAt: NOW }], + }), { status: 200, headers: { "Content-Type": "application/json" } }); + } + return new Response(JSON.stringify({ + reports: [openaiReport()], + availability: [ + { provider: "xai", status: "unavailable", reason: "upstream_unavailable", checkedAt: NOW }, + ], + }), { status: 200, headers: { "Content-Type": "application/json" } }); + }) as typeof fetch; + + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + try { + await waitFor(() => (container.textContent ?? "").includes("Quota unavailable")); + const strip = container.querySelector(".dash-plan-quota-unavailable"); + expect(strip).toBeTruthy(); + const retry = Array.from(container.querySelectorAll("button")) + .find(button => button.textContent?.trim() === "Retry quota check"); + expect(retry).toBeTruthy(); + await act(async () => { retry?.click(); }); + await waitFor(() => urls.some(url => url.includes("refresh=1"))); + await waitFor(() => !container.querySelector(".dash-plan-quota-unavailable")); + expect((container.textContent ?? "")).toContain("xAI Grok"); + expect((container.textContent ?? "")).not.toContain("Temporarily unavailable"); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("Plan & quota strip stays hidden when providers are available or availability is absent", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ + reports: [openaiReport()], + availability: [{ provider: "xai", status: "available", checkedAt: NOW }], + }), { status: 200, headers: { "Content-Type": "application/json" } })) as typeof fetch; + + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + try { + await waitFor(() => (container.textContent ?? "").includes("Plan & quota")); + expect(container.querySelector(".dash-plan-quota-unavailable")).toBeNull(); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } + + // No availability data at all: still no strip. + globalThis.fetch = (async () => + new Response(JSON.stringify({ + reports: [openaiReport()], + availability: [], + }), { status: 200, headers: { "Content-Type": "application/json" } })) as typeof fetch; + const container2 = document.createElement("div"); + document.body.append(container2); + const root2 = createRoot(container2); + await act(async () => { + root2.render( + + + , + ); + }); + try { + await waitFor(() => (container2.textContent ?? "").includes("Plan & quota")); + expect(container2.querySelector(".dash-plan-quota-unavailable")).toBeNull(); + } finally { + await act(async () => { root2.unmount(); }); + container2.remove(); + } +}); diff --git a/gui/tests/models-empty-provider.test.tsx b/gui/tests/models-empty-provider.test.tsx index beb4cc12de..2509a486da 100644 --- a/gui/tests/models-empty-provider.test.tsx +++ b/gui/tests/models-empty-provider.test.tsx @@ -232,7 +232,7 @@ test("Models page combines final visibility, atomic actions, discovery status, a expect(discoveryLink?.textContent).toContain("Auto-discovery on"); expect(discoveryLink?.getAttribute("aria-label")).toContain("Open provider settings"); expect(container.textContent).not.toContain("Not selected"); - expect(container.textContent).toContain("Reliable v1"); + expect(container.textContent).toContain("Reliable V1"); expect(container.textContent).toContain("Flexible model selection"); expect(container.textContent).toContain("Uncapped"); expect(container.textContent).toContain("Models use their full advertised window"); diff --git a/gui/tests/page-loading-contract.test.tsx b/gui/tests/page-loading-contract.test.tsx index 9793052778..aa98f45078 100644 --- a/gui/tests/page-loading-contract.test.tsx +++ b/gui/tests/page-loading-contract.test.tsx @@ -48,6 +48,13 @@ const MIGRATED = [ test("every migrated surface subscribes through the shared resource layer", async () => { for (const surface of MIGRATED) { const source = await read(surface.file); + if (surface.name === "Usage") { + // PR1 moved Usage onto the usage-report domain store. It still classifies render + // state through the shared data-surface machine (classifyDataSurface), so the + // contract holds; only the subscription mechanism changed. + expect(source, surface.name).toContain("classifyDataSurface"); + continue; + } expect(source, surface.name).toContain("useDataSurface"); } }); diff --git a/gui/tests/provider-capacity-shell.test.tsx b/gui/tests/provider-capacity-shell.test.tsx index 8c34855e47..b4b15cb62f 100644 --- a/gui/tests/provider-capacity-shell.test.tsx +++ b/gui/tests/provider-capacity-shell.test.tsx @@ -3,8 +3,14 @@ import { Window } from "happy-dom"; import { act } from "react"; import type { Root } from "react-dom/client"; import ProviderWorkspaceShell from "../src/components/provider-workspace/ProviderWorkspaceShell"; +import type { DetailSlotData } from "../src/components/provider-workspace/ProviderWorkspaceShell"; import { LanguageProvider } from "../src/i18n/provider"; import { readSessionListCache, writeSessionListCache } from "../src/session-list-cache"; +import { + clearProviderQuotaStoresForTests, + PROVIDER_QUOTA_STORAGE_NAME, + rehydrateProviderQuotaForTests, +} from "../src/provider-quota-store"; const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; let previous: Record<(typeof globals)[number], unknown>; @@ -146,6 +152,7 @@ function aggregateWindowPayload(weeklyIncomplete: boolean, monthlyIncomplete: bo } beforeEach(() => { + clearProviderQuotaStoresForTests(); previous = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previous; originalFetch = globalThis.fetch; win = new Window({ url: "http://localhost/" }); @@ -229,6 +236,71 @@ test("quota auth availability immediately moves Grok from connected to needs att expect(text).not.toContain("1Ready"); }); +test("detail slot keeps genuine account reauth separate from quota-derived attention", async () => { + quotaPayload = { + reports: [], + availability: [{ + provider: "xai", + status: "unavailable", + reason: "local_cli_refresh_required", + checkedAt: Date.now(), + }], + }; + + let capturedItem: { activeNeedsReauth?: boolean } | null = null; + let capturedData: DetailSlotData | null = null; + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root ??= createRoot(host); + root.render( + + {}} + onAddProvider={() => {}} + activeAccountNeedsReauth={undefined} + detail={(item, data) => { + capturedItem = item; + capturedData = data; + return null; + }} + /> + , + ); + }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); }); + // Quota-derived attention flips the merged flag, but the provenance slot stays + // false because no account-health input says a browser reauth is needed. + expect(capturedData?.accountNeedsReauth).toBe(false); + expect(capturedItem?.activeNeedsReauth).toBe(true); + + await act(async () => { + root?.render( + + {}} + onAddProvider={() => {}} + activeAccountNeedsReauth={{ xai: true }} + detail={(item, data) => { + capturedItem = item; + capturedData = data; + return null; + }} + /> + , + ); + }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); }); + expect(capturedData?.accountNeedsReauth).toBe(true); +}); + test("provider quota fetch preserves aggregate capacity through shell state and render", async () => { await mountShell(); @@ -248,29 +320,51 @@ test("provider quota fetch preserves aggregate capacity through shell state and expect(text).not.toMatch(/configured units|weighted units|units remaining|projected/i); }); -test("successful empty quota response removes cached providers and updates session cache", async () => { +test("successful empty quota response clears persisted quota data", async () => { const seeded = (aggregatePayload().reports[0]); - const { provider: _provider, ...cached } = seeded; - writeSessionListCache(QUOTA_CACHE_KEY, { openai: cached }); + const { provider: _provider, ...report } = seeded; + // A session-cache revisit seeds through the provider-quota store's persisted slice. + win.sessionStorage.setItem(PROVIDER_QUOTA_STORAGE_NAME, JSON.stringify({ + state: { + entries: { + "": { reports: { openai: report }, updatedAt: Date.now() }, + }, + }, + version: 0, + })); + rehydrateProviderQuotaForTests(); quotaPayload = { reports: [] }; await mountShell(); expect(host.textContent ?? "").not.toContain("Configured-weight pool estimate"); - expect(readSessionListCache(QUOTA_CACHE_KEY)).toEqual({}); + // The empty authoritative response removed the provider from the persisted slice. + const persisted = JSON.parse(win.sessionStorage.getItem(PROVIDER_QUOTA_STORAGE_NAME) ?? "{}"); + expect(persisted.state?.entries?.[""]).toBeUndefined(); }); test("expired session quota is rejected and a failed fetch cannot keep it rendered", async () => { const old = Date.now() - 31 * 60_000; - writeSessionListCache(QUOTA_CACHE_KEY, { - openai: { - label: "OpenAI (Codex login)", - source: "chatgpt:wham", - updatedAt: old, - quota: { weeklyPercent: 99, updatedAt: old }, - aggregation: { ...aggregatePayload().reports[0].aggregation, presentation: "aggregate" }, + win.sessionStorage.setItem(PROVIDER_QUOTA_STORAGE_NAME, JSON.stringify({ + state: { + entries: { + "": { + reports: { + openai: { + label: "OpenAI (Codex login)", + source: "chatgpt:wham", + updatedAt: old, + quota: { weeklyPercent: 99, updatedAt: old }, + aggregation: { ...aggregatePayload().reports[0].aggregation, presentation: "aggregate" }, + }, + }, + updatedAt: old, + }, + }, }, - }); + version: 0, + })); + rehydrateProviderQuotaForTests(); rejectQuotaFetch = true; await mountShell(); @@ -278,7 +372,9 @@ test("expired session quota is rejected and a failed fetch cannot keep it render const text = host.textContent ?? ""; expect(text).not.toContain("Configured-weight pool estimate"); expect(text).not.toContain("99% used"); - expect(readSessionListCache(QUOTA_CACHE_KEY)).toEqual({}); + // The stale seed was rejected at rehydrate, and the failed fetch persisted nothing. + const persisted = JSON.parse(win.sessionStorage.getItem(PROVIDER_QUOTA_STORAGE_NAME) ?? "{}"); + expect(persisted.state?.entries?.[""]).toBeUndefined(); }); test("a cancelled superseded quota rejection cannot rewrite state or session cache", async () => { diff --git a/gui/tests/provider-overview-quota-unavailable.test.tsx b/gui/tests/provider-overview-quota-unavailable.test.tsx new file mode 100644 index 0000000000..59f3792200 --- /dev/null +++ b/gui/tests/provider-overview-quota-unavailable.test.tsx @@ -0,0 +1,252 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import ProviderOverview from "../src/components/provider-workspace/ProviderOverview"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; +import type { ProviderQuotaReportView } from "../src/provider-workspace/report"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#providers" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +const item = { + name: "xai", + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + hasApiKey: false, +} as WorkspaceItem; + +async function mountOverview(props: { + quotaReport?: ProviderQuotaReportView; + quotaUnavailableReason?: string; + onRetryQuota?: () => void; + accountNeedsReauth?: boolean; + onReauthenticate?: () => void; + itemOverrides?: Partial; +}): Promise<{ root: Root; container: HTMLElement }> { + const container = document.createElement("div"); + document.body.append(container); + const { createRoot } = await import("react-dom/client"); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + return { root, container }; +} + +test("renders the quota-unavailable notice with reason copy and Retry when no report exists", async () => { + const { root, container } = await mountOverview({ + quotaUnavailableReason: "upstream_unavailable", + onRetryQuota: () => {}, + }); + try { + const text = container.textContent ?? ""; + expect(text).toContain("Quota unavailable"); + expect(text).toContain("Temporarily unavailable"); + expect(text).toContain("Retry"); + expect(text).not.toContain("Rate limits"); + // F6: the warning container has no live-region role; the message span does, + // and no interactive control lives inside it. + const warnings = container.querySelectorAll(".pws-auth-summary--warn"); + expect(warnings.length).toBe(1); + expect(warnings[0]?.getAttribute("role")).toBeNull(); + const status = warnings[0]?.querySelector('[role="status"]'); + expect(status?.textContent).toContain("Temporarily unavailable"); + expect(status?.querySelector("button, a")).toBeNull(); + const retry = Array.from(container.querySelectorAll("button")) + .find(button => button.textContent?.trim() === "Retry"); + expect(retry).toBeTruthy(); + expect(status?.contains(retry as Node)).toBe(false); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("reauth_required is owned by the auth warning, not a second quota warning", async () => { + let reauthed = false; + const { root, container } = await mountOverview({ + quotaUnavailableReason: "reauth_required", + onRetryQuota: () => { throw new Error("quota retry must not be offered when auth owns the warning"); }, + onReauthenticate: () => { reauthed = true; }, + itemOverrides: { activeNeedsReauth: true }, + }); + try { + const warnings = container.querySelectorAll(".pws-auth-summary--warn"); + expect(warnings.length).toBe(1); + const text = container.textContent ?? ""; + expect(text).toContain("Needs attention"); + expect(text).toContain("Active account needs re-authentication"); + expect(text).toContain("Re-authenticate"); + expect(text).not.toContain("Quota unavailable"); + expect(text).not.toContain("Sign in required"); + expect(text).not.toContain("Retry"); + const warn = warnings[0]!; + expect(warn.getAttribute("role")).toBeNull(); + const status = warn.querySelector('[role="status"]'); + expect(status?.textContent).toContain("Needs attention"); + expect(status?.querySelector("button, a")).toBeNull(); + await act(async () => { + (Array.from(container.querySelectorAll("button")) + .find(button => button.textContent?.trim() === "Re-authenticate"))?.click(); + }); + expect(reauthed).toBe(true); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("Retry invokes onRetryQuota", async () => { + let retried = false; + const { root, container } = await mountOverview({ + quotaUnavailableReason: "local_cli_refresh_required", + onRetryQuota: () => { retried = true; }, + }); + try { + const retry = Array.from(container.querySelectorAll("button")) + .find(button => button.textContent?.trim() === "Retry"); + expect(retry).toBeTruthy(); + await act(async () => { retry?.click(); }); + expect(retried).toBe(true); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("local_cli_refresh_required alone is owned by the quota notice, not the auth warning", async () => { + const { root, container } = await mountOverview({ + quotaUnavailableReason: "local_cli_refresh_required", + onRetryQuota: () => {}, + itemOverrides: { activeNeedsReauth: true }, + accountNeedsReauth: false, + }); + try { + const warnings = container.querySelectorAll(".pws-auth-summary--warn"); + expect(warnings.length).toBe(1); + const text = container.textContent ?? ""; + expect(text).toContain("Quota unavailable"); + expect(text).toContain("Login needs refresh"); + expect(text).toContain("Retry"); + expect(text).not.toContain("Active account needs re-authentication"); + expect(text).not.toContain("Re-authenticate"); + // The connection status row still surfaces attention (status metadata only). + expect(text).toContain("Needs attention"); + const warn = warnings[0]!; + expect(warn.getAttribute("role")).toBeNull(); + const status = warn.querySelector('[role="status"]'); + expect(status?.textContent).toContain("Login needs refresh"); + expect(status?.querySelector("button, a")).toBeNull(); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("local CLI refresh plus a genuine account reauth shows both warnings", async () => { + const { root, container } = await mountOverview({ + quotaUnavailableReason: "local_cli_refresh_required", + onRetryQuota: () => {}, + onReauthenticate: () => {}, + itemOverrides: { activeNeedsReauth: true }, + accountNeedsReauth: true, + }); + try { + expect(container.querySelectorAll(".pws-auth-summary--warn").length).toBe(2); + const text = container.textContent ?? ""; + expect(text).toContain("Login needs refresh"); + expect(text).toContain("Retry"); + expect(text).toContain("Active account needs re-authentication"); + expect(text).toContain("Re-authenticate"); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("reauth_required without merged attention keeps the quota notice visible", async () => { + const { root, container } = await mountOverview({ + quotaUnavailableReason: "reauth_required", + onRetryQuota: () => {}, + }); + try { + const text = container.textContent ?? ""; + expect(text).toContain("Quota unavailable"); + expect(text).toContain("Sign in required"); + expect(text).toContain("Retry"); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("quota card (not the notice) renders when a report exists", async () => { + const report: ProviderQuotaReportView = { + label: "xAI Grok", + source: "xai:api", + updatedAt: Date.now(), + quota: { weeklyPercent: 10 }, + aggregation: undefined, + }; + const { root, container } = await mountOverview({ + quotaReport: report, + quotaUnavailableReason: "upstream_unavailable", + }); + try { + const text = container.textContent ?? ""; + expect(text).toContain("Rate limits"); + expect(text).not.toContain("Temporarily unavailable"); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("no notice and no quota card when neither report nor reason exists", async () => { + const { root, container } = await mountOverview({}); + try { + const text = container.textContent ?? ""; + expect(text).not.toContain("Rate limits"); + expect(text).not.toContain("Quota unavailable"); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); diff --git a/gui/tests/provider-quota-store.test.ts b/gui/tests/provider-quota-store.test.ts new file mode 100644 index 0000000000..29c43b1228 --- /dev/null +++ b/gui/tests/provider-quota-store.test.ts @@ -0,0 +1,295 @@ +import { afterAll, afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; + +/** + * Store-level contract for the provider-quota domain store: keyed by apiBase, + * singleflight dedupe, force-refresh TTL bypass, stale-seed rejection at rehydrate, + * and the privacy invariant — the persisted slice never carries account identities. + * + * sessionStorage is installed before the store module is imported so zustand persist + * sees it (Bun shares the module registry within one run, so the lazy storage reads + * whatever sessionStorage is current at each call). + */ +const testWindow = new Window({ url: "http://localhost/" }); +const originalFetch = globalThis.fetch; +const INSTALLED_GLOBALS = ["document", "window", "navigator", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +const previousGlobals = Object.fromEntries( + INSTALLED_GLOBALS.map(key => [key, Reflect.get(globalThis, key)]), +) as Record<(typeof INSTALLED_GLOBALS)[number], unknown>; +Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, +}); +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const { + clearProviderQuotaStoresForTests, + PROVIDER_QUOTA_STORAGE_NAME, + quotaAvailabilityFromResponse, + rehydrateProviderQuotaForTests, + unavailableQuotaProviders, + useProviderQuotaStore, +} = await import("../src/provider-quota-store"); + +const now = Date.now(); +const aggregation = { + kind: "capacity-weighted-v1", + scope: "routable-known", + presentation: "aggregate", + incomplete: false, + excludedAccounts: 0, + unknownPlanAccounts: 0, + partialWindowAccounts: 0, +}; + +function report(overrides: Record = {}) { + return { + provider: "openai", + label: "OpenAI (Codex login)", + source: "chatgpt:wham", + updatedAt: now, + quota: { weeklyPercent: 20, updatedAt: now }, + aggregation, + ...overrides, + }; +} + +function quotaResponse(payload: unknown) { + return { + ok: true, + status: 200, + json: async () => payload, + } as unknown as Response; +} + +beforeEach(() => { + clearProviderQuotaStoresForTests(); + testWindow.sessionStorage.clear(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearProviderQuotaStoresForTests(); +}); + +afterAll(() => { + testWindow.close(); + for (const key of INSTALLED_GLOBALS) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function flush(times = 3): Promise { + for (let i = 0; i < times; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } +} + +test("force refresh adds the server TTL bypass (?refresh=1)", async () => { + const urls: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + urls.push(String(input)); + return quotaResponse({ reports: [report()], availability: [] }); + }) as typeof fetch; + + useProviderQuotaStore.getState().ensure(""); + await flush(); + useProviderQuotaStore.getState().refresh("", { force: true }); + await flush(); + expect(urls).toEqual(["/api/provider-quotas", "/api/provider-quotas?refresh=1"]); +}); + +test("singleflight dedupes concurrent subscribers into one fetch", async () => { + let calls = 0; + const gates: Array<() => void> = []; + globalThis.fetch = (async () => { + calls += 1; + await new Promise(resolve => gates.push(resolve)); + return quotaResponse({ reports: [report()], availability: [] }); + }) as typeof fetch; + + useProviderQuotaStore.getState().ensure(""); + useProviderQuotaStore.getState().ensure(""); + useProviderQuotaStore.getState().ensure(""); + expect(calls).toBe(1); + gates[0]!(); + await flush(); + expect(useProviderQuotaStore.getState().entries[""]?.reports.openai).toBeDefined(); + expect(useProviderQuotaStore.getState().entries[""]?.hasSucceeded).toBe(true); +}); + +test("persists only reports and a timestamp — never account identities", async () => { + // A hostile/legacy server response carrying account identity fields must never reach + // sessionStorage: the store projects the wire shape, not the raw payload — including + // identity fields stashed INSIDE quota or aggregation. + const payload = { + reports: [report({ + // Stray identity fields on the wire row (the real server never emits these). + accountId: "acct_12345", + account: { email: "acct@example.com" }, + quota: { + weeklyPercent: 20, + updatedAt: now, + accountId: "acct_12345", + account: { email: "acct@example.com" }, + }, + aggregation: { + ...aggregation, + currentAccount: { + isMain: true, + plan: "pro", + quota: { weeklyPercent: 8, updatedAt: now }, + email: "acct@example.com", + accountId: "acct_12345", + }, + }, + })], + availability: [{ provider: "openai", status: "available", checkedAt: now }], + // Stray top-level identity fields. + accountId: "acct_12345", + account: { email: "acct@example.com" }, + }; + globalThis.fetch = (async () => quotaResponse(payload)) as typeof fetch; + useProviderQuotaStore.getState().ensure(""); + await flush(); + + const persisted = testWindow.sessionStorage.getItem(PROVIDER_QUOTA_STORAGE_NAME) ?? ""; + expect(persisted).toContain("openai"); + expect(persisted).not.toContain("acct@example.com"); + expect(persisted).not.toContain("acct_12345"); + expect(persisted).not.toContain("accountId"); + const parsed = JSON.parse(persisted); + const entry = parsed.state.entries[""]; + expect(entry.reports.openai.provider).toBeUndefined(); + expect(entry.reports.openai.label).toBe("OpenAI (Codex login)"); + expect(entry.reports.openai.quota.weeklyPercent).toBe(20); + expect(typeof entry.updatedAt).toBe("number"); +}); + +test("a failed fetch keeps the last-known-good reports", async () => { + globalThis.fetch = (async () => quotaResponse({ reports: [report()], availability: [] })) as typeof fetch; + useProviderQuotaStore.getState().ensure(""); + await flush(); + + globalThis.fetch = (async () => new Response("boom", { status: 503 })) as typeof fetch; + useProviderQuotaStore.getState().refresh(""); + await flush(); + const entry = useProviderQuotaStore.getState().entries[""]; + expect(entry?.reports.openai).toBeDefined(); + expect(entry?.lastAttemptOk).toBe(false); +}); + +test("stale rehydrated seeds are rejected at rehydrate", async () => { + const old = Date.now() - 31 * 60_000; + testWindow.sessionStorage.setItem(PROVIDER_QUOTA_STORAGE_NAME, JSON.stringify({ + state: { + entries: { + "": { reports: { openai: report({ updatedAt: old }) }, updatedAt: old }, + }, + }, + version: 0, + })); + rehydrateProviderQuotaForTests(); + const entry = useProviderQuotaStore.getState().entries[""]; + // The stale row was dropped: either no entry, or an entry without reports. + expect(entry?.reports).toBeUndefined(); +}); + +test("quotaAvailabilityFromResponse projects provider/status/reason/checkedAt only", () => { + const projected = quotaAvailabilityFromResponse([ + { provider: "xai", status: "unavailable", reason: "upstream_unavailable", checkedAt: 123, email: "x@example.com", accountId: "acct_1" }, + { provider: "openai", status: "available", checkedAt: 456 }, + { provider: "anthropic", status: "unavailable", reason: "unknown_reason_code", checkedAt: 789 }, + { provider: "banana", status: "hostile_status", checkedAt: 111 }, + { provider: "" }, + null, + "garbage", + ]); + expect(projected).toEqual({ + xai: { status: "unavailable", reason: "upstream_unavailable", checkedAt: 123 }, + openai: { status: "available", checkedAt: 456 }, + // Unknown reason codes are dropped (never projected onto the DOM path). + anthropic: { status: "unavailable", checkedAt: 789 }, + // Unknown status strings are dropped too (never treated as unavailable). + banana: undefined, + }); + expect(projected.banana).toBeUndefined(); +}); + +test("unavailableQuotaProviders lists only non-available providers without reports, sorted", () => { + const availability = { + xai: { status: "unavailable", reason: "upstream_unavailable", checkedAt: 1 }, + openai: { status: "available", checkedAt: 2 }, + anthropic: { status: "stale", reason: "reauth_required", checkedAt: 3 }, + grok: { status: "unavailable", checkedAt: 4 }, + gemini: { status: "stale", checkedAt: 5 }, + }; + const reports = { openai: {}, anthropic: {} }; + expect(unavailableQuotaProviders(availability, reports)).toEqual([ + // stale-without-report qualifies (status !== "available"). + { provider: "gemini" }, + { provider: "grok" }, + { provider: "xai", reason: "upstream_unavailable" }, + ]); +}); + +test("a successful fetch populates availability; refresh updates it", async () => { + globalThis.fetch = (async () => quotaResponse({ + reports: [report()], + availability: [{ provider: "xai", status: "unavailable", reason: "upstream_unavailable", checkedAt: now }], + })) as typeof fetch; + useProviderQuotaStore.getState().ensure(""); + await flush(); + expect(useProviderQuotaStore.getState().entries[""]?.availability).toEqual({ + xai: { status: "unavailable", reason: "upstream_unavailable", checkedAt: now }, + }); + + globalThis.fetch = (async () => quotaResponse({ + reports: [report()], + availability: [{ provider: "xai", status: "available", checkedAt: now }], + })) as typeof fetch; + useProviderQuotaStore.getState().refresh(""); + await flush(); + expect(useProviderQuotaStore.getState().entries[""]?.availability.xai).toEqual({ + status: "available", + checkedAt: now, + }); +}); + +test("a failed fetch keeps the last-known-good availability", async () => { + globalThis.fetch = (async () => quotaResponse({ + reports: [report()], + availability: [{ provider: "xai", status: "unavailable", reason: "upstream_unavailable", checkedAt: now }], + })) as typeof fetch; + useProviderQuotaStore.getState().ensure(""); + await flush(); + + globalThis.fetch = (async () => new Response("boom", { status: 503 })) as typeof fetch; + useProviderQuotaStore.getState().refresh(""); + await flush(); + const entry = useProviderQuotaStore.getState().entries[""]; + expect(entry?.availability.xai.reason).toBe("upstream_unavailable"); + expect(entry?.lastAttemptOk).toBe(false); +}); + +test("availability is never persisted to sessionStorage", async () => { + const payload = { + reports: [report()], + availability: [ + { provider: "xai", status: "unavailable", reason: "upstream_unavailable", checkedAt: now, email: "x@example.com", accountId: "acct_xai" }, + ], + }; + globalThis.fetch = (async () => quotaResponse(payload)) as typeof fetch; + useProviderQuotaStore.getState().ensure(""); + await flush(); + + const persisted = testWindow.sessionStorage.getItem(PROVIDER_QUOTA_STORAGE_NAME) ?? ""; + const parsed = JSON.parse(persisted) as { state: { entries: Record } }; + const entry = parsed.state.entries[""] as Record; + expect(entry).not.toHaveProperty("availability"); + expect(persisted).not.toContain("upstream_unavailable"); + expect(persisted).not.toContain("acct_xai"); + expect(persisted).not.toContain("x@example.com"); +}); diff --git a/gui/tests/provider-revalidation-policy.test.tsx b/gui/tests/provider-revalidation-policy.test.tsx index 5bbbdb9f7e..e970ba1e40 100644 --- a/gui/tests/provider-revalidation-policy.test.tsx +++ b/gui/tests/provider-revalidation-policy.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client"; import Providers from "../src/pages/Providers"; import { LanguageProvider } from "../src/i18n/provider"; import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { clearProviderQuotaStoresForTests } from "../src/provider-quota-store"; /** * Quota revalidation policy. @@ -30,6 +31,9 @@ const PROVIDERS = ["anthropic", "cursor", "kimi"]; beforeEach(() => { previousGlobals = Object.fromEntries(globals.map(k => [k, Reflect.get(globalThis, k)])) as typeof previousGlobals; clearClientResourceStoresForTests(); + // Quota lives in the shared provider-quota store now; a sibling case's healthy entry + // would short-circuit the cold read this file pins (ensure() is a no-op on healthy data). + clearProviderQuotaStoresForTests(); testWindow = new Window({ url: "http://localhost/#providers" }); Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); Object.defineProperties(globalThis, { @@ -99,6 +103,7 @@ afterEach(async () => { root = null; } clearClientResourceStoresForTests(); + clearProviderQuotaStoresForTests(); for (const key of globals) { Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); } diff --git a/gui/tests/startup-usage-loading-race.test.tsx b/gui/tests/startup-usage-loading-race.test.tsx index e12e53c3f3..8db10e562e 100644 --- a/gui/tests/startup-usage-loading-race.test.tsx +++ b/gui/tests/startup-usage-loading-race.test.tsx @@ -164,6 +164,7 @@ test("an aborted Usage fetch must not clear loading while its replacement is in requests: 0, measuredRequests: 0, reportedRequests: 0, unreportedRequests: 0, unsupportedRequests: 0, estimatedRequests: 0, inputTokens: 0, outputTokens: 0, cachedInputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0, coverageRatio: 1, + estimatedCostUsd: 0, pricedRequests: 0, unpricedRequests: 0, unmeteredRequests: 0, }; const usage = (generatedAt: number) => ({ range: "7d", surface: "all", since: null, generatedAt, diff --git a/gui/tests/subagents-busy-race.test.tsx b/gui/tests/subagents-busy-race.test.tsx index 2acf07d87c..ffd622358c 100644 --- a/gui/tests/subagents-busy-race.test.tsx +++ b/gui/tests/subagents-busy-race.test.tsx @@ -113,7 +113,7 @@ async function mount() { function addToggle(id: string): HTMLButtonElement { const row = Array.from(container.querySelectorAll("button")).find((b) => - (b.getAttribute("aria-label") ?? "").includes(`Add ${id} to active roster`), + (b.getAttribute("aria-label") ?? "").includes(`Add ${id} to configured roster`), ); if (!row) throw new Error(`add toggle not found: ${id}`); return row as unknown as HTMLButtonElement; diff --git a/gui/tests/subagents-classic.test.ts b/gui/tests/subagents-classic.test.ts index ae13927c52..3da8391ce3 100644 --- a/gui/tests/subagents-classic.test.ts +++ b/gui/tests/subagents-classic.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; /** - * Subagents ships one command-center layout (active roster + library + policy). + * Subagents ships one command-center layout (configured roster + library + policy). * Classic stacked cards and the view-mode toggle are gone. */ diff --git a/gui/tests/subagents-classic.test.tsx b/gui/tests/subagents-classic.test.tsx index 9aab859136..76fea27727 100644 --- a/gui/tests/subagents-classic.test.tsx +++ b/gui/tests/subagents-classic.test.tsx @@ -159,46 +159,46 @@ async function mount() { /** Library add/remove toggles are labelled from sub.workspace.addToFeatured / removeFromFeatured. */ function addToggle(id: string): HTMLButtonElement { const row = Array.from(container.querySelectorAll("button")) - .find((b) => (b.getAttribute("aria-label") ?? "").includes(`Add ${id} to active roster`)); + .find((b) => (b.getAttribute("aria-label") ?? "").includes(`Add ${id} to configured roster`)); if (!row) throw new Error(`add toggle not found: ${id}`); return row as unknown as HTMLButtonElement; } -/** Active-roster remove only (the library also exposes remove toggles). */ +/** Configured-roster remove only (the library also exposes remove toggles). */ function removeButtons(): HTMLButtonElement[] { return Array.from(container.querySelectorAll(".swi-roster-actions button")).filter((b) => /^Remove /.test(b.getAttribute("aria-label") ?? "")) as unknown as HTMLButtonElement[]; } -test("renders one active roster, one agent library, and one run-policy card", async () => { +test("renders one configured roster, one agent library, and one run-policy card", async () => { await mount(); expect(container.querySelector(".subagents-workspace-shell")).toBeTruthy(); expect(container.querySelectorAll(".subagents-command-card").length).toBe(3); const headings = Array.from(container.querySelectorAll(".swi-card-title")) .map(node => node.textContent?.trim()); - expect(headings).toEqual(["Active Roster", "Agent Library", "Run Policy"]); + expect(headings).toEqual(["Configured Roster", "Agent Library", "Run Policy"]); expect(container.textContent).toContain("Use roster as worker guidance"); - expect(container.textContent).toContain("No preferred model — Codex chooses from roster"); + expect(container.textContent).toContain("No preferred model"); }); test("shows the encrypted V2 compatibility notice for base and V2, but not classic V1", async () => { policyMode = "v2"; await mount(); - expect(container.textContent).toContain("external providers cannot read (#92)"); + expect(container.textContent).toContain("V2 tasks are encrypted; external providers cannot read them"); const v2Root = root!; await act(async () => { v2Root.unmount(); }); root = null; policyMode = "default"; await mount(); - expect(container.textContent).toContain("external providers cannot read (#92)"); + expect(container.textContent).toContain("V2 tasks are encrypted; external providers cannot read them"); const currentRoot = root!; await act(async () => { currentRoot.unmount(); }); root = null; policyMode = "v1"; await mount(); - expect(container.textContent).not.toContain("external providers cannot read (#92)"); + expect(container.textContent).not.toContain("V2 tasks are encrypted"); }); test("shows the plaintext privacy notice when Codex defaults may select V2", async () => { @@ -206,7 +206,7 @@ test("shows the plaintext privacy notice when Codex defaults may select V2", asy messageDelivery = "plaintext"; await mount(); expect(container.textContent).toContain("including messages to native workers"); - expect(container.textContent).toContain("V2 task-message delivery from this parent is plaintext"); + expect(container.textContent).toContain("Task-message delivery from this parent is plaintext"); expect(container.textContent).toContain("does not require Apply"); }); diff --git a/gui/tests/usage-layout.test.ts b/gui/tests/usage-layout.test.ts index f3e77f2880..8e9043db0b 100644 --- a/gui/tests/usage-layout.test.ts +++ b/gui/tests/usage-layout.test.ts @@ -103,6 +103,10 @@ test("Usage renders Available history and a persistent qualification when histor reasoningOutputTokens: 0, totalTokens: 0, coverageRatio: 1, + estimatedCostUsd: 0, + pricedRequests: 0, + unpricedRequests: 0, + unmeteredRequests: 0, }, days: [], models: [], diff --git a/gui/tests/usage-report-store-rehydrate.test.ts b/gui/tests/usage-report-store-rehydrate.test.ts new file mode 100644 index 0000000000..6648f08849 --- /dev/null +++ b/gui/tests/usage-report-store-rehydrate.test.ts @@ -0,0 +1,160 @@ +import { afterAll, afterEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; + +/** + * Rehydration contract for the usage-report store: sessionStorage seeds become store + * entries marked `seedNeedsRevalidate`, and the first subscriber quiet-revalidates + * (exactly one fetch) without ever blanking the seeded data. + * + * The store module may already be loaded by another test file (Bun shares the module + * registry within one run), so the seed is written and `rehydrateUsageReportForTests` + * is invoked explicitly instead of relying on creation-time hydration. + */ +const testWindow = new Window({ url: "http://localhost/" }); +const originalFetch = globalThis.fetch; +const INSTALLED_GLOBALS = ["document", "window", "navigator", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +const previousGlobals = Object.fromEntries( + INSTALLED_GLOBALS.map(key => [key, Reflect.get(globalThis, key)]), +) as Record<(typeof INSTALLED_GLOBALS)[number], unknown>; +Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, +}); +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const SEED_KEY = "http://rehydrate:30d:all"; +const seedReport = { + range: "30d", + surface: "all", + since: null, + generatedAt: 1, + summary: { + requests: 7, + measuredRequests: 7, + reportedRequests: 7, + unreportedRequests: 0, + unsupportedRequests: 0, + estimatedRequests: 0, + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 500, + coverageRatio: 1, + estimatedCostUsd: 0.25, + pricedRequests: 7, + unpricedRequests: 0, + unmeteredRequests: 0, + }, + days: [], + models: [], + providers: [], + historyTruncated: false, + truncatedPrefixBytes: 0, + entriesTruncated: false, + entriesDropped: 0, +} as const; + +testWindow.sessionStorage.setItem( + "ccx.usage-reports.v1", + JSON.stringify({ + state: { + entries: { + [SEED_KEY]: { data: seedReport, persistedAt: 1234 }, + }, + }, + version: 0, + }), +); + +const { + clearUsageReportStoresForTests, + rehydrateUsageReportForTests, + useUsageReportStore, +} = await import("../src/usage-report-store"); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearUsageReportStoresForTests(); +}); + +afterAll(() => { + testWindow.close(); + for (const key of INSTALLED_GLOBALS) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +// Re-run persist rehydration against the seeded sessionStorage above. +rehydrateUsageReportForTests(); + +async function flush(times = 3): Promise { + for (let i = 0; i < times; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } +} + +function reseed(): void { + testWindow.sessionStorage.setItem( + "ccx.usage-reports.v1", + JSON.stringify({ + state: { + entries: { + [SEED_KEY]: { data: seedReport, persistedAt: 1234 }, + }, + }, + version: 0, + }), + ); + rehydrateUsageReportForTests(); +} + +test("rehydrated seed is available with seedNeedsRevalidate set", () => { + const entry = useUsageReportStore.getState().entries[SEED_KEY]; + expect(entry?.data).toEqual(seedReport); + expect(entry?.persistedAt).toBe(1234); + expect(entry?.seedNeedsRevalidate).toBe(true); +}); + +test("first subscriber quiet-revalidates the seed with exactly one fetch", async () => { + clearUsageReportStoresForTests(); + // Re-seed after clearing so this test owns a fresh seed. + reseed(); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return Response.json({ ...seedReport, generatedAt: 2 }); + }) as typeof fetch; + + useUsageReportStore.getState().ensure(SEED_KEY, "http://rehydrate", "30d", "all"); + // A second concurrent subscriber must dedupe onto the same revalidation. + useUsageReportStore.getState().ensure(SEED_KEY, "http://rehydrate", "30d", "all"); + await flush(); + expect(calls).toBe(1); + const entry = useUsageReportStore.getState().entries[SEED_KEY]; + expect(entry?.data?.generatedAt).toBe(2); + expect(entry?.seedNeedsRevalidate).toBe(false); + expect(entry?.hasSucceeded).toBe(true); +}); + +test("a failed refresh keeps the seeded last-good data", async () => { + clearUsageReportStoresForTests(); + reseed(); + const entry = useUsageReportStore.getState().entries[SEED_KEY]; + expect(entry?.data).toEqual(seedReport); + // A rehydrated seed reads as last-known-good: hasSucceeded true, retry armed. + expect(entry?.hasSucceeded).toBe(true); + expect(entry?.seedNeedsRevalidate).toBe(true); + globalThis.fetch = (async () => new Response("boom", { status: 503 })) as typeof fetch; + useUsageReportStore.getState().refresh(SEED_KEY, "http://rehydrate", "30d", "all"); + await flush(); + const after = useUsageReportStore.getState().entries[SEED_KEY]; + expect(after?.data).toEqual(seedReport); + expect(after?.lastAttemptOk).toBe(false); + // The failed revalidation restored the retry flag for the next subscriber and kept + // the seed readable as succeeded (healthy display, not a never-succeeded attempt). + expect(after?.seedNeedsRevalidate).toBe(true); + expect(after?.hasSucceeded).toBe(true); +}); diff --git a/gui/tests/usage-report-store.test.ts b/gui/tests/usage-report-store.test.ts new file mode 100644 index 0000000000..37a98f3480 --- /dev/null +++ b/gui/tests/usage-report-store.test.ts @@ -0,0 +1,208 @@ +import { afterAll, afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; + +/** + * Store-level contract for the usage-report domain store: key derivation, singleflight + * dedupe, AbortController cancellation, and persistence of validated reports only. + * + * sessionStorage is installed BEFORE the store module is imported so zustand persist + * captures it (Bun isolates module registries per test file). + */ +const testWindow = new Window({ url: "http://localhost/" }); +const originalFetch = globalThis.fetch; +const INSTALLED_GLOBALS = ["document", "window", "navigator", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +const previousGlobals = Object.fromEntries( + INSTALLED_GLOBALS.map(key => [key, Reflect.get(globalThis, key)]), +) as Record<(typeof INSTALLED_GLOBALS)[number], unknown>; +Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, +}); +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const { + clearUsageReportStoresForTests, + usageReportKey, + useUsageReportStore, + USAGE_REPORT_STORAGE_NAME, +} = await import("../src/usage-report-store"); + +type UsageReport = import("../src/usage-report-validation").UsageReport; + +function validReport(overrides: Partial = {}): UsageReport { + return { + range: "30d", + surface: "all", + since: null, + generatedAt: 1, + summary: { + requests: 0, + measuredRequests: 0, + reportedRequests: 0, + unreportedRequests: 0, + unsupportedRequests: 0, + estimatedRequests: 0, + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + coverageRatio: 1, + estimatedCostUsd: 0, + pricedRequests: 0, + unpricedRequests: 0, + unmeteredRequests: 0, + }, + days: [], + models: [], + providers: [], + historyTruncated: false, + truncatedPrefixBytes: 0, + entriesTruncated: false, + entriesDropped: 0, + ...overrides, + }; +} + +beforeEach(() => { + clearUsageReportStoresForTests(); + testWindow.sessionStorage.clear(); +}); + +afterEach(() => { + clearUsageReportStoresForTests(); + globalThis.fetch = originalFetch; +}); + +afterAll(() => { + // The default bun test runner shares one global scope across files: restore every + // global installed at module load so sibling files never inherit a closed window. + testWindow.close(); + for (const key of INSTALLED_GLOBALS) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function flush(times = 3): Promise { + for (let i = 0; i < times; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } +} + +test("usage report key derives from apiBase/range/surface", () => { + expect(usageReportKey("http://localhost:1234", "30d", "all")).toBe("http://localhost:1234:30d:all"); + expect(usageReportKey("http://x", "7d", "codex")).toBe("http://x:7d:codex"); +}); + +test("singleflight dedupes concurrent subscribers into one fetch", async () => { + let calls = 0; + const gates: Array<() => void> = []; + globalThis.fetch = (async () => { + calls += 1; + await new Promise(resolve => gates.push(resolve)); + return Response.json(validReport()); + }) as typeof fetch; + + const key = usageReportKey("http://sf", "30d", "all"); + useUsageReportStore.getState().ensure(key, "http://sf", "30d", "all"); + useUsageReportStore.getState().ensure(key, "http://sf", "30d", "all"); + useUsageReportStore.getState().ensure(key, "http://sf", "30d", "all"); + expect(calls).toBe(1); + + gates[0]!(); + await flush(); + expect(useUsageReportStore.getState().entries[key]?.data).toBeDefined(); + expect(useUsageReportStore.getState().entries[key]?.hasSucceeded).toBe(true); +}); + +test("refresh aborts the previous in-flight request for the same key", async () => { + let aborted = false; + const gates: Array<() => void> = []; + globalThis.fetch = (async (_input, init) => { + init?.signal?.addEventListener("abort", () => { aborted = true; }); + await new Promise(resolve => gates.push(resolve)); + if (init?.signal?.aborted) throw new DOMException("The operation was aborted.", "AbortError"); + return Response.json(validReport()); + }) as typeof fetch; + + const key = usageReportKey("http://abort", "30d", "all"); + useUsageReportStore.getState().ensure(key, "http://abort", "30d", "all"); + useUsageReportStore.getState().refresh(key, "http://abort", "30d", "all"); + expect(aborted).toBe(true); + + gates[0]!(); + gates[1]!(); + await flush(); + expect(useUsageReportStore.getState().entries[key]?.data).toBeDefined(); +}); + +test("persists only validated successful reports with a timestamp", async () => { + // An error envelope must never reach the persisted slice. + globalThis.fetch = (async () => + Response.json({ error: "read_failed", range: "30d", surface: "all" })) as typeof fetch; + const key = usageReportKey("http://persist", "30d", "all"); + useUsageReportStore.getState().ensure(key, "http://persist", "30d", "all"); + await flush(); + let parsed = JSON.parse(testWindow.sessionStorage.getItem(USAGE_REPORT_STORAGE_NAME) ?? "{}"); + expect(parsed.state?.entries?.[key]).toBeUndefined(); + expect(useUsageReportStore.getState().entries[key]?.data).toBeUndefined(); + + // A validated success is persisted as data + timestamp only. + globalThis.fetch = (async () => Response.json(validReport())) as typeof fetch; + useUsageReportStore.getState().refresh(key, "http://persist", "30d", "all"); + await flush(); + parsed = JSON.parse(testWindow.sessionStorage.getItem(USAGE_REPORT_STORAGE_NAME) ?? "{}"); + const persisted = parsed.state?.entries?.[key]; + expect(persisted?.data).toBeDefined(); + expect(typeof persisted?.persistedAt).toBe("number"); + // Never errors or in-flight flags in the persisted slice. + expect(persisted).not.toHaveProperty("error"); + expect(persisted).not.toHaveProperty("loading"); + expect(persisted).not.toHaveProperty("refreshing"); +}); + +test("a malformed report element is rejected and never persisted", async () => { + const key = usageReportKey("http://malformed", "30d", "all"); + const body = { + range: "30d", + surface: "all", + since: null, + generatedAt: 1, + summary: { + requests: 1, + measuredRequests: 1, + reportedRequests: 1, + unreportedRequests: 0, + unsupportedRequests: 0, + estimatedRequests: 0, + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 10, + coverageRatio: 1, + estimatedCostUsd: 0, + pricedRequests: 1, + unpricedRequests: 0, + unmeteredRequests: 0, + }, + days: [], + // A malformed model row: totalTokens is not a finite number. + models: [{ provider: "openai", model: "gpt-5", totalTokens: "many" }], + providers: [], + historyTruncated: false, + truncatedPrefixBytes: 0, + entriesTruncated: false, + entriesDropped: 0, + }; + globalThis.fetch = (async () => Response.json(body)) as typeof fetch; + useUsageReportStore.getState().ensure(key, "http://malformed", "30d", "all"); + await flush(); + const entry = useUsageReportStore.getState().entries[key]; + expect(entry?.data).toBeUndefined(); + expect(entry?.hasSucceeded).toBe(false); + const parsed = JSON.parse(testWindow.sessionStorage.getItem(USAGE_REPORT_STORAGE_NAME) ?? "{}"); + expect(parsed.state?.entries?.[key]).toBeUndefined(); +}); diff --git a/gui/tests/usage-report-validation.test.ts b/gui/tests/usage-report-validation.test.ts new file mode 100644 index 0000000000..6b82bd4ed8 --- /dev/null +++ b/gui/tests/usage-report-validation.test.ts @@ -0,0 +1,120 @@ +import { expect, test } from "bun:test"; +import { + parseUsageReport, + UsageReportValidationError, + type UsageReport, +} from "../src/usage-report-validation"; + +/** Element-level validation: days/models/providers rows must be well-formed, not cast. */ + +function validReport(): UsageReport { + return { + range: "30d", + surface: "all", + since: null, + generatedAt: 1, + summary: { + requests: 3, + measuredRequests: 3, + reportedRequests: 3, + unreportedRequests: 0, + unsupportedRequests: 0, + estimatedRequests: 0, + inputTokens: 100, + outputTokens: 50, + cachedInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 150, + coverageRatio: 1, + estimatedCostUsd: 0.25, + pricedRequests: 3, + unpricedRequests: 0, + unmeteredRequests: 0, + }, + days: [{ + date: "2026-08-01", + requests: 3, + measuredRequests: 3, + reportedRequests: 3, + totalTokens: 150, + models: [{ model: "gpt-5", provider: "openai", requests: 3, totalTokens: 150 }], + }], + models: [{ + provider: "openai", + model: "gpt-5", + requests: 3, + measuredRequests: 3, + reportedRequests: 3, + estimatedRequests: 0, + totalTokens: 150, + inputTokens: 100, + outputTokens: 50, + shareRatio: 1, + }], + providers: [{ + provider: "openai", + requests: 3, + measuredRequests: 3, + reportedRequests: 3, + estimatedRequests: 0, + totalTokens: 150, + shareRatio: 1, + }], + historyTruncated: false, + truncatedPrefixBytes: 0, + entriesTruncated: false, + entriesDropped: 0, + }; +} + +function mutate(report: UsageReport, mutate: (draft: Record) => void): unknown { + const draft = JSON.parse(JSON.stringify(report)) as Record; + mutate(draft); + return draft; +} + +test("a well-formed report passes element validation", () => { + const parsed = parseUsageReport(validReport()); + expect(parsed.days).toHaveLength(1); + expect(parsed.models[0]?.totalTokens).toBe(150); + expect(parsed.providers[0]?.shareRatio).toBe(1); +}); + +test("a malformed model row rejects the whole report", () => { + const body = mutate(validReport(), draft => { + ((draft.models as Array>)[0]!).totalTokens = "many"; + }); + expect(() => parseUsageReport(body)).toThrow(UsageReportValidationError); + expect(() => parseUsageReport(body)).toThrow(/models\[0\]\.totalTokens/); +}); + +test("a malformed day row rejects the whole report", () => { + const body = mutate(validReport(), draft => { + ((draft.days as Array>)[0]!).requests = null; + }); + expect(() => parseUsageReport(body)).toThrow(UsageReportValidationError); + expect(() => parseUsageReport(body)).toThrow(/days\[0\]\.requests/); +}); + +test("a malformed day-model row rejects the whole report", () => { + const body = mutate(validReport(), draft => { + ((draft.days as Array>)[0]!.models as Array>)[0]!.provider = 42; + }); + expect(() => parseUsageReport(body)).toThrow(UsageReportValidationError); + expect(() => parseUsageReport(body)).toThrow(/day models\[0\]\.provider/); +}); + +test("a malformed provider row rejects the whole report", () => { + const body = mutate(validReport(), draft => { + ((draft.providers as Array>)[0]!).shareRatio = undefined; + }); + expect(() => parseUsageReport(body)).toThrow(UsageReportValidationError); + expect(() => parseUsageReport(body)).toThrow(/providers\[0\]\.shareRatio/); +}); + +test("a non-array collection rejects the whole report", () => { + const body = mutate(validReport(), draft => { + draft.models = "not-an-array"; + }); + expect(() => parseUsageReport(body)).toThrow(UsageReportValidationError); +}); diff --git a/gui/tests/usage-validation.test.tsx b/gui/tests/usage-validation.test.tsx new file mode 100644 index 0000000000..c9caad8ab2 --- /dev/null +++ b/gui/tests/usage-validation.test.tsx @@ -0,0 +1,234 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { + clearUsageReportStoresForTests, + seedUsageReportForTests, + usageReportKey, + useUsageReportStore, +} from "../src/usage-report-store"; +import Usage from "../src/pages/Usage"; +import type { UsageReport } from "../src/usage-report-validation"; + +/** + * Phase 1b contract, now enforced through the usage-report store: only validated + * successful reports reach the store (never error envelopes), a defined zero cost + * renders $0.00 (only a genuinely missing legacy field shows "Unavailable"), a cold + * failure shows the failed-cold Notice with retry, and a failed refresh keeps + * last-known-good data with the stale/error banner. + */ + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT", "ResizeObserver"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; +const originalFetch = globalThis.fetch; + +function summary(overrides: Partial = {}) { + return { + requests: 0, + measuredRequests: 0, + reportedRequests: 0, + unreportedRequests: 0, + unsupportedRequests: 0, + estimatedRequests: 0, + inputTokens: 0, + outputTokens: 0, + cachedInputTokens: 0, + reasoningOutputTokens: 0, + totalTokens: 0, + coverageRatio: 1, + estimatedCostUsd: 0, + pricedRequests: 0, + unpricedRequests: 0, + unmeteredRequests: 0, + ...overrides, + }; +} + +function validReport(overrides: Partial = {}): UsageReport { + return { + range: "30d", + surface: "all", + since: null, + generatedAt: 1, + summary: summary(), + days: [], + models: [], + providers: [], + historyTruncated: false, + truncatedPrefixBytes: 0, + entriesTruncated: false, + entriesDropped: 0, + ...overrides, + }; +} + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + clearClientResourceStoresForTests(); + clearUsageReportStoresForTests(); + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + }); + class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} + } + Object.defineProperty(globalThis, "ResizeObserver", { configurable: true, value: ResizeObserverStub }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + testWindow.sessionStorage.clear(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearClientResourceStoresForTests(); + clearUsageReportStoresForTests(); + testWindow.close(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +async function renderUsage(apiBase: string): Promise<{ container: HTMLElement; root: Root }> { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => { + root.render( + + + , + ); + }); + return { container, root }; +} + +async function waitFor(predicate: () => boolean, timeoutMs = 1000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 10)); + }); + } +} + +test("cost row renders $0.00 for a defined zero", async () => { + globalThis.fetch = (async () => + Response.json(validReport({ summary: summary({ requests: 3, estimatedCostUsd: 0 }) }))) as typeof fetch; + const { container, root } = await renderUsage("http://usage-zero"); + try { + await waitFor(() => (container.textContent ?? "").includes("$0.00")); + expect(container.textContent).toContain("$0.00"); + // A defined zero must never fall back to the ~$0.0000 estimate rendering. + expect(container.textContent).not.toContain("~"); + expect(container.textContent).not.toContain("Unavailable"); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("legacy undefined cost field renders Unavailable", async () => { + const key = usageReportKey("http://usage-legacy", "30d", "all"); + const legacy = validReport({ summary: summary({ requests: 3 }) }); + // Simulate a pre-validation seed from an older server: no cost fields on the summary. + const { estimatedCostUsd: _cost, pricedRequests: _priced, unpricedRequests: _unpriced, unmeteredRequests: _unmetered, ...legacySummary } = legacy.summary; + seedUsageReportForTests(key, { ...legacy, summary: legacySummary } as unknown as UsageReport); + // The quiet revalidation also answers with the legacy shape, so it is rejected and the + // seeded last-known-good payload (with undefined cost) stays on screen. + globalThis.fetch = (async () => Response.json({ ...legacy, summary: legacySummary })) as typeof fetch; + + const { container, root } = await renderUsage("http://usage-legacy"); + try { + await waitFor(() => (container.textContent ?? "").includes("Unavailable")); + expect(container.textContent).toContain("Unavailable"); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("a 200 response with an error envelope is rejected and never cached", async () => { + const key = usageReportKey("http://usage-error-envelope", "30d", "all"); + globalThis.fetch = (async () => + Response.json({ error: "read_failed", range: "30d", surface: "all" })) as typeof fetch; + + const { container, root } = await renderUsage("http://usage-error-envelope"); + try { + await waitFor(() => (container.textContent ?? "").includes("Retry")); + expect(container.textContent).toContain("Retry"); + const entry = useUsageReportStore.getState().entries[key]; + expect(entry?.data).toBeUndefined(); + expect(entry?.hasSucceeded).toBe(false); + expect(entry?.lastAttemptOk).toBe(false); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("401 cold failure shows the failed-cold Notice with retry", async () => { + globalThis.fetch = (async () => new Response("unauthorized", { status: 401 })) as typeof fetch; + + const { container, root } = await renderUsage("http://usage-401"); + try { + await waitFor(() => (container.textContent ?? "").includes("Retry")); + expect(container.textContent).toContain("Retry"); + expect(container.querySelector("button")).not.toBeNull(); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("an HTTP 503 cold failure shows the failed-cold Notice with retry and caches nothing", async () => { + // The server's genuine read-failure contract answers 503 { error: "read_failed", ... }. + globalThis.fetch = (async () => new Response("read_failed", { status: 503 })) as typeof fetch; + const key = usageReportKey("http://usage-503", "30d", "all"); + + const { container, root } = await renderUsage("http://usage-503"); + try { + await waitFor(() => (container.textContent ?? "").includes("Retry")); + expect(container.textContent).toContain("Retry"); + const entry = useUsageReportStore.getState().entries[key]; + expect(entry?.data).toBeUndefined(); + expect(entry?.hasSucceeded).toBe(false); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("refresh failure retains last-good data and shows the stale/error banner", async () => { + const key = usageReportKey("http://usage-stale", "30d", "all"); + const good = validReport({ summary: summary({ requests: 42, totalTokens: 9000, estimatedCostUsd: 1.25 }) }); + // Last-known-good payload is seeded like a rehydrated session seed; the quiet + // revalidation fails and must not wipe it. + seedUsageReportForTests(key, good); + globalThis.fetch = (async () => new Response("boom", { status: 500 })) as typeof fetch; + + const { container, root } = await renderUsage("http://usage-stale"); + try { + await waitFor(() => (container.textContent ?? "").includes("42")); + expect(container.textContent).toContain("42"); + // The stale/error banner appears next to the retained data. + await waitFor(() => (container.textContent ?? "").includes("Could not load usage data")); + expect(container.textContent).toContain("Could not load usage data"); + // The failed refresh must not have wiped the store entry. + expect(useUsageReportStore.getState().entries[key]?.data).not.toBeUndefined(); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index caff67ad3d..09b56304bb 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -427,9 +427,10 @@ function reasoningBudget(effort: string): number { case "low": return 4096; case "high": return 16384; case "xhigh": return 24576; - case "max": return 32000; - case "medium": - default: return 8192; + case "max": + case "ultra": return 32000; // codex-rs maps ultra -> max before the wire; mirror it. + case "medium": return 8192; + default: return 16384; // unknown efforts clamp to "high" on the wire; budget must match. } } @@ -510,9 +511,25 @@ function supportsExplicitThinkingDisable(modelId: string): boolean { return meetsFamilyMinimum(modelId, EXPLICIT_THINKING_DISABLE_FAMILY_MINIMUMS); } -/** `output_config.effort` accepts low|medium|high|xhigh|max — "minimal" is rejected with a 400. */ +/** + * `output_config.effort` accepts low|medium|high|xhigh|max — "minimal" and anything + * above the ladder (e.g. "ultra") are rejected with a 400 ("Invalid reasoning effort"). + * "ultra" mirrors the codex-rs boundary (ultra -> max); unknown values clamp to "high" + * so the proxy never forwards an invalid effort to Anthropic. + */ function adaptiveEffort(effort: string): string { - return effort === "minimal" ? "low" : effort; + switch (effort) { + case "minimal": return "low"; + case "ultra": return "max"; + case "low": + case "medium": + case "high": + case "xhigh": + case "max": + return effort; + default: + return "high"; + } } function usageFromAnthropic(usage: Record | undefined): CodexCommanderUsage | undefined { diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 7d97ba2a23..b66e340703 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -89,6 +89,24 @@ function cloneRecordOfArrays(input: Record): Record [key, [...value]])); } +/** + * Per-key merge of record-of-arrays metadata: the seed (registry) fills keys missing from + * the saved map, while saved entries always win per key. Mirrors the request-time merge in + * router.ts so the catalog advertises the same ladder the wire clamps to — a partially + * saved map (e.g. an older grok-4.5-only ladder) still picks up new registry tiers. + */ +function mergeRecordOfArrays( + seed?: Record, + saved?: Record, +): Record | undefined { + if (!seed) return saved ? { ...saved } : undefined; + const out: Record = { ...(saved ?? {}) }; + for (const [key, value] of Object.entries(seed)) { + if (out[key] === undefined) out[key] = [...value]; + } + return out; +} + /** * Fill registry defaults BENEATH the user's per-model entries. * @@ -273,7 +291,9 @@ export function enrichProviderFromRegistry(name: string, prov: CodexCommanderPro prov.chatCompletionTokenField = seed.chatCompletionTokenField; } if (!prov.reasoningEfforts && seed.reasoningEfforts) prov.reasoningEfforts = [...seed.reasoningEfforts]; - if (!prov.modelReasoningEfforts && seed.modelReasoningEfforts) prov.modelReasoningEfforts = cloneRecordOfArrays(seed.modelReasoningEfforts); + if (seed.modelReasoningEfforts) { + prov.modelReasoningEfforts = mergeRecordOfArrays(seed.modelReasoningEfforts, prov.modelReasoningEfforts); + } if (!prov.modelDefaultReasoningEfforts && seed.modelDefaultReasoningEfforts) prov.modelDefaultReasoningEfforts = { ...seed.modelDefaultReasoningEfforts }; if (prov.reasoningContentMode === undefined && seed.reasoningContentMode !== undefined) prov.reasoningContentMode = seed.reasoningContentMode; if (seed.modelSupportsReasoningSummaries) { diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 2e9264f57a..8208d3626e 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -24,6 +24,7 @@ import { } from "../oauth/store"; import { antigravityUserAgent } from "../adapters/client-fingerprint"; import { apiKeyPoolEntryId } from "./api-keys"; +import { XAI_GROK_CLIENT_VERSION, XAI_GROK_COMPATIBILITY } from "./xai-transport"; import { getProviderRegistryEntry, providerCodexAccountMode } from "./registry"; import type { CodexCommanderConfig, CodexCommanderProviderConfig } from "../types"; import { readUsageSnapshotForManagement, usageTotalTokens, type PersistedUsageEntry } from "../usage/log"; @@ -537,6 +538,71 @@ function centsValue(value: unknown): number | undefined { return rec ? toFiniteNumber(rec.val) : undefined; } +const XAI_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing"; +const XAI_CREDITS_URL = `${XAI_BILLING_URL}?format=credits`; + +/** Decode JWT payload `sub` for xAI weekly credits when the stored credential lacks accountId. */ +function xaiUserIdFromAccessToken(accessToken: string): string | undefined { + const parts = accessToken.split("."); + if (parts.length < 2 || !parts[1]) return undefined; + try { + const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as { sub?: unknown }; + return typeof payload.sub === "string" && payload.sub.trim() ? payload.sub.trim() : undefined; + } catch { + return undefined; + } +} + +/** + * Grok Build weekly credits envelope: + * `{ config: { creditUsagePercent?, currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", end } } }`. + * Omitted percent is treated as 0 (proto3 default) — the same 0% the grok.com app shows + * for a fresh weekly pool. NOTE: a schema drift that stops emitting the field would also + * read as 0%, indistinguishable from a healthy fresh week; keep this heuristic documented. + */ +export function parseXaiCreditsResponse(value: unknown): { percent: number; resetAt?: number } | null { + const body = asRecord(value); + const config = asRecord(body?.config); + if (!config) return null; + const period = asRecord(config.currentPeriod); + if (!period || period.type !== "USAGE_PERIOD_TYPE_WEEKLY") return null; + const resetAt = normalizeResetAt(period.end); + if (resetAt === undefined) return null; + if (config.creditUsagePercent !== undefined) { + const percent = normalizePercent(config.creditUsagePercent); + if (percent === undefined) return null; + return { percent, resetAt }; + } + return { percent: 0, resetAt }; +} + +async function fetchXaiWeeklyCredits(accessToken: string, userId: string): Promise { + try { + const response = await fetch(XAI_CREDITS_URL, { + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + [XAI_GROK_COMPATIBILITY.headers.tokenAuth]: "xai-grok-cli", + [XAI_GROK_COMPATIBILITY.headers.authenticateResponse]: "authenticate-response", + "x-userid": userId, + [XAI_GROK_COMPATIBILITY.headers.clientVersion]: XAI_GROK_CLIENT_VERSION, + }, + redirect: "error", + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return null; + const parsed = parseXaiCreditsResponse(await response.json().catch(() => null)); + if (!parsed) return null; + return { + weeklyPercent: parsed.percent, + ...(parsed.resetAt !== undefined ? { weeklyResetAt: parsed.resetAt } : {}), + updatedAt: Date.now(), + }; + } catch { + return null; + } +} + async function fetchXaiQuota(provider: string): Promise { let auth: Awaited>; try { @@ -546,8 +612,25 @@ async function fetchXaiQuota(provider: string): Promise ( - fetch("https://cli-chat-proxy.grok.com/v1/billing", { + fetch(XAI_BILLING_URL, { headers: { Accept: "application/json", Authorization: `Bearer ${accessToken}` }, redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), @@ -564,6 +647,19 @@ async function fetchXaiQuota(provider: string): Promise 0) { + const resetAt = normalizeResetAt(config.billingPeriodEnd); + const quota: ProviderQuota = { + // Unlocalized display label by precedent (e.g. a6api "Prepaid credits"); the GUI + // renders custom windows with their reset time when present. + customWindows: [{ label: "No reported cap", percent: 0, ...(resetAt !== undefined ? { resetAt } : {}) }], + updatedAt: Date.now(), + }; + return report(provider, "xai:grok-billing", quota) + ?? quotaUnavailable("upstream_unavailable"); + } return quotaUnavailable("upstream_unavailable"); } const percent = normalizePercent((usedCents / limitCents) * 100); diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 3c830feeee..4dc8676ef0 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -959,8 +959,18 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // (docs.x.ai prompt-caching/multi-turn, verified 2026-07-13). // Models that never emit reasoning simply have no thinking parts to replay (no-op). preserveReasoningContentModels: ["grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning"], - // grok-4.5 reasoning is always-on with low/medium/high control (no off tier upstream). - modelReasoningEfforts: { "grok-4.5": ["low", "medium", "high"] }, + // grok reasoning is always-on with low/medium/high control (no off tier upstream); + // grok-4.6+ extends the ladder with xhigh per docs.x.ai. xAI rejects max/ultra outright + // (400 "Invalid reasoning effort"), so every grok reasoning model clamps ultra/max down + // to its real top tier. The provider default covers all other reasoning models (incl. + // live-discovered ones); per-model entries exist only to raise the ceiling where verified. + modelReasoningEfforts: { + "grok-4.5": ["low", "medium", "high"], + "grok-4.6": ["low", "medium", "high", "xhigh"], + }, + // Provider default for live-discovered reasoning models: clamp to the verified xAI + // ladder unless a per-model entry raises it (noReasoningModels stay effort-free). + reasoningEfforts: ["low", "medium", "high"], modelContextWindows: { "grok-4.5": 500_000, "grok-4.3": 1_000_000, diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index 7c9de8b758..0a97f68452 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -1,7 +1,4 @@ -import { randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; -import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { catalogModelSlug, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { catalogOnlyWorkerStateFromActivation, captureCodexCatalogDesiredSnapshot, @@ -12,60 +9,13 @@ import { } from "../../codex/catalog-activation"; import type { CatalogConfigAuthoritySnapshot } from "../../codex/catalog-admission"; import { resetCodexAppServerCatalogStateCache } from "../../codex/app-server-processes"; -import { - DEFAULT_SUBAGENT_MODELS, - codexAutoStartEnabled, - hasOwnProvider, - isValidProviderName, - loadConfig, - multiAgentGuidanceEnabled, - mutatePersistedConfig, - providerBaseUrlConfigError, - providerHeadersConfigError, - saveConfigPreservingClaudeCode, - subagentDefaultSyncEffective, -} from "../../config"; -import { - clearLoginState, - getLoginStatus, - isPublicOAuthProvider, - listOAuthProviders, - startLoginFlow, - submitManualLoginCode, - upsertOAuthProvider, -} from "../../oauth"; -import { removeCredential } from "../../oauth/store"; -import { providerDestinationResolvedError } from "../../lib/destination-policy"; -import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; -import { deriveProviderPresets } from "../../providers/derive"; -import { providerCodexAccountMode } from "../../providers/registry"; +import { loadConfig, multiAgentGuidanceEnabled, mutatePersistedConfig, saveConfigPreservingClaudeCode, subagentDefaultSyncEffective } from "../../config"; + import { routedSlug } from "../../providers/slug-codec"; -import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; -import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; -import { clearThreadAccountMap } from "../../codex/routing"; -import { primeCodexPoolQuotas } from "../../codex/auth-api"; -import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; -import { resolveCodexHomeDir } from "../../codex/home"; -import { readUsageEntries } from "../../usage/log"; -import { getUsageDebugLogEntries } from "../../usage/debug"; -import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; -import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; -import { getProviderRegistryEntry } from "../../providers/registry"; -import { getDebugLogEntries } from "../../lib/debug-log-buffer"; -import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; -import { - clearDebugSettings, - clearDebugSetting, - getDebugSettings, - setDebugSettings, - type DebugFlag, -} from "../../lib/debug-settings"; -import type { CodexCommanderClaudeCodeConfig, CodexCommanderConfig, CodexCommanderCustomModel, CodexCommanderProviderConfig } from "../../types"; -import { drainAndShutdown } from "../lifecycle"; -import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; -import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; -import type { PersistedUsageAttempt } from "../../usage/log"; -import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; + +import type { CodexCommanderClaudeCodeConfig, CodexCommanderConfig } from "../../types"; + +import { jsonResponse } from "../auth-cors"; import { applySystemEnvToggle } from "../system-env"; import { acquireProxyLifecycleAuthority, @@ -73,8 +23,8 @@ import { type ProxyLifecycleAuthority, } from "../proxy-lifecycle-authority"; -import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels, fetchGrokCandidateModels, buildClaudeDesktopState } from "./shared"; -import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; +import { fetchAllModels, fetchGrokCandidateModels, buildClaudeDesktopState } from "./shared"; + import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { projectCatalogActivationForPrincipal } from "./catalog-activation-routes"; diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index 2cdd118509..a345daa813 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -1,72 +1,21 @@ -import { randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; -import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; -import { - DEFAULT_SUBAGENT_MODELS, - codexAutoStartEnabled, - hasOwnProvider, - isValidProviderName, - multiAgentGuidanceEnabled, - providerBaseUrlConfigError, - providerHeadersConfigError, - saveConfigPreservingClaudeCode, -} from "../../config"; -import { - clearLoginState, - getLoginStatus, - isPublicOAuthProvider, - listOAuthProviders, - startLoginFlow, - submitManualLoginCode, - upsertOAuthProvider, -} from "../../oauth"; -import { removeCredential } from "../../oauth/store"; -import { providerDestinationResolvedError } from "../../lib/destination-policy"; -import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; -import { deriveProviderPresets } from "../../providers/derive"; -import { providerCodexAccountMode } from "../../providers/registry"; -import { routedSlug, slugEquals } from "../../providers/slug-codec"; -import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; -import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { saveConfigPreservingClaudeCode } from "../../config"; + import { CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR, codexAccountNamespaceForModel, } from "../../codex/account-namespace-match"; -import { clearThreadAccountMap } from "../../codex/routing"; -import { primeCodexPoolQuotas } from "../../codex/auth-api"; -import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; -import { resolveCodexHomeDir } from "../../codex/home"; -import { readUsageEntries } from "../../usage/log"; -import { getUsageDebugLogEntries } from "../../usage/debug"; -import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; -import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; -import { getProviderRegistryEntry } from "../../providers/registry"; -import { getDebugLogEntries } from "../../lib/debug-log-buffer"; -import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; -import { - clearDebugSettings, - clearDebugSetting, - getDebugSettings, - setDebugSettings, - type DebugFlag, -} from "../../lib/debug-settings"; -import type { CodexCommanderClaudeCodeConfig, CodexCommanderConfig, CodexCommanderCustomModel, CodexCommanderProviderConfig } from "../../types"; -import { drainAndShutdown } from "../lifecycle"; + import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; -import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; -import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; -import type { PersistedUsageAttempt } from "../../usage/log"; -import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; -import { applySystemEnvToggle } from "../system-env"; -import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; -import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; +import { jsonResponse } from "../auth-cors"; + +import { isPlainRecord } from "./shared"; + import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; export async function handleComboRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; if (url.pathname === "/api/combos" && req.method === "GET") { const { comboPublicModelId, getCombo, listComboIds } = await import("../../combos"); diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index ce002fc5e0..176cb5031f 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -1,27 +1,5 @@ -import { readFileSync } from "node:fs"; -import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; -import { - DEFAULT_SUBAGENT_MODELS, - codexAutoStartEnabled, - hasOwnProvider, - isValidProviderName, - multiAgentGuidanceEnabled, - providerBaseUrlConfigError, - providerHeadersConfigError, - saveConfigPreservingClaudeCode, -} from "../../config"; -import { - clearLoginState, - getLoginStatus, - isPublicOAuthProvider, - listOAuthProviders, - startLoginFlow, - submitManualLoginCode, - upsertOAuthProvider, -} from "../../oauth"; -import { removeCredential } from "../../oauth/store"; -import { providerDestinationResolvedError } from "../../lib/destination-policy"; +import { codexAutoStartEnabled, saveConfigPreservingClaudeCode } from "../../config"; + import { isStreamMode } from "../../lib/bun-stream-caps"; import { shadowSourceModels } from "../../lib/shadow-call"; import { @@ -31,36 +9,9 @@ import { MIN_APP_OWNED_MEMORY_BUDGET_MB, resolveAppOwnedMemoryBudgetBytes, } from "../../lib/app-owned-memory"; -import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; -import { deriveProviderPresets } from "../../providers/derive"; -import { providerCodexAccountMode } from "../../providers/registry"; -import { routedSlug, slugEquals } from "../../providers/slug-codec"; -import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; -import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; -import { clearThreadAccountMap } from "../../codex/routing"; -import { primeCodexPoolQuotas } from "../../codex/auth-api"; -import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; -import { resolveCodexHomeDir } from "../../codex/home"; -import { readUsageEntries } from "../../usage/log"; -import { getUsageDebugLogEntries } from "../../usage/debug"; -import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; -import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; -import { getProviderRegistryEntry } from "../../providers/registry"; -import { getDebugLogEntries } from "../../lib/debug-log-buffer"; -import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; -import { - clearDebugSettings, - clearDebugSetting, - getDebugSettings, - setDebugSettings, - type DebugFlag, -} from "../../lib/debug-settings"; -import type { CodexCommanderClaudeCodeConfig, CodexCommanderConfig, CodexCommanderCustomModel, CodexCommanderProviderConfig } from "../../types"; -import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; -import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; -import type { PersistedUsageAttempt } from "../../usage/log"; -import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; -import { applySystemEnvToggle } from "../system-env"; + +import { jsonResponse, safeConfigDTO } from "../auth-cors"; + import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache"; import { decorateStartupHealth, @@ -74,13 +25,13 @@ import { acquireProxyLifecycleAuthority, type ProxyLifecycleAuthority } from ".. import { validateProxyLifecycleLockLease } from "../proxy-start-lock"; import { readProxyLifecycleLockLeaseHeaders } from "../proxy-lifecycle-protocol"; -import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; -import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; +import { isPlainRecord } from "./shared"; + import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; export async function handleConfigRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, deps } = ctx; if (url.pathname === "/api/config" && req.method === "GET") { return jsonResponse(safeConfigDTO(config)); } diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index aff047db63..345e9cf207 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -1,40 +1,6 @@ -import { randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; -import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; -import { - DEFAULT_SUBAGENT_MODELS, - codexAutoStartEnabled, - hasOwnProvider, - isValidProviderName, - multiAgentGuidanceEnabled, - providerBaseUrlConfigError, - providerHeadersConfigError, - saveConfigPreservingClaudeCode, -} from "../../config"; -import { - clearLoginState, - getLoginStatus, - isPublicOAuthProvider, - listOAuthProviders, - startLoginFlow, - submitManualLoginCode, - upsertOAuthProvider, -} from "../../oauth"; -import { removeCredential } from "../../oauth/store"; -import { providerDestinationResolvedError } from "../../lib/destination-policy"; -import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; -import { deriveProviderPresets } from "../../providers/derive"; -import { providerCodexAccountMode } from "../../providers/registry"; -import { routedSlug, slugEquals } from "../../providers/slug-codec"; -import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; -import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; -import { clearThreadAccountMap } from "../../codex/routing"; -import { primeCodexPoolQuotas } from "../../codex/auth-api"; -import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; import { scanStorage } from "../../storage/scanner"; -import { executeArchivedCleanup, listTrashEntries, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode, type RestoreErrorCode } from "../../storage/cleanup"; +import { listTrashEntries, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode, type RestoreErrorCode } from "../../storage/cleanup"; import { runArchivedCleanupJob } from "../../storage/cleanup-job"; import { getRestoreTrashTestStreamResponse, runRestoreTrashEntryJob } from "../../storage/restore-job"; import { @@ -55,8 +21,7 @@ import { } from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; import { parseRange, parseUsageSurface, summarizeUsage, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary"; -import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; -import { getProviderRegistryEntry } from "../../providers/registry"; + import { getDebugLogEntries } from "../../lib/debug-log-buffer"; import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; import { @@ -66,22 +31,20 @@ import { setDebugSettings, type DebugFlag, } from "../../lib/debug-settings"; -import type { CodexCommanderClaudeCodeConfig, CodexCommanderConfig, CodexCommanderCustomModel, CodexCommanderProviderConfig } from "../../types"; -import { drainAndShutdown } from "../lifecycle"; -import { filterRequestLogs, filteredRequestLogCount, getRequestLogEntries, type RequestLogEntry } from "../request-log"; -import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; -import type { PersistedUsageAttempt } from "../../usage/log"; -import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; -import { applySystemEnvToggle } from "../system-env"; -import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; -import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; +import { filterRequestLogs, filteredRequestLogCount, getRequestLogEntries } from "../request-log"; + +import { jsonResponse } from "../auth-cors"; + +import { parseDebugLogQuery, requestLogDto } from "./shared"; + import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; import { discardUsageSummaryCacheEntry, getUsageSummaryCacheEntry, setUsageSummaryCacheEntry, + type CachedUsageSummary, } from "./usage-summary-cache"; const USAGE_DAY_MS = 86_400_000; @@ -121,7 +84,7 @@ function refreshedUsageSummary { - const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config } = ctx; if (url.pathname === "/api/logs" && req.method === "GET") { const all = getRequestLogEntries(); @@ -188,68 +151,46 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise> | null = null; try { - const cacheKey = `${range}:${surface}`; - const effectiveReadLimit = config.managementUsageMaxReadBytes ?? 64 * 1024 * 1024; + // File-read machinery: the log-file revision stat (usageLogRevision) and the + // snapshot read. A missing log file returns a zeroed snapshot (not a throw); only + // genuine read/stat/schema failures reach this catch and answer with the 503 + // contract. const observedRevisionKey = `${usageLogRevisionKey(currentUsageLogRevision())}\0${effectiveReadLimit}`; const cached = getUsageSummaryCacheEntry(cacheKey); if (cached && cached.revisionKey === observedRevisionKey && now < cached.expiresAt) { - return jsonResponse(refreshedUsageSummary(cached.summary, range, now)); + cachedHit = cached.summary; + } else { + if (cached) discardUsageSummaryCacheEntry(cacheKey); + snapshot = await readUsageSnapshotForManagement(effectiveReadLimit); } - if (cached) discardUsageSummaryCacheEntry(cacheKey); - const snapshot = await readUsageSnapshotForManagement(effectiveReadLimit); - const revisionReadAt = Date.now(); - const summary = { - ...summarizeUsage(snapshot.entries, range, now, surface), - historyTruncated: snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated, - truncatedPrefixBytes: snapshot.truncatedPrefixBytes, - entriesTruncated: snapshot.entriesTruncated, - entriesDropped: snapshot.entriesDropped, - }; - setUsageSummaryCacheEntry(cacheKey, { - revisionKey: `${usageLogRevisionKey(snapshot.revision)}\0${effectiveReadLimit}`, - expiresAt: usageSummaryExpiresAt(snapshot.entries, range, surface, now), - revisionReadAt, - summary, - }); - return jsonResponse(summary); } catch { - return jsonResponse({ - range, - surface, - since: null, - generatedAt: now, - summary: { - requests: 0, - attemptCount: 0, - measuredRequests: 0, - reportedRequests: 0, - unreportedRequests: 0, - unsupportedRequests: 0, - estimatedRequests: 0, - inputTokens: 0, - outputTokens: 0, - cachedInputTokens: 0, - cacheReadInputTokens: 0, - cacheCreationInputTokens: 0, - reasoningOutputTokens: 0, - totalTokens: 0, - coverageRatio: 0, - estimatedCostUsd: 0, - pricedRequests: 0, - unpricedRequests: 0, - unmeteredRequests: 0, - }, - days: [], - models: [], - providers: [], - historyTruncated: false, - truncatedPrefixBytes: 0, - entriesTruncated: false, - entriesDropped: 0, - error: "read_failed", - }); + return jsonResponse({ error: "read_failed", range, surface }, 503); } + // Outside the catch: genuine errors in the derivation/cache layer are server bugs and + // must surface as ordinary server errors, never misreported as read_failed. + if (cachedHit) { + return jsonResponse(refreshedUsageSummary(cachedHit, range, now)); + } + const revisionReadAt = Date.now(); + const summary = { + ...summarizeUsage(snapshot!.entries, range, now, surface), + historyTruncated: snapshot!.truncatedPrefixBytes > 0 || snapshot!.entriesTruncated, + truncatedPrefixBytes: snapshot!.truncatedPrefixBytes, + entriesTruncated: snapshot!.entriesTruncated, + entriesDropped: snapshot!.entriesDropped, + }; + setUsageSummaryCacheEntry(cacheKey, { + revisionKey: `${usageLogRevisionKey(snapshot!.revision)}\0${effectiveReadLimit}`, + expiresAt: usageSummaryExpiresAt(snapshot!.entries, range, surface, now), + revisionReadAt, + summary, + }); + return jsonResponse(summary); } if (url.pathname === "/api/storage" && req.method === "GET") { diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index f4765d6b35..d84e665129 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -1,5 +1,4 @@ import { randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; /** * Codex parses a catalog entry's `input_modalities` as a closed enum, and one out-of-enum @@ -29,80 +28,24 @@ function readInputModalities(raw: unknown): { values?: string[]; error?: string } return { values: raw as string[] }; } -import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, configuredNativeAliasSlugs, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; + +import { configuredNativeAliasSlugs, disabledNativeSlugs, nativeModelRows } from "../../codex/catalog"; import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch"; import { getProviderLiveModelCount } from "../../codex/model-cache"; -import { - DEFAULT_SUBAGENT_MODELS, - codexAutoStartEnabled, - hasOwnProvider, - isValidProviderName, - multiAgentGuidanceEnabled, - providerBaseUrlConfigError, - providerHeadersConfigError, - saveConfigPreservingClaudeCode, -} from "../../config"; -import { - clearLoginState, - getLoginStatus, - isPublicOAuthProvider, - listOAuthProviders, - startLoginFlow, - submitManualLoginCode, - upsertOAuthProvider, -} from "../../oauth"; -import { removeCredential } from "../../oauth/store"; -import { providerDestinationResolvedError } from "../../lib/destination-policy"; -import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; -import { deriveProviderPresets } from "../../providers/derive"; -import { providerCodexAccountMode } from "../../providers/registry"; +import { hasOwnProvider, isValidProviderName, saveConfigPreservingClaudeCode } from "../../config"; + import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { COMBO_NAMESPACE, comboDisabledModelSelectors, comboModelId, preservesPhysicalComboProvider } from "../../combos"; -import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; -import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; -import { clearThreadAccountMap } from "../../codex/routing"; -import { primeCodexPoolQuotas } from "../../codex/auth-api"; -import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; -import { resolveCodexHomeDir } from "../../codex/home"; -import { readUsageEntries } from "../../usage/log"; -import { getUsageDebugLogEntries } from "../../usage/debug"; -import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; -import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; -import { getProviderRegistryEntry } from "../../providers/registry"; -import { getDebugLogEntries } from "../../lib/debug-log-buffer"; -import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; -import { - clearDebugSettings, - clearDebugSetting, - getDebugSettings, - setDebugSettings, - type DebugFlag, -} from "../../lib/debug-settings"; -import type { CodexCommanderClaudeCodeConfig, CodexCommanderConfig, CodexCommanderCustomModel, CodexCommanderProviderConfig } from "../../types"; -import { drainAndShutdown } from "../lifecycle"; -import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; -import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; -import type { PersistedUsageAttempt } from "../../usage/log"; -import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO, corsHeaders } from "../auth-cors"; -import { applySystemEnvToggle } from "../system-env"; -import { - EXPORT_CLIENTS, - EXPORT_CLIENT_IDS, - OPENCODE_PROVIDER_ID, - buildClientConfigText, - isExportClientId, - opencodeProxyBaseUrl, -} from "../../clients/config-export"; -import type { - ExportClientId, - ExportModel, - OpencodeGeneratedConfig, - PiGeneratedConfig, -} from "../../clients/config-export"; -import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; -import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; +import type { CodexCommanderCustomModel } from "../../types"; + +import { jsonResponse, corsHeaders } from "../auth-cors"; + +import { EXPORT_CLIENTS, EXPORT_CLIENT_IDS, buildClientConfigText, isExportClientId, opencodeProxyBaseUrl } from "../../clients/config-export"; +import type { ExportClientId, ExportModel } from "../../clients/config-export"; + +import { isPlainRecord, fetchAllModels } from "./shared"; + import type { ManagementContext } from "./context"; import { listManagementModelRows, loadExportModels } from "./model-rows"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; @@ -121,7 +64,7 @@ function summarizeExportedModels(client: ExportClientId, document: unknown): { m } export async function handleModelRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, deps, convergeCodexCatalog } = ctx; // A handler persists the exact config object passed in. Production defaults to // the real store; tests that pass an in-memory fixture inject a no-op/spy. Do not // bypass this seam with a dynamic config import — doing so replaced a user's @@ -205,6 +148,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, convergeCodexCatalog } = ctx; // Which providers support real OAuth login (drives the GUI's "Log in with …" buttons). if (url.pathname === "/api/oauth/providers" && req.method === "GET") { return jsonResponse({ providers: listOAuthProviders() }); } + // DEPRECATED route: no GUI or other client calls /api/key-providers anymore (the key + // provider picker now reads /api/providers). Kept for script compatibility; retained + // only in tests/docs. // API-key "login" providers (open dashboard → paste key). Drives the GUI's key-provider picker. if (url.pathname === "/api/key-providers" && req.method === "GET") { return jsonResponse({ providers: listKeyLoginProviders() }); @@ -236,7 +203,6 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (url.pathname === "/api/oauth/accounts" && req.method === "GET") { const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); - const status = getLoginStatus(provider); const { getAccountSet } = await import("../../oauth/store"); const { oauthAccountHealthFields, diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index dda4b22bed..9b58959547 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -1,32 +1,9 @@ -import { randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; -import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; -import { - DEFAULT_SUBAGENT_MODELS, - codexAutoStartEnabled, - hasOwnProvider, - isValidProviderName, - multiAgentGuidanceEnabled, - providerBaseUrlConfigError, - providerHeadersConfigError, - saveConfigPreservingClaudeCode, - withConfigMutationLockSync, -} from "../../config"; -import { - clearLoginState, - getLoginStatus, - isPublicOAuthProvider, - listOAuthProviders, - startLoginFlow, - submitManualLoginCode, - upsertOAuthProvider, -} from "../../oauth"; -import { removeCredential } from "../../oauth/store"; +import { hasOwnProvider, isValidProviderName, providerHeadersConfigError, saveConfigPreservingClaudeCode, withConfigMutationLockSync } from "../../config"; + import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; import { ProviderOutboundPolicyError, providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound"; -import { enrichProviderFromCatalog, isPublicCatalogOnlyKeyValidation, listKeyLoginProviders } from "../../oauth/key-providers"; +import { enrichProviderFromCatalog, isPublicCatalogOnlyKeyValidation } from "../../oauth/key-providers"; import { providerCredentialVerification } from "../../providers/credential-verification"; import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry"; @@ -36,7 +13,7 @@ import { readBoundedDiscoveryJson, resolveProviderModelDiscovery, } from "../../providers/model-discovery"; -import { routedSlug, slugEquals } from "../../providers/slug-codec"; + import { clearProviderQuotaCache, fetchProviderQuotaReports, @@ -47,32 +24,17 @@ import { codexAccountNamespaceProviderCollisionError } from "../../codex/account import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { getProviderDiscoveryStatus } from "../../codex/model-cache"; -import { globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; -import { resolveCodexHomeDir } from "../../codex/home"; -import { readUsageEntries } from "../../usage/log"; -import { getUsageDebugLogEntries } from "../../usage/debug"; -import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; +import { globalContextCapValue, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; + import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; import { getProviderRegistryEntry } from "../../providers/registry"; -import { getDebugLogEntries } from "../../lib/debug-log-buffer"; -import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; -import { - clearDebugSettings, - clearDebugSetting, - getDebugSettings, - setDebugSettings, - type DebugFlag, -} from "../../lib/debug-settings"; -import type { CodexCommanderClaudeCodeConfig, CodexCommanderConfig, CodexCommanderCustomModel, CodexCommanderProviderConfig } from "../../types"; -import { drainAndShutdown } from "../lifecycle"; -import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; -import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; -import type { PersistedUsageAttempt } from "../../usage/log"; -import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; -import { applySystemEnvToggle } from "../system-env"; -import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared"; -import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared"; +import type { CodexCommanderConfig, CodexCommanderProviderConfig } from "../../types"; + +import { jsonResponse, providerManagementConfigError, publicProviderBaseUrl } from "../auth-cors"; + +import { isPlainRecord, stripRegistryOnlyStaticHeaders } from "./shared"; + import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; @@ -238,7 +200,7 @@ function applyProviderPatchFields( } export async function handleProviderRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx; + const { req, url, config, deps, convergeCodexCatalog } = ctx; if (url.pathname === "/api/provider-quotas" && req.method === "GET") { const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true"; diff --git a/src/server/management/shared.ts b/src/server/management/shared.ts index 4624d00a22..6987de4b95 100644 --- a/src/server/management/shared.ts +++ b/src/server/management/shared.ts @@ -1,61 +1,13 @@ -import { randomUUID } from "node:crypto"; -import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; -import { - DEFAULT_SUBAGENT_MODELS, - codexAutoStartEnabled, - hasOwnProvider, - isValidProviderName, - multiAgentGuidanceEnabled, - providerBaseUrlConfigError, - providerHeadersConfigError, - saveConfigPreservingClaudeCode, -} from "../../config"; -import { - clearLoginState, - getLoginStatus, - isPublicOAuthProvider, - listOAuthProviders, - startLoginFlow, - submitManualLoginCode, - upsertOAuthProvider, -} from "../../oauth"; -import { removeCredential } from "../../oauth/store"; -import { providerDestinationResolvedError } from "../../lib/destination-policy"; -import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; -import { deriveProviderPresets } from "../../providers/derive"; -import { providerCodexAccountMode } from "../../providers/registry"; -import { routedSlug, slugEquals } from "../../providers/slug-codec"; -import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; -import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; -import { clearThreadAccountMap } from "../../codex/routing"; -import { primeCodexPoolQuotas } from "../../codex/auth-api"; -import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; -import { resolveCodexHomeDir } from "../../codex/home"; -import { readUsageEntries } from "../../usage/log"; -import { getUsageDebugLogEntries } from "../../usage/debug"; -import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary"; -import { stripCodexRuntimeProviderFields } from "../../codex/auth-context"; + import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; -import { getDebugLogEntries } from "../../lib/debug-log-buffer"; -import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log"; -import { - clearDebugSettings, - clearDebugSetting, - getDebugSettings, - setDebugSettings, - type DebugFlag, -} from "../../lib/debug-settings"; -import type { CodexCommanderClaudeCodeConfig, CodexCommanderClaudeDesktopProfile, CodexCommanderConfig, CodexCommanderCustomModel, CodexCommanderProviderConfig } from "../../types"; + +import type { CodexCommanderClaudeDesktopProfile, CodexCommanderConfig, CodexCommanderProviderConfig } from "../../types"; import type { DesktopProfileModel } from "../../claude/desktop-profile"; -import { drainAndShutdown } from "../lifecycle"; -import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; + +import { type RequestLogEntry } from "../request-log"; import { estimateComboCost, estimateRequestCost, serviceTierContext, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; import type { PersistedUsageAttempt } from "../../usage/log"; -import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; -import { applySystemEnvToggle } from "../system-env"; - export function isPlainRecord(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); diff --git a/tests/anthropic-reasoning.test.ts b/tests/anthropic-reasoning.test.ts index 32f531bfc5..fa55ae1245 100644 --- a/tests/anthropic-reasoning.test.ts +++ b/tests/anthropic-reasoning.test.ts @@ -97,14 +97,40 @@ describe("anthropic extended-thinking gate", () => { }); test.each([ + ["medium", 16_384], // medium keeps its 8192 budget + headroom; never falls through to unknown. ["high", 24_576], ["xhigh", 32_768], ["max", 40_192], + ["ultra", 40_192], // ultra mirrors the codex-rs boundary and clamps to max on the wire. ])("adaptive-thinking %s effort reserves visible-output headroom", async (effort, expected) => { const b = await bodyOf(parsed(effort, {}, "claude-fable-5")); expect(b.max_tokens).toBe(expected); }); + test("adaptive-thinking model clamps unsupported 'ultra' effort to 'max'", async () => { + const b = await bodyOf(parsed("ultra", {}, "claude-fable-5")); + expect(b.thinking).toEqual({ type: "adaptive" }); + // Anthropic's output_config.effort ladder tops out at max; "ultra" would 400. + expect(b.output_config).toEqual({ effort: "max" }); + }); + + test("adaptive-thinking model clamps unknown efforts to 'high'", async () => { + const b = await bodyOf(parsed("ludicrous", {}, "claude-fable-5")); + expect(b.output_config).toEqual({ effort: "high" }); + // Unknown clamps to high on the wire AND budgets like high (16384 + headroom). + expect(b.max_tokens).toBe(24_576); + }); + + test("non-adaptive model maps ultra to the max thinking budget", async () => { + // Default modelId (claude-sonnet-4.5) is NOT an adaptive-thinking family, so ultra + // goes through the budget ladder: it must budget like max, above xhigh's 24576. + const b = await bodyOf(parsed("ultra")); + const thinking = b.thinking as { type: string; budget_tokens: number } | undefined; + expect(thinking?.type).toBe("enabled"); + expect(thinking?.budget_tokens ?? 0).toBeGreaterThan(24_576); + expect(b.max_tokens as number).toBeGreaterThan(thinking!.budget_tokens); + }); + test("Anthropic streaming and JSON responses preserve max_tokens stop reasons", async () => { const adapter = createAnthropicAdapter(provider); const sse = [ diff --git a/tests/api-usage.test.ts b/tests/api-usage.test.ts index 4b21a54a4a..b82388a30b 100644 --- a/tests/api-usage.test.ts +++ b/tests/api-usage.test.ts @@ -238,10 +238,9 @@ describe("GET /api/usage", () => { const server = startServer(0); try { const res = await fetch(new URL("/api/usage?surface=claude", server.url)); - expect(res.status).toBe(200); + expect(res.status).toBe(503); const body = await res.json(); expect(body.surface).toBe("claude"); - expect(body.summary.requests).toBe(0); expect(body.error).toBe("read_failed"); } finally { await server.stop(true); @@ -262,4 +261,20 @@ describe("GET /api/usage", () => { await server.stop(true); } }); + + test("success response includes cost and request classification fields", async () => { + writeFixture(Date.now()); + const server = startServer(0); + try { + const res = await fetch(new URL("/api/usage?range=all", server.url)); + expect(res.status).toBe(200); + const body = await res.json(); + expect(typeof body.summary.estimatedCostUsd).toBe("number"); + expect(typeof body.summary.pricedRequests).toBe("number"); + expect(typeof body.summary.unpricedRequests).toBe("number"); + expect(typeof body.summary.unmeteredRequests).toBe("number"); + } finally { + await server.stop(true); + } + }); }); diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 8d4a99f9da..f22b635a0b 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -15,10 +15,12 @@ import { markAccountNeedsReauth as markOAuthAccountNeedsReauth, saveCredential, } from "../src/oauth/store"; +import * as oauthApi from "../src/oauth"; import { getLoginStatus } from "../src/oauth"; import { clearProviderQuotaCache, fetchProviderQuotaReports, + parseXaiCreditsResponse, setProviderQuotaBeforePublishForTests, supportsProviderQuotaReporting, } from "../src/providers/quota"; @@ -1604,3 +1606,182 @@ describe("fetchProviderQuotaReports", () => { expect(pruned.reports).toEqual([]); }); }); + +test("parseXaiCreditsResponse maps weekly credits and rejects non-weekly periods", () => { + expect(parseXaiCreditsResponse({ + config: { + creditUsagePercent: 57.4, + currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", end: "2026-08-15T13:05:52.277209Z" }, + }, + })).toEqual({ + percent: 57.4, + resetAt: Date.parse("2026-08-15T13:05:52.277209Z"), + }); + expect(parseXaiCreditsResponse({ + config: { + currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", end: "2026-08-15T13:05:52.277209Z" }, + }, + })).toEqual({ + percent: 0, + resetAt: Date.parse("2026-08-15T13:05:52.277209Z"), + }); + expect(parseXaiCreditsResponse({ + config: { + creditUsagePercent: 10, + currentPeriod: { type: "USAGE_PERIOD_TYPE_MONTHLY", end: "2026-08-15T13:05:52.277209Z" }, + }, + })).toBeNull(); +}); + +test("xAI OAuth quota prefers weekly credits and falls back to monthly when weekly fails", async () => { + spyOn(oauthApi, "getValidAccessTokenSnapshot").mockResolvedValue({ + provider: "xai", + accountId: "xai-user-1", + generation: "test-generation", + accessToken: "xai-access-secret", + }); + const seen: { url: string; headers: Record }[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const headers = Object.fromEntries(new Headers(init?.headers).entries()); + seen.push({ url, headers }); + if (url === "https://cli-chat-proxy.grok.com/v1/billing?format=credits") { + return new Response(JSON.stringify({ + config: { + creditUsagePercent: 31, + currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", end: "2026-08-15T00:00:00Z" }, + raw_secret_should_not_escape: "xai-access-secret", + }, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + if (url === "https://cli-chat-proxy.grok.com/v1/billing") { + return new Response(JSON.stringify({ + config: { + monthlyLimit: { val: 10_000 }, + used: { val: 2_500 }, + billingPeriodEnd: "2026-08-31T00:00:00Z", + }, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const config = { + defaultProvider: "xai", + providers: { + xai: { adapter: "openai-chat", authMode: "oauth", baseUrl: "https://api.x.ai/v1" }, + }, + } as CodexCommanderConfig; + const weekly = await fetchProviderQuotaReports(config, true); + expect(weekly.reports).toHaveLength(1); + expect(weekly.reports[0]?.source).toBe("xai:grok-billing-credits"); + expect(weekly.reports[0]?.quota).toMatchObject({ + weeklyPercent: 31, + weeklyResetAt: Date.parse("2026-08-15T00:00:00Z"), + }); + expect(weekly.reports[0]?.quota.monthlyPercent).toBeUndefined(); + const creditsCall = seen.find(row => row.url.endsWith("format=credits")); + expect(creditsCall?.headers.authorization).toBe("Bearer xai-access-secret"); + expect(creditsCall?.headers["x-userid"]).toBe("xai-user-1"); + expect(creditsCall?.headers["x-xai-token-auth"]).toBe("xai-grok-cli"); + expect(creditsCall?.headers["x-authenticateresponse"]).toBe("authenticate-response"); + expect(creditsCall?.headers["x-grok-client-version"]).toBeTruthy(); + expect(JSON.stringify(weekly)).not.toContain("xai-access-secret"); + expect(JSON.stringify(weekly)).not.toContain("xai-user-1"); + + // Weekly non-2xx falls back to the monthly dollar pool. + seen.length = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + seen.push({ url, headers: {} }); + if (url.endsWith("format=credits")) { + return new Response("nope", { status: 503 }); + } + if (url === "https://cli-chat-proxy.grok.com/v1/billing") { + return new Response(JSON.stringify({ + config: { + monthlyLimit: { val: 10_000 }, + used: { val: 2_500 }, + billingPeriodEnd: "2026-08-31T00:00:00Z", + }, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + const monthly = await fetchProviderQuotaReports(config, true); + expect(monthly.reports[0]?.source).toBe("xai:grok-billing"); + expect(monthly.reports[0]?.quota.monthlyPercent).toBe(25); + expect(monthly.reports[0]?.quota.weeklyPercent).toBeUndefined(); + expect(seen.some(row => row.url.endsWith("format=credits"))).toBe(true); + expect(seen.some(row => row.url === "https://cli-chat-proxy.grok.com/v1/billing")).toBe(true); +}); + +test("xAI OAuth quota skips weekly when identity is absent and keeps monthly", async () => { + spyOn(oauthApi, "getValidAccessTokenSnapshot").mockResolvedValue({ + provider: "xai", + accountId: "", + generation: "test-generation", + accessToken: "xai-access-secret", + }); + const seen: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + seen.push(url); + if (url === "https://cli-chat-proxy.grok.com/v1/billing") { + return new Response(JSON.stringify({ + config: { + monthlyLimit: { val: 10_000 }, + used: { val: 2_500 }, + billingPeriodEnd: "2026-08-31T00:00:00Z", + }, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + const result = await fetchProviderQuotaReports({ + defaultProvider: "xai", + providers: { + xai: { adapter: "openai-chat", authMode: "oauth", baseUrl: "https://api.x.ai/v1" }, + }, + } as CodexCommanderConfig, true); + expect(seen.some(url => url.includes("format=credits"))).toBe(false); + expect(result.reports[0]?.source).toBe("xai:grok-billing"); + expect(result.reports[0]?.quota.monthlyPercent).toBe(25); +}); + +test("xAI quota reports observed usage when the account reports no cap", async () => { + spyOn(oauthApi, "getValidAccessTokenSnapshot").mockResolvedValue({ + provider: "xai", + accountId: "xai-user-1", + generation: "test-generation", + accessToken: "xai-access-secret", + }); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("format=credits")) return new Response("nope", { status: 503 }); + if (url === "https://cli-chat-proxy.grok.com/v1/billing") { + return new Response(JSON.stringify({ + config: { + monthlyLimit: { val: 0 }, + used: { val: 243 }, + billingPeriodEnd: "2026-09-01T00:00:00Z", + }, + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + const result = await fetchProviderQuotaReports({ + defaultProvider: "xai", + providers: { + xai: { adapter: "openai-chat", authMode: "oauth", baseUrl: "https://api.x.ai/v1" }, + }, + } as CodexCommanderConfig, true); + expect(result.reports[0]?.source).toBe("xai:grok-billing"); + expect(result.reports[0]?.quota.customWindows).toEqual([{ + label: "No reported cap", + percent: 0, + resetAt: Date.parse("2026-09-01T00:00:00Z"), + }]); + expect(result.reports[0]?.quota.monthlyResetAt).toBeUndefined(); + expect(result.availability?.[0]).toMatchObject({ provider: "xai", status: "available" }); +}); diff --git a/tests/reasoning-effort.test.ts b/tests/reasoning-effort.test.ts index b18ac2e38b..0d5d3a7372 100644 --- a/tests/reasoning-effort.test.ts +++ b/tests/reasoning-effort.test.ts @@ -4,6 +4,7 @@ import { createAnthropicAdapter } from "../src/adapters/anthropic"; import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; import type { AdapterRequest } from "../src/adapters/base"; import { configuredReasoningEfforts, mapReasoningEffort, sanitizeCodexReasoningEfforts } from "../src/reasoning-effort"; +import { enrichProviderFromRegistry } from "../src/providers/derive"; import { routeModel } from "../src/router"; import { resolveWireProtocolOverride } from "../src/server/adapter-resolve"; import type { CodexCommanderConfig, CodexCommanderParsedRequest, CodexCommanderProviderConfig } from "../src/types"; @@ -700,6 +701,47 @@ describe("ultra reasoning effort (upstream codex-rs parity)", () => { expect(mapReasoningEffort(base, "m", "ultra")).toBe("max"); }); + test("xAI registry ladder clamps max/ultra on the real route path (grok-4.6)", () => { + // xAI reasoning_effort accepts low|medium|high (xhigh on grok-4.6+ only); max and ultra + // are rejected with 400 "Invalid reasoning effort" (observed via the proxy request log: + // grok-4.6 + wireValue "max" -> 400). This exercises the REAL registry entry through the + // route path, so it fails on a registry that omits the grok-4.6 ladder. + const config = { + defaultProvider: "xai", + providers: { + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "oauth" }, + }, + } as unknown as CodexCommanderConfig; + const route = routeModel(config, "xai/grok-4.6"); + expect(route.provider.modelReasoningEfforts?.["grok-4.6"]).toEqual(["low", "medium", "high", "xhigh"]); + expect(mapReasoningEffort(route.provider, "grok-4.6", "ultra")).toBe("xhigh"); + expect(mapReasoningEffort(route.provider, "grok-4.6", "max")).toBe("xhigh"); + expect(mapReasoningEffort(route.provider, "grok-4.6", "xhigh")).toBe("xhigh"); + const older = routeModel(config, "xai/grok-4.5"); + expect(mapReasoningEffort(older.provider, "grok-4.5", "ultra")).toBe("high"); + expect(mapReasoningEffort(older.provider, "grok-4.5", "max")).toBe("high"); + // Live-discovered models without a per-model entry fall back to the provider ladder. + expect(mapReasoningEffort(route.provider, "grok-4.7", "ultra")).toBe("high"); + // noReasoningModels members stay effort-free. + expect(mapReasoningEffort(route.provider, "grok-build-0.1", "max")).toBeUndefined(); + }); + + test("enrichProviderFromRegistry merges new ladder keys into a partially saved map", () => { + // A config saved when the registry only knew grok-4.5 must pick up the grok-4.6 xhigh + // tier from the current registry for catalog advertisement, without overwriting the + // saved per-model entry (saved entries always win per key). + const provider: CodexCommanderProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + modelReasoningEfforts: { "grok-4.5": ["low", "medium", "high"] }, + }; + enrichProviderFromRegistry("xai", provider); + expect(provider.modelReasoningEfforts?.["grok-4.5"]).toEqual(["low", "medium", "high"]); + expect(provider.modelReasoningEfforts?.["grok-4.6"]).toEqual(["low", "medium", "high", "xhigh"]); + expect(provider.reasoningEfforts).toEqual(["low", "medium", "high"]); + }); + test("a max wire alias applies to converted ultra; a raw ultra alias never bypasses the boundary", () => { expect(mapReasoningEffort({ ...base, reasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], reasoningEffortMap: { max: "think-hard" } }, "m", "ultra")).toBe("think-hard"); // Upstream never lets "ultra" influence the provider wire; the alias table is consulted with