From e8a8ac727ca835db9bad55ecf3e95200420844f4 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 02:40:48 -0400 Subject: [PATCH 01/27] feat(usage): return 503 for usage read failures Genuine usage.jsonl read/stat/schema failures now respond 503 with a { error: read_failed, range, surface } body instead of a 200 zeroed summary. Missing log files still return 200 with a zeroed summary via readUsageSnapshotForManagement. Tests updated and extended to assert the 503 contract and that success responses carry estimatedCostUsd, pricedRequests, unpricedRequests, and unmeteredRequests. --- src/server/management/logs-usage-routes.ts | 41 ++++------------------ tests/api-usage.test.ts | 19 ++++++++-- 2 files changed, 23 insertions(+), 37 deletions(-) diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index aff047db63..509e32bd6d 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -214,41 +214,12 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise { 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); + } + }); }); From 50a491e3543dcdfa5bcc6f508ed86d8f0c8aac0b Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 02:45:04 -0400 Subject: [PATCH 02/27] feat(gui): harden usage response validation before caching The Usage page now validates GET /api/usage success bodies before any cache write: error envelopes and malformed summaries (missing or non-finite required summary fields, including estimatedCostUsd/pricedRequests/ unpricedRequests/unmeteredRequests) throw a typed UsageReportValidationError and are never persisted. A defined zero cost renders $0.00 while a genuinely missing legacy field renders "Unavailable". Cold failures keep the failed-cold Notice with retry, and failed refreshes retain last-known- good data with the stale/error banner. Shared validator lives in gui/src/usage-report-validation.ts for reuse by the upcoming domain store. --- gui/src/i18n/de.ts | 41 ++-- gui/src/i18n/en.ts | 43 ++-- gui/src/i18n/ja.ts | 41 ++-- gui/src/i18n/ko.ts | 41 ++-- gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Usage.tsx | 123 +++-------- gui/src/usage-report-validation.ts | 185 ++++++++++++++++ gui/tests/startup-usage-loading-race.test.tsx | 1 + gui/tests/usage-layout.test.ts | 4 + gui/tests/usage-validation.test.tsx | 204 ++++++++++++++++++ 11 files changed, 514 insertions(+), 171 deletions(-) create mode 100644 gui/src/usage-report-validation.ts create mode 100644 gui/tests/usage-validation.test.tsx diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index bfe1d16891..afb8e2f10f 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -283,7 +283,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 +427,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 +438,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 +489,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.", @@ -619,25 +619,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 +656,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 +751,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 +849,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.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index d1c63544cd..a6160312e1 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -299,7 +299,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 +448,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 +459,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 +510,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.", @@ -599,7 +599,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 +642,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 +679,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 +776,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 +878,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.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 3e00ce47a9..0cc3899151 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -295,7 +295,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 +437,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 +448,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 +499,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": "サブエージェントモードを保存しました。実行中のワーカーに適用し、セッションに紐付く変更のため新しいタスクを開始してください。", @@ -604,25 +604,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 +641,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 +738,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 +840,7 @@ export const ja: Record = { // usage page "usage.title": "使用量", - "usage.subtitle": "プロキシからのローカルトークン会計です。欠損した使用量はゼロとして表示されることはありません。", + "usage.subtitle": "プロキシが追跡したリクエストとトークンです。読み取りに失敗してもゼロとして表示されることはありません。", "usage.loading": "使用量データを読み込み中…", "usage.empty": "まだ使用量が記録されていません。プロキシ経由でリクエストを送信するとここにアクティビティが表示されます。", "usage.loadError": "使用量データを読み込めませんでした。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index c67cb6ea48..294d433e72 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -288,7 +288,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 +437,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 +448,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 +499,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": "서브에이전트 모드가 저장되었습니다. 실행 중인 워커에 적용한 뒤 세션 종속 변경을 위해 새 작업을 시작하세요.", @@ -631,25 +631,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 +668,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 +765,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 +866,7 @@ export const ko: Record = { // usage page "usage.title": "사용량", - "usage.subtitle": "프록시의 로컬 토큰 집계입니다. 누락된 사용량은 0으로 표시하지 않습니다.", + "usage.subtitle": "프록시가 추적한 요청과 토큰입니다. 읽기에 실패해도 0으로 표시되지 않습니다.", "usage.loading": "사용량 데이터를 불러오는 중…", "usage.empty": "아직 기록된 사용량이 없습니다. 프록시로 요청을 보내면 여기에 표시됩니다.", "usage.loadError": "사용량 데이터를 불러오지 못했습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 70d675c0cd..f242037d24 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -770,6 +770,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": "Основная информация", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index d7e12fbb11..8380b0b547 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -762,6 +762,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": "基本信息", diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 345a419b7a..14423e485e 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -10,86 +10,18 @@ import { useDataSurface } 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 { + parseUsageReport, + 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 +226,20 @@ function UsageSummaryCards({
{t("usage.card.coverage")}
{formatPct(summary.coverageRatio)}
{t("usage.card.activeDays")}
{activeDays}
- {summary.estimatedCostUsd !== undefined && ( + {summary.estimatedCostUsd === undefined ? ( + // Legacy server without the cost fields: the DTO now requires them, but a + // pre-validation session-cache seed may still carry the old shape. +
+ {t("usage.cost.total")} + {t("usage.cost.unavailable")} +
+ ) : (
{t("usage.cost.total")} - {formatUsdEstimate(summary.estimatedCostUsd, locale)} + {summary.estimatedCostUsd === 0 + ? "$0.00" + : 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; @@ -732,18 +673,18 @@ function UsageWorkspaceBody({ } /** Held usage payloads so provider/surface tab switches skip a cold ~5s refetch. */ -const usageMemoryCache = new Map(); +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 { +function readHeldUsage(apiBase: string, range: Range, surface: UsageSurface): UsageReport | null { const key = usageCacheKey(apiBase, range, surface); - return usageMemoryCache.get(key) ?? readSessionListCache(key); + return usageMemoryCache.get(key) ?? readSessionListCache(key); } -function writeHeldUsage(apiBase: string, range: Range, surface: UsageSurface, value: UsageResponse) { +function writeHeldUsage(apiBase: string, range: Range, surface: UsageSurface, value: UsageReport) { const key = usageCacheKey(apiBase, range, surface); usageMemoryCache.set(key, value); writeSessionListCache(key, value); @@ -755,10 +696,12 @@ export default function Usage({ apiBase }: { apiBase: string }) { const [surface, setSurface] = useState("all"); const [modelQuery, setModelQuery] = useState(""); - const loadUsage = useCallback(async (signal: AbortSignal): Promise => { + 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; + // Only validated successful reports may reach the held cache: error envelopes + // and malformed summaries throw here and are never persisted. + const next = parseUsageReport(await response.json()); writeHeldUsage(apiBase, range, surface, next); return next; }, [apiBase, range, surface]); @@ -767,7 +710,7 @@ export default function Usage({ apiBase }: { apiBase: string }) { 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( + const resource = useDataSurface( resourceKey, [apiBase, range, surface], loadUsage, diff --git a/gui/src/usage-report-validation.ts b/gui/src/usage-report-validation.ts new file mode 100644 index 0000000000..d4b50c15f6 --- /dev/null +++ b/gui/src/usage-report-validation.ts @@ -0,0 +1,185 @@ +/** + * 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, + }; +} + +/** + * 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 (!Array.isArray(body.days) || !Array.isArray(body.models) || !Array.isArray(body.providers)) { + throw new UsageReportValidationError("usage report days/models/providers must be arrays"); + } + 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: body.days as UsageDay[], + models: body.models as UsageModel[], + providers: body.providers as UsageProvider[], + 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/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/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-validation.test.tsx b/gui/tests/usage-validation.test.tsx new file mode 100644 index 0000000000..da396875c1 --- /dev/null +++ b/gui/tests/usage-validation.test.tsx @@ -0,0 +1,204 @@ +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 { readSessionListCache, writeSessionListCache } from "../src/session-list-cache"; +import Usage from "../src/pages/Usage"; +import type { UsageReport } from "../src/usage-report-validation"; + +/** + * Phase 1b contract: the Usage page only ever caches validated successful reports. + * Error envelopes and malformed summaries are rejected before any persistence, 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(); + 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(); + 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"); + expect(container.textContent).not.toContain("Unavailable"); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("legacy undefined cost field renders Unavailable", async () => { + const legacy = validReport({ summary: summary({ requests: 3 }) }); + // Simulate a pre-validation session-cache seed from an older server: no cost fields. + const { estimatedCostUsd: _cost, pricedRequests: _priced, unpricedRequests: _unpriced, unmeteredRequests: _unmetered, ...legacySummary } = legacy.summary; + const legacyReport = { ...legacy, summary: legacySummary }; + writeSessionListCache("ccx.usage.v1:http://usage-legacy:30d:all", legacyReport); + // The 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(legacyReport)) 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 = "ccx.usage.v1: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"); + expect(readSessionListCache(key)).toBeNull(); + expect(testWindow.sessionStorage.getItem(key)).toBeNull(); + } 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("refresh failure retains last-good data and shows the stale/error banner", async () => { + const key = "ccx.usage.v1:http://usage-stale:30d:all"; + const good = validReport({ summary: summary({ requests: 42, totalTokens: 9000, estimatedCostUsd: 1.25 }) }); + // Last-known-good payload is seeded from the session cache; the quiet revalidation fails. + writeSessionListCache(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 held cache. + expect(readSessionListCache(key)).not.toBeNull(); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); From 75210ad5eeca7d2ccda6b03e076adb46e6834916 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 02:47:46 -0400 Subject: [PATCH 03/27] style(gui): fix select-label spill, ID wrapping, and run-policy grid alignment - .select-trigger > span now ellipsizes inside the pill instead of spilling past it (global rule matching the dashboard sidecar pattern). - Roster/library/fallback/matrix model IDs wrap with overflow-wrap:anywhere instead of clipping mid-ID; name spans carry title tooltips. - Run Policy grid: align-items start (kills the 30px label staircase), guidance spans 4 columns + save cell pinned to column 5 (kills the void next to Save changes), helper text uses --text-label, 150px tracks widened to 170px, save cell magic min-height removed. - Library filter chips wrap instead of clipping at the right edge. - Notice tone=err now announces with role=alert; ok/warn stay status. - Rail IDs on Models/ClaudeCode/Storage/Combos get title tooltips. - Drop redundant style={{width:'100%'}} on policy selects (CSS owns it). --- gui/src/components/ComboWorkspace.tsx | 2 +- .../storage-workspace/StorageWorkspace.tsx | 2 +- .../SubagentRunPolicySection.tsx | 6 +--- .../SubagentsWorkspace.tsx | 6 ++-- gui/src/pages/ClaudeCode.tsx | 2 +- gui/src/pages/Models.tsx | 4 +-- gui/src/styles-subagents-workspace.css | 32 +++++++++---------- gui/src/styles.css | 8 +++++ gui/src/ui.tsx | 4 ++- 9 files changed, 36 insertions(+), 30 deletions(-) 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/storage-workspace/StorageWorkspace.tsx b/gui/src/components/storage-workspace/StorageWorkspace.tsx index dedcce01e2..53421757ab 100644 --- a/gui/src/components/storage-workspace/StorageWorkspace.tsx +++ b/gui/src/components/storage-workspace/StorageWorkspace.tsx @@ -108,7 +108,7 @@ export default function StorageWorkspace({ report, locale }: StorageWorkspacePro aria-current={selectedKey === bucket.key ? "true" : undefined} > - {bucketLabel(bucket, t)} + {bucketLabel(bucket, t)} {formatBytes(bucket.bytes, locale)} diff --git a/gui/src/components/subagents-workspace/SubagentRunPolicySection.tsx b/gui/src/components/subagents-workspace/SubagentRunPolicySection.tsx index a67c71ae3d..2bfd36aaa6 100644 --- a/gui/src/components/subagents-workspace/SubagentRunPolicySection.tsx +++ b/gui/src/components/subagents-workspace/SubagentRunPolicySection.tsx @@ -182,7 +182,6 @@ export default function SubagentRunPolicySection({ onChange={value => { setFeedback(null); policy.setMode(value as MultiAgentMode); }} disabled={saving} label={t("sub.policy.mode")} - style={{ width: "100%" }} /> {t(`models.modeOptionDesc_${policy.mode}` as TKey)}
@@ -199,7 +198,6 @@ export default function SubagentRunPolicySection({ }} disabled={saving} label={t("sub.policy.messageDelivery")} - style={{ width: "100%" }} /> {t(`sub.policy.messageDeliveryHint_${policy.messageDelivery}` as TKey)} @@ -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/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/styles-subagents-workspace.css b/gui/src/styles-subagents-workspace.css index 97a53f5b39..3c768629a5 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,15 @@ .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); + grid-column: 5; + min-height: 0; } .swi-policy-save-cell .btn { @@ -830,11 +831,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..ab138aff82 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -985,6 +985,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; 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}
    From 4e96b027a5c85a02bf0947627779e3f48432f03d Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 02:47:51 -0400 Subject: [PATCH 04/27] fix(gui): reconcile tests with Configured Roster copy and V1/V2 terminology - subagents-classic/busy-race tests assert the Configured Roster rename (heading + add/remove aria-labels); the aria-labels were never regressed. - Copy updates: no-preferred-model hint, shortened fallback hint, rewritten V2 compatibility notices (drop #92 and off-topic clauses), consolidated save/timing instructions (sub.policy.saved is now one line; timing lives in the details note), de-jargoned Usage subtitle, V1/V2 capitalization, 'protocol' as the single word for the collaboration selector, and 'configured roster' terminology (en/de/ja/ko landed with the usage validation commit; ru/zh follow here). - models-empty-provider test expects 'Reliable V1'. --- gui/src/i18n/ru.ts | 40 +++++++++++----------- gui/src/i18n/zh.ts | 42 ++++++++++++------------ gui/tests/models-empty-provider.test.tsx | 2 +- gui/tests/subagents-busy-race.test.tsx | 2 +- gui/tests/subagents-classic.test.ts | 2 +- gui/tests/subagents-classic.test.tsx | 18 +++++----- 6 files changed, 53 insertions(+), 53 deletions(-) diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index f242037d24..574a5cdbd9 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -295,7 +295,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 +442,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 +453,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 +504,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": "Режим подагента сохранён. Примените к работающему воркеру, затем начните новую задачу для сессионных изменений.", @@ -636,25 +636,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 +673,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": "Фильтровать модели по возможностям", @@ -872,7 +872,7 @@ export const ru: Record = { // usage page "usage.title": "Использование", - "usage.subtitle": "Локальный учёт токенов вашего прокси. Отсутствующие данные никогда не показываются как ноль.", + "usage.subtitle": "Запросы и токены, отслеживаемые вашим прокси. Сбой чтения никогда не показывается как ноль.", "usage.loading": "Загрузка данных об использовании…", "usage.empty": "Данных об использовании пока нет. Отправьте запрос через прокси, чтобы увидеть здесь активность.", "usage.loadError": "Не удалось загрузить данные об использовании.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 8380b0b547..cd3ffd3ab0 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -287,7 +287,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 +434,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 +445,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 +496,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": "子代理模式已保存。对运行中的工作器应用后,再为会话绑定的更改启动新任务。", @@ -585,7 +585,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 +628,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 +665,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": "按能力筛选模型", @@ -863,7 +863,7 @@ export const zh: Record = { // usage page "usage.title": "用量", - "usage.subtitle": "代理本地的 Token 用量统计。缺失的用量不会显示为零。", + "usage.subtitle": "代理跟踪的请求和 Token。读取失败时不会显示为零。", "usage.loading": "正在加载用量数据…", "usage.empty": "尚无用量记录。通过代理发送请求后将在此显示。", "usage.loadError": "无法加载用量数据。", 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/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"); }); From e16a3dd410f51163bb36be57efc905c13f62607d Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 02:53:53 -0400 Subject: [PATCH 05/27] chore(server): remove unused-import residue in management route modules Run under tsc --noUnusedLocals --noUnusedParameters: 356 diagnostics in src/server/management were stale imports left by earlier route consolidations. Removed unused bindings (compiler-API driven, verified per file), dropped unused destructured ctx fields, and normalized the blank lines left behind. No behavior change: bun run typecheck clean, management route tests (395) and privacy:scan pass. --- .../management/agent-settings-routes.ts | 68 ++------------- src/server/management/combo-routes.ts | 67 ++------------- src/server/management/config-routes.ts | 65 ++------------ src/server/management/logs-usage-routes.ts | 56 ++---------- src/server/management/model-routes.ts | 85 +++---------------- src/server/management/oauth-account-routes.ts | 65 +++----------- src/server/management/provider-routes.ts | 64 +++----------- src/server/management/shared.ts | 58 ++----------- 8 files changed, 80 insertions(+), 448 deletions(-) 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 509e32bd6d..878ffb8fce 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,16 +31,13 @@ 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 { @@ -121,7 +83,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(); diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index f4765d6b35..88a4d7a00f 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 diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 532cc759bc..86562e7b5d 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -1,19 +1,6 @@ import { randomBytes, 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, - readConfigDiagnostics, - reconcileLiveConfigFromDisk, - saveConfigPreservingClaudeCode, -} from "../../config"; + +import { hasOwnProvider, isValidProviderName, readConfigDiagnostics, reconcileLiveConfigFromDisk, saveConfigPreservingClaudeCode } from "../../config"; import { clearLoginState, getLoginStatus, @@ -23,50 +10,27 @@ import { submitManualLoginCode, } from "../../oauth"; import { OAuthMutationBusyError, removeCredential } from "../../oauth/store"; -import { providerDestinationResolvedError } from "../../lib/destination-policy"; + import { reconcileLiveStateStores } from "../../lib/state-store-registrations"; -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, fetchProviderAccountQuotas, fetchProviderQuotaReports, supportsPerAccountQuota } from "../../providers/quota"; -import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; -import { clearThreadAccountMap } from "../../codex/routing"; +import { listKeyLoginProviders } from "../../oauth/key-providers"; + +import { fetchProviderAccountQuotas, supportsPerAccountQuota } from "../../providers/quota"; + import { normalizeAccountPoolStickyLimit, normalizeAccountPoolStrategy, parseAccountPoolStickyLimit, parseAccountPoolStrategy, } from "../../codex/pool-rotation"; -import { primeCodexPoolQuotas } from "../../codex/auth-api"; + import { DATA_KEY_PREFIX } from "../../identity"; -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 { AUTH_MATRIX, isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; -import { applySystemEnvToggle } from "../system-env"; + +import { AUTH_MATRIX, jsonResponse } from "../auth-cors"; + import { buildApiAccessEndpoints } from "./api-access"; -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, readManagementJsonBodyOr, rethrowManagementBodyTooLarge } from "./body"; import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; @@ -113,7 +77,7 @@ function validateKeyName( } export async function handleOauthAccountRoutes(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") { @@ -236,7 +200,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); From 1e3d93d671ba0dfcdf3fe82c012e4a0d9b9b83fd Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 02:54:25 -0400 Subject: [PATCH 06/27] chore(server): mark dead management routes deprecated Cross-client grep: no GUI or other runtime client calls /api/disabled-models or /api/key-providers anymore; only tests, docs, and structure notes reference them. Add deprecation notes pointing at the replacements (PUT /api/model-visibility, GET /api/providers). Routes stay live for script compatibility. --- src/server/management/model-routes.ts | 3 +++ src/server/management/oauth-account-routes.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 88a4d7a00f..d84e665129 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -148,6 +148,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise Date: Thu, 13 Aug 2026 02:55:39 -0400 Subject: [PATCH 07/27] docs: standardize V1/V2 label capitalization in guides Sync English guide copy with the GUI labels: Reliable V1, Concurrent V2, V1/base/V2 mode descriptions, and prose references. CLI command syntax (ccx v2 mode v1, /api/v2) stays lowercase. Translated docs had no label strings to contradict. --- docs-site/src/components/Landing.astro | 2 +- .../content/docs/guides/codex-app-models.md | 6 ++-- .../content/docs/guides/codex-integration.md | 2 +- docs-site/src/content/docs/guides/combos.md | 10 +++--- .../content/docs/guides/sub-agent-surface.md | 32 +++++++++---------- .../src/content/docs/guides/web-dashboard.md | 6 ++-- 6 files changed, 29 insertions(+), 29 deletions(-) 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..30ac64370b 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. --- @@ -17,7 +17,7 @@ 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. | +| **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?] @@ -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 @@ -154,7 +154,7 @@ 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**. +- **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,12 +237,12 @@ 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. @@ -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..e40b3cdc1a 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -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. | @@ -139,7 +139,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,7 +153,7 @@ 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 From 234edd6d0740ae58b9b78630fe37cc09184a6e58 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 02:58:26 -0400 Subject: [PATCH 08/27] feat(gui): adopt zustand for usage reports (PR1) Introduces gui/src/usage-report-store.ts, a keyed zustand domain store for GET /api/usage reports (${apiBase}:${range}:${surface}) with client-resource-shaped snapshots, singleflight per key, AbortController cancellation, and sessionStorage persistence of validated reports plus a timestamp only (never errors or in-flight state). Rehydrated seeds are marked seedNeedsRevalidate so the first subscriber quiet-revalidates. Usage.tsx and the Dashboard now select the same 30d/all entry, collapsing the previous three independent usage fetches into one. The dashboard usage client-resource poll and USAGE_CACHE_PREFIX session-cache are removed; fetchDashboardUsage is deleted. The page-loading contract pin now accepts Usage's classifyDataSurface wiring through the shared data-surface state machine. Adds store unit tests, a rehydration test file, and updates usage-validation and dashboard-contract tests. --- gui/bun.lock | 3 + gui/package.json | 3 +- gui/src/pages/Usage.tsx | 56 +--- gui/src/pages/dashboard-core-poll.ts | 9 - gui/src/pages/use-dashboard-data.ts | 38 +-- gui/src/usage-report-store.ts | 312 ++++++++++++++++++ gui/tests/dashboard-contracts.test.ts | 15 +- gui/tests/page-loading-contract.test.tsx | 7 + .../usage-report-store-rehydrate.test.ts | 136 ++++++++ gui/tests/usage-report-store.test.ts | 150 +++++++++ gui/tests/usage-validation.test.tsx | 48 +-- 11 files changed, 676 insertions(+), 101 deletions(-) create mode 100644 gui/src/usage-report-store.ts create mode 100644 gui/tests/usage-report-store-rehydrate.test.ts create mode 100644 gui/tests/usage-report-store.test.ts 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/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 14423e485e..c7356b35e5 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -1,17 +1,16 @@ -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 { 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"; +import { useUsageReport } from "../usage-report-store"; import { - parseUsageReport, type UsageDay, type UsageModel, type UsageProvider, @@ -672,52 +671,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): UsageReport | null { - const key = usageCacheKey(apiBase, range, surface); - return usageMemoryCache.get(key) ?? readSessionListCache(key); -} - -function writeHeldUsage(apiBase: string, range: Range, surface: UsageSurface, value: UsageReport) { - 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()); - // Only validated successful reports may reach the held cache: error envelopes - // and malformed summaries throw here and are never persisted. - const next = parseUsageReport(await response.json()); - 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]); @@ -752,7 +718,7 @@ 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/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 8203403218..d95c75a350 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,21 @@ 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, + }, + }; + }, [usageReport.data]); const diagnosticsPoll = useKeyedClientResource( `dashboard-diagnostics:${apiBase}`, @@ -336,13 +339,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 +540,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/usage-report-store.ts b/gui/src/usage-report-store.ts new file mode 100644 index 0000000000..0576fe4fa8 --- /dev/null +++ b/gui/src/usage-report-store.ts @@ -0,0 +1,312 @@ +/** + * 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 }) => 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 }, + ) => 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; + 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, + }, + }, + })); + } + })(); +} + +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: true }); + }, + 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, + }; + } + } + 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 }) => { + 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 }, + }, + })); +} + +/** Test-only: re-run persist rehydration against the current sessionStorage. */ +export function rehydrateUsageReportForTests(): void { + void useUsageReportStore.persist.rehydrate(); +} 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/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/usage-report-store-rehydrate.test.ts b/gui/tests/usage-report-store-rehydrate.test.ts new file mode 100644 index 0000000000..871e6c89e0 --- /dev/null +++ b/gui/tests/usage-report-store-rehydrate.test.ts @@ -0,0 +1,136 @@ +import { 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/" }); +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"); + +// 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); + 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); +}); diff --git a/gui/tests/usage-report-store.test.ts b/gui/tests/usage-report-store.test.ts new file mode 100644 index 0000000000..600e59dcda --- /dev/null +++ b/gui/tests/usage-report-store.test.ts @@ -0,0 +1,150 @@ +import { 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/" }); +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 = undefined as unknown as typeof fetch; +}); + +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"); +}); diff --git a/gui/tests/usage-validation.test.tsx b/gui/tests/usage-validation.test.tsx index da396875c1..d2feccd55c 100644 --- a/gui/tests/usage-validation.test.tsx +++ b/gui/tests/usage-validation.test.tsx @@ -4,16 +4,21 @@ import { act } from "react"; import type { Root } from "react-dom/client"; import { LanguageProvider } from "../src/i18n/provider"; import { clearClientResourceStoresForTests } from "../src/client-resource"; -import { readSessionListCache, writeSessionListCache } from "../src/session-list-cache"; +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: the Usage page only ever caches validated successful reports. - * Error envelopes and malformed summaries are rejected before any persistence, 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. + * 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; @@ -64,6 +69,7 @@ function validReport(overrides: Partial = {}): UsageReport { 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 }, @@ -85,6 +91,7 @@ beforeEach(() => { afterEach(() => { globalThis.fetch = originalFetch; clearClientResourceStoresForTests(); + clearUsageReportStoresForTests(); testWindow.close(); for (const key of globals) { Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); @@ -131,14 +138,14 @@ test("cost row renders $0.00 for a defined zero", async () => { }); 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 session-cache seed from an older server: no cost fields. + // 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; - const legacyReport = { ...legacy, summary: legacySummary }; - writeSessionListCache("ccx.usage.v1:http://usage-legacy:30d:all", legacyReport); - // The revalidation also answers with the legacy shape, so it is rejected and the + 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(legacyReport)) as typeof fetch; + globalThis.fetch = (async () => Response.json({ ...legacy, summary: legacySummary })) as typeof fetch; const { container, root } = await renderUsage("http://usage-legacy"); try { @@ -151,7 +158,7 @@ test("legacy undefined cost field renders Unavailable", async () => { }); test("a 200 response with an error envelope is rejected and never cached", async () => { - const key = "ccx.usage.v1:http://usage-error-envelope:30d:all"; + const key = usageReportKey("http://usage-error-envelope", "30d", "all"); globalThis.fetch = (async () => Response.json({ error: "read_failed", range: "30d", surface: "all" })) as typeof fetch; @@ -159,8 +166,10 @@ test("a 200 response with an error envelope is rejected and never cached", async try { await waitFor(() => (container.textContent ?? "").includes("Retry")); expect(container.textContent).toContain("Retry"); - expect(readSessionListCache(key)).toBeNull(); - expect(testWindow.sessionStorage.getItem(key)).toBeNull(); + 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(); @@ -182,10 +191,11 @@ test("401 cold failure shows the failed-cold Notice with retry", async () => { }); test("refresh failure retains last-good data and shows the stale/error banner", async () => { - const key = "ccx.usage.v1:http://usage-stale:30d:all"; + 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 from the session cache; the quiet revalidation fails. - writeSessionListCache(key, good); + // 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"); @@ -195,8 +205,8 @@ test("refresh failure retains last-good data and shows the stale/error banner", // 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 held cache. - expect(readSessionListCache(key)).not.toBeNull(); + // 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(); From bf2a6fabb985ceebd5dd94b4c56449013c636b34 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 03:14:19 -0400 Subject: [PATCH 09/27] feat(gui): provider-quota store for dashboard + workspace shell (PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces gui/src/provider-quota-store.ts, a zustand domain store keyed by apiBase for GET /api/provider-quotas. ProviderWorkspaceShell now routes its quota fetch through the store (quotaRefreshEpoch/quotaForceRefresh map to a refresh({ force }) action that adds the ?refresh=1 TTL bypass), keeping its strict capacity-aggregation display filter on top of the store's permissive ingest so reference-window-only reports stay representable for the upcoming Dashboard Plan & quota section. Privacy invariant: only quota reports (provider/label/source/quota/updatedAt/ aggregation) plus a timestamp are persisted — never account emails or ids; stray identity fields on the wire are projected away. Stale (>30 min) rehydrated seeds are rejected. Store tests cover force-refresh, singleflight, last-good retention, stale-seed rejection, and the privacy invariant. Also fixes global hygiene in the store test files: the default bun test runner shares one global scope across files, so each test file now restores every installed global (sessionStorage/window/document/fetch) after its run. --- .../ProviderWorkspaceShell.tsx | 132 +----- gui/src/provider-quota-store.ts | 380 ++++++++++++++++++ gui/tests/provider-capacity-shell.test.tsx | 56 ++- gui/tests/provider-quota-store.test.ts | 179 +++++++++ .../usage-report-store-rehydrate.test.ts | 19 +- gui/tests/usage-report-store.test.ts | 18 +- 6 files changed, 655 insertions(+), 129 deletions(-) create mode 100644 gui/src/provider-quota-store.ts create mode 100644 gui/tests/provider-quota-store.test.ts diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 0c66b7a291..9acd126252 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -22,7 +22,8 @@ import { providerKind } from "../../provider-workspace/kind"; import { readJsonIfOk, readJsonOrThrow } from "../../fetch-json"; import { readSessionListCache, writeSessionListCache } from "../../session-list-cache"; import { countAvailableModels, parseAvailableModels, parseLiveModelCounts, parseSelectedModels, type ProviderAvailableModels, type ProviderLiveModelCounts, type ProviderModelCounts, type ProviderSelectedModels } from "../../provider-workspace/usage"; -import { capacityAggregationFromReport, type ProviderQuotaReportView } from "../../provider-workspace/report"; +import type { ProviderQuotaReportView } from "../../provider-workspace/report"; +import { freshQuotaReportRecord, useProviderQuota } from "../../provider-quota-store"; import { formatProviderDisplayName } from "../../provider-icons"; import { RailRow } from "./ProviderRail"; import type { PricingFilter, ProviderModelUsageRow, ProviderUsageTotals, StatusFilter, TypeFilter } from "./types"; @@ -53,72 +54,6 @@ const SORT_DEFS: { id: ProviderSortMode; labelKey: "pws.sort.az" | "pws.sort.za" { id: "accounts-first", labelKey: "pws.sort.accountsFirst" }, ]; -const QUOTA_REPORT_MAX_AGE_MS = 30 * 60_000; - -function freshQuotaReport(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; - if (!("quota" in row)) return null; - if (!("aggregation" in row)) return null; - if (row.label !== undefined && typeof row.label !== "string") return null; - if (row.source !== undefined && typeof row.source !== "string") return null; - const report: ProviderQuotaReportView = { - ...(typeof row.label === "string" ? { label: row.label } : {}), - ...(typeof row.source === "string" ? { source: row.source } : {}), - updatedAt: row.updatedAt, - quota: row.quota, - aggregation: row.aggregation, - }; - return capacityAggregationFromReport(report) ? report : null; -} - -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 readFreshQuotaReportCache(key: string): Record | null { - return freshQuotaReportRecord(readSessionListCache(key)); -} - -function freshQuotaReportsFromResponse(value: unknown, now = Date.now()): Record { - if (!Array.isArray(value)) return {}; - const out: Record = {}; - for (const raw of value) { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; - const provider = (raw as Record).provider; - const report = freshQuotaReport(raw, now); - 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 into workspace readiness so an open - * Providers page cannot keep saying Connected until its next account refresh. - */ -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; -} - export default function ProviderWorkspaceShell({ providers, apiBase, @@ -176,7 +111,6 @@ export default function ProviderWorkspaceShell({ const [selectedModels, setSelectedModels] = useState({}); const [modelsLoading, setModelsLoading] = useState(false); const [modelsLoadFailed, setModelsLoadFailed] = useState(false); - const quotasCacheKey = `ccx.providers.quotas.v1:${apiBase}`; const usageCacheKey = `ccx.providers.usage.v1:${apiBase}`; const [usageTotals, setUsageTotals] = useState>(() => ( readSessionListCache<{ totals: Record }>(usageCacheKey)?.totals ?? {} @@ -184,18 +118,22 @@ export default function ProviderWorkspaceShell({ const [usageModels, setUsageModels] = useState>(() => ( readSessionListCache<{ models: Record }>(usageCacheKey)?.models ?? {} )); - const [quotaReports, setQuotaReports] = useState>(() => ( - readFreshQuotaReportCache(quotasCacheKey) ?? {} - )); - const [quotaAuthAttention, setQuotaAuthAttention] = useState>({}); const [usageLoading, setUsageLoading] = useState(() => !readSessionListCache(usageCacheKey)); - const [quotasLoading, setQuotasLoading] = useState(() => { - const cached = readFreshQuotaReportCache(quotasCacheKey); - return !cached || Object.keys(cached).length === 0; - }); const [modelsLoadEpoch, setModelsLoadEpoch] = useState(0); const filterWrapRef = useRef(null); + // Provider quota data lives in the shared provider-quota store (keyed by apiBase); + // the Dashboard "Plan & quota" section selects the same entry. The workspace keeps + // its strict display filter (capacity-aggregation rows only) on top of the store. + const quota = useProviderQuota(apiBase); + const refreshQuotas = quota.refresh; + const quotaReports = useMemo( + () => freshQuotaReportRecord(quota.reports) ?? {}, + [quota.reports], + ); + const quotaAuthAttention = quota.authAttention; + const quotasLoading = quota.loading && Object.keys(quotaReports).length === 0; + const sections = useMemo(() => { const base = buildProviderWorkspace(publicWorkspaceProviders(providers)); return applyActiveAccountReauth(base, { @@ -284,43 +222,11 @@ export default function ProviderWorkspaceShell({ }, [apiBase, usageCacheKey]); useEffect(() => { - let cancelled = false; - const timeout = window.setTimeout(() => { - const cached = readFreshQuotaReportCache(quotasCacheKey); - if (!cached || Object.keys(cached).length === 0) setQuotasLoading(true); - // A forced bump means a mutation just changed the answer, so the server's TTL has to - // be bypassed. The old derived-key effect always read the cached view, which is why a - // switch could leave the bars showing the previous account's quota. - void fetch(`${apiBase}/api/provider-quotas${quotaForceRefresh ? "?refresh=1" : ""}`) - .then(r => readJsonIfOk<{ - reports?: Array<{ provider: string; label?: string; source?: string; updatedAt?: number; quota?: unknown; aggregation: unknown }>; - availability?: unknown; - }>(r)) - .then((data) => { - if (cancelled || !data) return; - // A successful endpoint response is authoritative, including an empty report list. - const next = freshQuotaReportsFromResponse(data.reports); - setQuotaReports(next); - setQuotaAuthAttention(quotaAuthAttentionFromResponse(data.availability)); - writeSessionListCache(quotasCacheKey, next); - }) - .catch(() => { - if (cancelled) return; - // Keep last-good only inside the same server freshness bound. - setQuotaReports(prev => { - const next = freshQuotaReportRecord(prev) ?? {}; - writeSessionListCache(quotasCacheKey, next); - return next; - }); - }) - .finally(() => { if (!cancelled) setQuotasLoading(false); }); - }, 0); - return () => { - cancelled = true; - window.clearTimeout(timeout); - }; - // Keyed on the explicit revision: account arrival is silent, real mutations re-read. - }, [apiBase, quotaRefreshEpoch, quotaForceRefresh, quotasCacheKey]); + // A forced bump means a mutation just changed the answer, so the server's TTL has to + // be bypassed (?refresh=1). The provider-quota store owns singleflight + persistence; + // this effect just maps the shell's revision semantics onto refresh(). + refreshQuotas({ force: quotaForceRefresh }); + }, [apiBase, quotaRefreshEpoch, quotaForceRefresh, refreshQuotas]); useEffect(() => { if (!filterOpen) return; diff --git a/gui/src/provider-quota-store.ts b/gui/src/provider-quota-store.ts new file mode 100644 index 0000000000..f044fe7057 --- /dev/null +++ b/gui/src/provider-quota-store.ts @@ -0,0 +1,380 @@ +/** + * 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 } 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; + updatedAt?: 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; + 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; + if (!row.quota || typeof row.quota !== "object" || Array.isArray(row.quota)) return null; + if (row.label !== undefined && typeof row.label !== "string") return null; + if (row.source !== undefined && typeof row.source !== "string") return null; + return { + ...(typeof row.label === "string" ? { label: row.label } : {}), + ...(typeof row.source === "string" ? { source: row.source } : {}), + updatedAt: row.updatedAt, + quota: row.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" in row ? row.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; +} + +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: {}, + loading: false, + refreshing: false, + hasSucceeded: false, + lastAttemptOk: false, + seedNeedsRevalidate: false, + }; +} + +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); + set(state => ({ + inflight: { ...state.inflight, [key]: null }, + entries: { + ...state.entries, + [key]: { + reports, + authAttention, + 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; + if (get().inflight[key] && opts?.force !== true) 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, { force: opts?.force, replace: opts?.force === true }); + 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); + + 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: entry?.reports ?? {}, + authAttention: entry?.authAttention ?? {}, + updatedAt: entry?.updatedAt, + error: entry?.error, + loading: entry?.loading ?? false, + refreshing: entry?.refreshing ?? false, + hasSucceeded: entry?.hasSucceeded ?? false, + lastAttemptOk: entry?.lastAttemptOk ?? false, + ensure, + refresh, + }; +} + +/** 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/tests/provider-capacity-shell.test.tsx b/gui/tests/provider-capacity-shell.test.tsx index 8c34855e47..21a767eb9d 100644 --- a/gui/tests/provider-capacity-shell.test.tsx +++ b/gui/tests/provider-capacity-shell.test.tsx @@ -5,6 +5,11 @@ import type { Root } from "react-dom/client"; import ProviderWorkspaceShell 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 +151,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/" }); @@ -248,29 +254,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 +306,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-quota-store.test.ts b/gui/tests/provider-quota-store.test.ts new file mode 100644 index 0000000000..cee8754300 --- /dev/null +++ b/gui/tests/provider-quota-store.test.ts @@ -0,0 +1,179 @@ +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, + rehydrateProviderQuotaForTests, + 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. + 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" }, + })], + 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(); +}); diff --git a/gui/tests/usage-report-store-rehydrate.test.ts b/gui/tests/usage-report-store-rehydrate.test.ts index 871e6c89e0..9c3d03009c 100644 --- a/gui/tests/usage-report-store-rehydrate.test.ts +++ b/gui/tests/usage-report-store-rehydrate.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { afterAll, afterEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; /** @@ -11,6 +11,11 @@ import { Window } from "happy-dom"; * 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 }, @@ -70,6 +75,18 @@ const { 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(); diff --git a/gui/tests/usage-report-store.test.ts b/gui/tests/usage-report-store.test.ts index 600e59dcda..6359107578 100644 --- a/gui/tests/usage-report-store.test.ts +++ b/gui/tests/usage-report-store.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; /** @@ -9,6 +9,11 @@ import { Window } from "happy-dom"; * 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 }, @@ -68,7 +73,16 @@ beforeEach(() => { afterEach(() => { clearUsageReportStoresForTests(); - globalThis.fetch = undefined as unknown as typeof fetch; + 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 { From 7cfe2203675deea02461f3ab790d810f345ef12c Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 03:19:41 -0400 Subject: [PATCH 10/27] feat(gui): dashboard 30d cost + plan & quota section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends UsageSummary30d with estimatedCostUsd/pricedRequests/unpricedRequests/ unmeteredRequests and shows a 30-day estimated cost stat (API list-price equivalent, never billed spend) with a request-coverage line in the dashboard overview head ($0.00 for a defined zero, em-dash when absent). Adds a Plan & quota section fed by the provider-quota store (same apiBase-keyed entry as the Providers workspace shell): per-provider plan, 5h/week/month windows via ProviderCapacityQuota, and observed reference spend vs published caps (referenceWindows) — all labeled provider-reported/estimates. Styling reuses existing dashboard tokens/classes; no new stylesheet. All new strings land in all 6 locales (lint:i18n green). Tests cover the cost stat (estimate, zero, absent), the Plan & quota render from a provider-quotas fixture, and the privacy invariant that the section never persists account identities to sessionStorage. --- gui/src/i18n/de.ts | 9 + gui/src/i18n/en.ts | 9 + gui/src/i18n/ja.ts | 9 + gui/src/i18n/ko.ts | 9 + gui/src/i18n/ru.ts | 9 + gui/src/i18n/zh.ts | 9 + gui/src/pages/dashboard-overview-head.tsx | 20 ++ gui/src/pages/dashboard-overview-panels.tsx | 2 + .../pages/dashboard-plan-quota-section.tsx | 121 +++++++ gui/src/pages/dashboard-shared.ts | 12 +- gui/src/pages/use-dashboard-data.ts | 4 + gui/tests/dashboard-plan-quota.test.tsx | 301 ++++++++++++++++++ 12 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 gui/src/pages/dashboard-plan-quota-section.tsx create mode 100644 gui/tests/dashboard-plan-quota.test.tsx diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index afb8e2f10f..0096b8b182 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -205,6 +205,15 @@ 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.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)", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index a6160312e1..bccc8a4ae6 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -219,6 +219,15 @@ 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", // 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.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0cc3899151..1da7d900a9 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -217,6 +217,15 @@ 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.mem.title": "メモリ可観測性", "dash.mem.hint": "読み取り専用のランタイム診断。観測メモリは max(RSS, external, ArrayBuffers) で、Windows の working set trimming がコミット済み保持を隠さないようにします。", "dash.mem.rss": "常駐メモリ (RSS)", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 294d433e72..68b815a6cf 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -209,6 +209,15 @@ 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.mem.title": "메모리 관찰", "dash.mem.hint": "읽기 전용 런타임 진단. 관측 메모리는 max(RSS, external, ArrayBuffers)라 Windows working set trimming이 커밋된 보존 메모리를 숨기지 못합니다.", "dash.mem.rss": "상주 메모리 (RSS)", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 574a5cdbd9..e71d1fe8d2 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -217,6 +217,15 @@ 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.mem.title": "Наблюдение за памятью", "dash.mem.hint": "Диагностика среды выполнения только для чтения. Наблюдаемая память — max(RSS, external, ArrayBuffers), чтобы trimming рабочего набора Windows не скрывал удержанную память.", "dash.mem.rss": "Резидентная память (RSS)", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index cd3ffd3ab0..6a0223ece2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -209,6 +209,15 @@ 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.mem.title": "内存可观测性", "dash.mem.hint": "只读运行时诊断。观测内存为 max(RSS, external, ArrayBuffers),避免 Windows working set trimming 隐藏已提交的保留内存。", "dash.mem.rss": "常驻内存 (RSS)", diff --git a/gui/src/pages/dashboard-overview-head.tsx b/gui/src/pages/dashboard-overview-head.tsx index 31e85eb1a1..d69bc79b8a 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 } from "../intl-formatters"; import { formatUptime } from "../formatUptime"; import type { useDashboardData } from "./use-dashboard-data"; @@ -81,6 +82,25 @@ export function DashboardOverviewHead({ : "\u00a0"}
    +
    +
    {t("dash.cost30d")}
    +
    + {usage30d && usage30d.summary.estimatedCostUsd !== undefined + ? usage30d.summary.estimatedCostUsd === 0 + ? "$0.00" + : formatEstimatedUsdValue(usage30d.summary.estimatedCostUsd, locale) + : "—"} +
    +
    + {usage30d && usage30d.summary.estimatedCostUsd !== undefined + ? 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..7413560d56 --- /dev/null +++ b/gui/src/pages/dashboard-plan-quota-section.tsx @@ -0,0 +1,121 @@ +/** + * 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 { + 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); + return ( +
    +
    +

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

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

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

    + ) : ( +
    + {entries.map(([provider, report]) => ( +
    +
    + {formatProviderDisplayName(provider, t)} + {report.source?.trim() && ( + {formatQuotaSourceLabel(report.source)} + )} +
    + + +
    + ))} +
    + )} +

    {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/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index d95c75a350..90bc872866 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -227,6 +227,10 @@ export function useDashboardData(apiBase: string) { 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]); diff --git a/gui/tests/dashboard-plan-quota.test.tsx b/gui/tests/dashboard-plan-quota.test.tsx new file mode 100644 index 0000000000..3b888b506a --- /dev/null +++ b/gui/tests/dashboard-plan-quota.test.tsx @@ -0,0 +1,301 @@ +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"); + } 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" }, + }, + ], + 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(); + } +}); From 8ee51cbda92daa2000b8f1ca8d88c0e3d8a3d402 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 03:38:35 -0400 Subject: [PATCH 11/27] fix(gui): de-jargon subagents page subtitle and right-align policy save button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sub.pageSubtitle now reads 'Choose which models sub-agents may use, which model guides them, and the fallback order.' (all 6 locales) — 'advertised' was jargon per the designer audit (C1). - Policy save cell right-aligns the button so it sits at the card edge (17px from the grid edge vs 423px void before; measured at 1380px). - Visual verification (playwright + mock API): labels all at 17px (no 30px staircase), select labels ellipsize with zero spill at 1380/1024/390/360, roster/library IDs fully visible (no mid-ID clip), filter chips wrap with no overflow at 360px. --- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/zh.ts | 2 +- gui/src/styles-subagents-workspace.css | 1 + 7 files changed, 7 insertions(+), 6 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 0096b8b182..dba3f97868 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -577,7 +577,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.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index bccc8a4ae6..da3b619145 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -600,7 +600,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.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 1da7d900a9..69fcf017b7 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -562,7 +562,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": "クイックピックはまだありません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 68b815a6cf..fb1ce6d2b2 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -589,7 +589,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": "아직 빠른 선택 항목이 없습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index e71d1fe8d2..9935872165 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -594,7 +594,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": "Быстрый выбор пока пуст.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 6a0223ece2..d730c4aa06 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -586,7 +586,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": "尚无快捷选择。", diff --git a/gui/src/styles-subagents-workspace.css b/gui/src/styles-subagents-workspace.css index 3c768629a5..660879f879 100644 --- a/gui/src/styles-subagents-workspace.css +++ b/gui/src/styles-subagents-workspace.css @@ -656,6 +656,7 @@ .swi-policy-save-cell { display: flex; align-items: flex-end; + justify-content: flex-end; grid-column: 5; min-height: 0; } From ec89d459cd15f38fe0519b00e4fde7b4b5f37f52 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 03:42:05 -0400 Subject: [PATCH 12/27] docs: document usage 503 contract and dashboard cost/quota features - management-api.md: GET /api/usage now documents the 503 read_failed envelope; missing log file stays 200/zero. - cli/agents.md: ccx usage reports the read failure instead of a zeroed report; missing log still prints empty. - web-dashboard.md: Dashboard summary includes the 30-day estimated cost; the cost/Plan & quota notes clarify provider-reported estimates are never billed spend. --- docs-site/src/content/docs/guides/web-dashboard.md | 11 +++++++---- docs-site/src/content/docs/reference/cli/agents.md | 6 ++++++ .../src/content/docs/reference/management-api.md | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index e40b3cdc1a..b23a490a84 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. | @@ -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 diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 24aa111b04..8d325a8b62 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -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/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 6da492129a..1e0ef8434d 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -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 | From 5e96f9d124c4690f7220e98f47ea34bbce4e8ce1 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 03:53:54 -0400 Subject: [PATCH 13/27] =?UTF-8?q?fix(gui):=20review=20round=20=E2=80=94=20?= =?UTF-8?q?seed=20semantics,=20quota=20projection,=20dashboard=20freshness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the post-review findings: - usage-report-store: a failed quiet revalidation of a rehydrated seed restores seedNeedsRevalidate (next subscriber retries) and seeds read as hasSucceeded (last-known-good, not never-succeeded). refresh() gains replace:false so the Dashboard can quiet-revalidate on a 60s interval (singleflight, pause-when- hidden) without reintroducing a client-resource poll. - usage-report-validation: days/models/providers array elements are now validated (finite numeric fields, string ids) instead of cast wholesale; a malformed element rejects the whole report so nothing unvalidated is cached. - provider-quota-store: quota/aggregation rows are deep-projected onto the known keys at ingest, so stray identity fields can never reach sessionStorage; ensure({force:true}) now always fetches. - ProviderWorkspaceShell: mount uses ensure() (healthy-entry short-circuit, never aborts shared in-flight fetches) with a StrictMode guard; explicit quota revision bumps still use refresh({force}). - logs-usage-routes: the 503 read_failed catch is narrowed to the file-read machinery (revision stat + snapshot read); derivation/cache-layer errors propagate as ordinary server errors. - Dashboard: 30-day cost stat uses a locale-aware zero formatter, hides the coverage line when all request classes are 0; Usage cold-failure Notice keeps localized text and moves raw error detail to the retry button title. Tests: element-level validation unit tests, malformed-element store rejection, HTTP 503 cold-failure page test, hostile identity fields inside quota/aggregation, seed-semantics assertions, and strengthened $0.00 (no tilde) assertions. --- .../ProviderWorkspaceShell.tsx | 21 ++- gui/src/intl-formatters.ts | 14 ++ gui/src/pages/Usage.tsx | 18 +- gui/src/pages/dashboard-overview-head.tsx | 7 +- gui/src/pages/use-dashboard-data.ts | 16 ++ gui/src/provider-quota-store.ts | 165 +++++++++++++++++- gui/src/usage-report-store.ts | 21 ++- gui/src/usage-report-validation.ts | 103 ++++++++++- gui/tests/dashboard-plan-quota.test.tsx | 8 + gui/tests/provider-quota-store.test.ts | 19 +- .../provider-revalidation-policy.test.tsx | 5 + .../usage-report-store-rehydrate.test.ts | 7 + gui/tests/usage-report-store.test.ts | 44 +++++ gui/tests/usage-report-validation.test.ts | 120 +++++++++++++ gui/tests/usage-validation.test.tsx | 20 +++ src/server/management/logs-usage-routes.ts | 58 +++--- 16 files changed, 593 insertions(+), 53 deletions(-) create mode 100644 gui/tests/usage-report-validation.test.ts diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 9acd126252..700a3d76b5 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -121,11 +121,14 @@ export default function ProviderWorkspaceShell({ const [usageLoading, setUsageLoading] = useState(() => !readSessionListCache(usageCacheKey)); const [modelsLoadEpoch, setModelsLoadEpoch] = useState(0); const filterWrapRef = useRef(null); + /** Last quota revision the shell acted on; null until the first effect run. */ + const mountedQuotaRevision = useRef<{ epoch: number; force: boolean } | null>(null); // Provider quota data lives in the shared provider-quota store (keyed by apiBase); // the Dashboard "Plan & quota" section selects the same entry. The workspace keeps // its strict display filter (capacity-aggregation rows only) on top of the store. const quota = useProviderQuota(apiBase); + const ensureQuotas = quota.ensure; const refreshQuotas = quota.refresh; const quotaReports = useMemo( () => freshQuotaReportRecord(quota.reports) ?? {}, @@ -222,11 +225,23 @@ export default function ProviderWorkspaceShell({ }, [apiBase, usageCacheKey]); useEffect(() => { + // Mount: cold fetch / quiet revalidation via ensure(), which short-circuits on a + // healthy entry and never aborts an in-flight request another surface owns. + // StrictMode double-invokes this effect with unchanged props — the ref guard makes + // the second run a no-op so it does not turn into a replace-refresh. + const revisionRef = { epoch: quotaRefreshEpoch, force: quotaForceRefresh }; + if (mountedQuotaRevision.current === null) { + mountedQuotaRevision.current = revisionRef; + ensureQuotas({ force: quotaForceRefresh }); + return; + } + const previous = mountedQuotaRevision.current; + if (previous.epoch === quotaRefreshEpoch && previous.force === quotaForceRefresh) return; // A forced bump means a mutation just changed the answer, so the server's TTL has to - // be bypassed (?refresh=1). The provider-quota store owns singleflight + persistence; - // this effect just maps the shell's revision semantics onto refresh(). + // be bypassed (?refresh=1). Explicit revision changes always re-read. + mountedQuotaRevision.current = revisionRef; refreshQuotas({ force: quotaForceRefresh }); - }, [apiBase, quotaRefreshEpoch, quotaForceRefresh, refreshQuotas]); + }, [apiBase, quotaRefreshEpoch, quotaForceRefresh, ensureQuotas, refreshQuotas]); useEffect(() => { if (!filterOpen) return; 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/Usage.tsx b/gui/src/pages/Usage.tsx index c7356b35e5..f0b2a538a6 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -2,7 +2,7 @@ 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 { formatEstimatedUsdValue as formatUsdEstimate, formatEstimatedUsdZero } from "../intl-formatters"; import { EmptyState, Notice } from "../ui"; import { modelLabel } from "../model-display"; import { classifyDataSurface } from "../data-surface"; @@ -226,8 +226,9 @@ function UsageSummaryCards({
    {t("usage.card.activeDays")}
    {activeDays}
    {summary.estimatedCostUsd === undefined ? ( - // Legacy server without the cost fields: the DTO now requires them, but a - // pre-validation session-cache seed may still carry the old shape. + // "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")} @@ -237,7 +238,7 @@ function UsageSummaryCards({ {t("usage.cost.total")} {summary.estimatedCostUsd === 0 - ? "$0.00" + ? formatEstimatedUsdZero(locale) : formatUsdEstimate(summary.estimatedCostUsd, locale)} {t("usage.cost.disclaimer")} @@ -717,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-overview-head.tsx b/gui/src/pages/dashboard-overview-head.tsx index d69bc79b8a..9fa0f5b314 100644 --- a/gui/src/pages/dashboard-overview-head.tsx +++ b/gui/src/pages/dashboard-overview-head.tsx @@ -1,7 +1,7 @@ import { IconAlert, IconInfo } from "../icons"; import { type TKey, useT } from "../i18n/shared"; import { formatTokens } from "../format-tokens"; -import { formatEstimatedUsdValue } from "../intl-formatters"; +import { formatEstimatedUsdValue, formatEstimatedUsdZero } from "../intl-formatters"; import { formatUptime } from "../formatUptime"; import type { useDashboardData } from "./use-dashboard-data"; @@ -87,12 +87,15 @@ export function DashboardOverviewHead({
    {usage30d && usage30d.summary.estimatedCostUsd !== undefined ? usage30d.summary.estimatedCostUsd === 0 - ? "$0.00" + ? 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, diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index 90bc872866..941b4fd261 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -235,6 +235,22 @@ export function useDashboardData(apiBase: string) { }; }, [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}`, [apiBase], diff --git a/gui/src/provider-quota-store.ts b/gui/src/provider-quota-store.ts index f044fe7057..5566c4c303 100644 --- a/gui/src/provider-quota-store.ts +++ b/gui/src/provider-quota-store.ts @@ -78,18 +78,20 @@ function quotaReportFromRow(value: unknown, now: number): ProviderQuotaReportVie 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; - if (!row.quota || typeof row.quota !== "object" || Array.isArray(row.quota)) 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: row.quota, + 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" in row ? row.aggregation : undefined, + aggregation: aggregation ?? undefined, }; } @@ -179,6 +181,153 @@ function emptyEntry(): ProviderQuotaEntry { }; } +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, @@ -263,12 +412,18 @@ export const useProviderQuotaStore = create()( inflight: {}, ensure: (apiBase, opts) => { const key = apiBase; - if (get().inflight[key] && opts?.force !== true) return; + // 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, { force: opts?.force, replace: opts?.force === true }); + fetchQuotas(set, get, apiBase, {}); return; } // Healthy cached data — nothing to do. diff --git a/gui/src/usage-report-store.ts b/gui/src/usage-report-store.ts index 0576fe4fa8..9de210c2f3 100644 --- a/gui/src/usage-report-store.ts +++ b/gui/src/usage-report-store.ts @@ -31,7 +31,7 @@ export type UsageReportResource = { refreshing: boolean; hasSucceeded: boolean; lastAttemptOk: boolean; - refresh: (opts?: { forceLoading?: boolean }) => void; + refresh: (opts?: { forceLoading?: boolean; replace?: boolean }) => void; }; export interface UsageReportEntry { @@ -62,7 +62,7 @@ interface UsageReportStoreState { apiBase: string, range: UsageRange, surface: UsageSurface, - opts?: { forceLoading?: boolean }, + opts?: { forceLoading?: boolean; replace?: boolean }, ) => void; clearForTests: () => void; } @@ -126,6 +126,10 @@ function fetchReport( 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 => { @@ -181,6 +185,10 @@ function fetchReport( 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, }, }, })); @@ -213,7 +221,7 @@ export const useUsageReportStore = create()( // Healthy cached data — nothing to do. }, refresh: (key, apiBase, range, surface, opts) => { - fetchReport(set, get, key, apiBase, range, surface, { ...opts, replace: true }); + fetchReport(set, get, key, apiBase, range, surface, { ...opts, replace: opts?.replace !== false }); }, clearForTests: () => { for (const controller of Object.values(get().inflight)) controller?.abort(); @@ -244,6 +252,9 @@ export const useUsageReportStore = create()( 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, }; } } @@ -273,7 +284,7 @@ export function useUsageReport( }, [key, apiBase, range, surface, ensure]); const refresh = useCallback( - (opts?: { forceLoading?: boolean }) => { + (opts?: { forceLoading?: boolean; replace?: boolean }) => { refreshAction(key, apiBase, range, surface, opts); }, [key, apiBase, range, surface, refreshAction], @@ -301,7 +312,7 @@ export function seedUsageReportForTests(key: string, data: UsageReport, persiste useUsageReportStore.setState(state => ({ entries: { ...state.entries, - [key]: { ...emptyEntry(), data, persistedAt, seedNeedsRevalidate: true }, + [key]: { ...emptyEntry(), data, persistedAt, seedNeedsRevalidate: true, hasSucceeded: true }, }, })); } diff --git a/gui/src/usage-report-validation.ts b/gui/src/usage-report-validation.ts index d4b50c15f6..89876aef0a 100644 --- a/gui/src/usage-report-validation.ts +++ b/gui/src/usage-report-validation.ts @@ -141,6 +141,100 @@ function parseSummary(value: unknown): UsageSummaryTotals { }; } +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 @@ -162,9 +256,6 @@ export function parseUsageReport(body: unknown): UsageReport { if (surface !== "all" && surface !== "codex" && surface !== "claude" && surface !== "grok") { throw new UsageReportValidationError("usage report surface is missing or invalid"); } - if (!Array.isArray(body.days) || !Array.isArray(body.models) || !Array.isArray(body.providers)) { - throw new UsageReportValidationError("usage report days/models/providers must be arrays"); - } if (!isFiniteNumber(body.generatedAt)) { throw new UsageReportValidationError("usage report generatedAt is missing or not a finite number"); } @@ -174,9 +265,9 @@ export function parseUsageReport(body: unknown): UsageReport { since: isFiniteNumber(body.since) ? body.since : null, generatedAt: body.generatedAt, summary: parseSummary(body.summary), - days: body.days as UsageDay[], - models: body.models as UsageModel[], - providers: body.providers as UsageProvider[], + 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, diff --git a/gui/tests/dashboard-plan-quota.test.tsx b/gui/tests/dashboard-plan-quota.test.tsx index 3b888b506a..0b959fe81c 100644 --- a/gui/tests/dashboard-plan-quota.test.tsx +++ b/gui/tests/dashboard-plan-quota.test.tsx @@ -170,6 +170,8 @@ test("Dashboard cost stat renders $0.00 for a defined zero and — when absent", }); 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(); @@ -271,6 +273,12 @@ test("Plan & quota section never persists account identities to sessionStorage", // 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: [], diff --git a/gui/tests/provider-quota-store.test.ts b/gui/tests/provider-quota-store.test.ts index cee8754300..503bfbb164 100644 --- a/gui/tests/provider-quota-store.test.ts +++ b/gui/tests/provider-quota-store.test.ts @@ -120,12 +120,29 @@ test("singleflight dedupes concurrent subscribers into one fetch", async () => { 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. + // 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. 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/usage-report-store-rehydrate.test.ts b/gui/tests/usage-report-store-rehydrate.test.ts index 9c3d03009c..6648f08849 100644 --- a/gui/tests/usage-report-store-rehydrate.test.ts +++ b/gui/tests/usage-report-store-rehydrate.test.ts @@ -144,10 +144,17 @@ test("a failed refresh keeps the seeded last-good data", async () => { 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 index 6359107578..37a98f3480 100644 --- a/gui/tests/usage-report-store.test.ts +++ b/gui/tests/usage-report-store.test.ts @@ -162,3 +162,47 @@ test("persists only validated successful reports with a timestamp", async () => 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 index d2feccd55c..c9caad8ab2 100644 --- a/gui/tests/usage-validation.test.tsx +++ b/gui/tests/usage-validation.test.tsx @@ -130,6 +130,8 @@ test("cost row renders $0.00 for a defined zero", async () => { 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(); }); @@ -190,6 +192,24 @@ test("401 cold failure shows the failed-cold Notice with retry", async () => { } }); +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 }) }); diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 878ffb8fce..345e9cf207 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -44,6 +44,7 @@ import { discardUsageSummaryCacheEntry, getUsageSummaryCacheEntry, setUsageSummaryCacheEntry, + type CachedUsageSummary, } from "./usage-summary-cache"; const USAGE_DAY_MS = 86_400_000; @@ -150,39 +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 { - // Genuine read/stat/schema failures are errors, not empty data. A missing - // log file never reaches this branch (readUsageSnapshotForManagement - // returns a zeroed snapshot for a missing file); it only throws on real - // failures such as the path being a directory, the file changing mid-read, - // or parse/stat errors. 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") { From bedacd72bb6d967ed0c17333c23e04f58dfb2f56 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 09:35:00 -0400 Subject: [PATCH 14/27] a11y(gui): tooltips on ellipsized integration values + integrations token swap - OpenCode config-destination now carries title={full path}; it ellipsizes inside .integration-facts (verified truncated=true with title set via mock-API harness). Grok model names already had titles (no-op). - styles-integrations.css: min-height 34px -> var(--control-md); off-scale gaps 18/10/7px and 14px paddings -> nearest space tokens (16/8/6/12px); the 6px/28px/4px chip values were already exact tokens. --- .../pages/integrations/OpenCodeIntegrationPage.tsx | 2 +- gui/src/styles-integrations.css | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) 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/styles-integrations.css b/gui/src/styles-integrations.css index 6fc1d8762c..5a49c0deea 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,7 +134,7 @@ .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 { @@ -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; } From 629630e7ac8d883033a6ed4fbbedd64cc7b1e3b6 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 09:35:07 -0400 Subject: [PATCH 15/27] style(gui): token-consistency sweep for off-scale spacing values Swap hardcoded paddings/gaps/margins to the nearest design token where the delta is <=2px and imperceptible (18->space-4, 14->space-3, 10->space-2, 9->space-2, 7->space-1-5, 3->space-1, 31->space-8, 6->space-1-5, 34->control-md, 11px->text-caption). Font-size 10.5px, line-height 1.45, font-weight 550, and the select/trigger pill box-shadow 0 2px 8px are deliberate tuning -> marked /* deliberate */ instead of swapped. Verified visually at 1380/390 via the mock-API harness; full GUI suite 791/0 green. --- gui/src/styles-apikeys-workspace.css | 4 +-- gui/src/styles-claudecode-workspace.css | 8 ++--- gui/src/styles-combos-workspace.css | 10 +++--- gui/src/styles.css | 45 +++++++++++++------------ 4 files changed, 34 insertions(+), 33 deletions(-) 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.css b/gui/src/styles.css index ab138aff82..8f4cf1cb17 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); @@ -1020,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; @@ -1084,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); } @@ -1092,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 { @@ -1668,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; } @@ -1716,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); } @@ -1728,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; } @@ -1889,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); } @@ -2262,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; } @@ -2313,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. */ @@ -2341,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: caption sub-line below --text-micro */ 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; @@ -2352,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); @@ -2369,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; } @@ -2396,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); } From 35d03f32409e7c030ad487c5fafa5e7341178668 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 09:36:36 -0400 Subject: [PATCH 16/27] docs: capitalize V1/V2 collaboration-protocol prose and align terminology Sweep the 8 guide/reference files: lowercase v1/v2 in prose that refers to the collaboration protocol becomes V1/V2 (code literals, config values, CLI syntax, /api/v2 paths, plaintext/encrypted options, multi_agent_v2 flag stay untouched). Align wording with the GUI labels: 'collaboration protocol' for the three-state selector (was 'surface mode'), V2 thread limit, and keep Configured Roster / Agent Library / quick picks / catalog nouns consistent. 503 and Est. cost/Plan & quota prose reviewed in context. Docs build: 46 pages. --- .../content/docs/guides/sub-agent-surface.md | 12 ++++----- .../src/content/docs/guides/web-dashboard.md | 4 +-- .../src/content/docs/reference/cli/agents.md | 10 +++---- .../docs/reference/configuration/agents.md | 26 +++++++++---------- .../docs/reference/configuration/routing.md | 2 +- .../content/docs/reference/management-api.md | 2 +- .../content/docs/reference/proxy-formats.md | 6 ++--- 7 files changed, 31 insertions(+), 31 deletions(-) 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 30ac64370b..984395cb0e 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -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. | +| **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. | +| **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. ::: @@ -39,7 +39,7 @@ to disk. That is why a mode change affects newly created App, CLI, and TUI sessi ### 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: @@ -153,7 +153,7 @@ switching an active conversation in place. ### GUI -- **Dashboard** → first stat cell: choose **v1**, **base**, or **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`. @@ -245,7 +245,7 @@ a positive partial count before passing a model or effort override. ### 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? diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index b23a490a84..2fbbac0421 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -159,7 +159,7 @@ they have been synchronized. See [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. @@ -235,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 8d325a8b62..36830288f4 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,16 +19,16 @@ 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. | +| `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 diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 03eb2b7752..506ec09a85 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. | +| `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 1e0ef8434d..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 | 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. From 1590986d5aaea6f7e70d432dd661a07ff9126446 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 09:43:07 -0400 Subject: [PATCH 17/27] =?UTF-8?q?fix(gui,docs):=20review=20round=20?= =?UTF-8?q?=E2=80=94=20comment=20accuracy,=20doc=20V1/V2=20consistency,=20?= =?UTF-8?q?chip=20tokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - styles.css: grok-model-names 10.5px comment corrected (value sits between --text-micro and --text-caption, not below micro). - styles-integrations.css: client-apps-flow-chip padding 4px 9px + gap 6px + min-height 28px now use tokens (space-1/space-2/space-1-5/control-sm). - sub-agent-surface.md: v1/v2 mode bullets -> V1/V2 (prose labels). - cli/agents.md: active V1/V2 thread limit row. - configuration/agents.md: V1/V2 developer guidance row. - Deferred (ledger): out-of-scope pages codex-app-models.md / how-it-works.mdx terminology, and remaining off-grid values outside the story's enumerated sweep list. --- docs-site/src/content/docs/guides/sub-agent-surface.md | 4 ++-- docs-site/src/content/docs/reference/cli/agents.md | 2 +- .../src/content/docs/reference/configuration/agents.md | 2 +- gui/src/styles-integrations.css | 6 +++--- gui/src/styles.css | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) 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 984395cb0e..8593a33c40 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -30,9 +30,9 @@ 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. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 36830288f4..220adf013f 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -29,7 +29,7 @@ Manage the Codex `multi_agent_v2` feature flag and the three-state collaboration | `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. | +| `threads ` | Set the active V1/V2 thread limit to an integer of at least 1. | ```bash ccx v2 status diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index 506ec09a85..a2db6c72fb 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -16,7 +16,7 @@ routes, and limits delegated work. | `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. | +| `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. | diff --git a/gui/src/styles-integrations.css b/gui/src/styles-integrations.css index 5a49c0deea..984d6016f2 100644 --- a/gui/src/styles-integrations.css +++ b/gui/src/styles-integrations.css @@ -140,9 +140,9 @@ .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); diff --git a/gui/src/styles.css b/gui/src/styles.css index 8f4cf1cb17..9b74f009e8 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2342,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; /* deliberate: caption sub-line below --text-micro */ 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; From 2e2a1976ee5b2bcc859ec659c0e0a5ebb68c8f62 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 10:06:57 -0400 Subject: [PATCH 18/27] feat(gui): surface quota-unavailable providers in dashboard and provider overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parity with the Mac app: /api/provider-quotas availability rows are now projected into the shared store (in-memory only — the persisted slice stays reports + timestamp) and rendered where a provider has no report: - Store: quotaAvailabilityFromResponse (provider/status/reason/checkedAt only; unknown reason codes dropped) + unavailableQuotaProviders selector (status !== available AND no report, sorted). Refresh populates; failed fetch retains last-known-good. authAttention flow untouched. - Dashboard: full-width 'Quota unavailable' strip below the Plan & quota grid listing '{provider} — {reason copy}' with a shared Retry (refresh(force) -> ?refresh=1); hidden when nothing is unavailable. - Provider Overview: quota slot shows a compact warn notice with reason copy + Retry when the selected provider has no report and is unavailable; quota card unchanged when a report exists. Threaded shell -> Providers -> ProviderDetails -> Overview. - Copy matches the Mac app summary: reauth_required -> Sign in required, local_cli_refresh_required -> Login needs refresh, else Temporarily unavailable. 7 new keys in all 6 locales (lint:i18n green). - Tests: store projection/selector/privacy/failure-retention, dashboard strip render + Retry-disappears + hidden cases, overview notice + Retry. Full GUI suite 804 pass / 0 fail; visual check via mock harness at 1380/390 (strip full-width, grid unchanged). --- .../provider-workspace/ProviderDetails.tsx | 6 + .../provider-workspace/ProviderOverview.tsx | 25 ++- .../ProviderWorkspaceShell.tsx | 8 + gui/src/i18n/de.ts | 7 + gui/src/i18n/en.ts | 7 + gui/src/i18n/ja.ts | 7 + gui/src/i18n/ko.ts | 7 + gui/src/i18n/ru.ts | 7 + gui/src/i18n/zh.ts | 7 + gui/src/pages/Providers.tsx | 2 + .../pages/dashboard-plan-quota-section.tsx | 17 ++ gui/src/provider-quota-store.ts | 77 +++++++++- gui/src/quota-unavailable.ts | 12 ++ gui/src/styles-dashboard-workspace.css | 22 +++ gui/tests/dashboard-plan-quota.test.tsx | 143 +++++++++++++++++ ...ovider-overview-quota-unavailable.test.tsx | 145 ++++++++++++++++++ gui/tests/provider-quota-store.test.ts | 92 +++++++++++ 17 files changed, 586 insertions(+), 5 deletions(-) create mode 100644 gui/src/quota-unavailable.ts create mode 100644 gui/tests/provider-overview-quota-unavailable.test.tsx diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index 66c230d5f5..46aeaccf85 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -31,6 +31,8 @@ export default function ProviderDetails({ usageTotals, modelUsage, quotaReport, + quotaUnavailableReason, + onRetryQuota, availableModels, hasLiveModels, selectedModels, @@ -62,6 +64,8 @@ export default function ProviderDetails({ usageTotals?: ProviderUsageTotals; modelUsage?: ProviderModelUsageRow[]; quotaReport?: ProviderQuotaReportView; + quotaUnavailableReason?: string; + onRetryQuota?: () => void; availableModels: string[]; /** Server-reported live-catalog provenance; see filterModels(). */ hasLiveModels: boolean; @@ -257,6 +261,8 @@ export default function ProviderDetails({ connectionIdentity={connectionIdentity} usageTotals={usageTotals} quotaReport={quotaReport} + quotaUnavailableReason={quotaUnavailableReason} + onRetryQuota={onRetryQuota} oauthEmail={oauthEmail} oauth={oauth} onEditSettings={() => switchTab("settings")} diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index b7a048a6a4..e83bff5b1b 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,7 @@ type ConnectionTestState = { }; export default function ProviderOverview({ - item, usageTotals, quotaReport, oauthEmail, oauth, + item, usageTotals, quotaReport, quotaUnavailableReason, onRetryQuota, oauthEmail, oauth, apiBase, connectionIdentity, onEditSettings, onViewUsage, onUpdateProvider, onReauthenticate, onCancelLogin, reauthBusy = false, @@ -38,6 +39,9 @@ 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; oauthEmail?: string; /** Login state for OAuth summaries that carry no email (e.g. Cursor/Kimi). */ oauth?: { loggedIn?: boolean }; @@ -201,12 +205,27 @@ export default function ProviderOverview({ )} - {quotaReport && ( + {quotaReport ? (

    {t("pws.rateLimits")}

    - )} + ) : quotaUnavailableReason ? ( +
    +

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

    +
    +
    +
    + ) : null}

    {t("pws.authSummary")}

    diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 700a3d76b5..7e2aa3d0df 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -37,6 +37,9 @@ export interface DetailSlotData { usageTotals?: import("./types").ProviderUsageTotals; modelUsage?: ProviderModelUsageRow[]; quotaReport?: ProviderQuotaReportView; + /** Set only when the selected provider's quota is unavailable and has no report. */ + quotaUnavailableReason?: string; + onRetryQuota?: () => void; availableModels: string[]; /** Did the last successful discovery return rows? Server-reported, never inferred. */ hasLiveModels: boolean; @@ -136,6 +139,9 @@ export default function ProviderWorkspaceShell({ ); const quotaAuthAttention = quota.authAttention; const quotasLoading = quota.loading && Object.keys(quotaReports).length === 0; + const quotaUnavailableReason = quota.unavailableProviders.find( + entry => entry.provider === selectedName, + )?.reason; const sections = useMemo(() => { const base = buildProviderWorkspace(publicWorkspaceProviders(providers)); @@ -526,6 +532,8 @@ export default function ProviderWorkspaceShell({ usageTotals: usageTotals[selectedItem.name], modelUsage: usageModels[selectedItem.name], quotaReport: quotaReports[selectedItem.name], + quotaUnavailableReason, + onRetryQuota: () => refreshQuotas({ force: true }), availableModels: availableModels[selectedItem.name] ?? [], hasLiveModels: (liveModelCounts[selectedItem.name] ?? 0) > 0, selectedModels: selectedModels[selectedItem.name] ?? [], diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index dba3f97868..638fb31b79 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -214,6 +214,8 @@ export const de: Record = { "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.retry": "Erneut versuchen", "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)", @@ -1767,6 +1769,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 da3b619145..09d5db1d3f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -228,6 +228,8 @@ export const en = { "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.retry": "Retry", // 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.", @@ -1223,6 +1225,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 69fcf017b7..c52040ba3f 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -226,6 +226,8 @@ export const ja: Record = { "dash.planQuota.empty": "プロバイダー割り当てデータはまだありません。", "dash.planQuota.disclaimer": "プロバイダー報告の上限とローカル推定値 — 請求書ではありません。", "dash.planQuota.referenceIntro": "公開上限とこのプロキシ経由で観測されたトラフィックの比較", + "dash.planQuota.unavailable": "クォータを利用できません", + "dash.planQuota.retry": "再試行", "dash.mem.title": "メモリ可観測性", "dash.mem.hint": "読み取り専用のランタイム診断。観測メモリは max(RSS, external, ArrayBuffers) で、Windows の working set trimming がコミット済み保持を隠さないようにします。", "dash.mem.rss": "常駐メモリ (RSS)", @@ -1174,6 +1176,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 fb1ce6d2b2..bf1220c03a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -218,6 +218,8 @@ export const ko: Record = { "dash.planQuota.empty": "아직 프로바이더 할당량 데이터가 없습니다.", "dash.planQuota.disclaimer": "프로바이더 보고 상한과 로컬 추정치 — 청구서가 아닙니다.", "dash.planQuota.referenceIntro": "게시된 상한과 이 프록시를 통해 관측된 트래픽 비교", + "dash.planQuota.unavailable": "할당량을 사용할 수 없음", + "dash.planQuota.retry": "다시 시도", "dash.mem.title": "메모리 관찰", "dash.mem.hint": "읽기 전용 런타임 진단. 관측 메모리는 max(RSS, external, ArrayBuffers)라 Windows working set trimming이 커밋된 보존 메모리를 숨기지 못합니다.", "dash.mem.rss": "상주 메모리 (RSS)", @@ -1789,6 +1791,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 9935872165..5b734c6151 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -226,6 +226,8 @@ export const ru: Record = { "dash.planQuota.empty": "Данных о квоте провайдера пока нет.", "dash.planQuota.disclaimer": "Лимиты от провайдера и локальные оценки — не платёжный документ.", "dash.planQuota.referenceIntro": "Опубликованные лимиты и трафик, наблюдаемый через этот прокси", + "dash.planQuota.unavailable": "Квота недоступна", + "dash.planQuota.retry": "Повторить", "dash.mem.title": "Наблюдение за памятью", "dash.mem.hint": "Диагностика среды выполнения только для чтения. Наблюдаемая память — max(RSS, external, ArrayBuffers), чтобы trimming рабочего набора Windows не скрывал удержанную память.", "dash.mem.rss": "Резидентная память (RSS)", @@ -1216,6 +1218,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 d730c4aa06..122bb645c1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -218,6 +218,8 @@ export const zh: Record = { "dash.planQuota.empty": "暂无服务商配额数据。", "dash.planQuota.disclaimer": "服务商报告的上限与本地估算 — 并非账单。", "dash.planQuota.referenceIntro": "公布上限与本代理观察到的流量对比", + "dash.planQuota.unavailable": "配额不可用", + "dash.planQuota.retry": "重试", "dash.mem.title": "内存可观测性", "dash.mem.hint": "只读运行时诊断。观测内存为 max(RSS, external, ArrayBuffers),避免 Windows working set trimming 隐藏已提交的保留内存。", "dash.mem.rss": "常驻内存 (RSS)", @@ -1786,6 +1788,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/pages/Providers.tsx b/gui/src/pages/Providers.tsx index b7f179d7bc..f457f90e75 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -403,6 +403,8 @@ export default function Providers({ apiBase }: { apiBase: string }) { usageTotals={data.usageTotals} modelUsage={data.modelUsage} quotaReport={data.quotaReport} + quotaUnavailableReason={data.quotaUnavailableReason} + onRetryQuota={data.onRetryQuota} availableModels={data.availableModels} hasLiveModels={data.hasLiveModels} selectedModels={data.selectedModels} diff --git a/gui/src/pages/dashboard-plan-quota-section.tsx b/gui/src/pages/dashboard-plan-quota-section.tsx index 7413560d56..4d593bdad8 100644 --- a/gui/src/pages/dashboard-plan-quota-section.tsx +++ b/gui/src/pages/dashboard-plan-quota-section.tsx @@ -16,6 +16,7 @@ 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 { formatQuotaSourceLabel, referenceQuotaFromReport, @@ -89,6 +90,7 @@ export function DashboardPlanQuotaSection({ apiBase }: { apiBase: string }) { }, [ensure]); const entries = Object.entries(quota.reports); + const unavailable = quota.unavailableProviders; return (
    @@ -115,6 +117,21 @@ export function DashboardPlanQuotaSection({ apiBase }: { apiBase: string }) { ))}
    )} + {unavailable.length > 0 && ( +
    + {t("dash.planQuota.unavailable")} + {unavailable.map(({ provider, reason }) => ( + + {formatProviderDisplayName(provider, t)} + {" — "} + {t(quotaUnavailableReasonKey(reason))} + + ))} + +
    + )}

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

    ); diff --git a/gui/src/provider-quota-store.ts b/gui/src/provider-quota-store.ts index 5566c4c303..4eed9c65e7 100644 --- a/gui/src/provider-quota-store.ts +++ b/gui/src/provider-quota-store.ts @@ -15,7 +15,7 @@ import { create } from "zustand"; import { persist, type PersistStorage, type StorageValue } from "zustand/middleware"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { capacityAggregationFromReport, type ProviderQuotaReportView, @@ -28,9 +28,21 @@ 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; @@ -48,6 +60,8 @@ export type ProviderQuotaResource = ProviderQuotaData & { 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; }; @@ -143,6 +157,54 @@ 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 (typeof row.status !== "string" || !row.status.trim()) 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 { @@ -173,6 +235,7 @@ function emptyEntry(): ProviderQuotaEntry { return { reports: {}, authAttention: {}, + availability: {}, loading: false, refreshing: false, hasSucceeded: false, @@ -369,6 +432,7 @@ function fetchQuotas( 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: { @@ -376,6 +440,7 @@ function fetchQuotas( [key]: { reports, authAttention, + availability, updatedAt: Date.now(), error: undefined, loading: false, @@ -481,6 +546,12 @@ export function useProviderQuota(apiBase: string): ProviderQuotaResource { const entry = useProviderQuotaStore(state => state.entries[key]); const ensureAction = useProviderQuotaStore(state => state.ensure); const refreshAction = useProviderQuotaStore(state => state.refresh); + const availability = entry?.availability ?? {}; + const reports = entry?.reports ?? {}; + const unavailableProviders = useMemo( + () => unavailableQuotaProviders(availability, reports), + [availability, reports], + ); const ensure = useCallback( (opts?: { force?: boolean }) => ensureAction(key, opts), @@ -493,8 +564,10 @@ export function useProviderQuota(apiBase: string): ProviderQuotaResource { return { key, - reports: entry?.reports ?? {}, + reports, authAttention: entry?.authAttention ?? {}, + availability, + unavailableProviders, updatedAt: entry?.updatedAt, error: entry?.error, loading: entry?.loading ?? false, 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-dashboard-workspace.css b/gui/src/styles-dashboard-workspace.css index a7a990d320..141b594e25 100644 --- a/gui/src/styles-dashboard-workspace.css +++ b/gui/src/styles-dashboard-workspace.css @@ -97,6 +97,28 @@ 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 + a shared Retry. */ +.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 { + color: var(--muted); +} + .dash-sidecar-card__row .font-semibold { min-width: 0; } diff --git a/gui/tests/dashboard-plan-quota.test.tsx b/gui/tests/dashboard-plan-quota.test.tsx index 0b959fe81c..e7e9f52acf 100644 --- a/gui/tests/dashboard-plan-quota.test.tsx +++ b/gui/tests/dashboard-plan-quota.test.tsx @@ -307,3 +307,146 @@ test("Plan & quota section never persists account identities to sessionStorage", 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 an unavailable provider", async () => { + globalThis.fetch = (async () => + 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 text = strip?.textContent ?? ""; + expect(text).toContain("xAI Grok"); + expect(text).toContain("Temporarily unavailable"); + expect(text).toContain("Retry"); + } 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"); + 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/provider-overview-quota-unavailable.test.tsx b/gui/tests/provider-overview-quota-unavailable.test.tsx new file mode 100644 index 0000000000..3d5f37d5c9 --- /dev/null +++ b/gui/tests/provider-overview-quota-unavailable.test.tsx @@ -0,0 +1,145 @@ +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; +}): 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"); + } finally { + await act(async () => { root.unmount(); }); + container.remove(); + } +}); + +test("reauth reasons map to their dedicated copy", async () => { + const { root, container } = await mountOverview({ quotaUnavailableReason: "reauth_required" }); + try { + expect(container.textContent ?? "").toContain("Sign in required"); + } 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("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 index 503bfbb164..97631f4b14 100644 --- a/gui/tests/provider-quota-store.test.ts +++ b/gui/tests/provider-quota-store.test.ts @@ -27,7 +27,9 @@ Object.defineProperties(globalThis, { const { clearProviderQuotaStoresForTests, PROVIDER_QUOTA_STORAGE_NAME, + quotaAvailabilityFromResponse, rehydrateProviderQuotaForTests, + unavailableQuotaProviders, useProviderQuotaStore, } = await import("../src/provider-quota-store"); @@ -194,3 +196,93 @@ test("stale rehydrated seeds are rejected at rehydrate", async () => { // 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: "" }, + 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 }, + }); +}); + +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 }, + }; + const reports = { openai: {}, anthropic: {} }; + expect(unavailableQuotaProviders(availability, reports)).toEqual([ + { 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"); +}); From c34d72d684ab90daa52fd6ae05f599d35dfe0110 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 10:14:41 -0400 Subject: [PATCH 19/27] =?UTF-8?q?fix(gui):=20review=20round=20=E2=80=94=20?= =?UTF-8?q?stable=20store=20fallbacks,=20status=20allowlist,=20reason-copy?= =?UTF-8?q?=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useProviderQuota uses module-level EMPTY availability/reports so derived memos don't recompute while the entry is absent (restores zero lint warnings; was 2 react-hooks/exhaustive-deps). - quotaAvailabilityFromResponse allowlists status to available/stale/ unavailable; hostile status strings are dropped at ingest. - Tests: selector covers stale-without-report inclusion; projection covers hostile status; dashboard strip covers the reauth_required reason copy (Sign in required) next to upstream_unavailable. - Full GUI suite 804 pass / 0 fail; typecheck + lint clean. --- gui/src/provider-quota-store.ts | 13 ++++++++++--- gui/tests/dashboard-plan-quota.test.tsx | 4 ++++ gui/tests/provider-quota-store.test.ts | 7 +++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/gui/src/provider-quota-store.ts b/gui/src/provider-quota-store.ts index 4eed9c65e7..5bb108e845 100644 --- a/gui/src/provider-quota-store.ts +++ b/gui/src/provider-quota-store.ts @@ -163,6 +163,8 @@ const KNOWN_QUOTA_UNAVAILABLE_REASONS = new Set([ "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 — @@ -176,7 +178,7 @@ export function quotaAvailabilityFromResponse(value: unknown): Record; if (typeof row.provider !== "string" || !row.provider.trim()) continue; - if (typeof row.status !== "string" || !row.status.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) @@ -546,8 +548,10 @@ export function useProviderQuota(apiBase: string): ProviderQuotaResource { const entry = useProviderQuotaStore(state => state.entries[key]); const ensureAction = useProviderQuotaStore(state => state.ensure); const refreshAction = useProviderQuotaStore(state => state.refresh); - const availability = entry?.availability ?? {}; - const reports = entry?.reports ?? {}; + // 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], @@ -579,6 +583,9 @@ export function useProviderQuota(apiBase: string): ProviderQuotaResource { }; } +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(); diff --git a/gui/tests/dashboard-plan-quota.test.tsx b/gui/tests/dashboard-plan-quota.test.tsx index e7e9f52acf..a92fe6b517 100644 --- a/gui/tests/dashboard-plan-quota.test.tsx +++ b/gui/tests/dashboard-plan-quota.test.tsx @@ -325,6 +325,7 @@ test("Plan & quota renders a full-width strip for an unavailable provider", asyn reports: [openaiReport()], availability: [ { provider: "xai", status: "unavailable", reason: "upstream_unavailable", checkedAt: NOW }, + { provider: "anthropic", status: "unavailable", reason: "reauth_required", checkedAt: NOW }, ], }), { status: 200, headers: { "Content-Type": "application/json" } })) as typeof fetch; @@ -346,6 +347,9 @@ test("Plan & quota renders a full-width strip for an unavailable provider", asyn const text = strip?.textContent ?? ""; expect(text).toContain("xAI Grok"); expect(text).toContain("Temporarily unavailable"); + // Reason mapping matches the Mac app summary for reauth too. + expect(text).toContain("Anthropic"); + expect(text).toContain("Sign in required"); expect(text).toContain("Retry"); } finally { await act(async () => { root.unmount(); }); diff --git a/gui/tests/provider-quota-store.test.ts b/gui/tests/provider-quota-store.test.ts index 97631f4b14..29c43b1228 100644 --- a/gui/tests/provider-quota-store.test.ts +++ b/gui/tests/provider-quota-store.test.ts @@ -202,6 +202,7 @@ test("quotaAvailabilityFromResponse projects provider/status/reason/checkedAt on { 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", @@ -211,7 +212,10 @@ test("quotaAvailabilityFromResponse projects provider/status/reason/checkedAt on 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", () => { @@ -220,9 +224,12 @@ test("unavailableQuotaProviders lists only non-available providers without repor 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" }, ]); From 93f5c8a5f2ab9159aca930dba437f6dcb378c928 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 11:11:08 -0400 Subject: [PATCH 20/27] fix(gui): assign quota attention to one owner and scope live regions --- .../provider-workspace/ProviderDetails.tsx | 12 +- .../provider-workspace/ProviderOverview.tsx | 31 ++++- .../ProviderWorkspaceShell.tsx | 7 ++ gui/src/i18n/de.ts | 3 +- gui/src/i18n/en.ts | 3 +- gui/src/i18n/ja.ts | 3 +- gui/src/i18n/ko.ts | 3 +- gui/src/i18n/ru.ts | 3 +- gui/src/i18n/zh.ts | 3 +- gui/src/pages/Providers.tsx | 1 + .../pages/dashboard-plan-quota-section.tsx | 44 +++++-- gui/src/styles-dashboard-workspace.css | 7 +- gui/tests/dashboard-plan-quota.test.tsx | 62 +++++++++- gui/tests/provider-capacity-shell.test.tsx | 66 ++++++++++ ...ovider-overview-quota-unavailable.test.tsx | 115 +++++++++++++++++- 15 files changed, 327 insertions(+), 36 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index 46aeaccf85..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; @@ -33,6 +33,7 @@ export default function ProviderDetails({ quotaReport, quotaUnavailableReason, onRetryQuota, + accountNeedsReauth, availableModels, hasLiveModels, selectedModels, @@ -66,6 +67,8 @@ export default function ProviderDetails({ 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; @@ -263,6 +266,7 @@ export default function ProviderDetails({ quotaReport={quotaReport} quotaUnavailableReason={quotaUnavailableReason} onRetryQuota={onRetryQuota} + accountNeedsReauth={accountNeedsReauth} oauthEmail={oauthEmail} oauth={oauth} onEditSettings={() => switchTab("settings")} @@ -275,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; } @@ -297,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 e83bff5b1b..e0f38525df 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -32,6 +32,7 @@ type ConnectionTestState = { export default function ProviderOverview({ item, usageTotals, quotaReport, quotaUnavailableReason, onRetryQuota, oauthEmail, oauth, + accountNeedsReauth = false, apiBase, connectionIdentity, onEditSettings, onViewUsage, onUpdateProvider, onReauthenticate, onCancelLogin, reauthBusy = false, @@ -42,6 +43,8 @@ export default function ProviderOverview({ /** 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 }; @@ -60,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" @@ -210,13 +229,13 @@ export default function ProviderOverview({

    {t("pws.rateLimits")}

    - ) : quotaUnavailableReason ? ( + ) : showQuotaUnavailableNotice ? (

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

    -
    +
    - {entries.length === 0 ? ( + {entries.length === 0 && unavailable.length === 0 ? (

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

    ) : ( -
    - {entries.map(([provider, report]) => ( -
    -
    - {formatProviderDisplayName(provider, t)} - {report.source?.trim() && ( - {formatQuotaSourceLabel(report.source)} - )} + entries.length > 0 && ( +
    + {entries.map(([provider, report]) => ( +
    +
    + {formatProviderDisplayName(provider, t)} + {report.source?.trim() && ( + {formatQuotaSourceLabel(report.source)} + )} +
    + +
    - - -
    - ))} -
    + ))} +
    + ) )} {unavailable.length > 0 && (
    @@ -134,7 +136,7 @@ export function DashboardPlanQuotaSection({ apiBase }: { apiBase: string }) { {t(quotaUnavailableReasonKey(reason))} {reason === "reauth_required" && ( - <> + {t("dash.planQuota.manageProvider", { provider: display })} - + )} ); diff --git a/gui/src/styles-dashboard-workspace.css b/gui/src/styles-dashboard-workspace.css index f0015fd190..ce4511ed3f 100644 --- a/gui/src/styles-dashboard-workspace.css +++ b/gui/src/styles-dashboard-workspace.css @@ -124,6 +124,13 @@ 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; } From 66c2d33ddc540fab7a46d0ad5a824a1f6112985f Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 12:06:47 -0400 Subject: [PATCH 22/27] chore: ignore playwright-cli scratch artifacts --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From a096e19d7e3f66e750225237c15a162ffd1ee0f0 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 12:34:40 -0400 Subject: [PATCH 23/27] fix(quota): prefer Grok weekly credits with graceful no-cap fallback --- src/providers/quota.ts | 90 +++++++++++++++++- tests/provider-quota.test.ts | 177 +++++++++++++++++++++++++++++++++++ 2 files changed, 266 insertions(+), 1 deletion(-) diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 2e9264f57a..8fe40224e7 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,70 @@ 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. + */ +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 +611,20 @@ 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 +641,17 @@ async function fetchXaiQuota(provider: string): Promise 0) { + const quota: ProviderQuota = { + customWindows: [{ label: "No reported cap", percent: 0 }], + monthlyResetAt: normalizeResetAt(config.billingPeriodEnd), + 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/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 8d4a99f9da..e4595386cd 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,178 @@ 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 }]); + expect(result.reports[0]?.quota.monthlyResetAt).toBe(Date.parse("2026-09-01T00:00:00Z")); + expect(result.availability?.[0]).toMatchObject({ provider: "xai", status: "available" }); +}); From 6653a1d39f4f4ed606b22e78171b6fcf3ea5f9b8 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 12:34:40 -0400 Subject: [PATCH 24/27] fix(anthropic): clamp unsupported reasoning efforts at the wire boundary --- src/adapters/anthropic.ts | 23 ++++++++++++++++++++--- tests/anthropic-reasoning.test.ts | 13 +++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index caff67ad3d..cd0a8ca658 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -427,7 +427,8 @@ function reasoningBudget(effort: string): number { case "low": return 4096; case "high": return 16384; case "xhigh": return 24576; - case "max": return 32000; + case "max": + case "ultra": return 32000; // codex-rs maps ultra -> max before the wire; mirror it. case "medium": default: return 8192; } @@ -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/tests/anthropic-reasoning.test.ts b/tests/anthropic-reasoning.test.ts index 32f531bfc5..5e12fd30a3 100644 --- a/tests/anthropic-reasoning.test.ts +++ b/tests/anthropic-reasoning.test.ts @@ -100,11 +100,24 @@ describe("anthropic extended-thinking gate", () => { ["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" }); + }); + test("Anthropic streaming and JSON responses preserve max_tokens stop reasons", async () => { const adapter = createAnthropicAdapter(provider); const sse = [ From e998f8642348c6fe6a88b14bf9d1dbc44d2c64ae Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 12:52:09 -0400 Subject: [PATCH 25/27] =?UTF-8?q?fix(server):=20review=20round=20=E2=80=94?= =?UTF-8?q?=20credits=20no-cap=20reset,=20unknown-effort=20budget=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapters/anthropic.ts | 2 +- src/providers/quota.ts | 14 +++++++++++--- tests/anthropic-reasoning.test.ts | 10 ++++++++++ tests/provider-quota.test.ts | 8 ++++++-- 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index cd0a8ca658..3a8de665d4 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -430,7 +430,7 @@ function reasoningBudget(effort: string): number { case "max": case "ultra": return 32000; // codex-rs maps ultra -> max before the wire; mirror it. case "medium": - default: return 8192; + default: return 16384; // unknown efforts clamp to "high" on the wire; budget must match. } } diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 8fe40224e7..8208d3626e 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -557,7 +557,8 @@ function xaiUserIdFromAccessToken(accessToken: string): string | 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. + * 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); @@ -616,8 +617,13 @@ async function fetchXaiQuota(provider: string): Promise 0) { + const resetAt = normalizeResetAt(config.billingPeriodEnd); const quota: ProviderQuota = { - customWindows: [{ label: "No reported cap", percent: 0 }], - monthlyResetAt: normalizeResetAt(config.billingPeriodEnd), + // 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) diff --git a/tests/anthropic-reasoning.test.ts b/tests/anthropic-reasoning.test.ts index 5e12fd30a3..3a3b3a8220 100644 --- a/tests/anthropic-reasoning.test.ts +++ b/tests/anthropic-reasoning.test.ts @@ -118,6 +118,16 @@ describe("anthropic extended-thinking gate", () => { expect(b.output_config).toEqual({ effort: "high" }); }); + 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/provider-quota.test.ts b/tests/provider-quota.test.ts index e4595386cd..f22b635a0b 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -1777,7 +1777,11 @@ test("xAI quota reports observed usage when the account reports no cap", async ( }, } 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 }]); - expect(result.reports[0]?.quota.monthlyResetAt).toBe(Date.parse("2026-09-01T00:00:00Z")); + 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" }); }); From 9c9e06701db457e001a1192a4d998f138ef84315 Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 12:56:23 -0400 Subject: [PATCH 26/27] fix(anthropic): restore medium thinking budget (judge finding) --- src/adapters/anthropic.ts | 2 +- tests/anthropic-reasoning.test.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 3a8de665d4..09b56304bb 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -429,7 +429,7 @@ function reasoningBudget(effort: string): number { case "xhigh": return 24576; case "max": case "ultra": return 32000; // codex-rs maps ultra -> max before the wire; mirror it. - case "medium": + case "medium": return 8192; default: return 16384; // unknown efforts clamp to "high" on the wire; budget must match. } } diff --git a/tests/anthropic-reasoning.test.ts b/tests/anthropic-reasoning.test.ts index 3a3b3a8220..fa55ae1245 100644 --- a/tests/anthropic-reasoning.test.ts +++ b/tests/anthropic-reasoning.test.ts @@ -97,6 +97,7 @@ 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], @@ -116,6 +117,8 @@ describe("anthropic extended-thinking gate", () => { 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 () => { From 8276aad2901ea7d98d98716b273b197070e494dc Mon Sep 17 00:00:00 2001 From: pavelhov Date: Thu, 13 Aug 2026 22:46:54 -0400 Subject: [PATCH 27/27] fix(xai): clamp grok reasoning efforts to the real ladder --- src/providers/derive.ts | 22 +++++++++++++++++- src/providers/registry.ts | 14 ++++++++++-- tests/reasoning-effort.test.ts | 42 ++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) 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/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/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