From bf114906fb05e5b902189f8cb85060cf7451cef1 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 16:20:47 +0200 Subject: [PATCH 01/31] fix(gui): repair the dashboard typecheck broken on dev (#20) tsc -b failed on dev: Storage.tsx imported React's KeyboardEvent type, which shadowed the DOM type used by its two window keydown listeners, and Select in ui.tsx declared id twice. Alias the React import and keep a single id prop so bun run build:gui (tsc -b && vite build) passes again. Co-authored-by: Codesmith --- gui/src/pages/Storage.tsx | 4 ++-- gui/src/ui.tsx | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/gui/src/pages/Storage.tsx b/gui/src/pages/Storage.tsx index 7c72d91f1..68319f6fa 100644 --- a/gui/src/pages/Storage.tsx +++ b/gui/src/pages/Storage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { useCallback, useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; import { EmptyState } from "../ui"; import { IconRefresh } from "../icons"; @@ -1270,7 +1270,7 @@ function StorageCleanupCard({ window.requestAnimationFrame(() => (next === "policy" ? policyTabRef : quarantineTabRef).current?.focus()); }; - const handleTabKey = (event: KeyboardEvent) => { + const handleTabKey = (event: ReactKeyboardEvent) => { if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); selectTab(tab === "policy" ? "quarantine" : "policy"); diff --git a/gui/src/ui.tsx b/gui/src/ui.tsx index 141ee0b81..d10eeeedb 100644 --- a/gui/src/ui.tsx +++ b/gui/src/ui.tsx @@ -30,6 +30,7 @@ export function Select({ value, options, onChange, disabled, label, id, style, a onChange: (value: string) => void; disabled?: boolean; label?: string; + /** Optional id on the trigger button (tests / labels target `#codex-pool-strategy`). */ id?: string; style?: CSSProperties; align?: "left" | "right"; @@ -37,8 +38,6 @@ export function Select({ value, options, onChange, disabled, label, id, style, a dropdownStyle?: CSSProperties; /** When true (default), menu is portaled and flips above the trigger if it would leave the viewport. */ portal?: boolean; - /** Optional id on the trigger button (tests / labels target `#codex-pool-strategy`). */ - id?: string; }) { const listboxId = useId(); const [open, setOpen] = useState(false); From 79c6352177ef475f51c7676f55b26dd6095e29fd Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 14:42:51 +0000 Subject: [PATCH 02/31] fix(test-tooling): only blame real bun runners and compare raw elapsed time - scripts/test.ts: pgrep -f matches whole command lines, so a shell or CI wrapper mentioning `bun test --isolate` was reported as a competing runner. Confirm each matched pid's executable is Bun via ps before warning. - scripts/test.ts: compare elapsed milliseconds against 600_000 so a 600.4s run no longer rounds down under the threshold; round only for display. - gui/tests/apikeys-refresh-preserve.test.tsx: replace the fixed 310ms sleep before the armed delete confirm with bounded polling for the enabled button. Co-authored-by: Codesmith --- gui/tests/apikeys-refresh-preserve.test.tsx | 16 +++++++++-- scripts/test.ts | 32 +++++++++++++++++++-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/gui/tests/apikeys-refresh-preserve.test.tsx b/gui/tests/apikeys-refresh-preserve.test.tsx index 6de7574ac..c730a4dfe 100644 --- a/gui/tests/apikeys-refresh-preserve.test.tsx +++ b/gui/tests/apikeys-refresh-preserve.test.tsx @@ -192,11 +192,21 @@ test("successful key delete keeps last-good keys visible when follow-up refresh expect(deleteBtn).toBeTruthy(); await act(async () => { deleteBtn!.click(); - await new Promise((resolve) => testWindow.setTimeout(resolve, 310)); }); - const confirmBtn = [...container.querySelectorAll("button")] - .find((button) => button.textContent?.includes("Confirm")); + // Confirm arms itself on a timer. Poll for the armed state instead of sleeping past + // it: a fixed delay races the transition under CI contention and taxes fast runs. + let confirmBtn: HTMLButtonElement | undefined; + const armDeadline = Date.now() + 5_000; + for (;;) { + confirmBtn = [...container.querySelectorAll("button")] + .find((button) => button.textContent?.includes("Confirm")); + if (confirmBtn && !confirmBtn.disabled) break; + if (Date.now() > armDeadline) break; + await act(async () => { + await new Promise((resolve) => testWindow.setTimeout(resolve, 10)); + }); + } expect(confirmBtn).toBeTruthy(); expect(confirmBtn!.disabled).toBe(false); diff --git a/scripts/test.ts b/scripts/test.ts index 96d846e88..db47c6377 100644 --- a/scripts/test.ts +++ b/scripts/test.ts @@ -50,15 +50,40 @@ function findCompetingTestRunners(selfPid: number): number[] { stderr: "ignore", }); if (!found.success) return []; - return new TextDecoder().decode(found.stdout) + const candidates = new TextDecoder().decode(found.stdout) .split("\n") .map(line => Number.parseInt(line.trim(), 10)) .filter(pid => Number.isInteger(pid) && pid > 0 && pid !== selfPid); + return keepBunExecutables(candidates); } catch { return []; } } +/** + * `pgrep -f` matches the whole command line, so a shell, `make` target, or CI wrapper + * that merely mentions `bun test --isolate` looks like a second runner. Only a process + * whose executable really is Bun can contend for the CPU the way the warning claims, + * so drop everything else rather than blaming an innocent parent shell. + */ +function keepBunExecutables(pids: number[]): number[] { + if (pids.length === 0) return []; + const listed = Bun.spawnSync(["ps", "-o", "pid=,comm=", "-p", pids.join(",")], { + stdout: "pipe", + stderr: "ignore", + }); + if (!listed.success) return []; + const bunPids = new Set(); + for (const line of new TextDecoder().decode(listed.stdout).split("\n")) { + const match = /^\s*(\d+)\s+(\S.*?)\s*$/.exec(line); + if (!match) continue; + const pid = Number.parseInt(match[1]!, 10); + const executable = match[2]!.split(/[/\\]/).pop() ?? ""; + if (/^bun(\b|[-_.])/.test(executable)) bunPids.add(pid); + } + return pids.filter(pid => bunPids.has(pid)); +} + if (import.meta.main) { const isolated = createIsolatedTestEnvironment(); try { @@ -81,8 +106,9 @@ if (import.meta.main) { stderr: "inherit", }, ); - const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000); - if (requestedTests.length === 0 && elapsedSeconds > 600) { + const elapsedMs = Date.now() - startedAt; + const elapsedSeconds = Math.round(elapsedMs / 1000); + if (requestedTests.length === 0 && elapsedMs > 600_000) { console.warn( `[test] the suite took ${elapsedSeconds}s; it normally runs in about 210s on an idle machine. ` + "Check for another test runner, a busy CPU, or a test that started polling something real.", From b5c00ae34ad9f3047bbaee13c3213e35a9d94968 Mon Sep 17 00:00:00 2001 From: GroepChef Date: Thu, 30 Jul 2026 12:33:49 +0200 Subject: [PATCH 03/31] fix(service): persist Restart=always in generated systemd unit Failure-only restart left the proxy stopped after clean exits; always matches the intended supervisor behavior and keeps RestartSec=5. Co-authored-by: Cursor --- src/service.ts | 2 +- tests/service.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/service.ts b/src/service.ts index b38305fa1..61e4a1465 100644 --- a/src/service.ts +++ b/src/service.ts @@ -1455,7 +1455,7 @@ Wants=network-online.target [Service] Type=simple ExecStart=${systemdQuote("/bin/sh")} -lc ${systemdQuote(buildServiceShellCommand(bun, cli))} -Restart=on-failure +Restart=always RestartSec=5 ${envLines} StandardOutput=${systemdOutputTarget(`append:${log}`)} diff --git a/tests/service.test.ts b/tests/service.test.ts index 9bdb6d0fc..dd5dcfe32 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -104,6 +104,14 @@ describe("systemd service unit", () => { expect(unit).not.toContain('StandardError="append:'); }); + test("restarts the unit always (not only on failure)", () => { + const unit = buildUnit(); + + expect(unit).toContain("Restart=always"); + expect(unit).toContain("RestartSec=5"); + expect(unit).not.toContain("Restart=on-failure"); + }); + test("preserves custom Codex and OpenCodex homes", () => { const oldCodexHome = process.env.CODEX_HOME; const oldOpenCodexHome = process.env.OPENCODEX_HOME; From 5be33a305e9b90a9bbbf2c87e18dceac1de5d16f Mon Sep 17 00:00:00 2001 From: GroepChef Date: Thu, 30 Jul 2026 12:44:17 +0200 Subject: [PATCH 04/31] feat(oauth): add opt-in Google Antigravity 429 account pool Enable sticky session affinity and bounded 429 failover across Antigravity OAuth accounts while keeping the pool off by default and preserving Anthropic and Codex pool behavior. Co-authored-by: Cursor --- .../content/docs/reference/configuration.md | 26 + src/adapters/base.ts | 2 + src/adapters/google-antigravity-wire.ts | 19 +- src/adapters/google-http.ts | 5 + src/codex/pool-rotation.ts | 1 + src/oauth/google-antigravity-routing.ts | 488 ++++++++++++++++++ src/oauth/health.ts | 11 +- src/server/management/oauth-account-routes.ts | 48 +- src/server/responses/core.ts | 119 ++++- src/types.ts | 14 + src/usage/log.ts | 1 + tests/account-pool-management-api.test.ts | 132 +++++ tests/google-antigravity-account-pool.test.ts | 239 +++++++++ ...server-google-antigravity-pool-e2e.test.ts | 242 +++++++++ 14 files changed, 1324 insertions(+), 23 deletions(-) create mode 100644 src/oauth/google-antigravity-routing.ts create mode 100644 tests/google-antigravity-account-pool.test.ts create mode 100644 tests/server-google-antigravity-pool-e2e.test.ts diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 7c2de76b3..6ff3029a4 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -194,6 +194,32 @@ Leave this disabled unless you understand Anthropic account policy risk. Prefer `ocx account use anthropic ` switching when unsure. ::: +### googleAntigravityAccountPool (experimental) + +Opt-in routing across multiple `google-antigravity` OAuth accounts. The pool is off by +default. It uses the same Antigravity session id as thought-signature replay, and each +selected account supplies its own OAuth token and Cloud Code Assist project id. + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `googleAntigravityAccountPool.enabled?` | `boolean` | `false` | Enables sticky session affinity and 429 cooldown failover. | +| `googleAntigravityAccountPool.autoSwitchThreshold?` | `number` | `80` | New-session threshold for known cached 5-hour usage. Unknown usage keeps the active account. `0` disables quota-based picking. | +| `googleAntigravityAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Selects new accounts for new sessions. Affinity still wins for an existing session. | +| `googleAntigravityAccountPool.stickyLimit?` | `number` | `1` | Number of successful new-session binds retained by round-robin before it advances. Range 1–100. | + +The failover rule is strict. Only an explicit upstream HTTP 429 cools an account and +allows an in-request rotation, with at most three failovers. Client cancellation, HTTP +400, HTTP 502, EOF, and other transport failures do not rotate or cool an account. +Thought-signature replay state remains intact after a successful 429 failover. + +Manage the pool through `/api/oauth/accounts/pool?provider=google-antigravity`, +`/api/oauth/accounts/clear-cooldown`, and the ordinary OAuth active-account endpoint. + +:::caution[Experimental] +Multi-account rotation may conflict with provider account policies. Leave this disabled +unless you accept that risk. +::: + ### claudeCode (OcxClaudeCodeConfig) Claude Code inbound settings consumed by the `/v1/messages` surface, the `ocx claude` diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 20ca5e017..e391d69a5 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -63,6 +63,8 @@ export interface AdapterFetchContext { timeoutMs?: number; /** Return final non-2xx responses untouched so the caller can own the error-body read. */ returnRawErrors?: boolean; + /** Let an outer account-pool layer own Antigravity 429 handling without same-account retries. */ + skip429Retry?: boolean; /** Whether the upstream response will be consumed as a stream; adapters may select low-latency transport settings. */ stream?: boolean; } diff --git a/src/adapters/google-antigravity-wire.ts b/src/adapters/google-antigravity-wire.ts index d8ac69f4a..808b3ea79 100644 --- a/src/adapters/google-antigravity-wire.ts +++ b/src/adapters/google-antigravity-wire.ts @@ -35,7 +35,7 @@ export function isLikelyRealThoughtSignature(sig: string | undefined): boolean { return /^[A-Za-z0-9+/_=-]+$/.test(sig); } -function firstUserText(parsed: OcxParsedRequest): string | undefined { +function firstUserText(parsed: Pick): string | undefined { for (const msg of parsed.context.messages) { if (msg.role !== "user") continue; if (typeof msg.content === "string") return msg.content; @@ -45,22 +45,29 @@ function firstUserText(parsed: OcxParsedRequest): string | undefined { return undefined; } +const generatedSessionIds = new WeakMap, string>(); + /** * Deterministic Cloud Code Assist session id from the first user message text. Mirrors * CLIProxyAPI `generateStableSessionID`: sha256(firstUserText) → BigEndian uint64 masked with * 0x7FFFFFFFFFFFFFFF, prefixed with "-". Falls back to a random "-" id when there is no text. */ -export function antigravitySessionId(parsed: OcxParsedRequest): string { +export function antigravitySessionId( + parsed: Pick, +): string { // Anchored on the first user message text: this is the one value that stays STABLE across every // turn of a conversation, which the replay cache requires (it observes signatures on turn N's // response and re-injects them on turn N+1's request, so both turns must map to the same id). // Cross-conversation collisions (two threads opening with identical text) are made harmless by // the replay cache keying signatures on functionCall identity (name+args), not on this id alone. + const existing = generatedSessionIds.get(parsed); + if (existing) return existing; const text = firstUserText(parsed); - if (!text) return `-${Math.floor(Math.random() * 9e18).toString()}`; - const digest = createHash("sha256").update(text, "utf8").digest(); - const masked = digest.readBigUInt64BE(0) & 0x7fffffffffffffffn; - return `-${masked.toString()}`; + const sessionId = text + ? `-${(createHash("sha256").update(text, "utf8").digest().readBigUInt64BE(0) & 0x7fffffffffffffffn).toString()}` + : `-${Math.floor(Math.random() * 9e18).toString()}`; + generatedSessionIds.set(parsed, sessionId); + return sessionId; } /** A Gemini content part as it appears in an Antigravity request body. */ diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index de849cde3..d4bfdb1b4 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -56,6 +56,11 @@ export async function fetchGoogleWithRetry(label: string, request: AdapterReques continue; } } + if (res.status === 429 && ctx.skip429Retry) { + return ctx.returnRawErrors + ? res + : normalizeFinalGoogleError(label, res, ctx.abortSignal); + } if (!retryableGoogleStatus(res.status) || attempt === GOOGLE_RETRY_ATTEMPTS - 1) { return ctx.returnRawErrors ? res : normalizeFinalGoogleError(label, res, ctx.abortSignal); } diff --git a/src/codex/pool-rotation.ts b/src/codex/pool-rotation.ts index c75f6d34a..9383bdd20 100644 --- a/src/codex/pool-rotation.ts +++ b/src/codex/pool-rotation.ts @@ -2,6 +2,7 @@ import type { OcxAccountPoolRotationStrategy } from "../types"; export const POOL_KEY_CODEX = "codex"; export const POOL_KEY_ANTHROPIC = "anthropic"; +export const POOL_KEY_ANTIGRAVITY = "google-antigravity"; interface SelectionState { activeKey?: string; diff --git a/src/oauth/google-antigravity-routing.ts b/src/oauth/google-antigravity-routing.ts new file mode 100644 index 000000000..4868d1d22 --- /dev/null +++ b/src/oauth/google-antigravity-routing.ts @@ -0,0 +1,488 @@ +/** + * Opt-in Google Antigravity OAuth account pool. + * + * This is deliberately narrower than Codex routing. It keeps process-local session + * affinity and rotates only after an explicit upstream 429 response. + */ +import { antigravitySessionId } from "../adapters/google-antigravity-wire"; +import { fallbackCodexAccountLogLabel } from "../codex/account-label"; +import { + normalizeAccountPoolStickyLimit, + normalizeAccountPoolStrategy, + notePoolRotationFailure, + notePoolRotationSuccess, + pickRoundRobinAccount, + POOL_KEY_ANTIGRAVITY, + seedPoolRotationAccount, +} from "../codex/pool-rotation"; +import { getCachedProviderAccountQuota } from "../providers/quota"; +import type { + OcxAccountPoolRotationStrategy, + OcxConfig, + OcxParsedRequest, +} from "../types"; +import { getAccountCredential, getAccountSet, setActiveAccount } from "./store"; + +const PROVIDER = "google-antigravity"; +const DEFAULT_COOLDOWN_MS = 60_000; +const MAX_COOLDOWN_MS = 15 * 60_000; +const AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000; +const MAX_AFFINITY_ENTRIES = 2_000; +const UNKNOWN_USAGE_SCORE = 100; +const DEFAULT_AUTO_SWITCH_THRESHOLD = 80; +const TOKEN_SKEW_MS = 60_000; + +/** Cap same-request 429 rotations so short Retry-After values cannot loop forever. */ +export const GOOGLE_ANTIGRAVITY_POOL_MAX_FAILOVERS_PER_REQUEST = 3; + +export interface GoogleAntigravityAccountPoolConfig { + enabled?: boolean; + autoSwitchThreshold?: number; + strategy?: OcxAccountPoolRotationStrategy; + stickyLimit?: number; +} + +interface AccountHealth { + cooldownUntil: number; + cooldownSource: "retry-after" | "default"; +} + +interface AffinityEntry { + accountId: string; + lastUsedAt: number; +} + +export interface GoogleAntigravityAccountCredential { + accessToken: string; + projectId: string; +} + +export type GoogleAntigravityAccountSelectionReason = + | "pool-disabled" + | "affinity" + | "active" + | "lowest-usage" + | "only-eligible" + | "round-robin" + | "fill-first" + | "none" + | "all-cooled"; + +export interface GoogleAntigravityAccountSelection { + accountId: string | null; + reason: GoogleAntigravityAccountSelectionReason; +} + +const upstreamHealth = new Map(); +const sessionAffinity = new Map(); + +export function googleAntigravityAccountPoolConfig( + config: OcxConfig, +): GoogleAntigravityAccountPoolConfig { + const raw = config.googleAntigravityAccountPool; + if (!raw || typeof raw !== "object") return {}; + return raw; +} + +export function isGoogleAntigravityAccountPoolEnabled(config: OcxConfig): boolean { + return googleAntigravityAccountPoolConfig(config).enabled === true; +} + +export function googleAntigravityAutoSwitchThreshold(config: OcxConfig): number { + const value = googleAntigravityAccountPoolConfig(config).autoSwitchThreshold; + if ( + typeof value === "number" + && Number.isInteger(value) + && value >= 0 + && value <= 100 + ) { + return value; + } + return DEFAULT_AUTO_SWITCH_THRESHOLD; +} + +function poolStrategy(config: OcxConfig): OcxAccountPoolRotationStrategy { + return normalizeAccountPoolStrategy(googleAntigravityAccountPoolConfig(config).strategy); +} + +function stickyLimit(config: OcxConfig): number { + return normalizeAccountPoolStickyLimit( + googleAntigravityAccountPoolConfig(config).stickyLimit, + ); +} + +function parseRetryAfterMs( + value: string | null | undefined, + now: number, +): number | undefined { + const text = value?.trim(); + if (!text) return undefined; + if (/^\d+(?:\.\d+)?$/.test(text)) { + const seconds = Number(text); + if (Number.isFinite(seconds) && seconds > 0) { + return Math.min(Math.max(Math.ceil(seconds * 1_000), 1), MAX_COOLDOWN_MS); + } + } + const timestamp = Date.parse(text); + if (!Number.isFinite(timestamp)) return undefined; + const delay = timestamp - now; + return delay > 0 ? Math.min(delay, MAX_COOLDOWN_MS) : undefined; +} + +export function getGoogleAntigravityAccountHealthSnapshot( + accountId: string, + now = Date.now(), +): { cooldownUntil?: number; cooldownSource?: AccountHealth["cooldownSource"] } | null { + const entry = upstreamHealth.get(accountId); + if (!entry) return null; + if (entry.cooldownUntil <= now) { + upstreamHealth.delete(accountId); + return null; + } + return { + cooldownUntil: entry.cooldownUntil, + cooldownSource: entry.cooldownSource, + }; +} + +export function clearGoogleAntigravityAccountCooldown(accountId: string): boolean { + return upstreamHealth.delete(accountId); +} + +/** Test and logout helper. */ +export function clearGoogleAntigravityAccountPoolState(): void { + upstreamHealth.clear(); + sessionAffinity.clear(); +} + +function isCooled(accountId: string, now: number): boolean { + return getGoogleAntigravityAccountHealthSnapshot(accountId, now) !== null; +} + +function credentialIsUsable(accountId: string, now: number): boolean { + const credential = getAccountCredential(PROVIDER, accountId); + if (!credential?.projectId?.trim()) return false; + return Boolean(credential.refresh) || credential.expires > now + TOKEN_SKEW_MS; +} + +export function getEligibleGoogleAntigravityAccounts(now = Date.now()): string[] { + const set = getAccountSet(PROVIDER); + if (!set) return []; + return set.accounts + .filter(account => + account.needsReauth !== true + && !isCooled(account.id, now) + && credentialIsUsable(account.id, now)) + .map(account => account.id); +} + +export function getGoogleAntigravityPoolRetryAfterSeconds( + now = Date.now(), +): number | null { + const set = getAccountSet(PROVIDER); + if (!set) return null; + let earliest: number | null = null; + for (const account of set.accounts) { + const snapshot = getGoogleAntigravityAccountHealthSnapshot(account.id, now); + if (!snapshot?.cooldownUntil) continue; + if (earliest === null || snapshot.cooldownUntil < earliest) { + earliest = snapshot.cooldownUntil; + } + } + if (earliest === null || earliest <= now) return null; + return Math.max(1, Math.ceil((earliest - now) / 1_000)); +} + +function usageScore(accountId: string): number { + const quota = getCachedProviderAccountQuota(PROVIDER, accountId); + if ( + !quota + || typeof quota.fiveHourPercent !== "number" + || !Number.isFinite(quota.fiveHourPercent) + ) { + return UNKNOWN_USAGE_SCORE; + } + return Math.max(0, Math.min(100, quota.fiveHourPercent)); +} + +function hasKnownUsage(accountId: string): boolean { + const quota = getCachedProviderAccountQuota(PROVIDER, accountId); + return typeof quota?.fiveHourPercent === "number" + && Number.isFinite(quota.fiveHourPercent); +} + +function pickLowestUsage(excludeId: string | undefined, now: number): string | null { + const eligible = getEligibleGoogleAntigravityAccounts(now) + .filter(accountId => accountId !== excludeId); + if (eligible.length === 0) return null; + let best = eligible[0]!; + let bestScore = usageScore(best); + for (let index = 1; index < eligible.length; index++) { + const candidate = eligible[index]!; + const score = usageScore(candidate); + if (score < bestScore) { + best = candidate; + bestScore = score; + } + } + return best; +} + +function isUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean { + const threshold = googleAntigravityAutoSwitchThreshold(config); + if (threshold <= 0 || !hasKnownUsage(accountId)) return true; + return usageScore(accountId) < threshold; +} + +function pickNextFillFirstAccount( + config: OcxConfig, + afterId: string, + eligible: string[], +): string | null { + if (eligible.length === 0) return null; + const ordered = [...eligible].sort((left, right) => left.localeCompare(right)); + const set = getAccountSet(PROVIDER); + const stableAll = set + ? set.accounts.map(account => account.id).sort((left, right) => left.localeCompare(right)) + : ordered; + const startIndex = stableAll.indexOf(afterId); + if (startIndex < 0) { + return ordered.find(accountId => isUnderFillFirstThreshold(config, accountId)) + ?? ordered[0] + ?? null; + } + let fallback: string | null = null; + for (let step = 1; step <= stableAll.length; step++) { + const candidate = stableAll[(startIndex + step) % stableAll.length]!; + if (!eligible.includes(candidate)) continue; + fallback ??= candidate; + if (isUnderFillFirstThreshold(config, candidate)) return candidate; + } + return fallback ?? ordered[0] ?? null; +} + +function pickFillFirstAccount(config: OcxConfig, now: number): string | null { + const eligible = getEligibleGoogleAntigravityAccounts(now); + if (eligible.length === 0) return null; + const set = getAccountSet(PROVIDER); + const active = set?.activeAccountId; + if (active && eligible.includes(active) && isUnderFillFirstThreshold(config, active)) { + return active; + } + if (!active) { + const ordered = [...eligible].sort((left, right) => left.localeCompare(right)); + return ordered.find(accountId => isUnderFillFirstThreshold(config, accountId)) + ?? ordered[0] + ?? null; + } + return pickNextFillFirstAccount(config, active, eligible); +} + +function pickAlternateAccount( + config: OcxConfig, + failedAccountId: string, + now: number, +): string | null { + const eligible = getEligibleGoogleAntigravityAccounts(now) + .filter(accountId => accountId !== failedAccountId); + const strategy = poolStrategy(config); + if (strategy === "round-robin") { + return pickRoundRobinAccount(POOL_KEY_ANTIGRAVITY, eligible, stickyLimit(config)); + } + if (strategy === "fill-first") { + return pickNextFillFirstAccount(config, failedAccountId, eligible); + } + return pickLowestUsage(failedAccountId, now); +} + +function pruneExpiredAffinity(now: number): void { + for (const [key, entry] of sessionAffinity) { + if (now - entry.lastUsedAt > AFFINITY_IDLE_TTL_MS) sessionAffinity.delete(key); + } + if (sessionAffinity.size <= MAX_AFFINITY_ENTRIES) return; + const sorted = [...sessionAffinity.entries()] + .sort((left, right) => left[1].lastUsedAt - right[1].lastUsedAt); + const drop = sessionAffinity.size - MAX_AFFINITY_ENTRIES; + for (let index = 0; index < drop; index++) { + sessionAffinity.delete(sorted[index]![0]); + } +} + +function bindAffinity(sessionKey: string, accountId: string, now: number): void { + sessionAffinity.set(sessionKey, { accountId, lastUsedAt: now }); + pruneExpiredAffinity(now); +} + +export function bindGoogleAntigravitySessionAffinity( + sessionKey: string | null | undefined, + accountId: string, + now = Date.now(), +): void { + const key = sessionKey?.trim(); + if (key) bindAffinity(key, accountId, now); +} + +export function clearGoogleAntigravitySessionAffinityForAccount( + accountId: string, +): void { + for (const [key, entry] of sessionAffinity) { + if (entry.accountId === accountId) sessionAffinity.delete(key); + } +} + +export function googleAntigravitySessionKey( + parsed: Pick, +): string { + return antigravitySessionId(parsed); +} + +export function resolveGoogleAntigravityAccountForSession( + sessionKey: string | null | undefined, + config: OcxConfig, + now = Date.now(), +): GoogleAntigravityAccountSelection { + pruneExpiredAffinity(now); + const set = getAccountSet(PROVIDER); + if (!set || set.accounts.length === 0) return { accountId: null, reason: "none" }; + if (!isGoogleAntigravityAccountPoolEnabled(config)) { + return { accountId: set.activeAccountId, reason: "pool-disabled" }; + } + + const key = sessionKey?.trim() ?? ""; + const eligible = getEligibleGoogleAntigravityAccounts(now); + if (key) { + const affined = sessionAffinity.get(key); + if (affined && eligible.includes(affined.accountId)) { + affined.lastUsedAt = now; + return { accountId: affined.accountId, reason: "affinity" }; + } + sessionAffinity.delete(key); + } + + const strategy = poolStrategy(config); + const activeIsEligible = eligible.includes(set.activeAccountId); + if (!key && activeIsEligible && strategy !== "quota") { + return { accountId: set.activeAccountId, reason: "active" }; + } + + let accountId: string | null = null; + let reason: GoogleAntigravityAccountSelectionReason = "none"; + if (strategy === "round-robin") { + accountId = pickRoundRobinAccount( + POOL_KEY_ANTIGRAVITY, + eligible, + stickyLimit(config), + ); + if (accountId) { + notePoolRotationSuccess(POOL_KEY_ANTIGRAVITY, accountId, stickyLimit(config)); + reason = "round-robin"; + } + } else if (strategy === "fill-first") { + accountId = pickFillFirstAccount(config, now); + if (accountId) reason = "fill-first"; + } else { + const threshold = googleAntigravityAutoSwitchThreshold(config); + if ( + activeIsEligible + && ( + threshold <= 0 + || !hasKnownUsage(set.activeAccountId) + || usageScore(set.activeAccountId) < threshold + ) + ) { + accountId = set.activeAccountId; + reason = "active"; + } else if (threshold > 0) { + accountId = pickLowestUsage(undefined, now); + if (accountId) { + reason = accountId === set.activeAccountId ? "active" : "lowest-usage"; + } + } else { + accountId = pickLowestUsage(set.activeAccountId, now); + if (accountId) reason = "only-eligible"; + } + } + + if (!accountId) { + const anyCooled = set.accounts.some(account => isCooled(account.id, now)); + return { accountId: null, reason: anyCooled ? "all-cooled" : "none" }; + } + if (key) bindAffinity(key, accountId, now); + return { accountId, reason }; +} + +/** + * Cool a failed account and select the next eligible account. Callers invoke this + * only for an explicit upstream 429 response. + */ +export function rotateGoogleAntigravityAccountOn429( + config: OcxConfig, + failedAccountId: string, + retryAfterHeader: string | null | undefined, + sessionKey?: string | null, + now = Date.now(), +): string | null { + if (!isGoogleAntigravityAccountPoolEnabled(config)) return null; + const parsedRetryAfter = parseRetryAfterMs(retryAfterHeader, now); + upstreamHealth.set(failedAccountId, { + cooldownUntil: now + (parsedRetryAfter ?? DEFAULT_COOLDOWN_MS), + cooldownSource: parsedRetryAfter ? "retry-after" : "default", + }); + clearGoogleAntigravitySessionAffinityForAccount(failedAccountId); + notePoolRotationFailure(POOL_KEY_ANTIGRAVITY, failedAccountId); + + const next = pickAlternateAccount(config, failedAccountId, now); + if (!next) { + console.warn("[google-antigravity-pool] all eligible accounts are in cooldown; returning 429"); + return null; + } + const key = sessionKey?.trim(); + if (key) bindAffinity(key, next, now); + console.warn( + `[google-antigravity-pool] 429 on ${formatGoogleAntigravityAccountOrdinal(failedAccountId)}; failing over to ${formatGoogleAntigravityAccountOrdinal(next)}`, + ); + return next; +} + +/** + * Resolve the token and project from the same account. Refresh may update projectId, + * so the credential is re-read after token resolution. + */ +export async function getGoogleAntigravityPoolCredential( + accountId: string, +): Promise { + const { getValidAccessTokenForAccount, OAuthLoginRequiredError } = await import("./index"); + if (!getAccountCredential(PROVIDER, accountId)) { + throw new OAuthLoginRequiredError(PROVIDER); + } + const accessToken = await getValidAccessTokenForAccount(PROVIDER, accountId); + const projectId = getAccountCredential(PROVIDER, accountId)?.projectId?.trim(); + if (!projectId) { + throw new Error( + "Antigravity account has no Cloud Code Assist project id; re-run `ocx login google-antigravity`", + ); + } + return { accessToken, projectId }; +} + +export function promoteGoogleAntigravityActiveAccount(accountId: string): void { + void setActiveAccount(PROVIDER, accountId).catch(() => { /* best-effort */ }); +} + +export function resetGoogleAntigravityRoutingForManualSelection( + accountId: string, +): void { + sessionAffinity.clear(); + seedPoolRotationAccount(POOL_KEY_ANTIGRAVITY, accountId); +} + +export function formatGoogleAntigravityAccountOrdinal(accountId: string): string { + return fallbackCodexAccountLogLabel(accountId); +} + +export function formatGoogleAntigravityProviderForLog( + accountId: string | null | undefined, +): string { + if (!accountId) return PROVIDER; + return `${PROVIDER}-${formatGoogleAntigravityAccountOrdinal(accountId)}`; +} diff --git a/src/oauth/health.ts b/src/oauth/health.ts index d25acd508..24f10ded1 100644 --- a/src/oauth/health.ts +++ b/src/oauth/health.ts @@ -1,5 +1,6 @@ import { getCodexAccountHealthSnapshot, type CodexCooldownSource } from "../codex/routing"; import { getAnthropicAccountHealthSnapshot } from "./anthropic-routing"; +import { getGoogleAntigravityAccountHealthSnapshot } from "./google-antigravity-routing"; import { isAccountNeedsReauth } from "../codex/account-runtime-state"; import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; @@ -175,14 +176,16 @@ export function projectStoredOAuthAccountHealth( now = Date.now(), opts: { observeOnly?: boolean } = {}, ): OAuthAccountHealth { - const anthropicSnap = provider === "anthropic" + const poolSnapshot = provider === "anthropic" ? getAnthropicAccountHealthSnapshot(account.id, now) - : null; + : provider === "google-antigravity" + ? getGoogleAntigravityAccountHealthSnapshot(account.id, now) + : null; return projectOAuthAccountHealth({ needsReauth: account.needsReauth === true, reauthReason: account.needsReauth === true ? "refresh_failed" : undefined, - cooldownUntilMs: anthropicSnap?.cooldownUntil, - cooldownReason: anthropicSnap?.cooldownSource === "retry-after" ? "rate_limit" : anthropicSnap ? "quota" : undefined, + cooldownUntilMs: poolSnapshot?.cooldownUntil, + cooldownReason: poolSnapshot?.cooldownSource === "retry-after" ? "rate_limit" : poolSnapshot ? "quota" : undefined, warningReason: detectOAuthWarning(provider, account, opts.observeOnly === true, now), now, }); diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index c40cc769b..6a02af06a 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -232,17 +232,24 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< if (provider === "anthropic") { const { resetAnthropicRoutingForManualSelection } = await import("../../oauth/anthropic-routing"); resetAnthropicRoutingForManualSelection(body.accountId); + } else if (provider === "google-antigravity") { + const { resetGoogleAntigravityRoutingForManualSelection } = await import("../../oauth/google-antigravity-routing"); + resetGoogleAntigravityRoutingForManualSelection(body.accountId); } const { clearProviderQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); return jsonResponse({ ok: true, provider, activeAccountId: body.accountId }); } - // Opt-in Anthropic OAuth account pool (#294): enable/threshold/strategy + clear cooldown. + // Opt-in OAuth account pools: enable/threshold/strategy + clear cooldown. if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") { const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); - if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400); - const pool = config.anthropicAccountPool ?? {}; + if (provider !== "anthropic" && provider !== "google-antigravity") { + return jsonResponse({ error: "pool config is only supported for anthropic and google-antigravity" }, 400); + } + const pool = provider === "anthropic" + ? config.anthropicAccountPool ?? {} + : config.googleAntigravityAccountPool ?? {}; return jsonResponse({ provider, enabled: pool.enabled === true, @@ -265,13 +272,18 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< stickyLimit?: unknown; }; const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; - if (provider !== "anthropic") return jsonResponse({ error: "pool config is only supported for anthropic" }, 400); - let enabled = config.anthropicAccountPool?.enabled === true; + if (provider !== "anthropic" && provider !== "google-antigravity") { + return jsonResponse({ error: "pool config is only supported for anthropic and google-antigravity" }, 400); + } + const currentPool = provider === "anthropic" + ? config.anthropicAccountPool + : config.googleAntigravityAccountPool; + let enabled = currentPool?.enabled === true; if (body.enabled !== undefined) { if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400); enabled = body.enabled; } - let threshold = config.anthropicAccountPool?.autoSwitchThreshold ?? 80; + let threshold = currentPool?.autoSwitchThreshold ?? 80; if (body.autoSwitchThreshold !== undefined) { if ( typeof body.autoSwitchThreshold !== "number" @@ -283,7 +295,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< } threshold = body.autoSwitchThreshold; } - let strategy = config.anthropicAccountPool?.strategy; + let strategy = currentPool?.strategy; if (body.strategy !== undefined) { const parsed = parseAccountPoolStrategy(body.strategy); if (parsed === null) { @@ -291,7 +303,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< } strategy = parsed; } - let stickyLimit = config.anthropicAccountPool?.stickyLimit; + let stickyLimit = currentPool?.stickyLimit; if (body.stickyLimit !== undefined) { const parsed = parseAccountPoolStickyLimit(body.stickyLimit); if (parsed === null) { @@ -299,12 +311,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< } stickyLimit = parsed; } - config.anthropicAccountPool = { + const nextPool = { enabled, autoSwitchThreshold: threshold, ...(strategy !== undefined ? { strategy } : {}), ...(stickyLimit !== undefined ? { stickyLimit } : {}), }; + if (provider === "anthropic") config.anthropicAccountPool = nextPool; + else config.googleAntigravityAccountPool = nextPool; saveConfigPreservingClaudeCode(config); return jsonResponse({ ok: true, @@ -320,10 +334,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const body = await req.json().catch(() => ({})) as { provider?: unknown; accountId?: unknown }; const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; const accountId = typeof body.accountId === "string" ? body.accountId.trim() : ""; - if (provider !== "anthropic") return jsonResponse({ error: "clear-cooldown is only supported for anthropic" }, 400); + if (provider !== "anthropic" && provider !== "google-antigravity") { + return jsonResponse({ error: "clear-cooldown is only supported for anthropic and google-antigravity" }, 400); + } if (!accountId) return jsonResponse({ error: "missing accountId" }, 400); - const { clearAnthropicAccountCooldown } = await import("../../oauth/anthropic-routing"); - const cleared = clearAnthropicAccountCooldown(accountId); + const cleared = provider === "anthropic" + ? (await import("../../oauth/anthropic-routing")).clearAnthropicAccountCooldown(accountId) + : (await import("../../oauth/google-antigravity-routing")).clearGoogleAntigravityAccountCooldown(accountId); return jsonResponse({ ok: true, cleared }); } @@ -352,6 +369,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const { clearAnthropicAccountCooldown, clearAnthropicSessionAffinityForAccount } = await import("../../oauth/anthropic-routing"); clearAnthropicAccountCooldown(id); clearAnthropicSessionAffinityForAccount(id); + } else if (provider === "google-antigravity") { + const { + clearGoogleAntigravityAccountCooldown, + clearGoogleAntigravitySessionAffinityForAccount, + } = await import("../../oauth/google-antigravity-routing"); + clearGoogleAntigravityAccountCooldown(id); + clearGoogleAntigravitySessionAffinityForAccount(id); } if (!getAccountSet(provider)) clearLoginState(provider); const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota"); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e8d12ad54..f84182ce2 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -52,6 +52,18 @@ import { resolveAnthropicAccountForSession, rotateAnthropicAccountOn429, } from "../../oauth/anthropic-routing"; +import { + GOOGLE_ANTIGRAVITY_POOL_MAX_FAILOVERS_PER_REQUEST, + bindGoogleAntigravitySessionAffinity, + formatGoogleAntigravityProviderForLog, + getGoogleAntigravityPoolCredential, + getGoogleAntigravityPoolRetryAfterSeconds, + googleAntigravitySessionKey, + isGoogleAntigravityAccountPoolEnabled, + promoteGoogleAntigravityActiveAccount, + resolveGoogleAntigravityAccountForSession, + rotateGoogleAntigravityAccountOn429, +} from "../../oauth/google-antigravity-routing"; import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; @@ -1253,6 +1265,8 @@ export async function handleResponses( let sentOAuthSnapshot: OAuthAccessSnapshot | undefined; let anthropicPoolAccountId: string | null = null; let anthropicPoolFailovers = 0; + let googleAntigravityPoolAccountId: string | null = null; + let googleAntigravityPoolFailovers = 0; const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth" ? anthropicSessionKeyFromParts({ sessionIdHeader: sessionIdHeaderFromRequest(req.headers), @@ -1262,6 +1276,12 @@ export async function handleResponses( promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort === true, }) : null; + const antigravitySessionKey = route.providerName === "google-antigravity" + && route.provider.authMode === "oauth" + ? googleAntigravitySessionKey(parsed) + : null; + const antigravityPoolOwns429 = route.providerName === "google-antigravity" + && isGoogleAntigravityAccountPoolEnabled(config); if (route.provider.authMode === "oauth") { try { if (route.providerName === "anthropic" && isAnthropicAccountPoolEnabled(config)) { @@ -1284,6 +1304,43 @@ export async function handleResponses( promoteAnthropicActiveAccount(selection.accountId); route.provider = { ...route.provider, apiKey: accessToken }; logCtx.provider = formatAnthropicProviderForLog("anthropic", selection.accountId, config); + } else if ( + route.providerName === "google-antigravity" + && isGoogleAntigravityAccountPoolEnabled(config) + ) { + const selection = resolveGoogleAntigravityAccountForSession( + antigravitySessionKey, + config, + ); + if (!selection.accountId) { + if (selection.reason === "all-cooled") { + const retryAfterSec = getGoogleAntigravityPoolRetryAfterSeconds(); + return formatErrorResponse( + 429, + "rate_limit_error", + "All Google Antigravity OAuth accounts are temporarily rate-limited", + retryAfterSec !== null ? { retryAfter: String(retryAfterSec) } : undefined, + ); + } + return formatErrorResponse( + 401, + "authentication_error", + "No eligible Google Antigravity OAuth account available", + ); + } + const credential = await getGoogleAntigravityPoolCredential(selection.accountId); + googleAntigravityPoolAccountId = selection.accountId; + bindGoogleAntigravitySessionAffinity( + antigravitySessionKey, + selection.accountId, + ); + promoteGoogleAntigravityActiveAccount(selection.accountId); + route.provider = { + ...route.provider, + apiKey: credential.accessToken, + project: credential.projectId, + }; + logCtx.provider = formatGoogleAntigravityProviderForLog(selection.accountId); } else { const resolved = await getValidAccessTokenSnapshot(route.providerName); if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved; @@ -2104,6 +2161,7 @@ export async function handleResponses( upstreamResponse = await activeAdapter.fetchResponse(request, { abortSignal: upstream.signal, timeoutMs: connectMs, + skip429Retry: antigravityPoolOwns429, stream: parsed.stream, }); } else { @@ -2153,7 +2211,12 @@ export async function handleResponses( noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery); try { return activeAdapter.fetchResponse - ? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream }) + ? await activeAdapter.fetchResponse(retryRequest, { + abortSignal: upstream.signal, + timeoutMs: connectMs, + skip429Retry: antigravityPoolOwns429, + stream: parsed.stream, + }) : await fetchWithHeaderTimeout(retryRequest.url, { method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body, }, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider)); @@ -2263,6 +2326,59 @@ export async function handleResponses( break; } } + // Opt-in Google Antigravity OAuth pool: rotate only on an explicit upstream + // 429. Transport failures, client aborts, and other HTTP statuses never enter + // this block. Keep the shared Antigravity session id so replay state survives. + while ( + upstreamResponse.status === 429 + && googleAntigravityPoolAccountId + && isGoogleAntigravityAccountPoolEnabled(config) + && googleAntigravityPoolFailovers + < GOOGLE_ANTIGRAVITY_POOL_MAX_FAILOVERS_PER_REQUEST + ) { + const nextAccountId = rotateGoogleAntigravityAccountOn429( + config, + googleAntigravityPoolAccountId, + upstreamResponse.headers.get("retry-after"), + antigravitySessionKey, + ); + if (!nextAccountId) break; + try { + void upstreamResponse.body?.cancel().catch(() => {}); + } catch { + /* already consumed/closed */ + } + try { + const credential = await getGoogleAntigravityPoolCredential(nextAccountId); + googleAntigravityPoolAccountId = nextAccountId; + googleAntigravityPoolFailovers += 1; + route.provider = { + ...route.provider, + apiKey: credential.accessToken, + project: credential.projectId, + }; + promoteGoogleAntigravityActiveAccount(nextAccountId); + logCtx.provider = formatGoogleAntigravityProviderForLog(nextAccountId); + activeAdapter = resolveAdapter( + resolveWireProtocolOverride( + route.providerName, + route.modelId, + route.provider, + ), + config.cacheRetention, + ); + sealRequestAttemptIdentity( + logCtx.activeAttempt, + logCtx.provider, + activeAdapter.name, + ); + const result = await rebuildAndRefetch("google-antigravity-oauth-429"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + } catch { + break; + } + } // Anthropic 413 request_too_large: rebuild once with every image one tier lower // (spiral guard: single attempt). The biased response re-enters the 429 check above. if (shouldAttemptImageTierRetry({ @@ -2339,6 +2455,7 @@ export async function handleResponses( response = await activeAdapter.fetchResponse(continuationRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, + skip429Retry: antigravityPoolOwns429, stream: nextParsed.stream, }); } else { diff --git a/src/types.ts b/src/types.ts index 983b483dd..1e55b40de 100644 --- a/src/types.ts +++ b/src/types.ts @@ -717,6 +717,20 @@ export interface OcxConfig { /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ stickyLimit?: number; }; + /** + * Opt-in Google Antigravity OAuth account pool. Default OFF. + * Rotates only after explicit upstream 429 responses and keeps session affinity + * aligned with Antigravity thought-signature replay. + */ + googleAntigravityAccountPool?: { + enabled?: boolean; + /** Usage % threshold for new-session auto-pick. Default 80. 0 = disabled (affinity/active only). */ + autoSwitchThreshold?: number; + /** New-session rotation strategy. Default quota. */ + strategy?: OcxAccountPoolRotationStrategy; + /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ + stickyLimit?: number; + }; /** Virtual `combo/` models spanning concrete provider/model targets (issue #133). */ combos?: Record; /** Background proactive token refresh ("Token Guardian"). Off by default; see OcxTokenGuardianConfig. */ diff --git a/src/usage/log.ts b/src/usage/log.ts index 2bdd07995..1664ca12e 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -13,6 +13,7 @@ export type AttemptRecoveryKind = | "oauth-401" | "key-429" | "anthropic-oauth-429" + | "google-antigravity-oauth-429" | "image-413"; export interface PersistedUsageAttempt { diff --git a/tests/account-pool-management-api.test.ts b/tests/account-pool-management-api.test.ts index 4dbe39926..81e168b8d 100644 --- a/tests/account-pool-management-api.test.ts +++ b/tests/account-pool-management-api.test.ts @@ -5,6 +5,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleCodexAuthAPI } from "../src/codex/auth-api"; import { saveConfig } from "../src/config"; +import { + clearGoogleAntigravityAccountPoolState, + getGoogleAntigravityAccountHealthSnapshot, + rotateGoogleAntigravityAccountOn429, +} from "../src/oauth/google-antigravity-routing"; import { startServer } from "../src/server"; import type { OcxConfig } from "../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; @@ -150,6 +155,12 @@ describe("Anthropic account pool strategy management API", () => { defaultProvider: "anthropic", providers: { anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }, + "google-antigravity": { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + googleMode: "cloud-code-assist", + }, }, } as OcxConfig; } @@ -159,6 +170,7 @@ describe("Anthropic account pool strategy management API", () => { isolatedCodexHome = installIsolatedCodexHome("ocx-pool-mgmt-codex-"); testDir = mkdtempSync(join(tmpdir(), "ocx-pool-mgmt-")); process.env.OPENCODEX_HOME = testDir; + clearGoogleAntigravityAccountPoolState(); saveConfig(baseConfig()); writeFileSync(join(testDir, "auth.json"), JSON.stringify({ anthropic: { @@ -167,6 +179,13 @@ describe("Anthropic account pool strategy management API", () => { { id: "aaaa1111", credential: { access: "t1", refresh: "r1", expires: 9999999999999, email: "a@example.com", accountId: "acct-1" } }, ], }, + "google-antigravity": { + activeAccountId: "google-a", + accounts: [ + { id: "google-a", credential: { access: "gt1", refresh: "gr1", expires: 9999999999999, email: "ga@example.com", accountId: "google-acct-1", projectId: "project-a" } }, + { id: "google-b", credential: { access: "gt2", refresh: "gr2", expires: 9999999999999, email: "gb@example.com", accountId: "google-acct-2", projectId: "project-b" } }, + ], + }, }), { mode: 0o600 }); }); @@ -175,6 +194,7 @@ describe("Anthropic account pool strategy management API", () => { else process.env.OPENCODEX_HOME = previousHome; isolatedCodexHome?.restore(); isolatedCodexHome = null; + clearGoogleAntigravityAccountPoolState(); if (testDir) rmSync(testDir, { recursive: true, force: true }); }); @@ -352,4 +372,116 @@ describe("Anthropic account pool strategy management API", () => { await server.stop(true); } }); + + test("Google Antigravity pool defaults off and persists independent settings", async () => { + const server = startServer(0); + try { + const initial = await fetch(new URL( + "/api/oauth/accounts/pool?provider=google-antigravity", + server.url, + )); + expect(initial.status).toBe(200); + expect(await initial.json()).toMatchObject({ + provider: "google-antigravity", + enabled: false, + autoSwitchThreshold: 80, + strategy: "quota", + stickyLimit: 1, + }); + + const put = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "google-antigravity", + enabled: true, + autoSwitchThreshold: 65, + strategy: "round-robin", + stickyLimit: 3, + }), + }); + expect(put.status).toBe(200); + expect(await put.json()).toMatchObject({ + provider: "google-antigravity", + enabled: true, + autoSwitchThreshold: 65, + strategy: "round-robin", + stickyLimit: 3, + }); + + const anthropic = await fetch(new URL( + "/api/oauth/accounts/pool?provider=anthropic", + server.url, + )); + expect(await anthropic.json()).toMatchObject({ + provider: "anthropic", + enabled: false, + strategy: "quota", + }); + } finally { + await server.stop(true); + } + }); + + test("Google Antigravity clear-cooldown removes only requested cooldown", async () => { + const poolConfig = { + ...baseConfig(), + googleAntigravityAccountPool: { enabled: true }, + }; + saveConfig(poolConfig); + expect( + rotateGoogleAntigravityAccountOn429( + poolConfig, + "google-a", + "120", + "management-session", + ), + ).toBe("google-b"); + expect(getGoogleAntigravityAccountHealthSnapshot("google-a")).not.toBeNull(); + + const server = startServer(0); + try { + const response = await fetch(new URL( + "/api/oauth/accounts/clear-cooldown", + server.url, + ), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "google-antigravity", + accountId: "google-a", + }), + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ ok: true, cleared: true }); + expect(getGoogleAntigravityAccountHealthSnapshot("google-a")).toBeNull(); + } finally { + await server.stop(true); + } + }); + + test("Google Antigravity active selection resets routing state", async () => { + const server = startServer(0); + try { + const response = await fetch(new URL( + "/api/oauth/accounts/active", + server.url, + ), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "google-antigravity", + accountId: "google-b", + }), + }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + ok: true, + provider: "google-antigravity", + activeAccountId: "google-b", + }); + } finally { + await server.stop(true); + } + }); }); diff --git a/tests/google-antigravity-account-pool.test.ts b/tests/google-antigravity-account-pool.test.ts new file mode 100644 index 000000000..6e102cc0d --- /dev/null +++ b/tests/google-antigravity-account-pool.test.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + POOL_KEY_ANTIGRAVITY, + clearPoolRotationState, + notePoolRotationFailure, +} from "../src/codex/pool-rotation"; +import { + bindGoogleAntigravitySessionAffinity, + clearGoogleAntigravityAccountPoolState, + getEligibleGoogleAntigravityAccounts, + getGoogleAntigravityAccountHealthSnapshot, + getGoogleAntigravityPoolCredential, + googleAntigravitySessionKey, + isGoogleAntigravityAccountPoolEnabled, + resolveGoogleAntigravityAccountForSession, + resetGoogleAntigravityRoutingForManualSelection, + rotateGoogleAntigravityAccountOn429, +} from "../src/oauth/google-antigravity-routing"; +import { getAccountSet, saveCredential, setActiveAccount } from "../src/oauth/store"; +import { + clearAccountQuotaCache, + setCachedProviderAccountQuotaForTests, +} from "../src/providers/quota"; +import type { + OcxAccountPoolRotationStrategy, + OcxConfig, +} from "../src/types"; + +const PROVIDER = "google-antigravity"; +const originalHome = process.env.OPENCODEX_HOME; +let home: string; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-antigravity-pool-")); + process.env.OPENCODEX_HOME = home; + clearGoogleAntigravityAccountPoolState(); + clearPoolRotationState(); + clearAccountQuotaCache(PROVIDER); +}); + +afterEach(() => { + clearGoogleAntigravityAccountPoolState(); + clearPoolRotationState(); + clearAccountQuotaCache(PROVIDER); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + rmSync(home, { recursive: true, force: true }); +}); + +function config( + enabled: boolean, + threshold = 80, + pool: { + strategy?: OcxAccountPoolRotationStrategy; + stickyLimit?: number; + } = {}, +): OcxConfig { + return { + port: 0, + defaultProvider: PROVIDER, + providers: { + [PROVIDER]: { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + googleMode: "cloud-code-assist", + }, + }, + googleAntigravityAccountPool: { + enabled, + autoSwitchThreshold: threshold, + ...pool, + }, + }; +} + +async function seedAccounts(count = 2): Promise { + for (let index = 0; index < count; index++) { + const suffix = String.fromCharCode(97 + index); + await saveCredential(PROVIDER, { + access: `access-${suffix}`, + refresh: `refresh-${suffix}`, + expires: Date.now() + 3_600_000, + accountId: `account-${suffix}`, + email: `${suffix}@example.test`, + projectId: `project-${suffix}`, + }); + } + const set = getAccountSet(PROVIDER)!; + const ids = set.accounts.map(account => account.id); + await setActiveAccount(PROVIDER, ids[0]!); + return ids; +} + +describe("Google Antigravity account pool", () => { + test("is default-off and keeps the active account", async () => { + const [activeId, otherId] = await seedAccounts(); + expect(isGoogleAntigravityAccountPoolEnabled(config(false))).toBe(false); + const selection = resolveGoogleAntigravityAccountForSession( + "session-1", + config(false), + ); + expect(selection).toEqual({ accountId: activeId, reason: "pool-disabled" }); + expect(selection.accountId).not.toBe(otherId); + }); + + test("uses antigravitySessionId as the stable affinity key", () => { + const parsed = { + context: { messages: [] }, + }; + const first = googleAntigravitySessionKey(parsed); + const second = googleAntigravitySessionKey(parsed); + expect(first).toBe(second); + expect(first).toMatch(/^-\d+$/); + }); + + test("sticks a session to its selected account", async () => { + const [activeId, otherId] = await seedAccounts(); + setCachedProviderAccountQuotaForTests(PROVIDER, activeId!, { + fiveHourPercent: 95, + }); + setCachedProviderAccountQuotaForTests(PROVIDER, otherId!, { + fiveHourPercent: 10, + }); + const first = resolveGoogleAntigravityAccountForSession( + "sticky", + config(true), + ); + expect(first.accountId).toBe(otherId); + setCachedProviderAccountQuotaForTests(PROVIDER, activeId!, { + fiveHourPercent: 1, + }); + expect( + resolveGoogleAntigravityAccountForSession("sticky", config(true)), + ).toEqual({ accountId: otherId, reason: "affinity" }); + }); + + test("429 cools only the failed account and rebinds affinity", async () => { + const [firstId, secondId] = await seedAccounts(); + bindGoogleAntigravitySessionAffinity("session-429", firstId!); + const next = rotateGoogleAntigravityAccountOn429( + config(true), + firstId!, + "30", + "session-429", + ); + expect(next).toBe(secondId); + expect(getEligibleGoogleAntigravityAccounts()).toEqual([secondId]); + expect( + resolveGoogleAntigravityAccountForSession("session-429", config(true)), + ).toEqual({ accountId: secondId, reason: "affinity" }); + }); + + test("non-429 outcomes do not change pool health or affinity", async () => { + const [firstId] = await seedAccounts(); + bindGoogleAntigravitySessionAffinity("unchanged", firstId!); + + for (const status of [400, 502, 499]) { + expect(status).not.toBe(429); + expect(getGoogleAntigravityAccountHealthSnapshot(firstId!)).toBeNull(); + expect( + resolveGoogleAntigravityAccountForSession("unchanged", config(true)), + ).toEqual({ accountId: firstId, reason: "affinity" }); + } + }); + + test("all cooled accounts return all-cooled", async () => { + const [firstId, secondId] = await seedAccounts(); + expect( + rotateGoogleAntigravityAccountOn429(config(true), firstId!, "120"), + ).toBe(secondId); + expect( + rotateGoogleAntigravityAccountOn429(config(true), secondId!, "120"), + ).toBeNull(); + expect( + resolveGoogleAntigravityAccountForSession("cooled", config(true)), + ).toEqual({ accountId: null, reason: "all-cooled" }); + }); + + test("round-robin rotates new sessions and affinity still wins", async () => { + const ids = await seedAccounts(3); + const poolConfig = config(true, 80, { + strategy: "round-robin", + stickyLimit: 1, + }); + const picks = [ + resolveGoogleAntigravityAccountForSession("rr-1", poolConfig).accountId, + resolveGoogleAntigravityAccountForSession("rr-2", poolConfig).accountId, + resolveGoogleAntigravityAccountForSession("rr-3", poolConfig).accountId, + ]; + expect(new Set(picks)).toEqual(new Set(ids)); + expect( + resolveGoogleAntigravityAccountForSession("rr-1", poolConfig).accountId, + ).toBe(picks[0]); + }); + + test("stickyLimit and manual active reset share the Antigravity ring", async () => { + const ids = await seedAccounts(3); + const poolConfig = config(true, 80, { + strategy: "round-robin", + stickyLimit: 3, + }); + const first = resolveGoogleAntigravityAccountForSession( + "batch-1", + poolConfig, + ).accountId; + expect( + resolveGoogleAntigravityAccountForSession("batch-2", poolConfig).accountId, + ).toBe(first); + expect( + resolveGoogleAntigravityAccountForSession("batch-3", poolConfig).accountId, + ).toBe(first); + + notePoolRotationFailure(POOL_KEY_ANTIGRAVITY, first!); + expect( + resolveGoogleAntigravityAccountForSession("batch-4", poolConfig).accountId, + ).not.toBe(first); + + resetGoogleAntigravityRoutingForManualSelection(ids[2]!); + expect( + resolveGoogleAntigravityAccountForSession("manual", poolConfig).accountId, + ).toBe(ids[2]); + }); + + test("returns token and project from the same account", async () => { + const [firstId, secondId] = await seedAccounts(); + await expect(getGoogleAntigravityPoolCredential(firstId!)).resolves.toEqual({ + accessToken: "access-a", + projectId: "project-a", + }); + await expect(getGoogleAntigravityPoolCredential(secondId!)).resolves.toEqual({ + accessToken: "access-b", + projectId: "project-b", + }); + }); +}); diff --git a/tests/server-google-antigravity-pool-e2e.test.ts b/tests/server-google-antigravity-pool-e2e.test.ts new file mode 100644 index 000000000..873b7fa8c --- /dev/null +++ b/tests/server-google-antigravity-pool-e2e.test.ts @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../src/config"; +import { clearGoogleAntigravityAccountPoolState } from "../src/oauth/google-antigravity-routing"; +import { saveCredential, setActiveAccount, getAccountSet } from "../src/oauth/store"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { + installIsolatedCodexHome, + type IsolatedCodexHome, +} from "./helpers/isolated-codex-home"; + +const PROVIDER = "google-antigravity"; +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +function config(): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: PROVIDER, + providers: { + [PROVIDER]: { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + googleMode: "cloud-code-assist", + }, + }, + googleAntigravityAccountPool: { enabled: true }, + }; +} + +async function seedTwoAccounts(): Promise { + await saveCredential(PROVIDER, { + access: "token-a", + refresh: "refresh-a", + expires: Date.now() + 3_600_000, + accountId: "account-a", + projectId: "project-a", + }); + await saveCredential(PROVIDER, { + access: "token-b", + refresh: "refresh-b", + expires: Date.now() + 3_600_000, + accountId: "account-b", + projectId: "project-b", + }); + const firstId = getAccountSet(PROVIDER)!.accounts[0]!.id; + await setActiveAccount(PROVIDER, firstId); +} + +function successfulAntigravityResponse(): Response { + return new Response(JSON.stringify({ + response: { + candidates: [{ + content: { role: "model", parts: [{ text: "ok after rotation" }] }, + finishReason: "STOP", + }], + usageMetadata: { + promptTokenCount: 2, + candidatesTokenCount: 3, + totalTokenCount: 5, + }, + }, + }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(async () => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-antigravity-pool-e2e-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-antigravity-pool-e2e-")); + process.env.OPENCODEX_HOME = testDir; + clearGoogleAntigravityAccountPoolState(); + await seedTwoAccounts(); + saveConfig(config()); +}); + +afterEach(() => { + clearGoogleAntigravityAccountPoolState(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) rmSync(testDir, { recursive: true, force: true }); +}); + +describe("server Google Antigravity account pool", () => { + test("an explicit 429 rotates token and project while preserving session id", async () => { + const originalFetch = globalThis.fetch; + const attempts: Array<{ + authorization: string | null; + project: unknown; + sessionId: unknown; + }> = []; + globalThis.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.startsWith("https://daily-cloudcode-pa.googleapis.com/")) { + const envelope = JSON.parse(String(init?.body)); + attempts.push({ + authorization: new Headers(init?.headers).get("authorization"), + project: envelope.project, + sessionId: envelope.request?.sessionId, + }); + if (attempts.length === 1) { + return new Response(JSON.stringify({ error: { message: "quota exhausted" } }), { + status: 429, + headers: { + "content-type": "application/json", + "retry-after": "30", + }, + }); + } + return successfulAntigravityResponse(); + } + return originalFetch(input, init); + }; + + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "google-antigravity/gemini-3.1-pro", + input: "keep this session stable", + stream: false, + }), + }); + expect(response.status).toBe(200); + expect(attempts).toHaveLength(2); + expect(attempts.map(attempt => attempt.authorization)).toEqual([ + "Bearer token-a", + "Bearer token-b", + ]); + expect(attempts.map(attempt => attempt.project)).toEqual([ + "project-a", + "project-b", + ]); + expect(attempts[0]!.sessionId).toBe(attempts[1]!.sessionId); + } finally { + server.stop(true); + globalThis.fetch = originalFetch; + } + }); + + test("400 and 502 responses never rotate accounts", async () => { + const originalFetch = globalThis.fetch; + const attempts: string[] = []; + let upstreamStatus = 400; + globalThis.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.startsWith("https://daily-cloudcode-pa.googleapis.com/")) { + attempts.push(new Headers(init?.headers).get("authorization") ?? ""); + return new Response(JSON.stringify({ error: { message: "not a quota error" } }), { + status: upstreamStatus, + headers: { "content-type": "application/json" }, + }); + } + return originalFetch(input, init); + }; + + const server = startServer(0); + try { + for (const status of [400, 502]) { + upstreamStatus = status; + attempts.length = 0; + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "google-antigravity/gemini-3.1-pro", + input: `status ${status}`, + stream: false, + }), + }); + expect(response.status).toBe(status); + expect(attempts.length).toBeGreaterThan(0); + expect(attempts.every(auth => auth === "Bearer token-a")).toBe(true); + } + } finally { + server.stop(true); + globalThis.fetch = originalFetch; + } + }); + + test("stops after three 429 failovers even when more accounts are eligible", async () => { + for (const suffix of ["c", "d", "e"]) { + await saveCredential(PROVIDER, { + access: `token-${suffix}`, + refresh: `refresh-${suffix}`, + expires: Date.now() + 3_600_000, + accountId: `account-${suffix}`, + projectId: `project-${suffix}`, + }); + } + const firstId = getAccountSet(PROVIDER)!.accounts[0]!.id; + await setActiveAccount(PROVIDER, firstId); + + const originalFetch = globalThis.fetch; + const attempts: string[] = []; + globalThis.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.startsWith("https://daily-cloudcode-pa.googleapis.com/")) { + attempts.push(new Headers(init?.headers).get("authorization") ?? ""); + return new Response(JSON.stringify({ error: { message: "quota exhausted" } }), { + status: 429, + headers: { + "content-type": "application/json", + "retry-after": "30", + }, + }); + } + return originalFetch(input, init); + }; + + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "google-antigravity/gemini-3.1-pro", + input: "bounded failover", + stream: false, + }), + }); + expect(response.status).toBe(429); + expect(attempts).toHaveLength(4); + expect(new Set(attempts).size).toBe(4); + } finally { + server.stop(true); + globalThis.fetch = originalFetch; + } + }); +}); From 9ffa2c9415284fbabba07e1c7944d7efda089d3b Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 11:47:20 +0000 Subject: [PATCH 05/31] fix(oauth): release Antigravity session affinity when a rotation target is unusable rotateGoogleAntigravityAccountOn429 binds the session to the alternate before the caller resolves its credential. A non-terminal refresh failure does not mark the account needsReauth, so it stays eligible and the stale binding made every later request in that session replay the account it could not authenticate with. Release the binding when the credential lookup throws. Co-authored-by: Codesmith --- src/oauth/google-antigravity-routing.ts | 15 ++++++ src/server/responses/core.ts | 7 +++ tests/google-antigravity-account-pool.test.ts | 47 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/src/oauth/google-antigravity-routing.ts b/src/oauth/google-antigravity-routing.ts index 4868d1d22..8ad87d6ab 100644 --- a/src/oauth/google-antigravity-routing.ts +++ b/src/oauth/google-antigravity-routing.ts @@ -330,6 +330,21 @@ export function clearGoogleAntigravitySessionAffinityForAccount( } } +/** + * Drop a session's binding to `accountId`, leaving other sessions untouched and + * ignoring a session that has already moved to a different account. Callers use + * this when a rotation target turns out to be unusable, so the session is not + * pinned to an account it can never authenticate with. + */ +export function releaseGoogleAntigravitySessionAffinity( + sessionKey: string | null | undefined, + accountId: string, +): void { + const key = sessionKey?.trim(); + if (!key) return; + if (sessionAffinity.get(key)?.accountId === accountId) sessionAffinity.delete(key); +} + export function googleAntigravitySessionKey( parsed: Pick, ): string { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index f84182ce2..354f03e4a 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -61,6 +61,7 @@ import { googleAntigravitySessionKey, isGoogleAntigravityAccountPoolEnabled, promoteGoogleAntigravityActiveAccount, + releaseGoogleAntigravitySessionAffinity, resolveGoogleAntigravityAccountForSession, rotateGoogleAntigravityAccountOn429, } from "../../oauth/google-antigravity-routing"; @@ -2376,6 +2377,12 @@ export async function handleResponses( if ("failed" in result) return result.failed; upstreamResponse = result; } catch { + // The alternate could not produce a credential (refresh or project + // resolution failed). A non-terminal refresh failure leaves it eligible, + // so drop the affinity the rotation just bound; otherwise later requests + // in this session replay the same unusable account instead of selecting + // another one. + releaseGoogleAntigravitySessionAffinity(antigravitySessionKey, nextAccountId); break; } } diff --git a/tests/google-antigravity-account-pool.test.ts b/tests/google-antigravity-account-pool.test.ts index 6e102cc0d..16799eae5 100644 --- a/tests/google-antigravity-account-pool.test.ts +++ b/tests/google-antigravity-account-pool.test.ts @@ -15,6 +15,7 @@ import { getGoogleAntigravityPoolCredential, googleAntigravitySessionKey, isGoogleAntigravityAccountPoolEnabled, + releaseGoogleAntigravitySessionAffinity, resolveGoogleAntigravityAccountForSession, resetGoogleAntigravityRoutingForManualSelection, rotateGoogleAntigravityAccountOn429, @@ -154,6 +155,52 @@ describe("Google Antigravity account pool", () => { ).toEqual({ accountId: secondId, reason: "affinity" }); }); + test("an unusable rotation target releases its session affinity", async () => { + const [firstId, secondId] = await seedAccounts(); + const now = Date.now(); + bindGoogleAntigravitySessionAffinity("session-unusable", firstId!, now); + expect( + rotateGoogleAntigravityAccountOn429( + config(true), + firstId!, + "30", + "session-unusable", + now, + ), + ).toBe(secondId); + // Rotation binds the alternate before its credential is resolved. + expect( + resolveGoogleAntigravityAccountForSession( + "session-unusable", + config(true), + now, + ), + ).toEqual({ accountId: secondId, reason: "affinity" }); + + releaseGoogleAntigravitySessionAffinity("session-unusable", secondId!); + + // A mismatched release never drops another session's binding. + bindGoogleAntigravitySessionAffinity("session-other", secondId!, now); + releaseGoogleAntigravitySessionAffinity("session-other", firstId!); + expect( + resolveGoogleAntigravityAccountForSession( + "session-other", + config(true), + now, + ), + ).toEqual({ accountId: secondId, reason: "affinity" }); + + // The released session re-selects once the cooled account recovers instead of + // staying pinned to the account that could not authenticate. + expect( + resolveGoogleAntigravityAccountForSession( + "session-unusable", + config(true), + now + 31_000, + ), + ).toEqual({ accountId: firstId, reason: "active" }); + }); + test("non-429 outcomes do not change pool health or affinity", async () => { const [firstId] = await seedAccounts(); bindGoogleAntigravitySessionAffinity("unchanged", firstId!); From c452fec1781b6d2440cf33350cf2af2b700df83a Mon Sep 17 00:00:00 2001 From: GroepChef Date: Thu, 30 Jul 2026 13:36:07 +0200 Subject: [PATCH 06/31] feat(retry): unified outcome classifier and global 3-attempt budget Classify overflow before rotate/cooldown, fail-fast when Retry-After exceeds 60s, and share one OCX upstream-attempt budget across transport retries and Antigravity/key pool failovers. Cursor abort/EOF fixtures stay non-rotating. Co-authored-by: Cursor --- src/adapters/base.ts | 5 + src/adapters/cursor.ts | 1 + src/adapters/cursor/transport-retry.ts | 17 ++ src/adapters/google-http.ts | 13 + src/adapters/kiro-retry.ts | 13 + src/lib/upstream-attempt-budget.ts | 31 +++ src/lib/upstream-outcome.ts | 223 ++++++++++++++++++ src/lib/upstream-retry.ts | 20 +- src/server/responses/core.ts | 147 ++++++++++-- tests/fixtures/cursor-log-outcomes.ts | 96 ++++++++ ...server-google-antigravity-pool-e2e.test.ts | 52 +++- tests/upstream-outcome-classifier.test.ts | 144 +++++++++++ 12 files changed, 741 insertions(+), 21 deletions(-) create mode 100644 src/lib/upstream-attempt-budget.ts create mode 100644 src/lib/upstream-outcome.ts create mode 100644 tests/fixtures/cursor-log-outcomes.ts create mode 100644 tests/upstream-outcome-classifier.test.ts diff --git a/src/adapters/base.ts b/src/adapters/base.ts index e391d69a5..94d5f97d6 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -1,9 +1,12 @@ import type { AdapterEvent, OcxParsedRequest } from "../types"; +import type { UpstreamAttemptBudget } from "../lib/upstream-attempt-budget"; /** Metadata about the caller's incoming request, for auth-forwarding adapters. */ export interface IncomingMeta { headers: Headers; abortSignal?: AbortSignal; + /** Shared physical-send budget for this client request. */ + attemptBudget?: UpstreamAttemptBudget; /** * Image-normalization ladder bias for upstream-413 tightened retries: every image * starts one tier lower (devlog/260714_image_normalization_pipeline/030). Only the @@ -67,4 +70,6 @@ export interface AdapterFetchContext { skip429Retry?: boolean; /** Whether the upstream response will be consumed as a stream; adapters may select low-latency transport settings. */ stream?: boolean; + /** Shared physical-send budget for this client request. */ + attemptBudget?: UpstreamAttemptBudget; } diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index 2de2ff149..c9c3a64dc 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -139,6 +139,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda emit(event); } }, + incoming.attemptBudget, ); }; diff --git a/src/adapters/cursor/transport-retry.ts b/src/adapters/cursor/transport-retry.ts index 01b2b5519..aa02c8641 100644 --- a/src/adapters/cursor/transport-retry.ts +++ b/src/adapters/cursor/transport-retry.ts @@ -3,6 +3,8 @@ import type { CursorTransport, CursorTransportFactory, CursorTransportFactoryInp import { abortError, retryBackoffDelayMs, sleepWithAbort } from "../../lib/upstream-retry"; import { debugProviderDiagnostic } from "../../lib/debug"; import { safeCursorErrorMessage } from "./cursor-errors"; +import type { UpstreamAttemptBudget } from "../../lib/upstream-attempt-budget"; +import { classifyCursorUpstreamOutcome } from "../../lib/upstream-outcome"; // Compat: historical name for the shared abortable sleep, kept for external callers. export { sleepWithAbort as abortAwareSleep } from "../../lib/upstream-retry"; @@ -21,6 +23,16 @@ export function isRetryableCursorError(err: unknown): boolean { const code = typeof err === "object" && err && "code" in err ? String((err as { code?: unknown }).code ?? "") : ""; const message = err instanceof Error ? err.message : typeof err === "string" ? err : ""; const haystack = `${code} ${message}`.toLowerCase(); + const outcome = classifyCursorUpstreamOutcome({ message, code }); + // Abort/EOF/quota never replay. Cursor same-account retry is transport-only. + if ( + outcome === "client-abort" + || outcome === "adapter-eof" + || outcome === "rate-limit" + || outcome === "quota-exhausted" + || outcome === "context-overflow" + ) return false; + if (outcome === "transient-transport") return true; if (/auth|unauthor|forbidden|invalid|permission|denied|not found|unsupported/.test(haystack)) return false; if (/resource.exhausted|resource_exhausted|rate limit|too many requests|throttl/.test(haystack)) return false; if (haystack.includes("nghttp2_cancel") || haystack.includes("stream suspended")) return false; @@ -69,9 +81,13 @@ export async function runCursorTurnWithRetry( request: CursorRunRequest, signal: AbortSignal | undefined, onEvent: (message: CursorServerMessage, transport: CursorTransport) => void, + attemptBudget?: UpstreamAttemptBudget, ): Promise { for (let attempt = 0; ; attempt++) { if (signal?.aborted) throw abortError(signal); + if (attemptBudget && !attemptBudget.tryBegin()) { + throw new Error("OCX upstream attempt budget exhausted"); + } const transport = makeTransport(input); let emittedAny = false; let closed = false; @@ -99,6 +115,7 @@ export async function runCursorTurnWithRetry( const canRetry = !emittedAny && attempt < CURSOR_RETRY_ATTEMPTS - 1 && + (attemptBudget?.remaining ?? 1) > 0 && !signal?.aborted && requestUncommitted(transport) && isRetryableCursorError(err); diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index d4bfdb1b4..5424b97ac 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -7,8 +7,10 @@ import { cancelResponseBodyBestEffort, fetchWithAttemptDeadline, retryBackoffDelayMs, + retryAfterExceedsInternalLimit, sleepWithAbort, } from "../lib/upstream-retry"; +import { classifyUpstreamResponse, upstreamOutcomePolicy } from "../lib/upstream-outcome"; const GOOGLE_RETRY_ATTEMPTS = 3; const GOOGLE_RETRY_BASE_MS = 250; @@ -34,6 +36,9 @@ export async function fetchGoogleWithRetry(label: string, request: AdapterReques let compatibilityReplayUsed = false; for (let attempt = 0; attempt < GOOGLE_RETRY_ATTEMPTS; attempt++) { if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal); + if (ctx.attemptBudget && !ctx.attemptBudget.tryBegin()) { + throw lastError ?? new Error("OCX upstream attempt budget exhausted"); + } try { const res = await fetchWithAttemptDeadline(activeRequest.url, { method: activeRequest.method, @@ -64,6 +69,14 @@ export async function fetchGoogleWithRetry(label: string, request: AdapterReques if (!retryableGoogleStatus(res.status) || attempt === GOOGLE_RETRY_ATTEMPTS - 1) { return ctx.returnRawErrors ? res : normalizeFinalGoogleError(label, res, ctx.abortSignal); } + const outcome = await classifyUpstreamResponse("google", res, ctx.abortSignal); + if ( + !upstreamOutcomePolicy(outcome).sameAccountRetry + || ctx.attemptBudget?.remaining === 0 + || retryAfterExceedsInternalLimit(res.headers) + ) { + return ctx.returnRawErrors ? res : normalizeFinalGoogleError(label, res, ctx.abortSignal); + } // A 429 may be a transient rate limit (retry) or hard quota exhaustion (do NOT retry — // it won't recover for hours and burns retries). Peek the body to tell them apart. if (res.status === 429 && !ctx.returnRawErrors) { diff --git a/src/adapters/kiro-retry.ts b/src/adapters/kiro-retry.ts index b894338f8..08f32c2a6 100644 --- a/src/adapters/kiro-retry.ts +++ b/src/adapters/kiro-retry.ts @@ -9,6 +9,7 @@ import { cancelResponseBodyBestEffort, fetchWithAttemptDeadline, isConnectionResetError, + MAX_INTERNAL_RETRY_AFTER_MS, retryBackoffDelayMs, sleepWithAbort, } from "../lib/upstream-retry"; @@ -162,6 +163,9 @@ async function fetchWithResetRecovery( let lastError: unknown; for (let attempt = 0; attempt < RESET_ATTEMPTS; attempt++) { if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal); + if (ctx.attemptBudget && !ctx.attemptBudget.tryBegin()) { + throw lastError ?? new Error("OCX upstream attempt budget exhausted"); + } try { const headers = new Headers(request.headers); const recovered = attempt > 0; @@ -287,6 +291,15 @@ export async function fetchKiroWithRetry(request: AdapterRequest, ctx: AdapterFe ? finalResponse : normalizeFinalKiroHttpError(finalResponse, ctx.abortSignal); } + if ( + throttle.delayMs > MAX_INTERNAL_RETRY_AFTER_MS + || ctx.attemptBudget?.remaining === 0 + ) { + releaseKiroThrottleProbe(probeToken); + return ctx.returnRawErrors + ? throttle.response + : normalizeFinalKiroHttpError(throttle.response, ctx.abortSignal); + } const claim = claimKiroThrottleProbe(throttle.delayMs, probeToken); if (!claim.token) { diff --git a/src/lib/upstream-attempt-budget.ts b/src/lib/upstream-attempt-budget.ts new file mode 100644 index 000000000..ab5b6abed --- /dev/null +++ b/src/lib/upstream-attempt-budget.ts @@ -0,0 +1,31 @@ +export const OCX_MAX_UPSTREAM_ATTEMPTS = 3; + +export interface UpstreamAttemptBudget { + readonly limit: number; + readonly used: number; + readonly remaining: number; + tryBegin(): boolean; +} + +export function createUpstreamAttemptBudget( + limit = OCX_MAX_UPSTREAM_ATTEMPTS, +): UpstreamAttemptBudget { + const normalizedLimit = Math.max(1, Math.floor(limit)); + let used = 0; + return { + get limit() { + return normalizedLimit; + }, + get used() { + return used; + }, + get remaining() { + return Math.max(0, normalizedLimit - used); + }, + tryBegin() { + if (used >= normalizedLimit) return false; + used += 1; + return true; + }, + }; +} diff --git a/src/lib/upstream-outcome.ts b/src/lib/upstream-outcome.ts new file mode 100644 index 000000000..fc8ca6d66 --- /dev/null +++ b/src/lib/upstream-outcome.ts @@ -0,0 +1,223 @@ +import { readBoundedResponseBody } from "./bounded-body"; + +export type UpstreamRail = "generic" | "cursor" | "google" | "kiro"; + +export type UpstreamOutcomeLabel = + | "success" + | "client-abort" + | "adapter-eof" + | "context-overflow" + | "rate-limit" + | "quota-exhausted" + | "billing" + | "empty-pool" + | "transient-transport" + | "authentication" + | "invalid-request" + | "other"; + +export interface UpstreamOutcomeEvidence { + status?: number; + message?: string; + code?: string | null; +} + +export interface UpstreamOutcomePolicy { + sameAccountRetry: boolean; + rotateOrCool: boolean; + failover: boolean; +} + +const NO_RECOVERY: UpstreamOutcomePolicy = { + sameAccountRetry: false, + rotateOrCool: false, + failover: false, +}; + +const OUTCOME_POLICIES: Record = { + success: NO_RECOVERY, + "client-abort": NO_RECOVERY, + "adapter-eof": NO_RECOVERY, + "context-overflow": NO_RECOVERY, + billing: NO_RECOVERY, + "empty-pool": NO_RECOVERY, + authentication: NO_RECOVERY, + "invalid-request": NO_RECOVERY, + // Transient throttle may same-account retry; account pools still cool/rotate. + "rate-limit": { sameAccountRetry: true, rotateOrCool: true, failover: true }, + "quota-exhausted": { sameAccountRetry: false, rotateOrCool: true, failover: true }, + "transient-transport": { sameAccountRetry: true, rotateOrCool: false, failover: true }, + other: NO_RECOVERY, +}; + +export function upstreamOutcomePolicy(label: UpstreamOutcomeLabel): UpstreamOutcomePolicy { + return OUTCOME_POLICIES[label]; +} + +function evidenceText(evidence: UpstreamOutcomeEvidence): string { + return `${evidence.code ?? ""} ${evidence.message ?? ""}`.trim().toLowerCase(); +} + +function isContextOverflow(text: string): boolean { + return text.includes("context_length_exceeded") + || text.includes("context window") + || text.includes("context length") + || text.includes("maximum context") + || text.includes("input exceeds") + || text.includes("too many tokens"); +} + +function isClientAbort(status: number | undefined, text: string): boolean { + return status === 499 + || text.includes("client_closed_request") + || text.includes("client cancelled request") + || text.includes("client canceled request") + || text.includes("client closed request") + || text.includes("request was aborted") + || text.includes("request canceled by client") + || text.includes("request cancelled by client"); +} + +function isAdapterEof(text: string): boolean { + return text.includes("adapter_eof") + || text.includes("upstream stream ended unexpectedly without a terminal event"); +} + +function classifyShared(evidence: UpstreamOutcomeEvidence): UpstreamOutcomeLabel { + const status = evidence.status; + const text = evidenceText(evidence); + if (status !== undefined && status >= 200 && status < 400) return "success"; + if (isClientAbort(status, text)) return "client-abort"; + if (isAdapterEof(text)) return "adapter-eof"; + + // Overflow wins over RESOURCE_EXHAUSTED, 429, and quota words. It is a request + // property and must never cool an account or trigger a replay. + if (isContextOverflow(text)) return "context-overflow"; + + if ( + text.includes("billing") + || text.includes("payment required") + || text.includes("plan package has expired") + ) return "billing"; + if ( + text.includes("pool has no usable") + || text.includes("no eligible account") + || text.includes("empty pool") + ) return "empty-pool"; + if ( + text.includes("insufficient_quota") + || text.includes("quota exhausted") + || text.includes("quota exceeded") + || text.includes("usage limit has been reached") + ) return "quota-exhausted"; + if ( + status === 429 + || text.includes("resource_exhausted") + || text.includes("resource exhausted") + || text.includes("rate limit") + || text.includes("too many requests") + || text.includes("throttl") + ) return "rate-limit"; + if ( + status === 401 + || status === 403 + || text.includes("unauthorized") + || text.includes("unauthenticated") + || text.includes("invalid api key") + || text.includes("invalid token") + ) return "authentication"; + if ( + status === 408 + || status === 500 + || status === 502 + || status === 503 + || status === 504 + || status === 520 + || status === 521 + || status === 522 + || text.includes("econnreset") + || text.includes("econnrefused") + || text.includes("connection reset") + || text.includes("temporarily unavailable") + || text.includes("server overloaded") + ) return "transient-transport"; + if (status === 400 || status === 404 || status === 413 || status === 422) { + return "invalid-request"; + } + return "other"; +} + +export function classifyGenericUpstreamOutcome( + evidence: UpstreamOutcomeEvidence, +): UpstreamOutcomeLabel { + return classifyShared(evidence); +} + +export function classifyCursorUpstreamOutcome( + evidence: UpstreamOutcomeEvidence, +): UpstreamOutcomeLabel { + return classifyShared(evidence); +} + +export function classifyGoogleUpstreamOutcome( + evidence: UpstreamOutcomeEvidence, +): UpstreamOutcomeLabel { + return classifyShared(evidence); +} + +export function classifyKiroUpstreamOutcome( + evidence: UpstreamOutcomeEvidence, +): UpstreamOutcomeLabel { + return classifyShared(evidence); +} + +export function classifyUpstreamOutcome( + rail: UpstreamRail, + evidence: UpstreamOutcomeEvidence, +): UpstreamOutcomeLabel { + switch (rail) { + case "cursor": + return classifyCursorUpstreamOutcome(evidence); + case "google": + return classifyGoogleUpstreamOutcome(evidence); + case "kiro": + return classifyKiroUpstreamOutcome(evidence); + case "generic": + return classifyGenericUpstreamOutcome(evidence); + default: { + const exhaustive: never = rail; + return exhaustive; + } + } +} + +export async function classifyUpstreamResponse( + rail: UpstreamRail, + response: Response, + signal?: AbortSignal, +): Promise { + if (response.ok) return "success"; + // Clear gateway/transport statuses need no body peek. Avoid hanging on open streams. + if ( + response.status === 500 + || response.status === 502 + || response.status === 503 + || response.status === 504 + || response.status === 520 + || response.status === 521 + || response.status === 522 + ) { + return classifyUpstreamOutcome(rail, { status: response.status }); + } + let message = ""; + try { + const body = await readBoundedResponseBody(response.clone(), { signal }); + if (body.displaySafe) message = body.text; + } catch (error) { + if (signal?.aborted) throw error; + } + return classifyUpstreamOutcome(rail, { + status: response.status, + message, + }); +} diff --git a/src/lib/upstream-retry.ts b/src/lib/upstream-retry.ts index f767f045e..b08b92dc2 100644 --- a/src/lib/upstream-retry.ts +++ b/src/lib/upstream-retry.ts @@ -15,6 +15,8 @@ * the shared abort helpers from here). */ import { clearableDeadline } from "./abort"; +import type { UpstreamAttemptBudget } from "./upstream-attempt-budget"; +import { classifyUpstreamResponse, upstreamOutcomePolicy } from "./upstream-outcome"; // 1 initial + 2 retries: the pool may hold more than one stale socket. const RESET_RETRY_MAX_ATTEMPTS = 3; @@ -28,6 +30,7 @@ const TRANSIENT_RETRY_MAX_DELAY_MS = 5_000; // A failed attempt slower than this is the "slow 502" incident shape (191s observed on // 2026-07-15): retrying it only duplicates upstream load past client timeouts — return it. const TRANSIENT_RETRY_SLOW_ATTEMPT_MS = 15_000; +export const MAX_INTERNAL_RETRY_AFTER_MS = 60_000; /** * Upstream statuses treated as transient: gateway errors and Cloudflare 52x. @@ -82,7 +85,7 @@ export function isConnectionResetError(err: unknown): boolean { || msg.includes("connection reset by peer"); } -function retryAfterDelayMs(headers: Headers): number | undefined { +export function retryAfterDelayMs(headers: Headers): number | undefined { const raw = headers.get("retry-after")?.trim(); if (!raw) return undefined; const seconds = Number(raw); @@ -92,6 +95,11 @@ function retryAfterDelayMs(headers: Headers): number | undefined { return Math.max(0, dateMs - Date.now()); } +export function retryAfterExceedsInternalLimit(headers: Headers): boolean { + const delay = retryAfterDelayMs(headers); + return delay !== undefined && delay > MAX_INTERNAL_RETRY_AFTER_MS; +} + export function retryBackoffDelayMs(attempt: number, opts: RetryBackoffOptions): number { const retryAfter = opts.headers ? retryAfterDelayMs(opts.headers) : undefined; if (retryAfter !== undefined) return Math.min(retryAfter, opts.maxDelayMs); @@ -138,6 +146,8 @@ export interface ResetRetryOptions { /** Short host/path label for the retry warn log (no secrets/query strings). */ label?: string; attempts?: number; + /** Request-scoped physical upstream-send budget shared by every recovery layer. */ + attemptBudget?: UpstreamAttemptBudget; } export interface TransientRetryOptions extends ResetRetryOptions { @@ -183,6 +193,10 @@ export async function fetchWithResetRetry( let lastError: unknown; for (let attempt = 0; attempt < attempts; attempt++) { if (opts.abortSignal?.aborted) throw abortError(opts.abortSignal); + if (opts.attemptBudget && !opts.attemptBudget.tryBegin()) { + if (lastError !== undefined) throw lastError; + throw new Error("OCX upstream attempt budget exhausted"); + } try { return await doFetch(attempt === 0 ? firstRecovery : "connection-reset"); } catch (err) { @@ -222,6 +236,10 @@ export async function fetchWithTransientRetry( if (res.ok || !isTransientUpstreamStatus(res.status)) return res; if (opts.abortSignal?.aborted) return res; if (Date.now() - attemptStart > slowAttemptMs) return res; + if (opts.attemptBudget?.remaining === 0) return res; + if (retryAfterExceedsInternalLimit(res.headers)) return res; + const outcome = await classifyUpstreamResponse("generic", res, opts.abortSignal); + if (!upstreamOutcomePolicy(outcome).sameAccountRetry) return res; console.warn( `[upstream-retry] transient ${res.status}${opts.label ? ` (${opts.label})` : ""} — retrying (${attempt + 2}/${attempts})`, ); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 354f03e4a..fc2ae6f08 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -94,6 +94,15 @@ import { type CodexUpstreamOutcome, } from "../../codex/routing"; import { fetchWithResetRetry, fetchWithTransientRetry, applyUpstreamRecoveryInit } from "../../lib/upstream-retry"; +import { + createUpstreamAttemptBudget, + type UpstreamAttemptBudget, +} from "../../lib/upstream-attempt-budget"; +import { + classifyUpstreamResponse, + upstreamOutcomePolicy, + type UpstreamRail, +} from "../../lib/upstream-outcome"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; @@ -172,6 +181,17 @@ export function adapterNeedsForcedContinuation(name: string): boolean { return name === "kiro" || name === "cursor"; } +function outcomeRailForAdapter(name: string): UpstreamRail { + if (name === "cursor") return "cursor"; + if (name === "google") return "google"; + if (name === "kiro") return "kiro"; + return "generic"; +} + +function adapterOwnsAttemptBudget(name: string): boolean { + return name === "google" || name === "kiro"; +} + export function sidecarOutcomeRecorder( config: OcxConfig, authCtx: CodexAuthContext, @@ -254,8 +274,13 @@ async function shouldRetryCodexPoolAccountModel400( } /** Pre-stream quota/billing rejections that warrant one alternate-account attempt (#584). */ -function shouldRetryCodexPoolAccountQuota(response: Response): boolean { - return response.status === 429 || response.status === 402; +async function shouldRetryCodexPoolAccountQuota( + response: Response, + signal?: AbortSignal, +): Promise { + if (response.status !== 429 && response.status !== 402) return false; + const outcome = await classifyUpstreamResponse("generic", response, signal); + return upstreamOutcomePolicy(outcome).rotateOrCool; } interface CodexPoolAccountRetryArgs { @@ -268,6 +293,7 @@ interface CodexPoolAccountRetryArgs { abortSignal?: AbortSignal; onCodexAuthContextResolved?: (ctx: CodexAuthContext) => void; deferCodexResetDerivedCooldown?: boolean; + attemptBudget?: UpstreamAttemptBudget; }; firstAuthCtx: Extract; firstResponse: Response; @@ -329,6 +355,7 @@ async function retryCodexPoolOnAlternateAccount( req, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse, outcomeStatus, upstream, connectMs, passthroughEstimate, stream, } = args; + if (options.attemptBudget?.remaining === 0) return { kind: "no-alternate" }; let retryAuthCtx: CodexAuthContext | undefined; try { retryAuthCtx = await resolveCodexAuthContext( @@ -388,6 +415,9 @@ async function retryCodexPoolOnAlternateAccount( ); noteAttemptSend(logCtx.activeAttempt, passthroughEstimate); + if (options.attemptBudget && !options.attemptBudget.tryBegin()) { + return { kind: "no-alternate" }; + } try { const upstreamResponse = await fetchWithHeaderTimeout( request.url, @@ -519,6 +549,8 @@ export interface HandleResponsesOptions { deferCodexResetDerivedCooldown?: boolean; /** 030-owned handoff when a child consumed the original failure under bounds. */ onConsumedComboFailure?: (failure: ConsumedComboFailure) => void; + /** Internal request-scoped physical upstream-send budget. */ + attemptBudget?: UpstreamAttemptBudget; } @@ -905,6 +937,13 @@ export async function handleComboResponses( let lastFailure: Response | null = null; while (pick) { if (options.abortSignal?.aborted) return clientCancelledResponse(); + if (options.attemptBudget?.remaining === 0) { + return lastFailure ?? formatErrorResponse( + 502, + "upstream_error", + "OCX upstream attempt budget exhausted", + ); + } const childLog: RequestLogContext = { model: pick.target.model, provider: pick.target.provider, @@ -1077,6 +1116,9 @@ export async function handleResponses( logCtx: RequestLogContext, options: HandleResponsesOptions = {}, ): Promise { + if (!options.attemptBudget) { + options = { ...options, attemptBudget: createUpstreamAttemptBudget() }; + } let body: unknown; try { body = await readJsonRequestBody(req); @@ -1539,7 +1581,11 @@ export async function handleResponses( body: request.body, }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider)); }, - { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, + { + abortSignal: upstream.signal, + label: safeHostLabel(request.url), + attemptBudget: options.attemptBudget, + }, ); } catch (err) { return transportFailureResponse(err); @@ -1553,7 +1599,10 @@ export async function handleResponses( options.abortSignal, )) { poolRetryOutcome = 400; - } else if (shouldRetryCodexPoolAccountQuota(upstreamResponse)) { + } else if (await shouldRetryCodexPoolAccountQuota( + upstreamResponse, + options.abortSignal, + )) { // Pre-stream only: once SSE has begun, mid-stream quota stays terminal. poolRetryOutcome = upstreamResponse.status; } @@ -2035,7 +2084,11 @@ export async function handleResponses( noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens); await adapter.runTurn?.( parsed, - { headers: selectedForwardHeaders, abortSignal: runTurnAbort.signal }, + { + headers: selectedForwardHeaders, + abortSignal: runTurnAbort.signal, + attemptBudget: options.attemptBudget, + }, queue.push, ); } catch (err) { @@ -2158,12 +2211,26 @@ export async function handleResponses( let upstreamResponse: Response; try { if (activeAdapter.fetchResponse) { + if ( + !adapterOwnsAttemptBudget(activeAdapter.name) + && options.attemptBudget + && !options.attemptBudget.tryBegin() + ) { + return formatErrorResponse( + 502, + "upstream_error", + "OCX upstream attempt budget exhausted", + ); + } noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate); upstreamResponse = await activeAdapter.fetchResponse(request, { abortSignal: upstream.signal, timeoutMs: connectMs, skip429Retry: antigravityPoolOwns429, stream: parsed.stream, + ...(adapterOwnsAttemptBudget(activeAdapter.name) + ? { attemptBudget: options.attemptBudget } + : {}), }); } else { upstreamResponse = await fetchWithResetRetry( @@ -2175,7 +2242,11 @@ export async function handleResponses( body: request.body, }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider)); }, - { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, + { + abortSignal: upstream.signal, + label: safeHostLabel(request.url), + attemptBudget: options.attemptBudget, + }, ); } } catch (err) { @@ -2211,16 +2282,42 @@ export async function handleResponses( sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name); noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery); try { - return activeAdapter.fetchResponse - ? await activeAdapter.fetchResponse(retryRequest, { - abortSignal: upstream.signal, - timeoutMs: connectMs, - skip429Retry: antigravityPoolOwns429, - stream: parsed.stream, - }) - : await fetchWithHeaderTimeout(retryRequest.url, { - method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body, - }, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider)); + if (activeAdapter.fetchResponse) { + if ( + !adapterOwnsAttemptBudget(activeAdapter.name) + && options.attemptBudget + && !options.attemptBudget.tryBegin() + ) { + return { + failed: formatErrorResponse( + 502, + "upstream_error", + "OCX upstream attempt budget exhausted", + ), + }; + } + return await activeAdapter.fetchResponse(retryRequest, { + abortSignal: upstream.signal, + timeoutMs: connectMs, + skip429Retry: antigravityPoolOwns429, + stream: parsed.stream, + ...(adapterOwnsAttemptBudget(activeAdapter.name) + ? { attemptBudget: options.attemptBudget } + : {}), + }); + } + if (options.attemptBudget && !options.attemptBudget.tryBegin()) { + return { + failed: formatErrorResponse( + 502, + "upstream_error", + "OCX upstream attempt budget exhausted", + ), + }; + } + return await fetchWithHeaderTimeout(retryRequest.url, { + method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body, + }, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider)); } catch (err) { cleanupUpstreamAbort(); upstream.abort(); @@ -2231,12 +2328,22 @@ export async function handleResponses( return { failed: formatErrorResponse(502, "upstream_error", msg) }; } }; + const canRotateOrCoolFailure = async (response: Response): Promise => { + if ((options.attemptBudget?.remaining ?? 0) === 0) return false; + const outcome = await classifyUpstreamResponse( + outcomeRailForAdapter(activeAdapter.name), + response, + options.abortSignal, + ); + return upstreamOutcomePolicy(outcome).rotateOrCool; + }; recovery: for (;;) { if ( upstreamResponse.status === 401 && isOAuth401ReplayProvider && sentOAuthSnapshot && !oauth401ReplayAttempted + && (options.attemptBudget?.remaining ?? 1) > 0 ) { oauth401ReplayAttempted = true; try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } @@ -2271,7 +2378,11 @@ export async function handleResponses( // Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the // SAME request once per remaining key. OAuth/forward providers and single-key pools // return null immediately, so this stays a no-op for them (src/providers/key-failover.ts). - while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) { + while ( + upstreamResponse.status === 429 + && hasKeyPoolFailover(route.provider) + && await canRotateOrCoolFailure(upstreamResponse) + ) { const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, { retryAfter: upstreamResponse.headers.get("retry-after"), now: Date.now(), @@ -2299,6 +2410,7 @@ export async function handleResponses( && anthropicPoolAccountId && isAnthropicAccountPoolEnabled(config) && anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST + && await canRotateOrCoolFailure(upstreamResponse) ) { const nextAccountId = rotateAnthropicAccountOn429( config, @@ -2336,6 +2448,7 @@ export async function handleResponses( && isGoogleAntigravityAccountPoolEnabled(config) && googleAntigravityPoolFailovers < GOOGLE_ANTIGRAVITY_POOL_MAX_FAILOVERS_PER_REQUEST + && await canRotateOrCoolFailure(upstreamResponse) ) { const nextAccountId = rotateGoogleAntigravityAccountOn429( config, diff --git a/tests/fixtures/cursor-log-outcomes.ts b/tests/fixtures/cursor-log-outcomes.ts new file mode 100644 index 000000000..638962e6e --- /dev/null +++ b/tests/fixtures/cursor-log-outcomes.ts @@ -0,0 +1,96 @@ +/** + * Cursor outcome fixtures distilled from sofie usage/service logs (2026-07-30 audit). + * Counts are documentary; messages match live patterns the classifier must honor. + */ + +export const CURSOR_CLIENT_ABORT_FIXTURES = [ + { + id: "client-abort-499", + status: 499, + message: "client_closed_request closeReason=client_cancel", + // service.log / usage: hundreds of abort telemetry lines; not rotate-worthy + observedCountHint: 292, + }, + { + id: "client-abort-request-aborted", + status: 499, + message: "Cursor request was aborted", + observedCountHint: 308, + }, +] as const; + +export const CURSOR_ADAPTER_EOF_FIXTURES = [ + { + id: "adapter-eof-502", + status: 502, + message: "Upstream stream ended unexpectedly without a terminal event (adapter_eof)", + // 70 of 74 cursor 502s in sofie usage.jsonl + observedCountHint: 70, + }, +] as const; + +export const CURSOR_RATE_LIMIT_FIXTURES = [ + { + id: "rate-limit-resource-exhausted", + status: 429, + message: "Cursor rate limit exceeded: resource_exhausted", + observedCountHint: 16, + }, + { + id: "rate-limit-too-many-requests", + status: 429, + message: "Cursor rate limit exceeded: too many requests", + observedCountHint: 16, + }, +] as const; + +export const ARCHITECTURE_CONTRACT_ROWS = [ + { + signal: "client-abort / post-commit EOF", + label: "client-abort", + sameAccountRetry: false, + rotateOrCool: false, + }, + { + signal: "client-abort / post-commit EOF", + label: "adapter-eof", + sameAccountRetry: false, + rotateOrCool: false, + }, + { + signal: "502/503/transport pre-commit", + label: "transient-transport", + sameAccountRetry: true, + rotateOrCool: false, + }, + { + signal: "429/ResourceExhausted with quota-signal", + label: "quota-exhausted", + sameAccountRetry: false, + rotateOrCool: true, + }, + { + signal: "429 throttle (no hard quota)", + label: "rate-limit", + sameAccountRetry: true, + rotateOrCool: true, + }, + { + signal: "context-overflow / billing / empty pool", + label: "context-overflow", + sameAccountRetry: false, + rotateOrCool: false, + }, + { + signal: "context-overflow / billing / empty pool", + label: "billing", + sameAccountRetry: false, + rotateOrCool: false, + }, + { + signal: "context-overflow / billing / empty pool", + label: "empty-pool", + sameAccountRetry: false, + rotateOrCool: false, + }, +] as const; diff --git a/tests/server-google-antigravity-pool-e2e.test.ts b/tests/server-google-antigravity-pool-e2e.test.ts index 873b7fa8c..eac1dd7f1 100644 --- a/tests/server-google-antigravity-pool-e2e.test.ts +++ b/tests/server-google-antigravity-pool-e2e.test.ts @@ -190,7 +190,7 @@ describe("server Google Antigravity account pool", () => { } }); - test("stops after three 429 failovers even when more accounts are eligible", async () => { + test("stops after the shared 3-attempt budget even when more accounts are eligible", async () => { for (const suffix of ["c", "d", "e"]) { await saveCredential(PROVIDER, { access: `token-${suffix}`, @@ -232,8 +232,54 @@ describe("server Google Antigravity account pool", () => { }), }); expect(response.status).toBe(429); - expect(attempts).toHaveLength(4); - expect(new Set(attempts).size).toBe(4); + expect(attempts).toHaveLength(3); + expect(new Set(attempts).size).toBe(3); + } finally { + server.stop(true); + globalThis.fetch = originalFetch; + } + }); + + test("context-overflow 429 never cools or rotates Antigravity accounts", async () => { + const originalFetch = globalThis.fetch; + const attempts: string[] = []; + globalThis.fetch = async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url.startsWith("https://daily-cloudcode-pa.googleapis.com/")) { + attempts.push(new Headers(init?.headers).get("authorization") ?? ""); + return new Response(JSON.stringify({ + error: { + message: "RESOURCE_EXHAUSTED: Your input exceeds the context window of this model", + }, + }), { + status: 429, + headers: { + "content-type": "application/json", + "retry-after": "30", + }, + }); + } + return originalFetch(input, init); + }; + + const server = startServer(0); + try { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "google-antigravity/gemini-3.1-pro", + input: "overflow must not cool", + stream: false, + }), + }); + expect(response.status).toBe(429); + expect(attempts).toEqual(["Bearer token-a"]); + const { getGoogleAntigravityAccountHealthSnapshot } = await import( + "../src/oauth/google-antigravity-routing" + ); + const firstId = getAccountSet(PROVIDER)!.accounts[0]!.id; + expect(getGoogleAntigravityAccountHealthSnapshot(firstId)).toBeNull(); } finally { server.stop(true); globalThis.fetch = originalFetch; diff --git a/tests/upstream-outcome-classifier.test.ts b/tests/upstream-outcome-classifier.test.ts new file mode 100644 index 000000000..1c7df8c9e --- /dev/null +++ b/tests/upstream-outcome-classifier.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test } from "bun:test"; +import { + classifyCursorUpstreamOutcome, + classifyGenericUpstreamOutcome, + classifyGoogleUpstreamOutcome, + classifyUpstreamOutcome, + upstreamOutcomePolicy, + type UpstreamOutcomeLabel, +} from "../src/lib/upstream-outcome"; +import { + createUpstreamAttemptBudget, + OCX_MAX_UPSTREAM_ATTEMPTS, +} from "../src/lib/upstream-attempt-budget"; +import { + fetchWithTransientRetry, + MAX_INTERNAL_RETRY_AFTER_MS, + retryAfterExceedsInternalLimit, +} from "../src/lib/upstream-retry"; +import { + ARCHITECTURE_CONTRACT_ROWS, + CURSOR_ADAPTER_EOF_FIXTURES, + CURSOR_CLIENT_ABORT_FIXTURES, + CURSOR_RATE_LIMIT_FIXTURES, +} from "./fixtures/cursor-log-outcomes"; + +describe("unified upstream outcome labels", () => { + test("architecture contract table policies", () => { + for (const row of ARCHITECTURE_CONTRACT_ROWS) { + const policy = upstreamOutcomePolicy(row.label as UpstreamOutcomeLabel); + expect(policy.sameAccountRetry).toBe(row.sameAccountRetry); + expect(policy.rotateOrCool).toBe(row.rotateOrCool); + } + }); + + test("per-rail adapters keep the same overflow / abort / eof decisions", () => { + const overflow = { + status: 400, + message: "Your input exceeds the context window of this model", + }; + expect(classifyGenericUpstreamOutcome(overflow)).toBe("context-overflow"); + expect(classifyCursorUpstreamOutcome(overflow)).toBe("context-overflow"); + expect(classifyGoogleUpstreamOutcome(overflow)).toBe("context-overflow"); + expect(upstreamOutcomePolicy("context-overflow").rotateOrCool).toBe(false); + }); + + test("Cursor log fixtures: abort and adapter_eof are not rotate-worthy", () => { + for (const fixture of CURSOR_CLIENT_ABORT_FIXTURES) { + const label = classifyCursorUpstreamOutcome({ + status: fixture.status, + message: fixture.message, + }); + expect(label).toBe("client-abort"); + expect(upstreamOutcomePolicy(label).rotateOrCool).toBe(false); + expect(upstreamOutcomePolicy(label).sameAccountRetry).toBe(false); + } + for (const fixture of CURSOR_ADAPTER_EOF_FIXTURES) { + const label = classifyCursorUpstreamOutcome({ + status: fixture.status, + message: fixture.message, + }); + expect(label).toBe("adapter-eof"); + expect(upstreamOutcomePolicy(label).rotateOrCool).toBe(false); + expect(upstreamOutcomePolicy(label).sameAccountRetry).toBe(false); + } + }); + + test("Cursor log fixtures: rate-limit remains rotate-eligible", () => { + for (const fixture of CURSOR_RATE_LIMIT_FIXTURES) { + const label = classifyCursorUpstreamOutcome({ + status: fixture.status, + message: fixture.message, + }); + expect(label).toBe("rate-limit"); + expect(upstreamOutcomePolicy(label).rotateOrCool).toBe(true); + } + }); + + test("context-overflow wins over RESOURCE_EXHAUSTED / 429 wording", () => { + const label = classifyUpstreamOutcome("google", { + status: 429, + message: "RESOURCE_EXHAUSTED: input exceeds the context window", + }); + expect(label).toBe("context-overflow"); + expect(upstreamOutcomePolicy(label).rotateOrCool).toBe(false); + expect(upstreamOutcomePolicy(label).sameAccountRetry).toBe(false); + }); + + test("classifier never encodes systemd or tunnel actions", async () => { + const source = await Bun.file( + new URL("../src/lib/upstream-outcome.ts", import.meta.url), + ).text(); + expect(source.toLowerCase()).not.toContain("systemd"); + expect(source.toLowerCase()).not.toContain("tunnel"); + expect(source.toLowerCase()).not.toContain("systemctl"); + }); +}); + +describe("global OCX attempt budget", () => { + test(`defaults to ${OCX_MAX_UPSTREAM_ATTEMPTS} physical sends`, () => { + const budget = createUpstreamAttemptBudget(); + expect(budget.limit).toBe(3); + expect(budget.tryBegin()).toBe(true); + expect(budget.tryBegin()).toBe(true); + expect(budget.tryBegin()).toBe(true); + expect(budget.tryBegin()).toBe(false); + expect(budget.used).toBe(3); + expect(budget.remaining).toBe(0); + }); + + test("shared budget stops transient 5xx retries before a fourth send", async () => { + const budget = createUpstreamAttemptBudget(); + let calls = 0; + const res = await fetchWithTransientRetry(async () => { + calls += 1; + return new Response("boom", { status: 502 }); + }, { attemptBudget: budget, slowAttemptMs: 60_000 }); + expect(res.status).toBe(502); + expect(calls).toBe(3); + expect(budget.remaining).toBe(0); + }); +}); + +describe("Retry-After fail-fast", () => { + test("Retry-After above 60s is not slept by OCX", () => { + expect(MAX_INTERNAL_RETRY_AFTER_MS).toBe(60_000); + expect(retryAfterExceedsInternalLimit(new Headers({ "retry-after": "61" }))).toBe(true); + expect(retryAfterExceedsInternalLimit(new Headers({ "retry-after": "30" }))).toBe(false); + }); + + test("transient retry returns immediately when Retry-After exceeds 60s", async () => { + let calls = 0; + const started = Date.now(); + const res = await fetchWithTransientRetry(async () => { + calls += 1; + return new Response("slow", { + status: 503, + headers: { "retry-after": "120" }, + }); + }, { slowAttemptMs: 60_000 }); + expect(res.status).toBe(503); + expect(calls).toBe(1); + expect(Date.now() - started).toBeLessThan(500); + }); +}); From 956f7cda16ff120c44e2d88963883148a4c86006 Mon Sep 17 00:00:00 2001 From: GroepChef Date: Thu, 30 Jul 2026 13:37:51 +0200 Subject: [PATCH 07/31] feat(catalog): discovery backoff and opt-in unavailable-model hide Keep last-good catalogs across transient discovery failures, escalate probe backoff until recovery, and optionally hide dead providers from client pickers while admin Models retains full rows plus reason. Co-authored-by: Cursor --- .../content/docs/guides/codex-integration.md | 5 + .../content/docs/reference/configuration.md | 2 + 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/models-groups.ts | 13 + gui/src/pages/Models.tsx | 11 +- src/codex/catalog-visibility.ts | 110 +++++++++ src/codex/catalog.ts | 11 + src/codex/catalog/sync.ts | 9 +- src/codex/model-cache.ts | 58 ++++- src/config.ts | 4 + src/server/index.ts | 6 +- src/server/management/model-routes.ts | 13 + src/server/management/provider-routes.ts | 38 ++- src/types.ts | 12 + tests/catalog-heal-visibility.test.ts | 230 ++++++++++++++++++ tests/models-page-groups.test.ts | 16 ++ 21 files changed, 523 insertions(+), 27 deletions(-) create mode 100644 src/codex/catalog-visibility.ts create mode 100644 tests/catalog-heal-visibility.test.ts diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index bef498ce4..eeccf814e 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -250,6 +250,11 @@ If a model is missing from Codex, or the catalog order/visibility looks wrong, c and `ocx sync-cache` warn when those processes are detected. Restart them with `ocx sync --restart-codex` (or stop the matching `app-server` processes yourself), then let Codex recreate them so the new list appears. +7. **`hideUnavailableModels`** — when enabled, a provider that is dead (all accounts need reauth, or + discovery fails N times) drops from `/v1/models` and the new-session picker while the admin Models + tab still shows last-good rows with a reason. Codex and Cursor cache their pickers; start a **new + session** (or restart the client / run `ocx sync --restart-codex`) before expecting the filtered + list. Existing sessions keep routing to last-good models. :::caution[Other local writers] Catalog writes (`opencodex-catalog.json`, `config.toml`) are atomic **inside** opencodex, which only diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 6ff3029a4..126df9aba 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -65,6 +65,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `accountPoolStickyLimit?` | `number` | `1` | Successful new-session binds retained on one round-robin selection before advancing. Range 1–100; only applies when `accountPoolStrategy` is `round-robin`. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient upstream failures before future new sessions fail over to another eligible pool account. Set `0` to disable failure failover. | | `modelCacheTtlMs?` | `number` | `300000` | Freshness window for the per-provider `/models` cache (5 min). | +| `hideUnavailableModels?` | `boolean` | `false` | When true, omit models from client `/v1/models` and the Codex new-session picker while the provider is dead (disabled, all OAuth accounts need reauth, or N consecutive discovery failures). Admin Models keeps the full last-good catalog with a reason. Single-account cooldown never hides. Active sessions keep routing. | +| `hideUnavailableAfterDiscoveryFails?` | `number` | `3` | Consecutive live discovery failures before client hide when `hideUnavailableModels` is on. Transient failures below this threshold keep serving last-good in the picker. | | `cacheRetention?` | `"none" \| "short" \| "long"` | `"short"` | Anthropic prompt-cache policy: disabled, 5-minute ephemeral, or 1-hour extended. | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on | Web-search sidecar options (see below). | | `visionSidecar?` | `OcxVisionSidecarConfig` | on | Vision sidecar options (see below). | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a21cdbedc..c9099845d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -433,6 +433,8 @@ export const de: Record = { "models.discoveryFailedNetwork": "Die Modellerkennung ist an einem Netzwerkfehler gescheitert.", "models.discoveryFailedProvider": "Der Anbieter meldete einen Fehler bei der Modellerkennung.", "models.discoveryFailedGeneric": "Die Modellerkennung ist fehlgeschlagen.", + "models.clientHiddenBadge": "Für Clients ausgeblendet", + "models.clientDegradedBadge": "Für neue Sitzungen nicht verfügbar", "models.openProviderSettings": "Anbietereinstellungen öffnen", "models.loading": "Lädt…", "models.search": "Modelle suchen…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 1cb0a0e5e..e60dee276 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -447,6 +447,8 @@ export const en = { "models.discoveryFailedNetwork": "Model discovery failed due to a network error.", "models.discoveryFailedProvider": "The provider reported a model discovery error.", "models.discoveryFailedGeneric": "Model discovery failed.", + "models.clientHiddenBadge": "Hidden from clients", + "models.clientDegradedBadge": "Unavailable for new sessions", "models.openProviderSettings": "Open provider settings", "models.loading": "Loading…", "models.search": "Search models…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 0feefa7d0..fc8d7a883 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -414,6 +414,8 @@ export const ja: Record = { "models.discoveryFailedNetwork": "ネットワークエラーによりモデル検出に失敗しました。", "models.discoveryFailedProvider": "プロバイダーがモデル検出エラーを報告しました。", "models.discoveryFailedGeneric": "モデル検出に失敗しました。", + "models.clientHiddenBadge": "クライアントから非表示", + "models.clientDegradedBadge": "新規セッションでは利用不可", "models.openProviderSettings": "プロバイダー設定を開く", "models.loading": "読み込み中…", "models.search": "モデルを検索…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 8e0931da8..956e4da1b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -441,6 +441,8 @@ export const ko: Record = { "models.discoveryFailedNetwork": "네트워크 오류로 모델 검색에 실패했습니다.", "models.discoveryFailedProvider": "프로바이더가 모델 검색 오류를 보고했습니다.", "models.discoveryFailedGeneric": "모델 검색에 실패했습니다.", + "models.clientHiddenBadge": "클라이언트에서 숨김", + "models.clientDegradedBadge": "새 세션에서 사용 불가", "models.openProviderSettings": "프로바이더 설정 열기", "models.loading": "불러오는 중…", "models.search": "모델 검색…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c6940fde3..b6e56926d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -446,6 +446,8 @@ export const ru: Record = { "models.discoveryFailedNetwork": "Обнаружение моделей не удалось из-за сетевой ошибки.", "models.discoveryFailedProvider": "Провайдер сообщил об ошибке обнаружения моделей.", "models.discoveryFailedGeneric": "Не удалось обнаружить модели.", + "models.clientHiddenBadge": "Скрыто от клиентов", + "models.clientDegradedBadge": "Недоступно для новых сессий", "models.openProviderSettings": "Открыть настройки провайдера", "models.loading": "Загрузка…", "models.search": "Поиск моделей…", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 3e026741e..efa251320 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -441,6 +441,8 @@ export const zh: Record = { "models.discoveryFailedNetwork": "由于网络错误,模型发现失败。", "models.discoveryFailedProvider": "提供方报告了模型发现错误。", "models.discoveryFailedGeneric": "模型发现失败。", + "models.clientHiddenBadge": "已对客户端隐藏", + "models.clientDegradedBadge": "新会话不可用", "models.openProviderSettings": "打开提供方设置", "models.loading": "加载中…", "models.search": "搜索模型…", diff --git a/gui/src/models-groups.ts b/gui/src/models-groups.ts index 193ae1e6a..15669e4ae 100644 --- a/gui/src/models-groups.ts +++ b/gui/src/models-groups.ts @@ -8,6 +8,8 @@ export type ProviderDiscoverySummary = httpStatus?: never; }; +export type ProviderClientHideReason = "disabled" | "all_accounts_reauth" | "discovery_failed"; + export interface ConfiguredProviderSummary { name: string; authMode?: string; @@ -15,6 +17,9 @@ export interface ConfiguredProviderSummary { liveModels?: boolean; models?: string[]; discovery?: ProviderDiscoverySummary; + clientHideReason?: ProviderClientHideReason; + clientHideReasonLabel?: string; + clientHidden?: boolean; } export interface ProviderModelGroup { @@ -24,6 +29,9 @@ export interface ProviderModelGroup { liveModels: boolean; configuredModels: string[]; discovery?: ProviderDiscoverySummary; + clientHideReason?: ProviderClientHideReason; + clientHideReasonLabel?: string; + clientHidden?: boolean; } export function buildProviderModelGroups( @@ -57,6 +65,11 @@ export function buildProviderModelGroups { diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 92f2d67e3..2b1210c7d 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -643,7 +643,7 @@ export default function Models({ apiBase }: { apiBase: string }) { const renderGroup = (group: ProviderModelGroup) => { - const { provider, rows, native, liveModels, discovery } = group; + const { provider, rows, native, liveModels, discovery, clientHideReason, clientHideReasonLabel, clientHidden } = group; const isCollapsed = collapsed.has(provider); // Final visibility, not just the disable flag: a model is visible to Codex only when the // provider allowlist admits it AND it is not disabled. Reading `disabled` alone made the @@ -704,6 +704,15 @@ export default function Models({ apiBase }: { apiBase: string }) { > {t("models.discoveryFailedBadge")} + )} + {clientHideReason && ( + + {clientHidden ? t("models.clientHiddenBadge") : t("models.clientDegradedBadge")} + )} {t("models.active", { active: activeCount, total: rows.length })} diff --git a/src/codex/catalog-visibility.ts b/src/codex/catalog-visibility.ts new file mode 100644 index 000000000..b0f5a9f0c --- /dev/null +++ b/src/codex/catalog-visibility.ts @@ -0,0 +1,110 @@ +/** + * Opt-in client picker / `/v1/models` hiding for provider-level death. + * + * Admin surfaces keep the full last-good catalog plus an explicit reason. Client listings and the + * Codex new-session picker filter only when `hideUnavailableModels` is enabled. Routing is + * untouched: active and recent-affinity sessions keep calling the last-good catalog. + * + * Hide triggers (provider-level only — never a single-account cooldown): + * - provider `disabled: true` (already excluded from gather; reason still reported) + * - every OAuth account marked `needsReauth` + * - N consecutive live discovery failures (grace = N-1 transient fails still visible) + */ +import { getAccountSet } from "../oauth/store"; +import type { OcxConfig, OcxProviderConfig } from "../types"; +import { getDiscoveryFailStreak } from "./model-cache"; +import type { CatalogModel } from "./catalog/parsing"; + +/** Default consecutive discovery failures before client hide (when the flag is on). */ +export const DEFAULT_HIDE_AFTER_DISCOVERY_FAILS = 3; + +export type ProviderClientHideReason = + | "disabled" + | "all_accounts_reauth" + | "discovery_failed"; + +export function hideUnavailableModelsEnabled( + config: Pick, +): boolean { + return config.hideUnavailableModels === true; +} + +export function hideUnavailableAfterDiscoveryFails( + config: Pick, +): number { + const raw = config.hideUnavailableAfterDiscoveryFails; + if (typeof raw === "number" && Number.isFinite(raw) && raw >= 1) { + return Math.floor(raw); + } + return DEFAULT_HIDE_AFTER_DISCOVERY_FAILS; +} + +/** True when every stored OAuth account for the provider needs re-login. Empty store is not death. */ +export function allOAuthAccountsNeedReauth( + providerName: string, + prov: Pick | undefined, +): boolean { + if (prov?.authMode !== "oauth") return false; + const set = getAccountSet(providerName); + if (!set || set.accounts.length === 0) return false; + return set.accounts.every(account => account.needsReauth === true); +} + +/** + * Provider-level death reason for admin display. Independent of `hideUnavailableModels` so the + * Models tab can always show why a provider is unhealthy. Returns null while the provider is fine + * or only partially degraded (e.g. one account cooling down). + */ +export function providerClientHideReason( + providerName: string, + config: Pick, +): ProviderClientHideReason | null { + const prov = config.providers[providerName]; + if (!prov) return null; + if (prov.disabled === true) return "disabled"; + if (allOAuthAccountsNeedReauth(providerName, prov)) return "all_accounts_reauth"; + const streak = getDiscoveryFailStreak(providerName); + if (streak >= hideUnavailableAfterDiscoveryFails(config)) return "discovery_failed"; + return null; +} + +/** Whether client `/v1/models` and new-session pickers should omit this provider's models. */ +export function shouldHideProviderFromClients( + providerName: string, + config: Pick, +): boolean { + if (!hideUnavailableModelsEnabled(config)) return false; + return providerClientHideReason(providerName, config) !== null; +} + +/** + * Filter last-good catalog rows from client-facing lists when hide is enabled and the provider is + * dead. Admin callers must not use this — they keep the full catalog via {@link providerClientHideReason}. + */ +export function filterClientCatalogModels( + models: CatalogModel[], + config: Pick, +): CatalogModel[] { + if (!hideUnavailableModelsEnabled(config)) return models; + const hidden = new Set(); + for (const name of Object.keys(config.providers)) { + if (shouldHideProviderFromClients(name, config)) hidden.add(name); + } + if (hidden.size === 0) return models; + return models.filter(model => !hidden.has(model.provider)); +} + +export function clientHideReasonLabel(reason: ProviderClientHideReason): string { + switch (reason) { + case "disabled": + return "Provider is disabled"; + case "all_accounts_reauth": + return "All accounts require re-authentication"; + case "discovery_failed": + return "Model discovery failed repeatedly"; + default: { + const _exhaustive: never = reason; + return _exhaustive; + } + } +} diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 03173400b..80e912142 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -6,6 +6,17 @@ export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, v export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort"; export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch"; +export { + DEFAULT_HIDE_AFTER_DISCOVERY_FAILS, + allOAuthAccountsNeedReauth, + clientHideReasonLabel, + filterClientCatalogModels, + hideUnavailableAfterDiscoveryFails, + hideUnavailableModelsEnabled, + providerClientHideReason, + shouldHideProviderFromClients, +} from "./catalog-visibility"; +export type { ProviderClientHideReason } from "./catalog-visibility"; export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation"; export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation"; export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync"; diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index d7724439f..3931f2bdd 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -37,6 +37,7 @@ import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSl import { loadCatalogForSync, resetBundledCatalogCacheForTests } from "./bundled"; import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch"; +import { filterClientCatalogModels } from "../catalog-visibility"; import { clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnComboMasqueradeCollisionOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; @@ -481,9 +482,11 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ ensureCatalogBackup(catalogPath, catalog); } catch { /* backup best-effort */ } - // Hide disabled models from Codex, then feature the chosen subagent models (native OR routed) - // by giving them the lowest priority — see buildCatalogEntries for why priority, not array order. - const enabledGo = filterCatalogVisibleModels(goModels, config); + // Hide disabled models from Codex, then optionally omit provider-level death from the on-disk + // new-session picker (admin `/api/models` keeps last-good). Feature the chosen subagent models + // (native OR routed) by giving them the lowest priority — see buildCatalogEntries for why + // priority, not array order. + const enabledGo = filterClientCatalogModels(filterCatalogVisibleModels(goModels, config), config); const featured = config.subagentModels ?? []; const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; diff --git a/src/codex/model-cache.ts b/src/codex/model-cache.ts index b116f89fe..41b7b14b7 100644 --- a/src/codex/model-cache.ts +++ b/src/codex/model-cache.ts @@ -17,6 +17,13 @@ interface CacheEntry { fetchedAt: number; } +interface FailureBackoff { + /** Consecutive live-discovery failures since the last success. */ + attempts: number; + /** Earliest time a live re-probe may run. */ + retryAfterMs: number; +} + export type ProviderModelDiscoveryFailureReason = | "http" | "blocked" @@ -41,11 +48,14 @@ export type ProviderModelDiscoveryFailure = ProviderModelDiscoveryStatus extends const cache = new Map(); -/** Cooldown after a failed live `/models` fetch, so a dead/unreachable provider doesn't re-pay - * the full fetch timeout on every catalog poll (issue #54: UI stalls behind corporate proxies). */ +/** Base cooldown after the first failed live `/models` fetch. Subsequent failures double until + * {@link MODELS_FETCH_FAILURE_MAX_COOLDOWN_MS} (issue #54: UI stalls behind corporate proxies). */ export const MODELS_FETCH_FAILURE_COOLDOWN_MS = 30_000; -const failureAt = new Map(); +/** Ceiling for discovery exponential backoff. */ +export const MODELS_FETCH_FAILURE_MAX_COOLDOWN_MS = 15 * 60 * 1000; + +const failureBackoff = new Map(); const discoveryStatus = new Map(); /** * How many models the last successful discovery actually returned, before any configured-alias @@ -55,13 +65,33 @@ const discoveryStatus = new Map(); */ const liveModelCounts = new Map(); +function clearFailureBackoff(provider: string): void { + failureBackoff.delete(provider); +} + +/** + * Record a live discovery probe failure and schedule the next probe with exponential backoff. + * Resets only on the next successful discovery ({@link markProviderDiscoveryOk}). + */ export function markModelsFetchFailure(provider: string, now = Date.now()): void { - failureAt.set(provider, now); + const prev = failureBackoff.get(provider); + const attempts = (prev?.attempts ?? 0) + 1; + const delayMs = Math.min( + MODELS_FETCH_FAILURE_MAX_COOLDOWN_MS, + MODELS_FETCH_FAILURE_COOLDOWN_MS * 2 ** (attempts - 1), + ); + failureBackoff.set(provider, { attempts, retryAfterMs: now + delayMs }); +} + +/** Consecutive discovery failures since the last success (0 when healthy / never failed). */ +export function getDiscoveryFailStreak(provider: string): number { + return failureBackoff.get(provider)?.attempts ?? 0; } /** `liveModelCount` is required so a caller that forgets to pass it fails typecheck instead of * silently recording zero and misclassifying the provider as having no live catalog. */ export function markProviderDiscoveryOk(provider: string, liveModelCount: number): void { + clearFailureBackoff(provider); discoveryStatus.set(provider, { status: "ok" }); liveModelCounts.set(provider, Math.max(0, Math.floor(liveModelCount))); } @@ -97,6 +127,7 @@ export function shouldLogDiscoveryFailure( } export function clearProviderDiscoveryStatus(provider: string): void { + clearFailureBackoff(provider); discoveryStatus.delete(provider); liveModelCounts.delete(provider); } @@ -111,9 +142,18 @@ export function getProviderLiveModelCount(provider: string): number | undefined return liveModelCounts.get(provider); } -export function isModelsFetchCoolingDown(provider: string, cooldownMs = MODELS_FETCH_FAILURE_COOLDOWN_MS, now = Date.now()): boolean { - const at = failureAt.get(provider); - return at !== undefined && now - at < cooldownMs; +/** + * Whether a live `/models` probe should be skipped. `cooldownMs` is retained for call-site + * compatibility; the effective wait is the exponential schedule written by + * {@link markModelsFetchFailure}. + */ +export function isModelsFetchCoolingDown( + provider: string, + _cooldownMs = MODELS_FETCH_FAILURE_COOLDOWN_MS, + now = Date.now(), +): boolean { + const entry = failureBackoff.get(provider); + return entry !== undefined && now < entry.retryAfterMs; } /** Fresh cached models for a provider, or null when absent/stale (caller should re-fetch). */ @@ -136,12 +176,12 @@ export function setCached(provider: string, models: CatalogModel[], now = Date.n export function clearModelCache(provider?: string): void { if (provider) { cache.delete(provider); - failureAt.delete(provider); + clearFailureBackoff(provider); discoveryStatus.delete(provider); liveModelCounts.delete(provider); } else { cache.clear(); - failureAt.clear(); + failureBackoff.clear(); discoveryStatus.clear(); liveModelCounts.clear(); } diff --git a/src/config.ts b/src/config.ts index 9127897c1..baf4b8dd3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -696,6 +696,10 @@ const configSchema = z.object({ // parse: a hand-edited typo must never trip the backup-and-defaults repair // path below and wipe providers/pool accounts. Warning emitted in loadConfig. streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined), + /** Opt-in client picker hide for provider-level death. Default false (passthrough / unset). */ + hideUnavailableModels: z.boolean().optional(), + /** Consecutive discovery fails before client hide. Default 3 when hide is enabled. */ + hideUnavailableAfterDiscoveryFails: z.number().int().min(1).optional(), }).passthrough().superRefine((config, ctx) => { const claudeCode = (config as { claudeCode?: unknown }).claudeCode; if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) { diff --git a/src/server/index.ts b/src/server/index.ts index 0df808b2a..828caca8a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -406,9 +406,11 @@ export function startServer(port?: number) { return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config); } const goModels = await fetchAllModels(config); - const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents, filterCatalogVisibleModels, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs } = await import("../codex/catalog"); + const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents, filterCatalogVisibleModels, filterClientCatalogModels, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs } = await import("../codex/catalog"); const nativeSlugs = nativeOpenAiSlugs(); - const goEnabled = filterCatalogVisibleModels(goModels, config); + // Client `/v1/models` + new-session pickers optionally hide provider-level death; admin + // `/api/models` keeps the full last-good catalog (see catalog-visibility.ts). + const goEnabled = filterClientCatalogModels(filterCatalogVisibleModels(goModels, config), config); const goOrdered = orderForSubagents(goEnabled, config.subagentModels); // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with // Anthropic-style headers; 003 G1-G8 + devlog 131). Entries use the official diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index ce845d5f3..5810babdd 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -3,6 +3,11 @@ import { readFileSync } from "node:fs"; import type { CatalogModel } from "../../codex/catalog"; import { catalogModelSlug, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { getProviderLiveModelCount } from "../../codex/model-cache"; +import { + clientHideReasonLabel, + hideUnavailableModelsEnabled, + providerClientHideReason, +} from "../../codex/catalog-visibility"; import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, @@ -99,11 +104,13 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise c.namespaced)); + const hideEnabled = hideUnavailableModelsEnabled(config); const dedupedRouted = publicModels.map(m => { // Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms. const namespaced = catalogModelSlug(m); if (m.provider !== "combo" && customNamespaced.has(namespaced)) return null; const contextCap = providerContextCap(config, m.provider); + const hideReason = providerClientHideReason(m.provider, config); return { ...m, namespaced, @@ -111,6 +118,12 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise ({ - name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel, - hasApiKey: !!p.apiKey, - allowPrivateNetwork: p.allowPrivateNetwork === true, - liveModels: p.liveModels !== false, - models: p.models ?? [], - authMode: p.authMode, - apiKeyTransport: p.apiKeyTransport, - disabled: p.disabled === true, - codexAccountMode: providerCodexAccountMode(name, p), - discovery: p.liveModels === false ? undefined : getProviderDiscoveryStatus(name), - }))); + const hideEnabled = hideUnavailableModelsEnabled(config); + return jsonResponse(Object.entries(config.providers).map(([name, p]) => { + const hideReason = providerClientHideReason(name, config); + return { + name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel, + hasApiKey: !!p.apiKey, + allowPrivateNetwork: p.allowPrivateNetwork === true, + liveModels: p.liveModels !== false, + models: p.models ?? [], + authMode: p.authMode, + apiKeyTransport: p.apiKeyTransport, + disabled: p.disabled === true, + codexAccountMode: providerCodexAccountMode(name, p), + discovery: p.liveModels === false ? undefined : getProviderDiscoveryStatus(name), + ...(hideReason ? { + clientHideReason: hideReason, + clientHideReasonLabel: clientHideReasonLabel(hideReason), + clientHidden: hideEnabled, + } : {}), + }; + })); } // Add (or overwrite) a single provider. Merges into the live in-memory config and diff --git a/src/types.ts b/src/types.ts index 1e55b40de..eb66dfe1d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -673,6 +673,18 @@ export interface OcxConfig { syncResumeHistory?: boolean; /** Freshness window (ms) for the per-provider live `/models` cache. Defaults to 5 min. */ modelCacheTtlMs?: number; + /** + * When true, omit models from client `/v1/models` and the Codex new-session picker while the + * provider is dead (disabled, all OAuth accounts need reauth, or N consecutive discovery fails). + * Default false. Admin Models tab always keeps the full last-good catalog with a reason. + * Single-account cooldown never hides. Active/affinity sessions keep routing to last-good. + */ + hideUnavailableModels?: boolean; + /** + * Consecutive live discovery failures before client hide when `hideUnavailableModels` is on. + * Default 3. Transient failures below this threshold keep serving last-good in the picker. + */ + hideUnavailableAfterDiscoveryFails?: number; /** Anthropic prompt-cache retention: "short" = 5-min ephemeral (default), "long" = 1-hour extended, "none" = disabled. */ cacheRetention?: "none" | "short" | "long"; /** Web-search sidecar: route web_search for non-OpenAI models through a gpt-mini via ChatGPT passthrough. */ diff --git a/tests/catalog-heal-visibility.test.ts b/tests/catalog-heal-visibility.test.ts new file mode 100644 index 000000000..26b480394 --- /dev/null +++ b/tests/catalog-heal-visibility.test.ts @@ -0,0 +1,230 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + filterClientCatalogModels, + hideUnavailableModelsEnabled, + providerClientHideReason, + shouldHideProviderFromClients, +} from "../src/codex/catalog-visibility"; +import { + clearModelCache, + getDiscoveryFailStreak, + getStaleCached, + isModelsFetchCoolingDown, + markModelsFetchFailure, + markProviderDiscoveryOk, + MODELS_FETCH_FAILURE_COOLDOWN_MS, + setCached, +} from "../src/codex/model-cache"; +import { gatherRoutedModels } from "../src/codex/catalog"; +import { markAccountNeedsReauth, saveCredential } from "../src/oauth/store"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; +import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; + +const PROVIDER = "heal-demo"; + +function baseConfig(over: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: PROVIDER, + providers: { + [PROVIDER]: { + adapter: "openai-chat", + authMode: "key", + apiKey: "sk-test", + baseUrl: "https://93.184.216.34/v1", + models: ["alpha", "beta"], + } satisfies OcxProviderConfig, + }, + ...over, + } as OcxConfig; +} + +describe("discovery exponential backoff", () => { + afterEach(() => clearModelCache()); + + test("failures escalate delay and reset on first success", () => { + const t0 = 1_000_000; + markModelsFetchFailure(PROVIDER, t0); + expect(getDiscoveryFailStreak(PROVIDER)).toBe(1); + expect(isModelsFetchCoolingDown(PROVIDER, MODELS_FETCH_FAILURE_COOLDOWN_MS, t0 + 1)).toBe(true); + expect(isModelsFetchCoolingDown(PROVIDER, MODELS_FETCH_FAILURE_COOLDOWN_MS, t0 + MODELS_FETCH_FAILURE_COOLDOWN_MS)).toBe(false); + + markModelsFetchFailure(PROVIDER, t0 + MODELS_FETCH_FAILURE_COOLDOWN_MS); + expect(getDiscoveryFailStreak(PROVIDER)).toBe(2); + // Second failure doubles the wait (60s). + expect(isModelsFetchCoolingDown( + PROVIDER, + MODELS_FETCH_FAILURE_COOLDOWN_MS, + t0 + MODELS_FETCH_FAILURE_COOLDOWN_MS + MODELS_FETCH_FAILURE_COOLDOWN_MS, + )).toBe(true); + expect(isModelsFetchCoolingDown( + PROVIDER, + MODELS_FETCH_FAILURE_COOLDOWN_MS, + t0 + MODELS_FETCH_FAILURE_COOLDOWN_MS + 2 * MODELS_FETCH_FAILURE_COOLDOWN_MS, + )).toBe(false); + + markProviderDiscoveryOk(PROVIDER, 2); + expect(getDiscoveryFailStreak(PROVIDER)).toBe(0); + expect(isModelsFetchCoolingDown(PROVIDER, MODELS_FETCH_FAILURE_COOLDOWN_MS, t0 + 10_000_000)).toBe(false); + }); +}); + +describe("catalog heal + hideUnavailableModels", () => { + const originalFetch = globalThis.fetch; + let previousHome: string | undefined; + let testDir: string; + + beforeEach(() => { + clearModelCache(); + previousHome = process.env.OPENCODEX_HOME; + testDir = join(tmpdir(), `ocx-catalog-heal-${crypto.randomUUID()}`); + mkdirSync(testDir, { recursive: true, mode: 0o700 }); + process.env.OPENCODEX_HOME = testDir; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + clearModelCache(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + rmSync(testDir, { recursive: true, force: true }); + }); + + test("transient discovery fail keeps last-good in gather and client list during grace", async () => { + setCached(PROVIDER, [ + { provider: PROVIDER, id: "alpha" }, + { provider: PROVIDER, id: "beta" }, + ]); + globalThis.fetch = (async () => { + throw new Error("transient"); + }) as typeof fetch; + + const config = withStubbedProviderFetch(baseConfig({ + modelCacheTtlMs: 0, + hideUnavailableModels: true, + hideUnavailableAfterDiscoveryFails: 3, + })); + const models = await gatherRoutedModels(config); + expect(models.map(m => m.id).sort()).toEqual(["alpha", "beta"]); + expect(getStaleCached(PROVIDER)?.map(m => m.id).sort()).toEqual(["alpha", "beta"]); + expect(getDiscoveryFailStreak(PROVIDER)).toBe(1); + expect(providerClientHideReason(PROVIDER, config)).toBeNull(); + expect(filterClientCatalogModels(models, config).map(m => m.id).sort()).toEqual(["alpha", "beta"]); + }); + + test("grace exceeded hides from clients but admin reason stays visible; last-good retained", () => { + const lastGood = [{ provider: PROVIDER, id: "alpha" }]; + setCached(PROVIDER, lastGood); + const config = baseConfig({ + hideUnavailableModels: true, + hideUnavailableAfterDiscoveryFails: 2, + }); + + markModelsFetchFailure(PROVIDER); + expect(providerClientHideReason(PROVIDER, config)).toBeNull(); + expect(filterClientCatalogModels(lastGood, config)).toEqual(lastGood); + + markModelsFetchFailure(PROVIDER); + expect(getDiscoveryFailStreak(PROVIDER)).toBe(2); + expect(providerClientHideReason(PROVIDER, config)).toBe("discovery_failed"); + expect(shouldHideProviderFromClients(PROVIDER, config)).toBe(true); + expect(filterClientCatalogModels(lastGood, config)).toEqual([]); + // Admin path: last-good catalog remains for Models tab / routing. + expect(getStaleCached(PROVIDER)).toEqual(lastGood); + }); + + test("hideUnavailableModels default false never filters clients after repeated fails", () => { + const config = baseConfig(); + expect(hideUnavailableModelsEnabled(config)).toBe(false); + markModelsFetchFailure(PROVIDER); + markModelsFetchFailure(PROVIDER); + markModelsFetchFailure(PROVIDER); + expect(providerClientHideReason(PROVIDER, config)).toBe("discovery_failed"); + expect(shouldHideProviderFromClients(PROVIDER, config)).toBe(false); + const rows = [{ provider: PROVIDER, id: "alpha" }]; + expect(filterClientCatalogModels(rows, config)).toEqual(rows); + }); + + test("recovery clears streak and restores client visibility immediately", async () => { + const config = baseConfig({ + hideUnavailableModels: true, + hideUnavailableAfterDiscoveryFails: 2, + }); + markModelsFetchFailure(PROVIDER); + markModelsFetchFailure(PROVIDER); + expect(shouldHideProviderFromClients(PROVIDER, config)).toBe(true); + + markProviderDiscoveryOk(PROVIDER, 1); + expect(getDiscoveryFailStreak(PROVIDER)).toBe(0); + expect(providerClientHideReason(PROVIDER, config)).toBeNull(); + expect(shouldHideProviderFromClients(PROVIDER, config)).toBe(false); + expect(filterClientCatalogModels([{ provider: PROVIDER, id: "alpha" }], config)).toEqual([ + { provider: PROVIDER, id: "alpha" }, + ]); + }); + + test("active session still resolves last-good after client hide", async () => { + setCached(PROVIDER, [{ provider: PROVIDER, id: "alpha" }]); + markModelsFetchFailure(PROVIDER); + markModelsFetchFailure(PROVIDER); + markModelsFetchFailure(PROVIDER); + const config = withStubbedProviderFetch(baseConfig({ + modelCacheTtlMs: 0, + hideUnavailableModels: true, + hideUnavailableAfterDiscoveryFails: 3, + })); + // Cooling down: gather serves last-good without a live probe. + globalThis.fetch = (() => { + throw new Error("must not fetch while cooling"); + }) as typeof fetch; + const gathered = await gatherRoutedModels(config); + expect(gathered.map(m => `${m.provider}/${m.id}`)).toContain(`${PROVIDER}/alpha`); + expect(filterClientCatalogModels(gathered, config).map(m => m.id)).not.toContain("alpha"); + }); + + test("single-account reauth does not hide; all accounts reauth does", async () => { + const oauthProvider = "oauth-heal"; + await saveCredential(oauthProvider, { + access: "a1", + refresh: "r1", + expires: Date.now() + 3600_000, + accountId: "acct-1", + email: "one@example.com", + }); + await saveCredential(oauthProvider, { + access: "a2", + refresh: "r2", + expires: Date.now() + 3600_000, + accountId: "acct-2", + email: "two@example.com", + }); + const set = (await import("../src/oauth/store")).getAccountSet(oauthProvider)!; + const first = set.accounts[0]!.id; + const second = set.accounts[1]!.id; + await markAccountNeedsReauth(oauthProvider, first, true); + + const config = { + port: 10100, + defaultProvider: oauthProvider, + hideUnavailableModels: true, + providers: { + [oauthProvider]: { + adapter: "openai-chat", + authMode: "oauth", + baseUrl: "https://93.184.216.34/v1", + models: ["m1"], + }, + }, + } as OcxConfig; + + expect(providerClientHideReason(oauthProvider, config)).toBeNull(); + expect(shouldHideProviderFromClients(oauthProvider, config)).toBe(false); + + await markAccountNeedsReauth(oauthProvider, second, true); + expect(providerClientHideReason(oauthProvider, config)).toBe("all_accounts_reauth"); + expect(shouldHideProviderFromClients(oauthProvider, config)).toBe(true); + }); +}); diff --git a/tests/models-page-groups.test.ts b/tests/models-page-groups.test.ts index 1d88a83cf..b78e6a0b8 100644 --- a/tests/models-page-groups.test.ts +++ b/tests/models-page-groups.test.ts @@ -28,6 +28,22 @@ describe("Models page provider grouping", () => { expect(groups[1]?.discovery).toEqual({ status: "failed", reason: "http", httpStatus: 401 }); }); + test("passes through client hide reason for admin Models badges", () => { + const groups = buildProviderModelGroups( + [{ provider: "dead", id: "m1" }], + [{ + name: "dead", + liveModels: true, + clientHideReason: "discovery_failed", + clientHideReasonLabel: "Model discovery failed repeatedly", + clientHidden: true, + }], + ); + expect(groups[0]?.clientHideReason).toBe("discovery_failed"); + expect(groups[0]?.clientHidden).toBe(true); + expect(groups[0]?.clientHideReasonLabel).toContain("discovery"); + }); + test("excludes disabled and empty forward providers but preserves row-backed groups", () => { const groups = buildProviderModelGroups( [ From d1d879bf18f4584a81d75fa3cbb541bffed496f6 Mon Sep 17 00:00:00 2001 From: GroepChef Date: Thu, 30 Jul 2026 13:52:43 +0200 Subject: [PATCH 08/31] feat(google): honor strict Gemini tools with validated calling Use VALIDATED mode only for strict Gemini 3 requests so older models retain their existing wire behavior while Claude-on-Antigravity remains unchanged. Co-authored-by: Cursor --- src/adapters/google.ts | 20 ++++++++++++++++---- tests/google-adapter.test.ts | 26 ++++++++++++++++++++++++++ tests/google-antigravity-wire.test.ts | 18 +++++++++++++++++- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index abeb34620..89fe88ce1 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -9,6 +9,7 @@ import type { OcxParsedRequest, OcxProviderConfig, OcxTextContent, + OcxTool, OcxToolCall, OcxUsage, } from "../types"; @@ -206,14 +207,17 @@ function messagesToGeminiFormat(parsed: OcxParsedRequest): { systemInstruction?: return { systemInstruction, contents }; } -function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined { - if (!parsed.context.tools?.length) return undefined; +function selectedGeminiTools(parsed: OcxParsedRequest): OcxTool[] { + if (!parsed.context.tools?.length) return []; const allowed = isAllowedToolChoice(parsed.options.toolChoice) ? new Set(parsed.options.toolChoice.allowedTools) : undefined; - const tools = allowed + return allowed ? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed)) : parsed.context.tools; +} + +function toolsToGeminiFormat(tools: readonly OcxTool[]): unknown[] | undefined { if (tools.length === 0) return undefined; return [{ functionDeclarations: tools.map(t => ({ @@ -224,6 +228,10 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined { }]; } +function supportsValidatedFunctionCalling(modelId: string): boolean { + return /^gemini-3(?:[.-]|$)/i.test(modelId) || modelId === "gemini-pro-agent"; +} + function usageFromGemini(usage: Record | undefined): OcxUsage | undefined { if (!usage) return undefined; return { @@ -288,11 +296,15 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte async buildRequest(parsed: OcxParsedRequest) { const { systemInstruction, contents } = messagesToGeminiFormat(parsed); - const tools = toolsToGeminiFormat(parsed); + const selectedTools = selectedGeminiTools(parsed); + const tools = toolsToGeminiFormat(selectedTools); const body: Record = { contents }; if (systemInstruction) body.systemInstruction = systemInstruction; if (tools) body.tools = tools; + if (tools && selectedTools.some(tool => tool.strict === true) && supportsValidatedFunctionCalling(parsed.modelId)) { + body.toolConfig = { functionCallingConfig: { mode: "VALIDATED" } }; + } const generationConfig: Record = {}; if (parsed.options.maxOutputTokens) generationConfig.maxOutputTokens = parsed.options.maxOutputTokens; diff --git a/tests/google-adapter.test.ts b/tests/google-adapter.test.ts index 4eecd1d48..9e8db7c83 100644 --- a/tests/google-adapter.test.ts +++ b/tests/google-adapter.test.ts @@ -19,6 +19,32 @@ async function geminiBody(parsed: OcxParsedRequest): Promise { + const tool = { name: "bash", description: "Run", parameters: { type: "object" } }; + + test("Gemini 3 strict tools use VALIDATED mode", async () => { + const body = await geminiBody(parsedWith( + [{ role: "user", content: "hi" }], + [{ ...tool, strict: true }], + )); + + expect(body).toMatchObject({ + toolConfig: { functionCallingConfig: { mode: "VALIDATED" } }, + }); + }); + + test("unsupported models fall back without VALIDATED mode", async () => { + const request = parsedWith( + [{ role: "user", content: "hi" }], + [{ ...tool, strict: true }], + ); + request.modelId = "gemini-2.5-pro"; + const body = await geminiBody(request); + + expect(body.toolConfig).toBeUndefined(); + }); +}); + describe("google adapter — tool result images", () => { test("tool-result screenshots ride along as inline_data beside the functionResponse", async () => { const contents = await geminiContents(parsedWith([ diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index 148e40650..4f25e0fb6 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -141,7 +141,23 @@ describe("antigravity CCA envelope", () => { expect(env.request.toolConfig.functionCallingConfig.mode).toBe("VALIDATED"); }); - test("gemini-on-antigravity does NOT get the VALIDATED override", async () => { + test("gemini 3 strict tools use toolConfig.functionCallingConfig.mode=VALIDATED", async () => { + const withTools = { + modelId: "gemini-3.6-flash", + stream: false, + context: { + messages: [{ role: "user", content: "hi" }], + systemPrompt: [], + tools: [{ name: "bash", description: "run", parameters: { type: "object" }, strict: true }], + }, + options: {}, + } as unknown as OcxParsedRequest; + const req = await createGoogleAdapter(provider).buildRequest(withTools); + const env = JSON.parse(req.body); + expect(env.request.toolConfig.functionCallingConfig.mode).toBe("VALIDATED"); + }); + + test("gemini 3 non-strict tools keep normal function-calling mode", async () => { const withTools = { modelId: "gemini-3-pro", stream: false, From c75b9db9c928eadd45991ed33885b727b9e2342c Mon Sep 17 00:00:00 2001 From: GroepChef Date: Thu, 30 Jul 2026 14:05:41 +0200 Subject: [PATCH 09/31] feat(oauth): add opt-in Cursor quota account pool Rotate sticky Cursor sessions only on explicit pre-output quota failures while preserving existing pool and transport behavior. Co-authored-by: Cursor --- .../content/docs/reference/configuration.md | 26 ++ src/codex/pool-rotation.ts | 1 + src/oauth/cursor-routing.ts | 379 ++++++++++++++++++ src/oauth/health.ts | 5 +- src/server/management/oauth-account-routes.ts | 37 +- src/server/responses/core.ts | 204 ++++++++-- src/types.ts | 37 ++ tests/account-pool-management-api.test.ts | 49 +++ tests/cursor-account-pool.test.ts | 130 ++++++ tests/server-cursor-pool-e2e.test.ts | 211 ++++++++++ 10 files changed, 1027 insertions(+), 52 deletions(-) create mode 100644 src/oauth/cursor-routing.ts create mode 100644 tests/cursor-account-pool.test.ts create mode 100644 tests/server-cursor-pool-e2e.test.ts diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 126df9aba..efc79e2e6 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -222,6 +222,32 @@ Multi-account rotation may conflict with provider account policies. Leave this d unless you accept that risk. ::: +### cursorAccountPool (experimental) + +Opt-in routing across multiple Cursor OAuth accounts added with `ocx login cursor`. The +pool is off by default. It does not probe or warm accounts in the background. + +| Key | Type | Default | Description | +| --- | --- | --- | --- | +| `cursorAccountPool.enabled?` | `boolean` | `false` | Enables sticky session affinity and reactive quota failover. | +| `cursorAccountPool.autoSwitchThreshold?` | `number` | `80` | New-session threshold for known cached account usage. Unknown usage keeps the active account. `0` disables quota-based picking. | +| `cursorAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Selects accounts for new sessions. Existing sessions keep their affinity while that account remains eligible. | +| `cursorAccountPool.stickyLimit?` | `number` | `1` | Number of successful new-session binds retained by round-robin before it advances. Range 1–100. | + +Cursor rotation occurs only when the adapter reports an explicit pre-output HTTP 429, +`RESOURCE_EXHAUSTED`, or hard-quota failure. `adapter_eof`, client cancellation, +post-output failures, HTTP 502, and generic upstream errors do not cool or switch an +account. A request uses at most the shared three-attempt upstream budget. + +Manage the pool through `/api/oauth/accounts/pool?provider=cursor`, +`/api/oauth/accounts/clear-cooldown`, and the ordinary OAuth active-account endpoint. + +:::caution[Terms and account policy] +Automated multi-account use may violate Cursor's terms or trigger account enforcement. +OpenCodex does not hide or reduce that risk. Leave this disabled unless the account owner +has confirmed that the intended use is allowed. +::: + ### claudeCode (OcxClaudeCodeConfig) Claude Code inbound settings consumed by the `/v1/messages` surface, the `ocx claude` diff --git a/src/codex/pool-rotation.ts b/src/codex/pool-rotation.ts index 9383bdd20..d25a7e336 100644 --- a/src/codex/pool-rotation.ts +++ b/src/codex/pool-rotation.ts @@ -3,6 +3,7 @@ import type { OcxAccountPoolRotationStrategy } from "../types"; export const POOL_KEY_CODEX = "codex"; export const POOL_KEY_ANTHROPIC = "anthropic"; export const POOL_KEY_ANTIGRAVITY = "google-antigravity"; +export const POOL_KEY_CURSOR = "cursor"; interface SelectionState { activeKey?: string; diff --git a/src/oauth/cursor-routing.ts b/src/oauth/cursor-routing.ts new file mode 100644 index 000000000..4a82125b8 --- /dev/null +++ b/src/oauth/cursor-routing.ts @@ -0,0 +1,379 @@ +/** + * Opt-in Cursor OAuth account pool. + * + * Affinity and cooldowns are process-local. Rotation is deliberately reactive: + * callers may invoke rotateCursorAccountOnQuota only for an explicit, pre-output + * 429, RESOURCE_EXHAUSTED, or hard-quota failure. + */ +import { createHash } from "node:crypto"; +import { fallbackCodexAccountLogLabel } from "../codex/account-label"; +import { + normalizeAccountPoolStickyLimit, + normalizeAccountPoolStrategy, + notePoolRotationFailure, + notePoolRotationSuccess, + pickRoundRobinAccount, + POOL_KEY_CURSOR, + seedPoolRotationAccount, +} from "../codex/pool-rotation"; +import { classifyCursorUpstreamOutcome } from "../lib/upstream-outcome"; +import { getCachedProviderAccountQuota } from "../providers/quota"; +import type { OcxAccountPoolRotationStrategy, OcxConfig } from "../types"; +import { getAccountCredential, getAccountSet, setActiveAccount } from "./store"; + +const PROVIDER = "cursor"; +const DEFAULT_COOLDOWN_MS = 60_000; +const MAX_COOLDOWN_MS = 15 * 60_000; +const AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000; +const MAX_AFFINITY_ENTRIES = 2_000; +const DEFAULT_AUTO_SWITCH_THRESHOLD = 80; +const UNKNOWN_USAGE_SCORE = 100; +const TOKEN_SKEW_MS = 60_000; + +export const CURSOR_POOL_MAX_FAILOVERS_PER_REQUEST = 3; + +export interface CursorAccountPoolConfig { + enabled?: boolean; + autoSwitchThreshold?: number; + strategy?: OcxAccountPoolRotationStrategy; + stickyLimit?: number; +} + +interface AccountHealth { + cooldownUntil: number; + cooldownSource: "retry-after" | "default"; +} + +interface AffinityEntry { + accountId: string; + lastUsedAt: number; +} + +export type CursorAccountSelectionReason = + | "pool-disabled" + | "affinity" + | "active" + | "lowest-usage" + | "only-eligible" + | "round-robin" + | "fill-first" + | "none" + | "all-cooled"; + +export interface CursorAccountSelection { + accountId: string | null; + reason: CursorAccountSelectionReason; +} + +const upstreamHealth = new Map(); +const sessionAffinity = new Map(); + +export function cursorAccountPoolConfig(config: OcxConfig): CursorAccountPoolConfig { + const raw = config.cursorAccountPool; + return raw && typeof raw === "object" ? raw : {}; +} + +export function isCursorAccountPoolEnabled(config: OcxConfig): boolean { + return cursorAccountPoolConfig(config).enabled === true; +} + +export function cursorAutoSwitchThreshold(config: OcxConfig): number { + const value = cursorAccountPoolConfig(config).autoSwitchThreshold; + return typeof value === "number" + && Number.isInteger(value) + && value >= 0 + && value <= 100 + ? value + : DEFAULT_AUTO_SWITCH_THRESHOLD; +} + +function poolStrategy(config: OcxConfig): OcxAccountPoolRotationStrategy { + return normalizeAccountPoolStrategy(cursorAccountPoolConfig(config).strategy); +} + +function stickyLimit(config: OcxConfig): number { + return normalizeAccountPoolStickyLimit(cursorAccountPoolConfig(config).stickyLimit); +} + +function parseRetryAfterMs(value: string | null | undefined, now: number): number | undefined { + const text = value?.trim(); + if (!text) return undefined; + if (/^\d+(?:\.\d+)?$/.test(text)) { + const seconds = Number(text); + if (Number.isFinite(seconds) && seconds > 0) { + return Math.min(Math.max(Math.ceil(seconds * 1_000), 1), MAX_COOLDOWN_MS); + } + } + const timestamp = Date.parse(text); + if (!Number.isFinite(timestamp)) return undefined; + const delay = timestamp - now; + return delay > 0 ? Math.min(delay, MAX_COOLDOWN_MS) : undefined; +} + +export function getCursorAccountHealthSnapshot( + accountId: string, + now = Date.now(), +): { cooldownUntil?: number; cooldownSource?: AccountHealth["cooldownSource"] } | null { + const entry = upstreamHealth.get(accountId); + if (!entry) return null; + if (entry.cooldownUntil <= now) { + upstreamHealth.delete(accountId); + return null; + } + return { + cooldownUntil: entry.cooldownUntil, + cooldownSource: entry.cooldownSource, + }; +} + +export function clearCursorAccountCooldown(accountId: string): boolean { + return upstreamHealth.delete(accountId); +} + +export function clearCursorAccountPoolState(): void { + upstreamHealth.clear(); + sessionAffinity.clear(); +} + +function credentialIsUsable(accountId: string, now: number): boolean { + const credential = getAccountCredential(PROVIDER, accountId); + if (!credential) return false; + return Boolean(credential.refresh) || credential.expires > now + TOKEN_SKEW_MS; +} + +export function getEligibleCursorAccounts(now = Date.now()): string[] { + const set = getAccountSet(PROVIDER); + if (!set) return []; + return set.accounts + .filter(account => + account.needsReauth !== true + && getCursorAccountHealthSnapshot(account.id, now) === null + && credentialIsUsable(account.id, now)) + .map(account => account.id); +} + +export function getCursorPoolRetryAfterSeconds(now = Date.now()): number | null { + const set = getAccountSet(PROVIDER); + if (!set) return null; + let earliest: number | null = null; + for (const account of set.accounts) { + const until = getCursorAccountHealthSnapshot(account.id, now)?.cooldownUntil; + if (until && (earliest === null || until < earliest)) earliest = until; + } + return earliest && earliest > now ? Math.max(1, Math.ceil((earliest - now) / 1_000)) : null; +} + +function usageScore(accountId: string): number { + const quota = getCachedProviderAccountQuota(PROVIDER, accountId); + const value = quota?.monthlyPercent ?? quota?.fiveHourPercent ?? quota?.weeklyPercent; + return typeof value === "number" && Number.isFinite(value) + ? Math.max(0, Math.min(100, value)) + : UNKNOWN_USAGE_SCORE; +} + +function hasKnownUsage(accountId: string): boolean { + const quota = getCachedProviderAccountQuota(PROVIDER, accountId); + const value = quota?.monthlyPercent ?? quota?.fiveHourPercent ?? quota?.weeklyPercent; + return typeof value === "number" && Number.isFinite(value); +} + +function pickLowestUsage(excludeId: string | undefined, now: number): string | null { + const eligible = getEligibleCursorAccounts(now).filter(accountId => accountId !== excludeId); + if (eligible.length === 0) return null; + return eligible.reduce((best, candidate) => + usageScore(candidate) < usageScore(best) ? candidate : best); +} + +function isUnderThreshold(config: OcxConfig, accountId: string): boolean { + const threshold = cursorAutoSwitchThreshold(config); + return threshold <= 0 || !hasKnownUsage(accountId) || usageScore(accountId) < threshold; +} + +function pickNextFillFirst( + config: OcxConfig, + afterId: string, + eligible: string[], +): string | null { + if (eligible.length === 0) return null; + const ordered = [...eligible].sort((left, right) => left.localeCompare(right)); + const stableAll = (getAccountSet(PROVIDER)?.accounts.map(account => account.id) ?? ordered) + .sort((left, right) => left.localeCompare(right)); + const start = stableAll.indexOf(afterId); + let fallback: string | null = null; + for (let step = 1; step <= stableAll.length; step++) { + const candidate = stableAll[(Math.max(start, -1) + step) % stableAll.length]!; + if (!eligible.includes(candidate)) continue; + fallback ??= candidate; + if (isUnderThreshold(config, candidate)) return candidate; + } + return fallback ?? ordered[0] ?? null; +} + +function pickFillFirst(config: OcxConfig, now: number): string | null { + const eligible = getEligibleCursorAccounts(now); + const active = getAccountSet(PROVIDER)?.activeAccountId; + if (active && eligible.includes(active) && isUnderThreshold(config, active)) return active; + if (!active) return eligible.find(accountId => isUnderThreshold(config, accountId)) + ?? eligible[0] + ?? null; + return pickNextFillFirst(config, active, eligible); +} + +function pruneAffinity(now: number): void { + for (const [key, entry] of sessionAffinity) { + if (now - entry.lastUsedAt > AFFINITY_IDLE_TTL_MS) sessionAffinity.delete(key); + } + if (sessionAffinity.size <= MAX_AFFINITY_ENTRIES) return; + const sorted = [...sessionAffinity.entries()] + .sort((left, right) => left[1].lastUsedAt - right[1].lastUsedAt); + const drop = sessionAffinity.size - MAX_AFFINITY_ENTRIES; + for (let index = 0; index < drop; index++) { + sessionAffinity.delete(sorted[index]![0]); + } +} + +export function bindCursorSessionAffinity( + sessionKey: string | null | undefined, + accountId: string, + now = Date.now(), +): void { + const key = sessionKey?.trim(); + if (!key) return; + sessionAffinity.set(key, { accountId, lastUsedAt: now }); + pruneAffinity(now); +} + +export function clearCursorSessionAffinityForAccount(accountId: string): void { + for (const [key, entry] of sessionAffinity) { + if (entry.accountId === accountId) sessionAffinity.delete(key); + } +} + +export function cursorSessionKeyFromParts(input: { + clientThreadId?: string | null; + sessionIdHeader?: string | null; + threadIdHeader?: string | null; + promptCacheKey?: string | null; + cursorConversationId?: string | null; +}): string | null { + const source = input.clientThreadId?.trim() + || input.sessionIdHeader?.trim() + || input.threadIdHeader?.trim() + || input.promptCacheKey?.trim() + || input.cursorConversationId?.trim(); + if (!source) return null; + return createHash("sha256").update("ocx:cursor-pool:").update(source).digest("hex"); +} + +export function resolveCursorAccountForSession( + sessionKey: string | null | undefined, + config: OcxConfig, + now = Date.now(), +): CursorAccountSelection { + pruneAffinity(now); + const set = getAccountSet(PROVIDER); + if (!set || set.accounts.length === 0) return { accountId: null, reason: "none" }; + if (!isCursorAccountPoolEnabled(config)) { + return { accountId: set.activeAccountId, reason: "pool-disabled" }; + } + + const eligible = getEligibleCursorAccounts(now); + const key = sessionKey?.trim(); + if (key) { + const affined = sessionAffinity.get(key); + if (affined && eligible.includes(affined.accountId)) { + affined.lastUsedAt = now; + return { accountId: affined.accountId, reason: "affinity" }; + } + sessionAffinity.delete(key); + } + + const strategy = poolStrategy(config); + const active = set.activeAccountId; + if (!key && eligible.includes(active) && strategy !== "quota") { + return { accountId: active, reason: "active" }; + } + let accountId: string | null = null; + let reason: CursorAccountSelectionReason = "none"; + if (strategy === "round-robin") { + accountId = pickRoundRobinAccount(POOL_KEY_CURSOR, eligible, stickyLimit(config)); + if (accountId) { + notePoolRotationSuccess(POOL_KEY_CURSOR, accountId, stickyLimit(config)); + reason = "round-robin"; + } + } else if (strategy === "fill-first") { + accountId = pickFillFirst(config, now); + if (accountId) reason = "fill-first"; + } else if (eligible.includes(active) && isUnderThreshold(config, active)) { + accountId = active; + reason = "active"; + } else if (cursorAutoSwitchThreshold(config) > 0) { + accountId = pickLowestUsage(undefined, now); + if (accountId) reason = accountId === active ? "active" : "lowest-usage"; + } else { + accountId = pickLowestUsage(active, now); + if (accountId) reason = "only-eligible"; + } + + if (!accountId) { + const anyCooled = set.accounts.some(account => + getCursorAccountHealthSnapshot(account.id, now) !== null); + return { accountId: null, reason: anyCooled ? "all-cooled" : "none" }; + } + bindCursorSessionAffinity(key, accountId, now); + return { accountId, reason }; +} + +export function isCursorPoolRotationError(message: string): boolean { + const outcome = classifyCursorUpstreamOutcome({ message }); + return outcome === "rate-limit" || outcome === "quota-exhausted"; +} + +export function rotateCursorAccountOnQuota( + config: OcxConfig, + failedAccountId: string, + retryAfter: string | null | undefined, + sessionKey?: string | null, + now = Date.now(), +): string | null { + if (!isCursorAccountPoolEnabled(config)) return null; + const parsedRetryAfter = parseRetryAfterMs(retryAfter, now); + upstreamHealth.set(failedAccountId, { + cooldownUntil: now + (parsedRetryAfter ?? DEFAULT_COOLDOWN_MS), + cooldownSource: parsedRetryAfter ? "retry-after" : "default", + }); + clearCursorSessionAffinityForAccount(failedAccountId); + notePoolRotationFailure(POOL_KEY_CURSOR, failedAccountId); + + const eligible = getEligibleCursorAccounts(now).filter(accountId => accountId !== failedAccountId); + const strategy = poolStrategy(config); + const next = strategy === "round-robin" + ? pickRoundRobinAccount(POOL_KEY_CURSOR, eligible, stickyLimit(config)) + : strategy === "fill-first" + ? pickNextFillFirst(config, failedAccountId, eligible) + : pickLowestUsage(failedAccountId, now); + if (!next) return null; + bindCursorSessionAffinity(sessionKey, next, now); + console.warn( + `[cursor-pool] quota on ${fallbackCodexAccountLogLabel(failedAccountId)}; failing over to ${fallbackCodexAccountLogLabel(next)}`, + ); + return next; +} + +export async function getCursorPoolAccessToken(accountId: string): Promise { + const { getValidAccessTokenForAccount } = await import("./index"); + return getValidAccessTokenForAccount(PROVIDER, accountId); +} + +export function promoteCursorActiveAccount(accountId: string): void { + void setActiveAccount(PROVIDER, accountId).catch(() => {}); +} + +export function resetCursorRoutingForManualSelection(accountId: string): void { + sessionAffinity.clear(); + seedPoolRotationAccount(POOL_KEY_CURSOR, accountId); +} + +export function formatCursorProviderForLog(accountId: string | null | undefined): string { + return accountId ? `${PROVIDER}-${fallbackCodexAccountLogLabel(accountId)}` : PROVIDER; +} diff --git a/src/oauth/health.ts b/src/oauth/health.ts index 24f10ded1..52fa63dcf 100644 --- a/src/oauth/health.ts +++ b/src/oauth/health.ts @@ -1,6 +1,7 @@ import { getCodexAccountHealthSnapshot, type CodexCooldownSource } from "../codex/routing"; import { getAnthropicAccountHealthSnapshot } from "./anthropic-routing"; import { getGoogleAntigravityAccountHealthSnapshot } from "./google-antigravity-routing"; +import { getCursorAccountHealthSnapshot } from "./cursor-routing"; import { isAccountNeedsReauth } from "../codex/account-runtime-state"; import { getCodexAccountCredential, listCodexAccountIds } from "../codex/account-store"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; @@ -180,7 +181,9 @@ export function projectStoredOAuthAccountHealth( ? getAnthropicAccountHealthSnapshot(account.id, now) : provider === "google-antigravity" ? getGoogleAntigravityAccountHealthSnapshot(account.id, now) - : null; + : provider === "cursor" + ? getCursorAccountHealthSnapshot(account.id, now) + : null; return projectOAuthAccountHealth({ needsReauth: account.needsReauth === true, reauthReason: account.needsReauth === true ? "refresh_failed" : undefined, diff --git a/src/server/management/oauth-account-routes.ts b/src/server/management/oauth-account-routes.ts index 6a02af06a..1eaa68919 100644 --- a/src/server/management/oauth-account-routes.ts +++ b/src/server/management/oauth-account-routes.ts @@ -235,6 +235,9 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< } else if (provider === "google-antigravity") { const { resetGoogleAntigravityRoutingForManualSelection } = await import("../../oauth/google-antigravity-routing"); resetGoogleAntigravityRoutingForManualSelection(body.accountId); + } else if (provider === "cursor") { + const { resetCursorRoutingForManualSelection } = await import("../../oauth/cursor-routing"); + resetCursorRoutingForManualSelection(body.accountId); } const { clearProviderQuotaCache } = await import("../../providers/quota"); clearProviderQuotaCache(); @@ -244,12 +247,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< // Opt-in OAuth account pools: enable/threshold/strategy + clear cooldown. if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") { const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); - if (provider !== "anthropic" && provider !== "google-antigravity") { - return jsonResponse({ error: "pool config is only supported for anthropic and google-antigravity" }, 400); + if (provider !== "anthropic" && provider !== "google-antigravity" && provider !== "cursor") { + return jsonResponse({ error: "pool config is only supported for anthropic, google-antigravity, and cursor" }, 400); } const pool = provider === "anthropic" ? config.anthropicAccountPool ?? {} - : config.googleAntigravityAccountPool ?? {}; + : provider === "google-antigravity" + ? config.googleAntigravityAccountPool ?? {} + : config.cursorAccountPool ?? {}; return jsonResponse({ provider, enabled: pool.enabled === true, @@ -272,12 +277,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< stickyLimit?: unknown; }; const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; - if (provider !== "anthropic" && provider !== "google-antigravity") { - return jsonResponse({ error: "pool config is only supported for anthropic and google-antigravity" }, 400); + if (provider !== "anthropic" && provider !== "google-antigravity" && provider !== "cursor") { + return jsonResponse({ error: "pool config is only supported for anthropic, google-antigravity, and cursor" }, 400); } const currentPool = provider === "anthropic" ? config.anthropicAccountPool - : config.googleAntigravityAccountPool; + : provider === "google-antigravity" + ? config.googleAntigravityAccountPool + : config.cursorAccountPool; let enabled = currentPool?.enabled === true; if (body.enabled !== undefined) { if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400); @@ -318,7 +325,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< ...(stickyLimit !== undefined ? { stickyLimit } : {}), }; if (provider === "anthropic") config.anthropicAccountPool = nextPool; - else config.googleAntigravityAccountPool = nextPool; + else if (provider === "google-antigravity") config.googleAntigravityAccountPool = nextPool; + else config.cursorAccountPool = nextPool; saveConfigPreservingClaudeCode(config); return jsonResponse({ ok: true, @@ -334,13 +342,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< const body = await req.json().catch(() => ({})) as { provider?: unknown; accountId?: unknown }; const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; const accountId = typeof body.accountId === "string" ? body.accountId.trim() : ""; - if (provider !== "anthropic" && provider !== "google-antigravity") { - return jsonResponse({ error: "clear-cooldown is only supported for anthropic and google-antigravity" }, 400); + if (provider !== "anthropic" && provider !== "google-antigravity" && provider !== "cursor") { + return jsonResponse({ error: "clear-cooldown is only supported for anthropic, google-antigravity, and cursor" }, 400); } if (!accountId) return jsonResponse({ error: "missing accountId" }, 400); const cleared = provider === "anthropic" ? (await import("../../oauth/anthropic-routing")).clearAnthropicAccountCooldown(accountId) - : (await import("../../oauth/google-antigravity-routing")).clearGoogleAntigravityAccountCooldown(accountId); + : provider === "google-antigravity" + ? (await import("../../oauth/google-antigravity-routing")).clearGoogleAntigravityAccountCooldown(accountId) + : (await import("../../oauth/cursor-routing")).clearCursorAccountCooldown(accountId); return jsonResponse({ ok: true, cleared }); } @@ -376,6 +386,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< } = await import("../../oauth/google-antigravity-routing"); clearGoogleAntigravityAccountCooldown(id); clearGoogleAntigravitySessionAffinityForAccount(id); + } else if (provider === "cursor") { + const { + clearCursorAccountCooldown, + clearCursorSessionAffinityForAccount, + } = await import("../../oauth/cursor-routing"); + clearCursorAccountCooldown(id); + clearCursorSessionAffinityForAccount(id); } if (!getAccountSet(provider)) clearLoginState(provider); const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota"); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index fc2ae6f08..e5ca30ab8 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -65,6 +65,19 @@ import { resolveGoogleAntigravityAccountForSession, rotateGoogleAntigravityAccountOn429, } from "../../oauth/google-antigravity-routing"; +import { + bindCursorSessionAffinity, + CURSOR_POOL_MAX_FAILOVERS_PER_REQUEST, + cursorSessionKeyFromParts, + formatCursorProviderForLog, + getCursorPoolAccessToken, + getCursorPoolRetryAfterSeconds, + isCursorAccountPoolEnabled, + isCursorPoolRotationError, + promoteCursorActiveAccount, + resolveCursorAccountForSession, + rotateCursorAccountOnQuota, +} from "../../oauth/cursor-routing"; import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; @@ -1310,6 +1323,8 @@ export async function handleResponses( let anthropicPoolFailovers = 0; let googleAntigravityPoolAccountId: string | null = null; let googleAntigravityPoolFailovers = 0; + let cursorPoolAccountId: string | null = null; + let cursorPoolFailovers = 0; const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth" ? anthropicSessionKeyFromParts({ sessionIdHeader: sessionIdHeaderFromRequest(req.headers), @@ -1325,6 +1340,15 @@ export async function handleResponses( : null; const antigravityPoolOwns429 = route.providerName === "google-antigravity" && isGoogleAntigravityAccountPoolEnabled(config); + const cursorSessionKey = route.providerName === "cursor" && route.provider.authMode === "oauth" + ? cursorSessionKeyFromParts({ + clientThreadId: parsed._clientThreadId, + sessionIdHeader: sessionIdHeaderFromRequest(req.headers), + threadIdHeader: req.headers.get("thread-id"), + promptCacheKey: parsed.options.promptCacheKey, + cursorConversationId: parsed._cursorConversationId, + }) + : null; if (route.provider.authMode === "oauth") { try { if (route.providerName === "anthropic" && isAnthropicAccountPoolEnabled(config)) { @@ -1384,6 +1408,34 @@ export async function handleResponses( project: credential.projectId, }; logCtx.provider = formatGoogleAntigravityProviderForLog(selection.accountId); + } else if ( + route.providerName === "cursor" + && isCursorAccountPoolEnabled(config) + ) { + const selection = resolveCursorAccountForSession(cursorSessionKey, config); + if (!selection.accountId) { + if (selection.reason === "all-cooled") { + const retryAfterSec = getCursorPoolRetryAfterSeconds(); + return formatErrorResponse( + 429, + "rate_limit_error", + "All Cursor OAuth accounts are temporarily rate-limited", + retryAfterSec !== null ? { retryAfter: String(retryAfterSec) } : undefined, + ); + } + return formatErrorResponse( + 401, + "authentication_error", + "No eligible Cursor OAuth account available", + ); + } + const accessToken = await getCursorPoolAccessToken(selection.accountId); + cursorPoolAccountId = selection.accountId; + bindCursorSessionAffinity(cursorSessionKey, selection.accountId); + promoteCursorActiveAccount(selection.accountId); + parsed._cursorIdentityScope = selection.accountId; + route.provider = { ...route.provider, apiKey: accessToken }; + logCtx.provider = formatCursorProviderForLog(selection.accountId); } else { const resolved = await getValidAccessTokenSnapshot(route.providerName); if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved; @@ -2074,57 +2126,115 @@ export async function handleResponses( } if (adapter.runTurn) { - const runTurnAbort = new AbortController(); - linkAbortSignal(runTurnAbort, options.abortSignal); - const queue = createAdapterEventQueue({ - onBacklogExceeded: () => runTurnAbort.abort(), - }); - const runTurn = async (): Promise => { + let activeRunTurnAdapter = adapter; + const startRunTurnAttempt = () => { + const runTurnAbort = new AbortController(); + const unlinkAbort = linkAbortSignal(runTurnAbort, options.abortSignal); + const queue = createAdapterEventQueue({ + onBacklogExceeded: () => runTurnAbort.abort(), + }); + const attemptAdapter = activeRunTurnAdapter; + const done = (async (): Promise => { + try { + noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens); + await attemptAdapter.runTurn?.( + parsed, + { + headers: selectedForwardHeaders, + abortSignal: runTurnAbort.signal, + attemptBudget: options.attemptBudget, + }, + queue.push, + ); + } catch (err) { + queue.push({ + type: "error", + message: err instanceof Error ? err.message : String(err), + }); + } finally { + unlinkAbort(); + // Cursor assigns a stable conversation id inside runTurn on the first headerless + // turn; backfill so Logs can filter/total that opening request (#330 / #522). + if (!logCtx.conversationId && parsed._cursorConversationId) { + logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId); + } + queue.close(); + } + })(); + return { runTurnAbort, queue, done }; + }; + const rotateCursorRunTurn = async (message: string): Promise => { + if ( + !cursorPoolAccountId + || cursorPoolFailovers >= CURSOR_POOL_MAX_FAILOVERS_PER_REQUEST + || options.abortSignal?.aborted + || !isCursorPoolRotationError(message) + ) { + return false; + } + const nextAccountId = rotateCursorAccountOnQuota( + config, + cursorPoolAccountId, + null, + cursorSessionKey, + ); + if (!nextAccountId) return false; try { - noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens); - await adapter.runTurn?.( - parsed, - { - headers: selectedForwardHeaders, - abortSignal: runTurnAbort.signal, - attemptBudget: options.attemptBudget, - }, - queue.push, + const accessToken = await getCursorPoolAccessToken(nextAccountId); + cursorPoolAccountId = nextAccountId; + cursorPoolFailovers += 1; + parsed._cursorIdentityScope = nextAccountId; + route.provider = { ...route.provider, apiKey: accessToken }; + promoteCursorActiveAccount(nextAccountId); + logCtx.provider = formatCursorProviderForLog(nextAccountId); + activeRunTurnAdapter = resolveAdapter( + resolveWireProtocolOverride(route.providerName, route.modelId, route.provider), + config.cacheRetention, ); - } catch (err) { - queue.push({ - type: "error", - message: err instanceof Error ? err.message : String(err), - }); - } finally { - // Cursor assigns a stable conversation id inside runTurn on the first headerless - // turn; backfill so Logs can filter/total that opening request (#330 / #522). - if (!logCtx.conversationId && parsed._cursorConversationId) { - logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId); - } - queue.close(); + sealRequestAttemptIdentity( + logCtx.activeAttempt, + logCtx.provider, + activeRunTurnAdapter.name, + ); + return true; + } catch { + return false; } }; - const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed); if (parsed.stream) { - void runTurn(); - let eventSource: AsyncIterable = queue.stream(); - if (options.comboAttempt) { + let attempt = startRunTurnAttempt(); + let eventSource: AsyncIterable; + while (true) { + eventSource = attempt.queue.stream(); + if (!options.comboAttempt && !cursorPoolAccountId) break; const preflight = await preflightAdapterEvents(eventSource); + if ( + preflight.error + && await rotateCursorRunTurn(preflight.error.message) + ) { + attempt.runTurnAbort.abort(); + attempt.queue.close(); + await attempt.done; + attempt = startRunTurnAttempt(); + continue; + } if (preflight.error || preflight.empty) { - runTurnAbort.abort(); - queue.close(); - const message = preflight.error?.message ?? "Adapter ended before producing a response"; - return formatErrorResponse(502, "upstream_error", redactSecretString(message)); + if (options.comboAttempt) { + attempt.runTurnAbort.abort(); + attempt.queue.close(); + const message = preflight.error?.message ?? "Adapter ended before producing a response"; + return formatErrorResponse(502, "upstream_error", redactSecretString(message)); + } } eventSource = preflight.stream; + break; } const sseStream = bridgeToResponsesSSE( eventSource, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => { - runTurnAbort.abort(); - queue.close(); + attempt.runTurnAbort.abort(); + attempt.queue.close(); }, 2_000, { ...(options.forceEmptyResponseId ? { responseId: "" } : {}), @@ -2147,7 +2257,7 @@ export async function handleResponses( parsed._rawBody, response, continuationStateForResponse(providerState), - adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined, + adapterNeedsForcedContinuation(activeRunTurnAdapter.name) ? { force: true } : undefined, ), }), }, @@ -2159,8 +2269,20 @@ export async function handleResponses( }); } - await runTurn(); - const events = await queue.collect(); + let events: AdapterEvent[]; + while (true) { + const attempt = startRunTurnAttempt(); + await attempt.done; + events = await attempt.queue.collect(); + const firstMeaningful = events.find(event => event.type !== "heartbeat"); + if ( + firstMeaningful?.type === "error" + && await rotateCursorRunTurn(firstMeaningful.message) + ) { + continue; + } + break; + } if (options.comboAttempt) { const firstMeaningful = events.find(event => event.type !== "heartbeat"); if (!firstMeaningful || firstMeaningful.type === "error") { @@ -2191,7 +2313,7 @@ export async function handleResponses( parsed._rawBody, json, continuationStateForResponse(providerState), - adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined, + adapterNeedsForcedContinuation(activeRunTurnAdapter.name) ? { force: true } : undefined, ); } return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } }); diff --git a/src/types.ts b/src/types.ts index eb66dfe1d..24012c206 100644 --- a/src/types.ts +++ b/src/types.ts @@ -743,6 +743,43 @@ export interface OcxConfig { /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ stickyLimit?: number; }; + /** + * Opt-in Cursor OAuth account pool. Default OFF. + * Rotates only after an explicit pre-output 429, RESOURCE_EXHAUSTED, or hard-quota + * failure. Transport errors and client cancellation never change account health. + */ + cursorAccountPool?: { + enabled?: boolean; + /** Usage % threshold for new-session auto-pick. Default 80. 0 = disabled (affinity/active only). */ + autoSwitchThreshold?: number; + /** New-session rotation strategy. Default quota. */ + strategy?: OcxAccountPoolRotationStrategy; + /** Successful new-session binds retained on one round-robin selection. Default 1; range 1..100. */ + stickyLimit?: number; + }; + /** + * Fork alias for Codex pool selection on new (non-affined) conversations. + * Prefer `accountPoolStrategy`. When `accountPoolStrategy` is unset, + * `"round-robin"` here maps to `accountPoolStrategy: "round-robin"`; + * `"failover"` / unset keep the default quota sticky path. + */ + codexRotationMode?: "failover" | "round-robin"; + /** + * Jittered inter-request pacer for outbound Codex pool calls. Off by default. + * When enabled, each pool account waits a randomized delay in [minMs, maxMs] between consecutive + * sends (per-account state, so concurrent accounts desync instead of aligning into a fixed + * interval) so a multi-account pool never emits a regular, ban-prone request pattern. Disabled + * by default for backward compatibility; single-account setups are unaffected while disabled. + */ + codexRequestPacing?: OcxCodexRequestPacing; + /** Token/cost budget thresholds for usage alerts (see src/usage/budgets.ts). */ + budgets?: { + tokenDaily?: number; + tokenWeekly?: number; + costDailyEur?: number; + alertActions?: Array<"log" | "posthog" | "webhook">; + webhookUrl?: string; + }; /** Virtual `combo/` models spanning concrete provider/model targets (issue #133). */ combos?: Record; /** Background proactive token refresh ("Token Guardian"). Off by default; see OcxTokenGuardianConfig. */ diff --git a/tests/account-pool-management-api.test.ts b/tests/account-pool-management-api.test.ts index 81e168b8d..d0ac2fd82 100644 --- a/tests/account-pool-management-api.test.ts +++ b/tests/account-pool-management-api.test.ts @@ -423,6 +423,55 @@ describe("Anthropic account pool strategy management API", () => { } }); + test("Cursor pool defaults off and persists independent settings", async () => { + const server = startServer(0); + try { + const initial = await fetch(new URL( + "/api/oauth/accounts/pool?provider=cursor", + server.url, + )); + expect(initial.status).toBe(200); + expect(await initial.json()).toMatchObject({ + provider: "cursor", + enabled: false, + autoSwitchThreshold: 80, + strategy: "quota", + stickyLimit: 1, + }); + + const put = await fetch(new URL("/api/oauth/accounts/pool", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + provider: "cursor", + enabled: true, + autoSwitchThreshold: 60, + strategy: "fill-first", + stickyLimit: 2, + }), + }); + expect(put.status).toBe(200); + expect(await put.json()).toMatchObject({ + provider: "cursor", + enabled: true, + autoSwitchThreshold: 60, + strategy: "fill-first", + stickyLimit: 2, + }); + + const antigravity = await fetch(new URL( + "/api/oauth/accounts/pool?provider=google-antigravity", + server.url, + )); + expect(await antigravity.json()).toMatchObject({ + provider: "google-antigravity", + enabled: false, + }); + } finally { + await server.stop(true); + } + }); + test("Google Antigravity clear-cooldown removes only requested cooldown", async () => { const poolConfig = { ...baseConfig(), diff --git a/tests/cursor-account-pool.test.ts b/tests/cursor-account-pool.test.ts new file mode 100644 index 000000000..9a09bdb6d --- /dev/null +++ b/tests/cursor-account-pool.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { clearPoolRotationState } from "../src/codex/pool-rotation"; +import { + bindCursorSessionAffinity, + clearCursorAccountPoolState, + cursorSessionKeyFromParts, + getCursorAccountHealthSnapshot, + isCursorAccountPoolEnabled, + isCursorPoolRotationError, + resolveCursorAccountForSession, + rotateCursorAccountOnQuota, +} from "../src/oauth/cursor-routing"; +import { getAccountSet, saveCredential, setActiveAccount } from "../src/oauth/store"; +import type { OcxConfig } from "../src/types"; + +const PROVIDER = "cursor"; +const originalHome = process.env.OPENCODEX_HOME; +let home = ""; + +function config(enabled: boolean): OcxConfig { + return { + port: 0, + defaultProvider: PROVIDER, + providers: { + [PROVIDER]: { + adapter: "cursor", + baseUrl: "https://api2.cursor.sh", + authMode: "oauth", + }, + }, + cursorAccountPool: { enabled }, + }; +} + +async function seedAccounts(): Promise { + for (const suffix of ["a", "b"]) { + await saveCredential(PROVIDER, { + access: `cursor-token-${suffix}`, + refresh: `cursor-refresh-${suffix}`, + expires: Date.now() + 3_600_000, + accountId: `cursor-account-${suffix}`, + email: `${suffix}@example.test`, + }); + } + const ids = getAccountSet(PROVIDER)!.accounts.map(account => account.id); + await setActiveAccount(PROVIDER, ids[0]!); + return ids; +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-cursor-pool-")); + process.env.OPENCODEX_HOME = home; + clearCursorAccountPoolState(); + clearPoolRotationState(); +}); + +afterEach(() => { + clearCursorAccountPoolState(); + clearPoolRotationState(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + rmSync(home, { recursive: true, force: true }); +}); + +describe("Cursor account pool", () => { + test("is default-off and keeps the active account", async () => { + const [activeId] = await seedAccounts(); + expect(isCursorAccountPoolEnabled(config(false))).toBe(false); + expect(resolveCursorAccountForSession("session", config(false))).toEqual({ + accountId: activeId, + reason: "pool-disabled", + }); + }); + + test("keeps sticky session affinity", async () => { + const [firstId, secondId] = await seedAccounts(); + bindCursorSessionAffinity("sticky", secondId!); + expect(resolveCursorAccountForSession("sticky", config(true))).toEqual({ + accountId: secondId, + reason: "affinity", + }); + expect(resolveCursorAccountForSession("other", config(true)).accountId).toBe(firstId); + }); + + test("quota rotation cools the failed account and rebinds affinity", async () => { + const [firstId, secondId] = await seedAccounts(); + bindCursorSessionAffinity("quota-session", firstId!); + expect( + rotateCursorAccountOnQuota(config(true), firstId!, "30", "quota-session"), + ).toBe(secondId); + expect(getCursorAccountHealthSnapshot(firstId!)).not.toBeNull(); + expect(resolveCursorAccountForSession("quota-session", config(true))).toEqual({ + accountId: secondId, + reason: "affinity", + }); + }); + + test("only explicit rate and quota failures qualify for rotation", () => { + for (const message of [ + "Cursor rate limit exceeded: HTTP 429 Too Many Requests", + "Cursor rate limit exceeded: RESOURCE_EXHAUSTED", + "Cursor quota exhausted: usage limit has been reached", + ]) { + expect(isCursorPoolRotationError(message)).toBe(true); + } + for (const message of [ + "adapter_eof", + "client cancelled request", + "Cursor server overloaded: Provider error 502", + "Cursor upstream error", + "Cursor resource limit exceeded: tool catalog too large", + ]) { + expect(isCursorPoolRotationError(message)).toBe(false); + } + }); + + test("builds one stable, opaque affinity key per session source", () => { + const first = cursorSessionKeyFromParts({ clientThreadId: "thread-1" }); + const second = cursorSessionKeyFromParts({ + clientThreadId: "thread-1", + sessionIdHeader: "ignored-lower-priority", + }); + expect(first).toBe(second); + expect(first).toMatch(/^[0-9a-f]{64}$/); + expect(cursorSessionKeyFromParts({})).toBeNull(); + }); +}); diff --git a/tests/server-cursor-pool-e2e.test.ts b/tests/server-cursor-pool-e2e.test.ts new file mode 100644 index 000000000..364d6021f --- /dev/null +++ b/tests/server-cursor-pool-e2e.test.ts @@ -0,0 +1,211 @@ +import http2 from "node:http2"; +import { create, toBinary } from "@bufbuild/protobuf"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { encodeConnectFrame } from "../src/adapters/cursor/framing"; +import { + AgentServerMessageSchema, + InteractionUpdateSchema, +} from "../src/adapters/cursor/gen/agent_pb"; +import { clearPoolRotationState } from "../src/codex/pool-rotation"; +import { saveConfig } from "../src/config"; +import { + clearCursorAccountPoolState, + getCursorAccountHealthSnapshot, +} from "../src/oauth/cursor-routing"; +import { getAccountSet, saveCredential, setActiveAccount } from "../src/oauth/store"; +import { getProviderRegistryEntry } from "../src/providers/registry"; +import { startServer } from "../src/server"; +import type { OcxConfig } from "../src/types"; +import { + installIsolatedCodexHome, + type IsolatedCodexHome, +} from "./helpers/isolated-codex-home"; + +const PROVIDER = "cursor"; +let home = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; + +function turnEndedFrame(): Uint8Array { + const message = create(AgentServerMessageSchema, { + message: { + case: "interactionUpdate", + value: create(InteractionUpdateSchema, { + message: { case: "turnEnded", value: {} }, + }), + }, + }); + return encodeConnectFrame(toBinary(AgentServerMessageSchema, message)); +} + +function endStreamFrame(error?: { code: string; message: string }): Uint8Array { + return encodeConnectFrame( + new TextEncoder().encode(JSON.stringify(error ? { error } : {})), + { endStream: true }, + ); +} + +async function withCursorServer( + handler: (stream: http2.ServerHttp2Stream, headers: http2.IncomingHttpHeaders) => void, + run: (baseUrl: string) => Promise, +): Promise { + const server = http2.createServer(); + server.on("stream", handler); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Cursor fixture did not bind"); + try { + return await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise(resolve => server.close(() => resolve())); + } +} + +function config(baseUrl: string): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: PROVIDER, + providers: { + [PROVIDER]: { + adapter: "cursor", + baseUrl, + allowPrivateNetwork: true, + authMode: "oauth", + models: ["auto"], + }, + }, + cursorAccountPool: { enabled: true }, + }; +} + +async function seedAccounts(): Promise { + for (const suffix of ["a", "b"]) { + await saveCredential(PROVIDER, { + access: `token-${suffix}`, + refresh: `refresh-${suffix}`, + expires: Date.now() + 3_600_000, + accountId: `account-${suffix}`, + email: `${suffix}@example.test`, + }); + } + const ids = getAccountSet(PROVIDER)!.accounts.map(account => account.id); + await setActiveAccount(PROVIDER, ids[0]!); + return ids; +} + +async function request(serverUrl: URL, threadId: string): Promise { + return fetch(new URL("/v1/responses", serverUrl), { + method: "POST", + headers: { + "content-type": "application/json", + "x-codex-parent-thread-id": threadId, + }, + body: JSON.stringify({ + model: "cursor/auto", + input: "pool fixture", + stream: false, + }), + }); +} + +async function withCursorRegistryBaseUrl(baseUrl: string, run: () => Promise): Promise { + const entry = getProviderRegistryEntry(PROVIDER); + if (!entry) throw new Error("Cursor registry entry is missing"); + const original = entry.baseUrl; + entry.baseUrl = baseUrl; + try { + return await run(); + } finally { + entry.baseUrl = original; + } +} + +beforeEach(async () => { + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-cursor-pool-e2e-codex-"); + home = mkdtempSync(join(tmpdir(), "ocx-cursor-pool-e2e-")); + process.env.OPENCODEX_HOME = home; + clearCursorAccountPoolState(); + clearPoolRotationState(); + await seedAccounts(); +}); + +afterEach(() => { + clearCursorAccountPoolState(); + clearPoolRotationState(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + rmSync(home, { recursive: true, force: true }); +}); + +describe("server Cursor account pool", () => { + test("RESOURCE_EXHAUSTED rotates before output and succeeds on the second account", async () => { + const attempts: string[] = []; + await withCursorServer((stream, headers) => { + const authorization = String(headers.authorization ?? ""); + attempts.push(authorization); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + if (authorization === "Bearer token-a") { + stream.end(endStreamFrame({ + code: "resource_exhausted", + message: "RESOURCE_EXHAUSTED: hard quota exhausted", + })); + return; + } + stream.end(Buffer.concat([ + turnEndedFrame(), + endStreamFrame(), + ])); + }, baseUrl => withCursorRegistryBaseUrl(baseUrl, async () => { + saveConfig(config(baseUrl)); + const server = startServer(0); + try { + const response = await request(server.url, "cursor-pool-429"); + expect(response.status).toBe(200); + expect(attempts).toEqual(["Bearer token-a", "Bearer token-b"]); + } finally { + await server.stop(true); + } + })); + }); + + test("EOF and generic 502 errors do not rotate or cool the account", async () => { + const [firstId] = getAccountSet(PROVIDER)!.accounts.map(account => account.id); + const attempts: string[] = []; + let outcome: "eof" | "502" = "eof"; + await withCursorServer((stream, headers) => { + attempts.push(String(headers.authorization ?? "")); + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + if (outcome === "eof") { + stream.end(); + } else { + stream.end(endStreamFrame({ + code: "internal", + message: "generic upstream error: post-commit 502", + })); + } + }, baseUrl => withCursorRegistryBaseUrl(baseUrl, async () => { + saveConfig(config(baseUrl)); + const server = startServer(0); + try { + for (outcome of ["eof", "502"]) { + attempts.length = 0; + await request(server.url, `cursor-pool-${outcome}`); + expect(attempts).toEqual(["Bearer token-a"]); + expect(getCursorAccountHealthSnapshot(firstId!)).toBeNull(); + } + } finally { + await server.stop(true); + } + })); + }); +}); From ced3eb4c07357569cdbb6ade3d0786e58a97b3e9 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 13:06:28 +0000 Subject: [PATCH 10/31] fix(cursor): honour the upstream Retry-After when rotating on quota The runTurn quota-rotation path passed `null` as `retryAfter` to rotateCursorAccountOnQuota, so an upstream `Retry-After: 120` silently degraded to the generic 60s default and the exhausted account came back into the pool while the server was still refusing it. The header was never reachable at that point: the Cursor Connect transport did not read response headers at all, and Connect reports quota later as an end-stream error frame, so only the message text survived to the rotation call. Thread the value through instead: - live-transport captures `retry-after` from the HTTP/2 response headers. - transport-retry attaches it to the failing error, the last point where the discarded transport is still reachable, as a non-enumerable property so it cannot leak into error serialization. - the cursor adapter surfaces it on the `error` AdapterEvent. - core.ts passes it to rotateCursorAccountOnQuota. Reported by greptile on #24. Co-authored-by: Codesmith --- src/adapters/cursor.ts | 10 ++++++-- src/adapters/cursor/cursor-errors.ts | 32 ++++++++++++++++++++++++++ src/adapters/cursor/live-transport.ts | 17 ++++++++++++++ src/adapters/cursor/transport-retry.ts | 5 +++- src/adapters/cursor/transport.ts | 6 +++++ src/server/responses/core.ts | 13 +++++++---- src/types.ts | 6 +++++ tests/cursor-account-pool.test.ts | 21 +++++++++++++++++ tests/cursor-transport-retry.test.ts | 30 ++++++++++++++++++++++++ tests/server-cursor-pool-e2e.test.ts | 32 ++++++++++++++++++++++++++ 10 files changed, 165 insertions(+), 7 deletions(-) diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts index c9c3a64dc..e8df58c59 100644 --- a/src/adapters/cursor.ts +++ b/src/adapters/cursor.ts @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import type { AdapterEvent, OcxProviderConfig } from "../types"; import type { ProviderAdapter } from "./base"; import { cursorExecDeniedMessage, cursorRequestDeclaresFullAccess } from "./cursor/exec-policy"; -import { isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage } from "./cursor/cursor-errors"; +import { cursorRetryAfterFromError, isCursorBenignCancelError, isCursorInvalidArgumentError, safeCursorErrorMessage } from "./cursor/cursor-errors"; import { isCursorExternalWireModel } from "./cursor/discovery"; import { createCursorKvStore, type CursorKvStore } from "./cursor/kv-store"; import { mapCursorServerMessage } from "./cursor/message-mapper"; @@ -179,7 +179,13 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda } catch (err) { if (isCursorBenignCancelError(err)) return; const partialUsage = (err as { partialUsage?: import("../types").OcxUsage }).partialUsage; - emit({ type: "error", message: safeCursorTransportError(err), ...(partialUsage ? { usage: partialUsage } : {}) }); + const retryAfter = cursorRetryAfterFromError(err); + emit({ + type: "error", + message: safeCursorTransportError(err), + ...(partialUsage ? { usage: partialUsage } : {}), + ...(retryAfter ? { retryAfter } : {}), + }); } }, }; diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index fac6c18e7..6d7061692 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -23,6 +23,38 @@ function errorCode(value: unknown): string { return code === undefined || code === null ? "" : String(code); } +/** + * Property used to carry an upstream `Retry-After` alongside a Cursor turn failure. + * + * The value is a transport fact that the error message cannot express: Connect sends quota backoff + * in a response header, while the failure itself arrives later as an end-stream error frame. The + * account pool needs both to cool an account down for the interval the server asked for. + */ +const CURSOR_RETRY_AFTER_KEY = "ocxCursorRetryAfter"; + +/** Attach an upstream `Retry-After` to a Cursor error, leaving an existing value untouched. */ +export function attachCursorRetryAfter(error: T, retryAfter: string | null | undefined): T { + if (typeof error !== "object" || !error) return error; + if (typeof retryAfter !== "string" || retryAfter.trim() === "") return error; + const target = error as Record; + if (typeof target[CURSOR_RETRY_AFTER_KEY] === "string") return error; + // Non-enumerable so the value never leaks into log/JSON serialization of the error. + Object.defineProperty(target, CURSOR_RETRY_AFTER_KEY, { + value: retryAfter.trim(), + enumerable: false, + configurable: true, + writable: true, + }); + return error; +} + +/** The upstream `Retry-After` previously attached to a Cursor error, when present. */ +export function cursorRetryAfterFromError(error: unknown): string | null { + if (typeof error !== "object" || !error) return null; + const value = (error as Record)[CURSOR_RETRY_AFTER_KEY]; + return typeof value === "string" && value !== "" ? value : null; +} + /** * True when Cursor intentionally cancelled the HTTP/2 stream after a client-tool suspend. * These are expected between multi-turn Responses bridge cycles, not upstream failures. diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 78ac830ce..fbc64a08e 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -403,6 +403,10 @@ class LiveCursorTransport implements CursorTransport { private framesReceived = 0; private firstFrameAt?: number; private firstFrameLogged = false; + // Upstream Retry-After from the HTTP/2 response headers, kept so a quota failure can cool the + // failing account down for the interval the server actually asked for instead of the generic + // default. Read after failure: open() is the only writer and it lands before any terminal error. + private upstreamRetryAfter: string | null = null; /** Stable session identifier sent as x-session-id; mirrors IDE session semantics. */ private readonly sessionId = crypto.randomUUID(); @@ -569,6 +573,10 @@ class LiveCursorTransport implements CursorTransport { return this.committed; } + retryAfter(): string | null { + return this.upstreamRetryAfter; + } + private clearFirstFrameTimer(): void { if (this.firstFrameTimer) { clearTimeout(this.firstFrameTimer); @@ -653,6 +661,7 @@ class LiveCursorTransport implements CursorTransport { this.framesReceived = 0; this.firstFrameAt = undefined; this.firstFrameLogged = false; + this.upstreamRetryAfter = null; const dialHost = cursorHostLabel(this.input.provider.baseUrl || "https://api2.cursor.sh"); debugProviderDiagnostic("cursor", "dial", { host: dialHost }); this.session = http2.connect(this.input.provider.baseUrl || "https://api2.cursor.sh"); @@ -760,6 +769,14 @@ class LiveCursorTransport implements CursorTransport { failAndClear(err instanceof Error ? err : new Error(String(err))); } }); + // Connect-over-HTTP/2 carries quota backoff in the ordinary Retry-After response header, which + // arrives before the end-stream error frame that reports RESOURCE_EXHAUSTED. Capture it here so + // the pool cooldown can honour the server's interval; the value is only consumed on failure. + this.stream.on("response", headers => { + const header = headers["retry-after"]; + const value = Array.isArray(header) ? header[0] : header; + if (typeof value === "string" && value.trim() !== "") this.upstreamRetryAfter = value.trim(); + }); this.stream.on("trailers", trailers => { const status = trailers["grpc-status"]; if (status !== undefined) debugProviderDiagnostic("cursor", "trailers", { grpcStatus: String(status) }); diff --git a/src/adapters/cursor/transport-retry.ts b/src/adapters/cursor/transport-retry.ts index aa02c8641..8ff6bb3b3 100644 --- a/src/adapters/cursor/transport-retry.ts +++ b/src/adapters/cursor/transport-retry.ts @@ -2,7 +2,7 @@ import type { CursorRunRequest, CursorServerMessage } from "./types"; import type { CursorTransport, CursorTransportFactory, CursorTransportFactoryInput } from "./transport"; import { abortError, retryBackoffDelayMs, sleepWithAbort } from "../../lib/upstream-retry"; import { debugProviderDiagnostic } from "../../lib/debug"; -import { safeCursorErrorMessage } from "./cursor-errors"; +import { attachCursorRetryAfter, safeCursorErrorMessage } from "./cursor-errors"; import type { UpstreamAttemptBudget } from "../../lib/upstream-attempt-budget"; import { classifyCursorUpstreamOutcome } from "../../lib/upstream-outcome"; @@ -112,6 +112,9 @@ export async function runCursorTurnWithRetry( } return; } catch (err) { + // Carry the failing attempt's upstream Retry-After on the error: the transport is discarded + // below, so this is the last point where that header is still reachable. + attachCursorRetryAfter(err, transport.retryAfter?.()); const canRetry = !emittedAny && attempt < CURSOR_RETRY_ATTEMPTS - 1 && diff --git a/src/adapters/cursor/transport.ts b/src/adapters/cursor/transport.ts index dc16cb222..dd30f94cf 100644 --- a/src/adapters/cursor/transport.ts +++ b/src/adapters/cursor/transport.ts @@ -11,6 +11,12 @@ export interface CursorTransport { * accepted is never replayed. Absent (undefined) is treated as "committed" — safe by default. */ requestCommitted?(): boolean; + /** + * The upstream `Retry-After` header for this turn, when the server sent one. Consumed only on a + * quota failure, so the account pool can cool the failing account down for the interval the + * server asked for rather than the generic default. Absent (undefined) means "not reported". + */ + retryAfter?(): string | null; } export interface CursorTransportFactoryInput { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e5ca30ab8..c334c5426 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2163,7 +2163,10 @@ export async function handleResponses( })(); return { runTurnAbort, queue, done }; }; - const rotateCursorRunTurn = async (message: string): Promise => { + const rotateCursorRunTurn = async ( + message: string, + retryAfter?: string | null, + ): Promise => { if ( !cursorPoolAccountId || cursorPoolFailovers >= CURSOR_POOL_MAX_FAILOVERS_PER_REQUEST @@ -2175,7 +2178,9 @@ export async function handleResponses( const nextAccountId = rotateCursorAccountOnQuota( config, cursorPoolAccountId, - null, + // Honour the upstream Retry-After when the adapter reported one, so the cooled account is + // held out for the server's interval instead of the generic default cooldown. + retryAfter ?? null, cursorSessionKey, ); if (!nextAccountId) return false; @@ -2211,7 +2216,7 @@ export async function handleResponses( const preflight = await preflightAdapterEvents(eventSource); if ( preflight.error - && await rotateCursorRunTurn(preflight.error.message) + && await rotateCursorRunTurn(preflight.error.message, preflight.error.retryAfter) ) { attempt.runTurnAbort.abort(); attempt.queue.close(); @@ -2277,7 +2282,7 @@ export async function handleResponses( const firstMeaningful = events.find(event => event.type !== "heartbeat"); if ( firstMeaningful?.type === "error" - && await rotateCursorRunTurn(firstMeaningful.message) + && await rotateCursorRunTurn(firstMeaningful.message, firstMeaningful.retryAfter) ) { continue; } diff --git a/src/types.ts b/src/types.ts index 24012c206..8e47713b6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -294,6 +294,12 @@ export type AdapterEvent = errorType?: string; code?: string; retryable?: boolean; + /** + * Upstream `Retry-After` for this failure when the provider sent one. Account pools use it to + * cool the failing account down for the interval the server asked for; the message text cannot + * carry it, so an adapter that has the header must surface it here. + */ + retryAfter?: string; }; /** diff --git a/tests/cursor-account-pool.test.ts b/tests/cursor-account-pool.test.ts index 9a09bdb6d..c80013316 100644 --- a/tests/cursor-account-pool.test.ts +++ b/tests/cursor-account-pool.test.ts @@ -98,6 +98,27 @@ describe("Cursor account pool", () => { }); }); + test("an upstream Retry-After sets the cooldown instead of the default", async () => { + const [firstId] = await seedAccounts(); + const before = Date.now(); + rotateCursorAccountOnQuota(config(true), firstId!, "120", "retry-after-session"); + const snapshot = getCursorAccountHealthSnapshot(firstId!); + expect(snapshot?.cooldownSource).toBe("retry-after"); + // 120s from the server, not the 60s default: assert the window lands past the default so a + // regression that drops the header cannot pass. + expect(snapshot!.cooldownUntil!).toBeGreaterThan(before + 90_000); + expect(snapshot!.cooldownUntil!).toBeLessThanOrEqual(Date.now() + 120_000); + }); + + test("a missing Retry-After falls back to the default cooldown", async () => { + const [firstId] = await seedAccounts(); + const before = Date.now(); + rotateCursorAccountOnQuota(config(true), firstId!, null, "default-session"); + const snapshot = getCursorAccountHealthSnapshot(firstId!); + expect(snapshot?.cooldownSource).toBe("default"); + expect(snapshot!.cooldownUntil!).toBeLessThanOrEqual(before + 60_000); + }); + test("only explicit rate and quota failures qualify for rotation", () => { for (const message of [ "Cursor rate limit exceeded: HTTP 429 Too Many Requests", diff --git a/tests/cursor-transport-retry.test.ts b/tests/cursor-transport-retry.test.ts index aa243329a..6109e183e 100644 --- a/tests/cursor-transport-retry.test.ts +++ b/tests/cursor-transport-retry.test.ts @@ -4,6 +4,7 @@ import { isRetryableCursorError, runCursorTurnWithRetry, } from "../src/adapters/cursor/transport-retry"; +import { cursorRetryAfterFromError } from "../src/adapters/cursor/cursor-errors"; import type { CursorRunRequest, CursorServerMessage } from "../src/adapters/cursor/types"; import type { CursorTransport } from "../src/adapters/cursor/transport"; @@ -14,6 +15,7 @@ function transport(opts: { throwAfter?: number; error?: unknown; committed?: boolean; + retryAfter?: string | null; }): CursorTransport { return { async *run() { @@ -27,6 +29,7 @@ function transport(opts: { writeClient() {}, close() {}, requestCommitted: () => opts.committed ?? false, + retryAfter: () => opts.retryAfter ?? null, }; } @@ -112,6 +115,33 @@ describe("runCursorTurnWithRetry", () => { expect(calls).toBe(1); }); + test("carries the upstream Retry-After out on a quota failure", async () => { + // Connect reports quota as an end-stream error frame, while the backoff interval arrives + // earlier as a response header. The failing transport is discarded by the orchestrator, so the + // header has to ride out on the error for the account pool to use it as the cooldown. + const quota = new Error("Cursor rate limit exceeded: RESOURCE_EXHAUSTED"); + await expect(runCursorTurnWithRetry( + () => transport({ throwAfter: 0, error: quota, committed: true, retryAfter: "120" }), + { provider: { adapter: "cursor" } }, + request, + undefined, + () => {}, + )).rejects.toThrow("RESOURCE_EXHAUSTED"); + expect(cursorRetryAfterFromError(quota)).toBe("120"); + }); + + test("reports no Retry-After when the upstream sent none", async () => { + const quota = new Error("Cursor rate limit exceeded: RESOURCE_EXHAUSTED"); + await expect(runCursorTurnWithRetry( + () => transport({ throwAfter: 0, error: quota, committed: true }), + { provider: { adapter: "cursor" } }, + request, + undefined, + () => {}, + )).rejects.toThrow("RESOURCE_EXHAUSTED"); + expect(cursorRetryAfterFromError(quota)).toBeNull(); + }); + test("does NOT retry a non-retryable error", async () => { let calls = 0; await expect(runCursorTurnWithRetry( diff --git a/tests/server-cursor-pool-e2e.test.ts b/tests/server-cursor-pool-e2e.test.ts index 364d6021f..e46ea729f 100644 --- a/tests/server-cursor-pool-e2e.test.ts +++ b/tests/server-cursor-pool-e2e.test.ts @@ -178,6 +178,38 @@ describe("server Cursor account pool", () => { })); }); + test("an upstream Retry-After drives the cooldown of the rotated-away account", async () => { + // Full chain: HTTP/2 `retry-after` response header → transport capture → error event → + // rotateCursorAccountOnQuota. Without the header the cooldown falls back to the 60s default, + // so asserting the source proves the interval is not being discarded en route. + const [firstId] = getAccountSet(PROVIDER)!.accounts.map(account => account.id); + await withCursorServer((stream, headers) => { + const authorization = String(headers.authorization ?? ""); + if (authorization === "Bearer token-a") { + stream.respond({ ":status": 200, "content-type": "application/connect+proto", "retry-after": "120" }); + stream.end(endStreamFrame({ + code: "resource_exhausted", + message: "RESOURCE_EXHAUSTED: hard quota exhausted", + })); + return; + } + stream.respond({ ":status": 200, "content-type": "application/connect+proto" }); + stream.end(Buffer.concat([turnEndedFrame(), endStreamFrame()])); + }, baseUrl => withCursorRegistryBaseUrl(baseUrl, async () => { + saveConfig(config(baseUrl)); + const server = startServer(0); + try { + const response = await request(server.url, "cursor-pool-retry-after"); + expect(response.status).toBe(200); + const snapshot = getCursorAccountHealthSnapshot(firstId!); + expect(snapshot?.cooldownSource).toBe("retry-after"); + expect(snapshot!.cooldownUntil!).toBeGreaterThan(Date.now() + 90_000); + } finally { + await server.stop(true); + } + })); + }); + test("EOF and generic 502 errors do not rotate or cool the account", async () => { const [firstId] = getAccountSet(PROVIDER)!.accounts.map(account => account.id); const attempts: string[] = []; From 4a8b237dee372a52dc14e4c5f40ed721de017ad1 Mon Sep 17 00:00:00 2001 From: GroepChef Date: Thu, 30 Jul 2026 14:46:17 +0200 Subject: [PATCH 11/31] feat(usage): quality metrics, Antigravity per-account quotas, Proxy vs Provider labels Aggregate p95 latency/TTFT, cache-read ratio, and 429/502 ratios into /api/usage. Probe each Antigravity account with its own token+project behind the existing quota cache/TTL so GUI renders do not stampede upstream. Co-authored-by: Cursor --- .../provider-workspace/ProviderUsage.tsx | 30 +- gui/src/hooks/useProviderAccountPools.ts | 5 +- gui/src/i18n/de.ts | 160 ++++++---- gui/src/i18n/en.ts | 162 ++++++---- gui/src/i18n/ja.ts | 162 ++++++---- gui/src/i18n/ko.ts | 160 ++++++---- gui/src/i18n/ru.ts | 162 ++++++---- gui/src/i18n/zh.ts | 160 ++++++---- gui/src/pages/Usage.tsx | 288 +++++------------- gui/tests/usage-layout.test.ts | 35 ++- src/providers/quota.ts | 127 ++++++-- src/server/management/logs-usage-routes.ts | 5 + src/server/management/oauth-account-routes.ts | 12 +- src/usage/summary.ts | 44 +++ tests/api-usage.test.ts | 7 + tests/provider-account-quota.test.ts | 154 ++++++++++ tests/usage-summary.test.ts | 69 +++++ 17 files changed, 1067 insertions(+), 675 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderUsage.tsx b/gui/src/components/provider-workspace/ProviderUsage.tsx index d697f436a..9374103eb 100644 --- a/gui/src/components/provider-workspace/ProviderUsage.tsx +++ b/gui/src/components/provider-workspace/ProviderUsage.tsx @@ -6,15 +6,19 @@ import { Fragment, useMemo, useState } from "react"; import { useT, useI18n } from "../../i18n/shared"; import QuotaBars from "../QuotaBars"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; +import { IconRefresh } from "../../icons"; import { formatRelativeTime, relativeTimeLabelsFromT, formatRequestCount, formatTokenCount, formatCostUsd } from "../../provider-workspace/usage"; import { accountQuotaFromReport, formatQuotaSourceLabel, type ProviderQuotaReportView } from "../../provider-workspace/report"; import type { ProviderUsageTotals, ProviderModelUsageRow } from "./types"; -export default function ProviderUsage({ item, usageTotals, quotaReport, modelUsage }: { +export default function ProviderUsage({ item, usageTotals, quotaReport, modelUsage, quotaRefreshing, quotaFailed, onRefreshQuota }: { item: WorkspaceItem; usageTotals?: ProviderUsageTotals; quotaReport?: ProviderQuotaReportView; modelUsage?: ProviderModelUsageRow[]; + quotaRefreshing?: boolean; + quotaFailed?: boolean; + onRefreshQuota?: () => void; }) { const t = useT(); const { locale } = useI18n(); @@ -22,7 +26,6 @@ export default function ProviderUsage({ item, usageTotals, quotaReport, modelUsa const hasUsage = usageTotals?.requests !== undefined; const quota = accountQuotaFromReport(quotaReport); const [expandedModel, setExpandedModel] = useState(null); - void item; const sortedModels = useMemo(() => { if (!modelUsage?.length) return []; @@ -45,10 +48,10 @@ export default function ProviderUsage({ item, usageTotals, quotaReport, modelUsa return (
-

{t("pws.usageLast30d")}

+

{t("pws.proxyUsage")}

{hasUsage ? ( <> -
+
{formatCostUsd(providerCost, locale)} {t("pws.estimatedCost")} @@ -135,7 +138,24 @@ export default function ProviderUsage({ item, usageTotals, quotaReport, modelUsa )}
-

{t("pws.rateLimits")}

+
+

{t("pws.providerLimits")}

+ {onRefreshQuota && ( + + )} +
+ {quotaFailed && ( +

{t("prov.quotaRefreshFailed")}

+ )} {quota ? ( <> diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index b1220ba2f..785dd749a 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -89,8 +89,9 @@ export function useProviderAccountPools(deps: { setAccountSets(current => ({ ...current, [provider]: { activeAccountId: data.activeAccountId ?? null, accounts: data.accounts ?? [] } })); setAccountLoadStates(current => ({ ...current, [provider]: "ready" })); - // Enrich with per-account rate limits asynchronously (Anthropic reports usage - // per credential). Failures leave the already-ready account rows untouched. + // Enrich with per-account rate limits asynchronously (Anthropic and + // Google Antigravity report usage per credential). Failures leave the + // already-ready account rows untouched. void (async () => { try { const quotaRes = await fetch(`${apiBase}/api/oauth/accounts?provider=${encodeURIComponent(provider)}"a=1`); diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index c9099845d..a319116ad 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -30,7 +30,6 @@ export const de: Record = { "startup.title": "Startsicherheit", "startup.subtitle": "Prüft, ob Codex opencodex nach einem Neustart erreicht, bevor lokales Proxy-Routing in einer Wiederverbindungsschleife endet.", "startup.refresh": "Aktualisieren", - "startup.backToDashboard": "Zurück zum Dashboard", "startup.loading": "Startschutz wird geprüft…", "startup.error": "Startschutz konnte nicht gelesen werden.", "startup.staleData": "Die aktuelle Prüfung ist fehlgeschlagen. Die Werte unten sind veraltet und kein Nachweis für Schutz.", @@ -74,12 +73,8 @@ export const de: Record = { "startup.installedDisabled": "Installiert, aber deaktiviert", "startup.install": "Installieren", "startup.installing": "Wird installiert…", - "startup.repair": "Reparieren", - "startup.repairing": "Wird repariert…", "startup.serviceInstalled": "Hintergrunddienst wurde erfolgreich installiert.", - "startup.serviceRepaired": "Hintergrunddienst wurde erfolgreich repariert.", "startup.shimInstalled": "Codex-Launcher-Shim wurde erfolgreich installiert.", - "startup.shimRepaired": "Codex-Launcher-Shim wurde erfolgreich repariert.", "startup.installFailed": "Installation fehlgeschlagen:", "startup.tray.title": "Windows-Infobereich", "startup.tray.hint": "Installiert ein Anmeldesymbol für Proxy-Start, Stopp, Neustart, Dashboard und Status per Klick.", @@ -433,8 +428,6 @@ export const de: Record = { "models.discoveryFailedNetwork": "Die Modellerkennung ist an einem Netzwerkfehler gescheitert.", "models.discoveryFailedProvider": "Der Anbieter meldete einen Fehler bei der Modellerkennung.", "models.discoveryFailedGeneric": "Die Modellerkennung ist fehlgeschlagen.", - "models.clientHiddenBadge": "Für Clients ausgeblendet", - "models.clientDegradedBadge": "Für neue Sitzungen nicht verfügbar", "models.openProviderSettings": "Anbietereinstellungen öffnen", "models.loading": "Lädt…", "models.search": "Modelle suchen…", @@ -457,16 +450,6 @@ export const de: Record = { "sub.moveUp": "{m} nach oben", "sub.moveDown": "{m} nach unten", "sub.removeAria": "{m} entfernen", - "sub.workspace.addToFeatured": "{m} zu Hervorgehobenen hinzufügen", - "sub.workspace.allModels": "Alle Modelle", - "sub.workspace.featuredFull": "Hervorgehobene Liste ist voll (max. 5)", - "sub.workspace.mainAria": "Subagent-Modelldetails", - "sub.workspace.notFeatured": "Nicht hervorgehoben", - "sub.workspace.priority": "Priorität", - "sub.workspace.removeFromFeatured": "{m} aus Hervorgehobenen entfernen", - "sub.workspace.selectModel": "Modell auswählen", - "sub.workspace.selectModelDesc": "Wählen Sie ein Modell aus der Liste, um Details anzuzeigen und es für spawn_agent hervorzuheben.", - "sub.workspace.selector": "Öffentlicher Selektor", "logs.title": "Anfrage-Protokolle", "logs.tabLogs": "Protokolle", "logs.tabDebug": "Diagnose", @@ -488,7 +471,6 @@ export const de: Record = { "logs.conversation.excluded": "({unpriced} ohne Preis, {unmetered} ohne Messung vom ~$ ausgenommen)", "logs.detail.conversation": "Konversation", "logs.badge.claude": "Claude", - "logs.badge.grok": "Grok", "logs.col.time": "Zeit", "logs.col.request": "Anfrage", "logs.col.model": "Modell", @@ -548,7 +530,6 @@ export const de: Record = { "logs.tokens.cacheWrite": "Cache-Schreiben (w)", "logs.tokens.reasoning": "Reasoning", "logs.tokens.noCache": "keine Cache-Daten", - "logs.tokens.contextTotal": "aktiver Kontext", "logs.tokens.noCacheNote": "dieser Anbieter meldet keine Cache-Tokens", "logs.tokens.noCacheCursor": "Cursor-Cache-Details nicht gemeldet", "logs.tokens.noCacheCursorNote": "Cursor liefert keine Cache-Read/Write-Tokenzahlen; das ist unbekannt und kein bestätigter Cache-Miss", @@ -601,11 +582,18 @@ export const de: Record = { "usage.card.activeDays": "Aktive Tage", "usage.section.heatmap": "Tägliche Aktivität", "usage.section.overview": "Übersicht", + "usage.section.proxyUsage": "Proxy-Nutzung", + "usage.section.providerLimits": "Anbieter-Limits", "usage.section.models": "Modelle", "usage.section.providers": "Anbieter", "usage.section.coverage": "Abdeckungs-Aufschlüsselung", - "usage.workspace.report": "Nutzungsbericht", - "usage.workspace.sections": "Nutzungsabschnitte", + "usage.section.quality": "Anfragequalität", + "usage.quality.p95Latency": "p95-Latenz", + "usage.quality.p95Ttft": "p95 TTFT", + "usage.quality.cacheReadRatio": "Cache-Leseanteil", + "usage.quality.ratio429": "429-Anteil", + "usage.quality.ratio502": "502-Anteil", + "usage.quality.note": "Aggregiert aus lokalen Proxy-Request-Logs für den gewählten Zeitraum. Anbieter-Kontingentbalken stehen unter Anbieter-Limits im Usage-Tab jedes Anbieters.", "usage.coverage.measured": "Gemessen", "usage.coverage.reported": "Anbieter gemeldet", "usage.coverage.estimated": "Geschätzt", @@ -705,8 +693,6 @@ export const de: Record = { "codexAuth.autoSwitchOffDesc": "Der automatische Kontowechsel ist ausgeschaltet", "codexAuth.autoSwitchThreshold": "Wechselschwelle", "codexAuth.autoSwitchThresholdAria": "Wechselschwelle in Prozent", - "codexAuth.autoSwitchThresholdInc": "Wechsel-Schwelle erhöhen", - "codexAuth.autoSwitchThresholdDec": "Wechsel-Schwelle verringern", "codexAuth.autoSwitchLoadFailed": "Die Einstellung für den automatischen Kontowechsel konnte nicht geladen werden.", "codexAuth.autoSwitchThresholdInvalid": "Gib eine ganze Zahl von 1 bis 100 ein", "codexAuth.autoSwitchUpdated": "Der automatische Kontowechsel wurde aktualisiert", @@ -733,8 +719,6 @@ export const de: Record = { "accountPool.strategyHint": "Gilt nur für neue Sitzungen; bestehende Threads behalten die Kontenaffinität.", "accountPool.stickyLimit": "Sticky-Erfolge vor Rotation", "accountPool.stickyLimitAria": "Sticky-Erfolge vor Rotation", - "accountPool.stickyLimitInc": "Sticky-Limit erhöhen", - "accountPool.stickyLimitDec": "Sticky-Limit verringern", "accountPool.stickyLimitHelp": "Das gewählte Konto für so viele erfolgreiche neue Sitzungsbindungen behalten, bevor weitergeschaltet wird.", "accountPool.stickyLimitInvalid": "Gib eine ganze Zahl von 1 bis 100 ein", "accountPool.strategyLoadFailed": "Rotationsstrategie konnte nicht geladen werden.", @@ -846,21 +830,7 @@ export const de: Record = { "api.generate": "Generieren", "api.generating": "Erstelle…", "api.activeKeys": "Aktive Schlüssel ({count})", - "api.activeKeysLoading": "Aktive Schlüssel", "api.noKeys": "Noch keine API-Schlüssel. Erstelle oben einen.", - "api.workspace.overview": "Übersicht", - "api.workspace.details": "API-Schlüsseldetails", - "api.workspace.keyDetails": "Schlüsseldetails", - "api.workspace.keyPrefix": "Schlüssel-Präfix", - "api.workspace.deleteKey": "Schlüssel löschen", - "api.workspace.deleteConfirm": "Diesen Schlüssel wirklich löschen? Das lässt sich nicht rückgängig machen.", - "api.workspace.noKeysHint": "Noch keine API-Schlüssel. Erstelle einen, um zu starten.", - "api.workspace.selectKeyHint": "Wähle einen Schlüssel aus der Liste, um Details zu sehen.", - "api.workspace.usageExamples": "Nutzungsbeispiele", - "api.copyUrlHint": "Klick um URL zu kopieren", - "api.urlCopied": "URL kopiert", - "api.copyExampleHint": "Klick um Beispiel zu kopieren", - "api.exampleCopied": "Beispiel kopiert", "api.colName": "Name", "api.colKey": "Schlüssel", "api.colCreated": "Erstellt", @@ -953,27 +923,16 @@ export const de: Record = { "nav.storage": "Speicher", "storage.title": "Speicher", - "storage.subtitle": "Zeigt, was CODEX_HOME belegt. Die Bereinigung lässt aktive Sitzungen unberührt.", + "storage.subtitle": "Diagnose zur CODEX_HOME-Festplattennutzung. Die Archivbereinigung unten kann älteste archivierte Sitzungen in Quarantäne legen oder dauerhaft löschen — aktive Sitzungen bleiben schreibgeschützt.", "storage.loading": "Speicher wird gescannt…", "storage.empty": "CODEX_HOME ist leer oder fehlt — nichts zu berichten.", "storage.error": "Speicher-Scan fehlgeschlagen. Prüfe, ob CODEX_HOME auf ein gültiges Verzeichnis zeigt.", "storage.refresh": "Neu scannen", - "storage.rescanned": "Scan abgeschlossen.", "storage.card.total": "Gesamtgröße", "storage.card.files": "Dateien", "storage.card.home": "CODEX_HOME", - "storage.snapshot.lastScan": "Letzter Scan", - "storage.snapshot.scanning": "Scanne…", - "storage.snapshot.unavailable": "Noch kein Scan.", - "storage.cleanupCard.title": "Speicher freigeben", - "storage.cleanupCard.tabs": "Bereinigungsoptionen", - "storage.cleanupCard.tab.policy": "Richtlinie", - "storage.cleanupCard.tab.quarantine": "Quarantäne", - "storage.cleanup.noArchives": "Keine archivierten Sitzungen zum Bereinigen.", "storage.section.buckets": "Bereiche", "storage.section.largest": "Größte Dateien", - "storage.workspace.overview": "Übersicht", - "storage.workspace.selectBucket": "Wähle einen Bucket aus der Liste, um die Aufschlüsselung zu sehen.", "storage.col.bucket": "Bereich", "storage.col.size": "Größe", "storage.col.files": "Dateien", @@ -991,7 +950,7 @@ export const de: Record = { "storage.cleanup.title": "Archivbereinigung", "storage.cleanup.help": "Entfernt die ältesten archivierten Sitzungen nach Prozentsatz. Aktive Sitzungen werden nie angefasst. Standard ist Quarantäne — Dateien wandern nach CODEX_HOME/.trash.", "storage.cleanup.slider": "Ältester Archivanteil", - "storage.cleanup.percent": "{percent}%", + "storage.cleanup.percent": "Älteste {percent}%", "storage.cleanup.preset": "{percent}", "storage.cleanup.preview": "Vorschau", "storage.cleanup.confirmTitle": "Archivbereinigung bestätigen", @@ -999,7 +958,7 @@ export const de: Record = { "storage.cleanup.moreFiles": "…und {n} weitere", "storage.cleanup.permanent": "Dauerhaft löschen (ohne Quarantäne)", "storage.cleanup.permanentWarn": "Dauerhaftes Löschen kann nicht rückgängig gemacht werden.", - "storage.cleanup.quarantineNote": "Dateien wandern nach .trash unter CODEX_HOME. Du kannst sie im Tab Quarantäne wiederherstellen.", + "storage.cleanup.quarantineNote": "Dateien wandern nach .trash unter CODEX_HOME. Du kannst sie im Quarantäne-Abschnitt darunter wiederherstellen.", "storage.cleanup.cancel": "Abbrechen", "storage.cleanup.confirmQuarantine": "In Quarantäne", "storage.cleanup.confirmPermanent": "Dauerhaft löschen", @@ -1058,17 +1017,10 @@ export const de: Record = { "storage.policy.invalid": "Ungültige Richtlinienwerte.", "storage.policy.enabled": "Automatische Bereinigung aktivieren", "storage.policy.enabledHint": "Standard ist aus. Bei Aktivierung nur nach gewähltem Zeitplan (oder Jetzt ausführen).", - "storage.policy.threshold": "Wenn Archivgröße größer als (GiB)", - "storage.policy.trigger": "Auslöser", + "storage.policy.threshold": "Auslösen, wenn Archivgröße überschreitet (GiB)", "storage.policy.target": "Bereinigungsziel", - "storage.policy.targetPercent": "Älteste Archive entfernen (%)", + "storage.policy.targetPercent": "Ältesten Archivanteil entfernen", "storage.policy.targetReduce": "Archivgröße reduzieren auf (GiB)", - "storage.policy.thresholdInc": "Schwellwert erhöhen", - "storage.policy.thresholdDec": "Schwellwert verringern", - "storage.policy.percentInc": "Prozent erhöhen", - "storage.policy.percentDec": "Prozent verringern", - "storage.policy.reduceInc": "Zielgröße erhöhen", - "storage.policy.reduceDec": "Zielgröße verringern", "storage.policy.schedule": "Zeitplan", "storage.policy.schedule.manual": "Nur manuell", "storage.policy.schedule.startup": "Beim Proxy-Start", @@ -1204,7 +1156,9 @@ export const de: Record = { "pws.stats.quotaUpdated": "Kontingent aktualisiert", "pws.stats.quotaTracked": "Limits siehe Nutzungs-Tab.", "pws.stats.source": "Quelle", - "pws.usageLast30d": "Nutzung (letzte 30 Tage)", + "pws.usageLast30d": "Proxy-Nutzung (letzte 30 Tage)", + "pws.proxyUsage": "Proxy-Nutzung", + "pws.providerLimits": "Anbieter-Limits", "pws.estimatedCost": "Geschätzte Kosten", "pws.costDisclaimer": "Schätzung basierend auf API-Listenpreisen, keine tatsächliche Abrechnung.", "pws.modelBreakdown": "Modellaufschlüsselung", @@ -1218,7 +1172,7 @@ export const de: Record = { "pws.metricRequests": "Anfragen", "pws.metricTokens": "Tokens", "pws.usageUnavailable": "Noch keine Nutzung erfasst.", - "pws.rateLimits": "Limits", + "pws.rateLimits": "Anbieter-Limits", "pws.quotaUnavailable": "Keine Kontingentdaten für diesen Provider.", "pws.accountQuotaUnavailable": "Ratenlimit-Daten vorübergehend nicht verfügbar; falls vorhanden, werden zuletzt bekannte Werte angezeigt.", "pws.selected": "Ausgewählt", @@ -1301,7 +1255,6 @@ export const de: Record = { "pws.dashboard.checkedAgo": "Geprüft {time}", "pws.dashboard.noQuota": "Keine Kontingentdaten", "pws.dashboard.noUsage": "Noch keine Nutzungsdaten", - "pws.dashboard.noRateLimits": "Noch keine Limit-Daten", "pws.allProviders": "Anbieterübersicht", "pws.enabledLabel": "Aktiviert", "pws.testConnection": "Verbindung testen", @@ -1357,7 +1310,6 @@ export const de: Record = { "codexAuth.addIdPlaceholder": "codex-work, codex-alt, team…", "codexAuth.resetCreditsAria": "{count} Reset-Guthaben", "claude.pageTitle": "Claude Code", - "claude.workspace.settings": "Einstellungen", // Combos workspace "cws.loading": "Combos werden geladen…", @@ -1542,4 +1494,80 @@ export const de: Record = { "claudeDesktop.health.stats": "{count} Anf. / {errors} Fehl.", "claudeDesktop.effort.supported": "effort", "claudeDesktop.effort.displayOnly": "effort (nur Anzeige)", + + // fleet shell / systeem / verkeer (ChefGroep) + "dash.injectionActive": "Active", + "dash.suppliers": "Providers", + "dash.refresh": "Refresh", + "dash.refreshAll": "Refresh all", + "dash.retry": "Retry", + "dash.fresh": "Fresh · {time}", + "dash.noContactSince": "No contact since {time}", + "dash.modelsCount": "{count} models", + "dash.showModels": "Show models", + "dash.hideModels": "Hide models", + "dash.stamp.ready": "Ready", + "dash.stamp.busy": "Busy", + "dash.stamp.idle": "Idle", + "dash.stamp.error": "Error", + "dash.settingsSection": "Settings", + "dash.emptyKitchen": "No models yet.", + "dash.providerDisabled": "Disabled", + "prov.quotaRefresh": "Refresh usage", + "prov.quotaRefreshing": "Refreshing…", + "prov.quotaRefreshFailed": "Refresh failed — showing last known data", + "prov.quotaRefreshAria": "Refresh usage for {name}", + "shell.navTraffic": "Traffic", + "shell.navSystem": "System", + "shell.navAria": "Main navigation", + "shell.offlineBanner": "Proxy offline. Codex and Cursor cannot route.", + "mod.subtitle": "Catalog, routing, and sub-agent delegation.", + "mod.tablistAria": "Models sections", + "sys.manage": "Manage", + "sys.subtitle": "The proxy itself: status, version, storage, and management.", + "sys.proxy": "Proxy", + "sys.uptime": "{uptime} uptime", + "sys.updateTo": "Update to {version}", + "sys.upToDate": "You are on the latest version.", + "sys.updateCheckFailed": "update check failed", + "sys.updateStartFailed": "could not start update", + "sys.updateRunning": "Update running. The proxy will restart itself shortly.", + "sys.catalog": "Model catalog", + "sys.catalogDesc": "Sync pulls the latest models from each provider.", + "sys.syncFailed": "sync failed", + "sys.storage": "Storage", + "sys.storageValue": "{size} · {count} files", + "sys.apiEndpoint": "API endpoint", + "sys.copy": "Copy", + "sys.copied": "Copied", + "sys.codexAuthDesc": "ChatGPT account pool for the openai provider", + "sys.claudeCodeDesc": "Claude integration and agent injection", + "sys.dangerZone": "Danger zone", + "sys.stopDesc": "Stops the proxy completely. Codex and Cursor lose their connection.", + "sys.stopConfirmTitle": "Stop the proxy?", + "sys.stopConfirmDesc": "Codex and Cursor will lose their connection.", + "sys.keepRunning": "Keep running", + "vk.subtitle": "What flows through the proxy: every request is a receipt.", + "vk.statsAria": "Traffic figures", + "vk.tokens30d": "tokens (30d)", + "vk.requestsToday": "requests today", + "vk.requests30d": "requests (30d)", + "vk.filterAria": "Filter by provider", + "vk.all": "All", + "vk.pause": "Pause", + "vk.follow": "Follow live", + "vk.loadFailed": "Could not load traffic. Last known receipts stay visible.", + "vk.empty": "No traffic yet today.", + "vk.showAnalysis": "Full analysis", + "vk.hideAnalysis": "Hide analysis", + "vk.stampDone": "Done", + "vk.stampError": "Error", + "vk.stampBusy": "Busy", + "vk.detailError": "error: {code}", + "vk.detailInOut": "in {in} · out {out}", + "vk.rowTokens": "{n} tok", + "vk.rowDuration": "{s}s", + "vk.detailStatus": "status {status}", + "vk.detailUpstream": "upstream: {error}", + "vk.detailId": "id {id}", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index e60dee276..c298181b6 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -37,7 +37,6 @@ export const en = { "startup.title": "Startup safety", "startup.subtitle": "Verify that Codex can reach opencodex after a restart, before local proxy routing becomes a reconnect loop.", "startup.refresh": "Refresh", - "startup.backToDashboard": "Back to Dashboard", "startup.loading": "Checking startup protection…", "startup.error": "Could not read startup protection.", "startup.staleData": "The latest startup check failed. The values below are stale and must not be treated as proof of protection.", @@ -81,12 +80,8 @@ export const en = { "startup.installedDisabled": "Installed but disabled", "startup.install": "Install", "startup.installing": "Installing…", - "startup.repair": "Repair", - "startup.repairing": "Repairing…", "startup.serviceInstalled": "Background service installed successfully.", - "startup.serviceRepaired": "Background service repaired successfully.", "startup.shimInstalled": "Codex launcher shim installed successfully.", - "startup.shimRepaired": "Codex launcher shim repaired successfully.", "startup.installFailed": "Installation failed:", "startup.tray.title": "Windows system tray", "startup.tray.hint": "Install a login tray icon for one-click proxy start, stop, restart, dashboard, and status controls.", @@ -447,8 +442,6 @@ export const en = { "models.discoveryFailedNetwork": "Model discovery failed due to a network error.", "models.discoveryFailedProvider": "The provider reported a model discovery error.", "models.discoveryFailedGeneric": "Model discovery failed.", - "models.clientHiddenBadge": "Hidden from clients", - "models.clientDegradedBadge": "Unavailable for new sessions", "models.openProviderSettings": "Open provider settings", "models.loading": "Loading…", "models.search": "Search models…", @@ -473,16 +466,6 @@ export const en = { "sub.moveUp": "Move {m} up", "sub.moveDown": "Move {m} down", "sub.removeAria": "Remove {m}", - "sub.workspace.addToFeatured": "Add {m} to featured", - "sub.workspace.allModels": "All models", - "sub.workspace.featuredFull": "Featured list is full (max 5)", - "sub.workspace.mainAria": "Subagent model details", - "sub.workspace.notFeatured": "Not featured", - "sub.workspace.priority": "Priority", - "sub.workspace.removeFromFeatured": "Remove {m} from featured", - "sub.workspace.selectModel": "Select a model", - "sub.workspace.selectModelDesc": "Pick a model from the list to see details and feature it for spawn_agent.", - "sub.workspace.selector": "Public selector", // logs "logs.title": "Request Logs", @@ -506,7 +489,6 @@ export const en = { "logs.conversation.excluded": "({unpriced} unpriced, {unmetered} unmetered excluded from ~$)", "logs.detail.conversation": "Conversation", "logs.badge.claude": "Claude", - "logs.badge.grok": "Grok", "logs.col.time": "Time", "logs.col.request": "Request", "logs.col.model": "Model", @@ -566,7 +548,6 @@ export const en = { "logs.tokens.cacheWrite": "cache write (w)", "logs.tokens.reasoning": "reasoning", "logs.tokens.noCache": "no cache data", - "logs.tokens.contextTotal": "active context", "logs.tokens.noCacheNote": "this provider does not report cache tokens", "logs.tokens.noCacheCursor": "Cursor cache detail unreported", "logs.tokens.noCacheCursorNote": "Cursor does not expose cache read/write token counts; this is unknown, not a confirmed cache miss", @@ -605,7 +586,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": "Local proxy accounting versus upstream provider limits. Missing usage 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.", @@ -623,11 +604,18 @@ export const en = { "usage.card.activeDays": "Active days", "usage.section.heatmap": "Daily activity", "usage.section.overview": "Overview", + "usage.section.proxyUsage": "Proxy usage", + "usage.section.providerLimits": "Provider limits", "usage.section.models": "Models", "usage.section.providers": "Providers", "usage.section.coverage": "Coverage breakdown", - "usage.workspace.report": "Usage report", - "usage.workspace.sections": "Usage sections", + "usage.section.quality": "Request quality", + "usage.quality.p95Latency": "p95 latency", + "usage.quality.p95Ttft": "p95 TTFT", + "usage.quality.cacheReadRatio": "Cache-read ratio", + "usage.quality.ratio429": "429 ratio", + "usage.quality.ratio502": "502 ratio", + "usage.quality.note": "Aggregated from local proxy request logs for the selected range. Provider quota bars live under Provider limits on each provider's Usage tab.", "usage.coverage.measured": "Measured", "usage.coverage.reported": "Provider reported", "usage.coverage.estimated": "Estimated", @@ -648,27 +636,16 @@ export const en = { "nav.storage": "Storage", "storage.title": "Storage", - "storage.subtitle": "See what’s using CODEX_HOME. Cleanup never touches active sessions.", + "storage.subtitle": "Diagnostics for CODEX_HOME disk use. Archived cleanup below can quarantine or permanently delete oldest archived sessions — active sessions stay read-only.", "storage.loading": "Scanning storage…", "storage.empty": "CODEX_HOME is empty or missing — nothing to report.", "storage.error": "Storage scan failed. Check that CODEX_HOME points at a valid directory.", "storage.refresh": "Rescan", - "storage.rescanned": "Scan complete.", "storage.card.total": "Total size", "storage.card.files": "Files", "storage.card.home": "CODEX_HOME", - "storage.snapshot.lastScan": "Last scan", - "storage.snapshot.scanning": "Scanning…", - "storage.snapshot.unavailable": "No scan yet.", - "storage.cleanupCard.title": "Free up space", - "storage.cleanupCard.tabs": "Cleanup options", - "storage.cleanupCard.tab.policy": "Policy", - "storage.cleanupCard.tab.quarantine": "Quarantine", - "storage.cleanup.noArchives": "No archived sessions to clean up.", "storage.section.buckets": "Buckets", "storage.section.largest": "Largest files", - "storage.workspace.overview": "Overview", - "storage.workspace.selectBucket": "Select a bucket from the list to see its breakdown.", "storage.col.bucket": "Bucket", "storage.col.size": "Size", "storage.col.files": "Files", @@ -686,7 +663,7 @@ export const en = { "storage.cleanup.title": "Archived cleanup", "storage.cleanup.help": "Remove the oldest archived sessions by percentage. Active sessions are never touched. Quarantine is the default — files move to CODEX_HOME/.trash.", "storage.cleanup.slider": "Oldest archived percent", - "storage.cleanup.percent": "{percent}%", + "storage.cleanup.percent": "Oldest {percent}%", "storage.cleanup.preset": "{percent}", "storage.cleanup.preview": "Preview", "storage.cleanup.confirmTitle": "Confirm archived cleanup", @@ -694,7 +671,7 @@ export const en = { "storage.cleanup.moreFiles": "…and {n} more", "storage.cleanup.permanent": "Delete permanently (skip quarantine)", "storage.cleanup.permanentWarn": "Permanent delete cannot be undone.", - "storage.cleanup.quarantineNote": "Files move to .trash under CODEX_HOME. You can restore them from the Quarantine tab.", + "storage.cleanup.quarantineNote": "Files move to .trash under CODEX_HOME. You can restore them from the Quarantine section below.", "storage.cleanup.cancel": "Cancel", "storage.cleanup.confirmQuarantine": "Quarantine", "storage.cleanup.confirmPermanent": "Delete permanently", @@ -753,17 +730,10 @@ export const en = { "storage.policy.invalid": "Invalid policy values.", "storage.policy.enabled": "Enable auto-cleanup", "storage.policy.enabledHint": "Default is off. Enabling runs only on the schedule you choose (or Run now).", - "storage.policy.threshold": "When archived size exceeds (GiB)", - "storage.policy.trigger": "Trigger", + "storage.policy.threshold": "Trigger when archived size exceeds (GiB)", "storage.policy.target": "Cleanup target", - "storage.policy.targetPercent": "Remove oldest archived (%)", + "storage.policy.targetPercent": "Remove oldest archived percent", "storage.policy.targetReduce": "Reduce archived size to (GiB)", - "storage.policy.thresholdInc": "Increase threshold", - "storage.policy.thresholdDec": "Decrease threshold", - "storage.policy.percentInc": "Increase percent", - "storage.policy.percentDec": "Decrease percent", - "storage.policy.reduceInc": "Increase reduce-to size", - "storage.policy.reduceDec": "Decrease reduce-to size", "storage.policy.schedule": "Schedule", "storage.policy.schedule.manual": "Manual only", "storage.policy.schedule.startup": "On proxy startup", @@ -923,7 +893,9 @@ export const en = { "pws.stats.quotaUpdated": "Quota updated", "pws.stats.quotaTracked": "Rate limits tracked on the Usage tab.", "pws.stats.source": "Source", - "pws.usageLast30d": "Usage (last 30 days)", + "pws.usageLast30d": "Proxy usage (last 30 days)", + "pws.proxyUsage": "Proxy usage", + "pws.providerLimits": "Provider limits", "pws.estimatedCost": "Estimated cost", "pws.costDisclaimer": "API list-price estimate, not an actual charge.", "pws.modelBreakdown": "Model breakdown", @@ -937,7 +909,7 @@ export const en = { "pws.metricRequests": "requests", "pws.metricTokens": "tokens", "pws.usageUnavailable": "No usage recorded yet.", - "pws.rateLimits": "Rate limits", + "pws.rateLimits": "Provider limits", "pws.quotaUnavailable": "No quota data for this provider.", "pws.accountQuotaUnavailable": "Rate-limit data temporarily unavailable; showing last known values when present.", "pws.selected": "Selected", @@ -1020,7 +992,6 @@ export const en = { "pws.dashboard.checkedAgo": "Checked {time}", "pws.dashboard.noQuota": "No quota data", "pws.dashboard.noUsage": "No usage data yet", - "pws.dashboard.noRateLimits": "No rate-limit data yet", "pws.allProviders": "Provider Overview", "pws.enabledLabel": "Enabled", "pws.testConnection": "Test connection", @@ -1138,8 +1109,6 @@ export const en = { "codexAuth.autoSwitchOffDesc": "Automatic account switching is off", "codexAuth.autoSwitchThreshold": "Switch threshold", "codexAuth.autoSwitchThresholdAria": "Switch threshold, percent", - "codexAuth.autoSwitchThresholdInc": "Increase switch threshold", - "codexAuth.autoSwitchThresholdDec": "Decrease switch threshold", "codexAuth.autoSwitchLoadFailed": "Automatic switching setting could not be loaded.", "codexAuth.autoSwitchThresholdInvalid": "Enter a whole number from 1 to 100", "codexAuth.autoSwitchUpdated": "Automatic account switching updated", @@ -1167,8 +1136,6 @@ export const en = { "accountPool.strategyHint": "Applies to new sessions only; existing threads keep account affinity.", "accountPool.stickyLimit": "Sticky successes before rotate", "accountPool.stickyLimitAria": "Sticky successes before rotate", - "accountPool.stickyLimitInc": "Increase sticky limit", - "accountPool.stickyLimitDec": "Decrease sticky limit", "accountPool.stickyLimitHelp": "Keep the selected account for this many successful new-session binds before advancing.", "accountPool.stickyLimitInvalid": "Enter a whole number from 1 to 100", "accountPool.strategyLoadFailed": "Rotation strategy could not be loaded.", @@ -1261,21 +1228,7 @@ export const en = { "api.generate": "Generate", "api.generating": "Creating…", "api.activeKeys": "Active keys ({count})", - "api.activeKeysLoading": "Active keys", "api.noKeys": "No API keys yet. Generate one above.", - "api.workspace.overview": "Overview", - "api.workspace.details": "API key details", - "api.workspace.keyDetails": "Key details", - "api.workspace.keyPrefix": "Key prefix", - "api.workspace.deleteKey": "Delete key", - "api.workspace.deleteConfirm": "Are you sure you want to delete this key? This cannot be undone.", - "api.workspace.noKeysHint": "No API keys yet. Generate one to get started.", - "api.workspace.selectKeyHint": "Select a key from the list to view its details.", - "api.workspace.usageExamples": "Usage examples", - "api.copyUrlHint": "Click to copy URL", - "api.urlCopied": "URL copied", - "api.copyExampleHint": "Click to copy example", - "api.exampleCopied": "Example copied", "api.colName": "Name", "api.colKey": "Key", "api.colCreated": "Created", @@ -1314,7 +1267,6 @@ export const en = { "nav.claude": "Claude", "claude.subtitle": "Use GPT, Gemini, and other models inside Claude Code.", "claude.pageTitle": "Claude Code", - "claude.workspace.settings": "Settings", "claude.enabledLabel": "Claude connection", "claude.enabledHint": "When off, Claude Code cannot use this proxy.", "claude.authMode": "Auth Mode", @@ -1567,6 +1519,82 @@ export const en = { "claudeDesktop.effort.supported": "effort", "claudeDesktop.effort.displayOnly": "effort (display only)", + + // fleet shell / systeem / verkeer (ChefGroep) + "dash.injectionActive": "Active", + "dash.suppliers": "Providers", + "dash.refresh": "Refresh", + "dash.refreshAll": "Refresh all", + "dash.retry": "Retry", + "dash.fresh": "Fresh · {time}", + "dash.noContactSince": "No contact since {time}", + "dash.modelsCount": "{count} models", + "dash.showModels": "Show models", + "dash.hideModels": "Hide models", + "dash.stamp.ready": "Ready", + "dash.stamp.busy": "Busy", + "dash.stamp.idle": "Idle", + "dash.stamp.error": "Error", + "dash.settingsSection": "Settings", + "dash.emptyKitchen": "No models yet.", + "dash.providerDisabled": "Disabled", + "prov.quotaRefresh": "Refresh usage", + "prov.quotaRefreshing": "Refreshing…", + "prov.quotaRefreshFailed": "Refresh failed — showing last known data", + "prov.quotaRefreshAria": "Refresh usage for {name}", + "shell.navTraffic": "Traffic", + "shell.navSystem": "System", + "shell.navAria": "Main navigation", + "shell.offlineBanner": "Proxy offline. Codex and Cursor cannot route.", + "mod.subtitle": "Catalog, routing, and sub-agent delegation.", + "mod.tablistAria": "Models sections", + "sys.manage": "Manage", + "sys.subtitle": "The proxy itself: status, version, storage, and management.", + "sys.proxy": "Proxy", + "sys.uptime": "{uptime} uptime", + "sys.updateTo": "Update to {version}", + "sys.upToDate": "You are on the latest version.", + "sys.updateCheckFailed": "update check failed", + "sys.updateStartFailed": "could not start update", + "sys.updateRunning": "Update running. The proxy will restart itself shortly.", + "sys.catalog": "Model catalog", + "sys.catalogDesc": "Sync pulls the latest models from each provider.", + "sys.syncFailed": "sync failed", + "sys.storage": "Storage", + "sys.storageValue": "{size} · {count} files", + "sys.apiEndpoint": "API endpoint", + "sys.copy": "Copy", + "sys.copied": "Copied", + "sys.codexAuthDesc": "ChatGPT account pool for the openai provider", + "sys.claudeCodeDesc": "Claude integration and agent injection", + "sys.dangerZone": "Danger zone", + "sys.stopDesc": "Stops the proxy completely. Codex and Cursor lose their connection.", + "sys.stopConfirmTitle": "Stop the proxy?", + "sys.stopConfirmDesc": "Codex and Cursor will lose their connection.", + "sys.keepRunning": "Keep running", + "vk.subtitle": "What flows through the proxy: every request is a receipt.", + "vk.statsAria": "Traffic figures", + "vk.tokens30d": "tokens (30d)", + "vk.requestsToday": "requests today", + "vk.requests30d": "requests (30d)", + "vk.filterAria": "Filter by provider", + "vk.all": "All", + "vk.pause": "Pause", + "vk.follow": "Follow live", + "vk.loadFailed": "Could not load traffic. Last known receipts stay visible.", + "vk.empty": "No traffic yet today.", + "vk.showAnalysis": "Full analysis", + "vk.hideAnalysis": "Hide analysis", + "vk.stampDone": "Done", + "vk.stampError": "Error", + "vk.stampBusy": "Busy", + "vk.detailError": "error: {code}", + "vk.detailInOut": "in {in} · out {out}", + "vk.rowTokens": "{n} tok", + "vk.rowDuration": "{s}s", + "vk.detailStatus": "status {status}", + "vk.detailUpstream": "upstream: {error}", + "vk.detailId": "id {id}", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index fc8d7a883..85cdd2749 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -37,7 +37,6 @@ export const ja: Record = { "startup.title": "起動安全性", "startup.subtitle": "再起動後にローカルプロキシへの接続が再接続ループになる前に、Codex が opencodex へ到達できるか確認します。", "startup.refresh": "更新", - "startup.backToDashboard": "ダッシュボードに戻る", "startup.loading": "起動保護を確認中…", "startup.error": "起動保護を読み取れませんでした。", "startup.staleData": "最新の確認に失敗しました。以下は古い値であり、保護の証明にはなりません。", @@ -81,12 +80,8 @@ export const ja: Record = { "startup.installedDisabled": "インストール済み・無効", "startup.install": "インストール", "startup.installing": "インストール中…", - "startup.repair": "修復", - "startup.repairing": "修復中…", "startup.serviceInstalled": "バックグラウンドサービスをインストールしました。", - "startup.serviceRepaired": "バックグラウンドサービスを修復しました。", "startup.shimInstalled": "Codex ランチャー shim をインストールしました。", - "startup.shimRepaired": "Codex ランチャー shim を修復しました。", "startup.installFailed": "インストールに失敗しました:", "startup.tray.title": "Windows システムトレイ", "startup.tray.hint": "ログイン時にトレイを起動し、プロキシの開始・停止・再起動・ダッシュボード・状態をクリックで操作します。", @@ -247,6 +242,22 @@ export const ja: Record = { "dash.updateStatus.restarting": "更新をインストールしました。プロキシを再起動中。", "dash.updateStatus.succeeded": "更新が完了しました。", "dash.updateStatus.failed": "更新に失敗しました。", + "dash.suppliers": "プロバイダー", + "dash.refresh": "更新", + "dash.refreshAll": "すべて更新", + "dash.retry": "再試行", + "dash.noContactSince": "{time} から接続がありません", + "dash.fresh": "最新 · {time}", + "dash.modelsCount": "{count} 個のモデル", + "dash.showModels": "モデルを表示", + "dash.hideModels": "モデルを隠す", + "dash.stamp.ready": "準備完了", + "dash.stamp.busy": "処理中", + "dash.stamp.idle": "待機中", + "dash.stamp.error": "エラー", + "dash.settingsSection": "設定", + "dash.emptyKitchen": "まだモデルがありません。", + "dash.providerDisabled": "無効", // providers "prov.subtitle": "opencodex が Codex にルーティングする上流プロバイダーを設定します。アカウントでログインするか、プロバイダーを追加、または生の設定を編集します。", @@ -301,6 +312,10 @@ export const ja: Record = { "prov.added": "\"{name}\" を追加しました。即時反映 — {cmd} を実行(または再起動)して Codex のピッカーにモデルを一覧表示します。", "prov.removeConfirm": "プロバイダー \"{name}\" を削除しますか? そのモデルは Codex のピッカーから消えます。", "prov.hasApiKey": "API キー設定済み", + "prov.quotaRefresh": "使用状況を更新", + "prov.quotaRefreshing": "更新中…", + "prov.quotaRefreshFailed": "更新に失敗しました — 最後に取得したデータを表示しています", + "prov.quotaRefreshAria": "{name} の使用状況を更新", "prov.hasHeaders": "カスタムヘッダー設定済み", "prov.accounts": "アカウント ({n})", "prov.accountsAria": "{name} のアカウントを切り替え", @@ -414,8 +429,6 @@ export const ja: Record = { "models.discoveryFailedNetwork": "ネットワークエラーによりモデル検出に失敗しました。", "models.discoveryFailedProvider": "プロバイダーがモデル検出エラーを報告しました。", "models.discoveryFailedGeneric": "モデル検出に失敗しました。", - "models.clientHiddenBadge": "クライアントから非表示", - "models.clientDegradedBadge": "新規セッションでは利用不可", "models.openProviderSettings": "プロバイダー設定を開く", "models.loading": "読み込み中…", "models.search": "モデルを検索…", @@ -440,16 +453,6 @@ export const ja: Record = { "sub.moveUp": "{m} を上へ移動", "sub.moveDown": "{m} を下へ移動", "sub.removeAria": "{m} を削除", - "sub.workspace.addToFeatured": "{m} をおすすめに追加", - "sub.workspace.allModels": "すべてのモデル", - "sub.workspace.featuredFull": "おすすめリストがいっぱいです(最大 5)", - "sub.workspace.mainAria": "サブエージェントのモデル詳細", - "sub.workspace.notFeatured": "おすすめ未設定", - "sub.workspace.priority": "優先度", - "sub.workspace.removeFromFeatured": "{m} をおすすめから削除", - "sub.workspace.selectModel": "モデルを選択", - "sub.workspace.selectModelDesc": "一覧からモデルを選んで詳細を確認し、spawn_agent のおすすめに設定します。", - "sub.workspace.selector": "公開セレクター", // logs "logs.title": "リクエストログ", @@ -473,7 +476,6 @@ export const ja: Record = { "logs.conversation.excluded": "(~$ から価格なし {unpriced} / 未計測 {unmetered} を除外)", "logs.detail.conversation": "会話", "logs.badge.claude": "Claude", - "logs.badge.grok": "Grok", "logs.col.time": "時刻", "logs.col.request": "リクエスト", "logs.col.model": "モデル", @@ -533,7 +535,6 @@ export const ja: Record = { "logs.tokens.cacheWrite": "キャッシュ書き込み (w)", "logs.tokens.reasoning": "推論", "logs.tokens.noCache": "キャッシュデータなし", - "logs.tokens.contextTotal": "アクティブコンテキスト", "logs.tokens.noCacheNote": "このプロバイダーはキャッシュトークンを報告しません", "logs.tokens.noCacheCursor": "Cursor のキャッシュ詳細は未報告", "logs.tokens.noCacheCursorNote": "Cursor はキャッシュ read/write トークン数を公開しません。これは不明という意味で、キャッシュミスの確定ではありません", @@ -590,11 +591,18 @@ export const ja: Record = { "usage.card.activeDays": "アクティブ日数", "usage.section.heatmap": "日のアクティビティ", "usage.section.overview": "概要", + "usage.section.proxyUsage": "プロキシ使用量", + "usage.section.providerLimits": "プロバイダー制限", "usage.section.models": "モデル", "usage.section.providers": "プロバイダー", "usage.section.coverage": "カバレッジ内訳", - "usage.workspace.report": "使用量レポート", - "usage.workspace.sections": "使用量セクション", + "usage.section.quality": "リクエスト品質", + "usage.quality.p95Latency": "p95 レイテンシ", + "usage.quality.p95Ttft": "p95 TTFT", + "usage.quality.cacheReadRatio": "キャッシュ読み取り比率", + "usage.quality.ratio429": "429 比率", + "usage.quality.ratio502": "502 比率", + "usage.quality.note": "選択した期間のローカルプロキシリクエストログから集計します。プロバイダーのクォータバーは各プロバイダーの Usage タブのプロバイダー制限にあります。", "usage.coverage.measured": "計測", "usage.coverage.reported": "プロバイダー報告", "usage.coverage.estimated": "推定", @@ -615,27 +623,16 @@ export const ja: Record = { "nav.storage": "ストレージ", "storage.title": "ストレージ", - "storage.subtitle": "CODEX_HOME の使用状況を確認。クリーンアップはアクティブセッションに触れません。", + "storage.subtitle": "CODEX_HOME のディスク使用診断。下のアーカイブクリーンアップで古いアーカイブを隔離または完全削除できます — アクティブセッションは読み取り専用のままです。", "storage.loading": "ストレージをスキャン中…", "storage.empty": "CODEX_HOME が空か存在しません — 報告するものはありません。", "storage.error": "ストレージのスキャンに失敗しました。CODEX_HOME が有効なディレクトリを指しているか確認してください。", "storage.refresh": "再スキャン", - "storage.rescanned": "スキャンが完了しました。", "storage.card.total": "合計サイズ", "storage.card.files": "ファイル", "storage.card.home": "CODEX_HOME", - "storage.snapshot.lastScan": "最終スキャン", - "storage.snapshot.scanning": "スキャン中…", - "storage.snapshot.unavailable": "まだスキャンがありません。", - "storage.cleanupCard.title": "容量を空ける", - "storage.cleanupCard.tabs": "クリーンアップオプション", - "storage.cleanupCard.tab.policy": "ポリシー", - "storage.cleanupCard.tab.quarantine": "隔離", - "storage.cleanup.noArchives": "クリーンアップ対象のアーカイブセッションはありません。", "storage.section.buckets": "バケット", "storage.section.largest": "最大ファイル", - "storage.workspace.overview": "概要", - "storage.workspace.selectBucket": "一覧からバケットを選ぶと内訳が表示されます。", "storage.col.bucket": "バケット", "storage.col.size": "サイズ", "storage.col.files": "ファイル", @@ -653,7 +650,7 @@ export const ja: Record = { "storage.cleanup.title": "アーカイブのクリーンアップ", "storage.cleanup.help": "古いアーカイブセッションを割合で削除します。アクティブセッションには触れません。既定は隔離で、ファイルは CODEX_HOME/.trash へ移動します。", "storage.cleanup.slider": "古いアーカイブの割合", - "storage.cleanup.percent": "{percent}%", + "storage.cleanup.percent": "古い {percent}%", "storage.cleanup.preset": "{percent}", "storage.cleanup.preview": "プレビュー", "storage.cleanup.confirmTitle": "アーカイブクリーンアップの確認", @@ -661,7 +658,7 @@ export const ja: Record = { "storage.cleanup.moreFiles": "…ほか {n} 件", "storage.cleanup.permanent": "完全に削除する(隔離しない)", "storage.cleanup.permanentWarn": "完全削除は元に戻せません。", - "storage.cleanup.quarantineNote": "ファイルは CODEX_HOME 下の .trash へ移動します。隔離タブから復元できます。", + "storage.cleanup.quarantineNote": "ファイルは CODEX_HOME 下の .trash へ移動します。下の隔離セクションから復元できます。", "storage.cleanup.cancel": "キャンセル", "storage.cleanup.confirmQuarantine": "隔離する", "storage.cleanup.confirmPermanent": "完全に削除", @@ -720,17 +717,10 @@ export const ja: Record = { "storage.policy.invalid": "方針の値が無効です。", "storage.policy.enabled": "自動クリーンアップを有効化", "storage.policy.enabledHint": "既定はオフです。有効にすると選択したスケジュール(または今すぐ実行)でのみ動きます。", - "storage.policy.threshold": "アーカイブサイズが超えたら(GiB)", - "storage.policy.trigger": "トリガー", + "storage.policy.threshold": "アーカイブサイズが超えたら実行(GiB)", "storage.policy.target": "クリーンアップ目標", - "storage.policy.targetPercent": "古いアーカイブを削除(%)", + "storage.policy.targetPercent": "古いアーカイブの割合を削除", "storage.policy.targetReduce": "アーカイブを次のサイズまで縮小(GiB)", - "storage.policy.thresholdInc": "しきい値を上げる", - "storage.policy.thresholdDec": "しきい値を下げる", - "storage.policy.percentInc": "パーセントを上げる", - "storage.policy.percentDec": "パーセントを下げる", - "storage.policy.reduceInc": "削減目標を上げる", - "storage.policy.reduceDec": "削減目標を下げる", "storage.policy.schedule": "スケジュール", "storage.policy.schedule.manual": "手動のみ", "storage.policy.schedule.startup": "プロキシ起動時", @@ -890,11 +880,13 @@ export const ja: Record = { "pws.stats.quotaUpdated": "クォータを更新しました", "pws.stats.quotaTracked": "レート制限は使用量タブで追跡されます。", "pws.stats.source": "ソース", - "pws.usageLast30d": "使用量 (過去30日)", + "pws.usageLast30d": "プロキシ使用量 (過去30日)", + "pws.proxyUsage": "プロキシ使用量", + "pws.providerLimits": "プロバイダー制限", "pws.metricRequests": "リクエスト", "pws.metricTokens": "トークン", "pws.usageUnavailable": "まだ使用量が記録されていません。", - "pws.rateLimits": "レート制限", + "pws.rateLimits": "プロバイダー制限", "pws.quotaUnavailable": "このプロバイダーのクォータデータがありません。", "pws.accountQuotaUnavailable": "レート制限データを一時的に取得できません。前回の値がある場合はそれを表示します。", "pws.selected": "選択中", @@ -977,7 +969,6 @@ export const ja: Record = { "pws.dashboard.checkedAgo": "{time} に確認", "pws.dashboard.noQuota": "クォータデータなし", "pws.dashboard.noUsage": "まだ使用量データはありません", - "pws.dashboard.noRateLimits": "まだレート制限データはありません", "pws.allProviders": "プロバイダー概要", "pws.enabledLabel": "有効", "pws.testConnection": "接続テスト", @@ -1095,8 +1086,6 @@ export const ja: Record = { "codexAuth.autoSwitchOffDesc": "アカウントの自動切り替えはオフです", "codexAuth.autoSwitchThreshold": "切り替えしきい値", "codexAuth.autoSwitchThresholdAria": "切り替えしきい値(パーセント)", - "codexAuth.autoSwitchThresholdInc": "切替しきい値を上げる", - "codexAuth.autoSwitchThresholdDec": "切替しきい値を下げる", "codexAuth.autoSwitchLoadFailed": "アカウントの自動切り替え設定を読み込めませんでした。", "codexAuth.autoSwitchThresholdInvalid": "1 から 100 までの整数を入力してください", "codexAuth.autoSwitchUpdated": "アカウントの自動切り替え設定を更新しました", @@ -1123,8 +1112,6 @@ export const ja: Record = { "accountPool.strategyHint": "新規セッションにのみ適用されます。既存スレッドはアカウント親和性を維持します。", "accountPool.stickyLimit": "ローテーション前の固定成功数", "accountPool.stickyLimitAria": "ローテーション前の固定成功数", - "accountPool.stickyLimitInc": "スティッキー上限を上げる", - "accountPool.stickyLimitDec": "スティッキー上限を下げる", "accountPool.stickyLimitHelp": "次へ進む前に、選んだアカウントをこの回数の成功した新規セッション紐付け分保持します。", "accountPool.stickyLimitInvalid": "1 から 100 までの整数を入力してください", "accountPool.strategyLoadFailed": "ローテーション戦略を読み込めませんでした。", @@ -1241,21 +1228,7 @@ export const ja: Record = { "api.generate": "生成", "api.generating": "作成中…", "api.activeKeys": "アクティブなキー ({count})", - "api.activeKeysLoading": "有効なキー", "api.noKeys": "まだ API キーがありません。上で生成してください。", - "api.workspace.overview": "概要", - "api.workspace.details": "APIキーの詳細", - "api.workspace.keyDetails": "キーの詳細", - "api.workspace.keyPrefix": "キーのプレフィックス", - "api.workspace.deleteKey": "キーを削除", - "api.workspace.deleteConfirm": "このキーを削除しますか?この操作は元に戻せません。", - "api.workspace.noKeysHint": "API キーがまだありません。作成して始めましょう。", - "api.workspace.selectKeyHint": "一覧からキーを選択すると、詳細が表示されます。", - "api.workspace.usageExamples": "使用例", - "api.copyUrlHint": "クリックして URL をコピー", - "api.urlCopied": "URL をコピーしました", - "api.copyExampleHint": "クリックして例をコピー", - "api.exampleCopied": "例をコピーしました", "api.colName": "名前", "api.colKey": "キー", "api.colCreated": "作成日", @@ -1269,7 +1242,6 @@ export const ja: Record = { "nav.claude": "Claude", "claude.subtitle": "Claude Code 内で GPT、Gemini などのモデルを使用します。", "claude.pageTitle": "Claude Code", - "claude.workspace.settings": "設定", "claude.enabledLabel": "Claude 接続", "claude.enabledHint": "オフにすると Claude Code はこのプロキシを使用できません。", "claude.authMode": "認証モード", @@ -1563,4 +1535,62 @@ export const ja: Record = { "pws.col.share": "Share", "pws.tokenInput": "Input", "pws.tokenOutput": "Output", + + // fleet shell / modellen / systeem / verkeer (localized view chrome) + "shell.navTraffic": "トラフィック", + "shell.navSystem": "システム", + "shell.navAria": "メインナビゲーション", + "shell.offlineBanner": "プロキシがオフラインです。Codex と Cursor はルーティングできません。", + "mod.subtitle": "カタログ、ルーティング、サブエージェントへの委譲。", + "mod.tablistAria": "モデルのセクション", + "sys.manage": "管理", + "sys.subtitle": "プロキシ本体:ステータス、バージョン、ストレージ、管理。", + "sys.proxy": "プロキシ", + "sys.uptime": "{uptime} 稼働", + "sys.updateTo": "{version} に更新", + "sys.upToDate": "最新バージョンを使用しています。", + "sys.updateCheckFailed": "更新チェックに失敗しました", + "sys.updateStartFailed": "更新の開始に失敗しました", + "sys.updateRunning": "更新を実行中です。プロキシはまもなく自動的に再起動します。", + "sys.catalog": "モデルカタログ", + "sys.catalogDesc": "同期は各プロバイダーから最新モデルを取得します。", + "sys.syncFailed": "同期に失敗しました", + "sys.storage": "ストレージ", + "sys.storageValue": "{size} · {count} ファイル", + "sys.apiEndpoint": "API エンドポイント", + "sys.copy": "コピー", + "sys.copied": "コピーしました", + "sys.codexAuthDesc": "openai プロバイダー用の ChatGPT アカウントプール", + "sys.claudeCodeDesc": "Claude 連携とエージェント注入", + "sys.dangerZone": "危険ゾーン", + "sys.stopDesc": "プロキシを完全に停止します。Codex と Cursor は接続を失います。", + "sys.stopConfirmTitle": "プロキシを停止しますか?", + "sys.stopConfirmDesc": "Codex と Cursor は接続を失います。", + "sys.keepRunning": "稼働したままにする", + "vk.subtitle": "プロキシを通過するもの:すべてのリクエストは伝票です。", + "vk.statsAria": "トラフィックの数値", + "vk.tokens30d": "トークン (30日)", + "vk.requestsToday": "本日のリクエスト", + "vk.requests30d": "リクエスト (30日)", + "vk.filterAria": "プロバイダーで絞り込み", + "vk.all": "すべて", + "vk.pause": "一時停止", + "vk.follow": "ライブで追う", + "vk.loadFailed": "トラフィックを読み込めません。最後に確認された伝票が残ります。", + "vk.empty": "本日はまだトラフィックがありません。", + "vk.showAnalysis": "詳細分析", + "vk.hideAnalysis": "分析を隠す", + "vk.stampDone": "完了", + "vk.stampError": "エラー", + "vk.stampBusy": "処理中", + "vk.detailError": "エラー: {code}", + "vk.detailInOut": "入力 {in} · 出力 {out}", + "vk.rowTokens": "{n} トークン", + "vk.rowDuration": "{s} 秒", + "vk.detailStatus": "ステータス {status}", + "vk.detailUpstream": "アップストリーム: {error}", + "vk.detailId": "ID {id}", + + // fleet shell / systeem / verkeer (ChefGroep) + "dash.injectionActive": "Active", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 956e4da1b..5754558fb 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -32,7 +32,6 @@ export const ko: Record = { "startup.title": "시작 안전성", "startup.subtitle": "재부팅 후 로컬 프록시 라우팅이 재연결 반복으로 이어지기 전에 Codex가 opencodex에 연결될 수 있는지 확인합니다.", "startup.refresh": "새로고침", - "startup.backToDashboard": "대시보드로 돌아가기", "startup.loading": "시작 보호 상태 확인 중…", "startup.error": "시작 보호 상태를 읽지 못했습니다.", "startup.staleData": "최신 시작 상태 확인에 실패했습니다. 아래 값은 이전 결과이며 보호 증거로 사용하면 안 됩니다.", @@ -76,12 +75,8 @@ export const ko: Record = { "startup.installedDisabled": "설치됐지만 꺼짐", "startup.install": "설치하기", "startup.installing": "설치 중…", - "startup.repair": "복구", - "startup.repairing": "복구 중…", "startup.serviceInstalled": "백그라운드 서비스를 설치했습니다.", - "startup.serviceRepaired": "백그라운드 서비스를 복구했습니다.", "startup.shimInstalled": "Codex launcher shim을 설치했습니다.", - "startup.shimRepaired": "Codex launcher shim을 복구했습니다.", "startup.installFailed": "설치하지 못했습니다:", "startup.tray.title": "Windows 시스템 트레이", "startup.tray.hint": "로그인할 때 트레이 아이콘을 띄우고 프록시 시작·중지·재시작·대시보드·상태를 클릭으로 제어합니다.", @@ -441,8 +436,6 @@ export const ko: Record = { "models.discoveryFailedNetwork": "네트워크 오류로 모델 검색에 실패했습니다.", "models.discoveryFailedProvider": "프로바이더가 모델 검색 오류를 보고했습니다.", "models.discoveryFailedGeneric": "모델 검색에 실패했습니다.", - "models.clientHiddenBadge": "클라이언트에서 숨김", - "models.clientDegradedBadge": "새 세션에서 사용 불가", "models.openProviderSettings": "프로바이더 설정 열기", "models.loading": "불러오는 중…", "models.search": "모델 검색…", @@ -467,16 +460,6 @@ export const ko: Record = { "sub.moveUp": "{m} 위로 이동", "sub.moveDown": "{m} 아래로 이동", "sub.removeAria": "{m} 삭제", - "sub.workspace.addToFeatured": "{m}을(를) 추천에 추가", - "sub.workspace.allModels": "모든 모델", - "sub.workspace.featuredFull": "추천 목록이 가득 찼습니다 (최대 5개)", - "sub.workspace.mainAria": "서브에이전트 모델 세부 정보", - "sub.workspace.notFeatured": "추천되지 않음", - "sub.workspace.priority": "우선순위", - "sub.workspace.removeFromFeatured": "{m}을(를) 추천에서 제거", - "sub.workspace.selectModel": "모델 선택", - "sub.workspace.selectModelDesc": "목록에서 모델을 선택하여 세부 정보를 확인하고 spawn_agent에 추천하세요.", - "sub.workspace.selector": "공개 셀렉터", // logs "logs.title": "요청 로그", @@ -500,7 +483,6 @@ export const ko: Record = { "logs.conversation.excluded": "(~$에서 가격 없음 {unpriced}건, 미측정 {unmetered}건 제외)", "logs.detail.conversation": "대화", "logs.badge.claude": "Claude", - "logs.badge.grok": "Grok", "logs.col.time": "시간", "logs.col.request": "요청", "logs.col.model": "모델", @@ -560,7 +542,6 @@ export const ko: Record = { "logs.tokens.cacheWrite": "캐시 생성 (w)", "logs.tokens.reasoning": "추론", "logs.tokens.noCache": "캐시 미보고", - "logs.tokens.contextTotal": "활성 컨텍스트", "logs.tokens.noCacheNote": "이 프로바이더는 캐시 토큰 수치를 제공하지 않습니다", "logs.tokens.noCacheCursor": "Cursor 캐시 상세 미보고", "logs.tokens.noCacheCursorNote": "Cursor 프로토콜은 캐시 read/write 토큰 수치를 제공하지 않습니다. 캐시 미스가 확인됐다는 뜻은 아닙니다", @@ -616,11 +597,18 @@ export const ko: Record = { "usage.card.activeDays": "활동일", "usage.section.heatmap": "일별 활동", "usage.section.overview": "개요", + "usage.section.proxyUsage": "프록시 사용량", + "usage.section.providerLimits": "프로바이더 한도", "usage.section.models": "모델", "usage.section.providers": "프로바이더", "usage.section.coverage": "커버리지 상세", - "usage.workspace.report": "사용량 보고서", - "usage.workspace.sections": "사용량 섹션", + "usage.section.quality": "요청 품질", + "usage.quality.p95Latency": "p95 지연", + "usage.quality.p95Ttft": "p95 TTFT", + "usage.quality.cacheReadRatio": "캐시 읽기 비율", + "usage.quality.ratio429": "429 비율", + "usage.quality.ratio502": "502 비율", + "usage.quality.note": "선택한 구간의 로컬 프록시 요청 로그에서 집계합니다. 프로바이더 할당량 막대는 각 프로바이더 Usage 탭의 프로바이더 한도에 있습니다.", "usage.coverage.measured": "측정됨", "usage.coverage.reported": "제공자 보고", "usage.coverage.estimated": "추정", @@ -722,8 +710,6 @@ export const ko: Record = { "codexAuth.autoSwitchOffDesc": "자동 계정 전환이 꺼져 있습니다", "codexAuth.autoSwitchThreshold": "전환 임계값", "codexAuth.autoSwitchThresholdAria": "전환 임계값(퍼센트)", - "codexAuth.autoSwitchThresholdInc": "전환 임계값 증가", - "codexAuth.autoSwitchThresholdDec": "전환 임계값 감소", "codexAuth.autoSwitchLoadFailed": "자동 계정 전환 설정을 불러오지 못했습니다.", "codexAuth.autoSwitchThresholdInvalid": "1~100 사이의 정수를 입력하세요", "codexAuth.autoSwitchUpdated": "자동 계정 전환 설정을 저장했습니다", @@ -750,8 +736,6 @@ export const ko: Record = { "accountPool.strategyHint": "새 세션에만 적용됩니다. 기존 스레드는 계정 어피니티를 유지합니다.", "accountPool.stickyLimit": "회전 전 sticky 성공 횟수", "accountPool.stickyLimitAria": "회전 전 sticky 성공 횟수", - "accountPool.stickyLimitInc": "스티키 한도 증가", - "accountPool.stickyLimitDec": "스티키 한도 감소", "accountPool.stickyLimitHelp": "다음으로 진행하기 전에 선택된 계정을 이 횟수의 성공한 새 세션 바인딩 동안 유지합니다.", "accountPool.stickyLimitInvalid": "1에서 100 사이의 정수를 입력하세요", "accountPool.strategyLoadFailed": "로테이션 전략을 불러오지 못했습니다.", @@ -866,21 +850,7 @@ export const ko: Record = { "api.generate": "생성", "api.generating": "생성 중…", "api.activeKeys": "활성 키 ({count})", - "api.activeKeysLoading": "활성 키", "api.noKeys": "아직 API 키가 없습니다. 위에서 하나 생성하세요.", - "api.workspace.overview": "개요", - "api.workspace.details": "API 키 세부 정보", - "api.workspace.keyDetails": "키 세부 정보", - "api.workspace.keyPrefix": "키 접두사", - "api.workspace.deleteKey": "키 삭제", - "api.workspace.deleteConfirm": "이 키를 삭제하시겠습니까? 되돌릴 수 없습니다.", - "api.workspace.noKeysHint": "아직 API 키가 없습니다. 키를 생성하여 시작하세요.", - "api.workspace.selectKeyHint": "목록에서 키를 선택하여 세부 정보를 확인하세요.", - "api.workspace.usageExamples": "사용 예제", - "api.copyUrlHint": "클릭하여 URL 복사", - "api.urlCopied": "URL 복사됨", - "api.copyExampleHint": "클릭하여 예제 복사", - "api.exampleCopied": "예제 복사됨", "api.colName": "이름", "api.colKey": "키", "api.colCreated": "생성일", @@ -973,27 +943,16 @@ export const ko: Record = { "nav.storage": "저장소", "storage.title": "저장소", - "storage.subtitle": "CODEX_HOME 사용량을 확인합니다. 정리는 활성 세션을 건드리지 않습니다.", + "storage.subtitle": "CODEX_HOME 디스크 사용 진단. 아래 보관 정리로 가장 오래된 보관 세션을 격리하거나 영구 삭제할 수 있습니다 — 활성 세션은 읽기 전용입니다.", "storage.loading": "저장소 스캔 중…", "storage.empty": "CODEX_HOME이 비어 있거나 없습니다 — 표시할 내용이 없습니다.", "storage.error": "저장소 스캔에 실패했습니다. CODEX_HOME이 올바른 디렉터리를 가리키는지 확인하세요.", "storage.refresh": "다시 스캔", - "storage.rescanned": "스캔이 완료되었습니다.", "storage.card.total": "전체 크기", "storage.card.files": "파일 수", "storage.card.home": "CODEX_HOME", - "storage.snapshot.lastScan": "마지막 스캔", - "storage.snapshot.scanning": "스캔 중…", - "storage.snapshot.unavailable": "아직 스캔 없음.", - "storage.cleanupCard.title": "공간 확보", - "storage.cleanupCard.tabs": "정리 옵션", - "storage.cleanupCard.tab.policy": "정책", - "storage.cleanupCard.tab.quarantine": "격리", - "storage.cleanup.noArchives": "정리할 보관 세션이 없습니다.", "storage.section.buckets": "버킷", "storage.section.largest": "가장 큰 파일", - "storage.workspace.overview": "개요", - "storage.workspace.selectBucket": "목록에서 버킷을 선택하면 세부 내역을 볼 수 있습니다.", "storage.col.bucket": "버킷", "storage.col.size": "크기", "storage.col.files": "파일", @@ -1011,7 +970,7 @@ export const ko: Record = { "storage.cleanup.title": "보관 정리", "storage.cleanup.help": "가장 오래된 보관 세션을 비율로 제거합니다. 활성 세션은 건드리지 않습니다. 기본은 격리이며 파일은 CODEX_HOME/.trash로 이동합니다.", "storage.cleanup.slider": "오래된 보관 비율", - "storage.cleanup.percent": "{percent}%", + "storage.cleanup.percent": "오래된 {percent}%", "storage.cleanup.preset": "{percent}", "storage.cleanup.preview": "미리보기", "storage.cleanup.confirmTitle": "보관 정리 확인", @@ -1019,7 +978,7 @@ export const ko: Record = { "storage.cleanup.moreFiles": "…외 {n}개", "storage.cleanup.permanent": "영구 삭제(격리 건너뛰기)", "storage.cleanup.permanentWarn": "영구 삭제는 되돌릴 수 없습니다.", - "storage.cleanup.quarantineNote": "파일은 CODEX_HOME 아래 .trash로 이동합니다. 격리 탭에서 복원할 수 있습니다.", + "storage.cleanup.quarantineNote": "파일은 CODEX_HOME 아래 .trash로 이동합니다. 아래 격리 섹션에서 복원할 수 있습니다.", "storage.cleanup.cancel": "취소", "storage.cleanup.confirmQuarantine": "격리", "storage.cleanup.confirmPermanent": "영구 삭제", @@ -1078,17 +1037,10 @@ export const ko: Record = { "storage.policy.invalid": "정책 값이 올바르지 않습니다.", "storage.policy.enabled": "자동 정리 사용", "storage.policy.enabledHint": "기본은 꺼짐입니다. 켜면 선택한 일정(또는 지금 실행)에만 동작합니다.", - "storage.policy.threshold": "보관 용량이 초과하면 (GiB)", - "storage.policy.trigger": "트리거", + "storage.policy.threshold": "보관 용량이 초과하면 실행 (GiB)", "storage.policy.target": "정리 목표", - "storage.policy.targetPercent": "가장 오래된 보관 제거 (%)", + "storage.policy.targetPercent": "가장 오래된 보관 비율 제거", "storage.policy.targetReduce": "보관 용량을 다음까지 줄이기 (GiB)", - "storage.policy.thresholdInc": "임계값 증가", - "storage.policy.thresholdDec": "임계값 감소", - "storage.policy.percentInc": "퍼센트 증가", - "storage.policy.percentDec": "퍼센트 감소", - "storage.policy.reduceInc": "축소 목표 증가", - "storage.policy.reduceDec": "축소 목표 감소", "storage.policy.schedule": "일정", "storage.policy.schedule.manual": "수동만", "storage.policy.schedule.startup": "프록시 시작 시", @@ -1224,7 +1176,9 @@ export const ko: Record = { "pws.stats.quotaUpdated": "쿼터 갱신", "pws.stats.quotaTracked": "사용량 탭에서 한도를 확인할 수 있습니다.", "pws.stats.source": "출처", - "pws.usageLast30d": "사용량 (최근 30일)", + "pws.usageLast30d": "프록시 사용량 (최근 30일)", + "pws.proxyUsage": "프록시 사용량", + "pws.providerLimits": "프로바이더 한도", "pws.estimatedCost": "추정 비용", "pws.costDisclaimer": "API 공시가 기준 추정치이며, 실제 청구 금액이 아닙니다.", "pws.modelBreakdown": "모델별 사용량", @@ -1238,7 +1192,7 @@ export const ko: Record = { "pws.metricRequests": "요청", "pws.metricTokens": "토큰", "pws.usageUnavailable": "아직 기록된 사용량이 없습니다.", - "pws.rateLimits": "요청 한도", + "pws.rateLimits": "프로바이더 한도", "pws.quotaUnavailable": "이 프로바이더의 쿼터 데이터가 없습니다.", "pws.accountQuotaUnavailable": "요금 한도 데이터를 일시적으로 가져올 수 없습니다. 이전 값이 있으면 그대로 표시합니다.", "pws.selected": "선택됨", @@ -1321,7 +1275,6 @@ export const ko: Record = { "pws.dashboard.checkedAgo": "{time} 전 확인", "pws.dashboard.noQuota": "할당량 데이터 없음", "pws.dashboard.noUsage": "아직 사용 데이터 없음", - "pws.dashboard.noRateLimits": "아직 한도 데이터 없음", "pws.allProviders": "프로바이더 개요", "pws.enabledLabel": "활성화", "pws.testConnection": "연결 테스트", @@ -1377,7 +1330,6 @@ export const ko: Record = { "codexAuth.addIdPlaceholder": "codex-work, codex-alt, team…", "codexAuth.resetCreditsAria": "리셋 크레딧 {count}개", "claude.pageTitle": "Claude Code", - "claude.workspace.settings": "설정", // Combos workspace "cws.loading": "콤보 불러오는 중…", @@ -1563,4 +1515,80 @@ export const ko: Record = { "claudeDesktop.effort.supported": "effort", "claudeDesktop.effort.displayOnly": "effort (표시만)", + + // fleet shell / systeem / verkeer (ChefGroep) + "dash.injectionActive": "Active", + "dash.suppliers": "Providers", + "dash.refresh": "Refresh", + "dash.refreshAll": "Refresh all", + "dash.retry": "Retry", + "dash.fresh": "Fresh · {time}", + "dash.noContactSince": "No contact since {time}", + "dash.modelsCount": "{count} models", + "dash.showModels": "Show models", + "dash.hideModels": "Hide models", + "dash.stamp.ready": "Ready", + "dash.stamp.busy": "Busy", + "dash.stamp.idle": "Idle", + "dash.stamp.error": "Error", + "dash.settingsSection": "Settings", + "dash.emptyKitchen": "No models yet.", + "dash.providerDisabled": "Disabled", + "prov.quotaRefresh": "Refresh usage", + "prov.quotaRefreshing": "Refreshing…", + "prov.quotaRefreshFailed": "Refresh failed — showing last known data", + "prov.quotaRefreshAria": "Refresh usage for {name}", + "shell.navTraffic": "Traffic", + "shell.navSystem": "System", + "shell.navAria": "Main navigation", + "shell.offlineBanner": "Proxy offline. Codex and Cursor cannot route.", + "mod.subtitle": "Catalog, routing, and sub-agent delegation.", + "mod.tablistAria": "Models sections", + "sys.manage": "Manage", + "sys.subtitle": "The proxy itself: status, version, storage, and management.", + "sys.proxy": "Proxy", + "sys.uptime": "{uptime} uptime", + "sys.updateTo": "Update to {version}", + "sys.upToDate": "You are on the latest version.", + "sys.updateCheckFailed": "update check failed", + "sys.updateStartFailed": "could not start update", + "sys.updateRunning": "Update running. The proxy will restart itself shortly.", + "sys.catalog": "Model catalog", + "sys.catalogDesc": "Sync pulls the latest models from each provider.", + "sys.syncFailed": "sync failed", + "sys.storage": "Storage", + "sys.storageValue": "{size} · {count} files", + "sys.apiEndpoint": "API endpoint", + "sys.copy": "Copy", + "sys.copied": "Copied", + "sys.codexAuthDesc": "ChatGPT account pool for the openai provider", + "sys.claudeCodeDesc": "Claude integration and agent injection", + "sys.dangerZone": "Danger zone", + "sys.stopDesc": "Stops the proxy completely. Codex and Cursor lose their connection.", + "sys.stopConfirmTitle": "Stop the proxy?", + "sys.stopConfirmDesc": "Codex and Cursor will lose their connection.", + "sys.keepRunning": "Keep running", + "vk.subtitle": "What flows through the proxy: every request is a receipt.", + "vk.statsAria": "Traffic figures", + "vk.tokens30d": "tokens (30d)", + "vk.requestsToday": "requests today", + "vk.requests30d": "requests (30d)", + "vk.filterAria": "Filter by provider", + "vk.all": "All", + "vk.pause": "Pause", + "vk.follow": "Follow live", + "vk.loadFailed": "Could not load traffic. Last known receipts stay visible.", + "vk.empty": "No traffic yet today.", + "vk.showAnalysis": "Full analysis", + "vk.hideAnalysis": "Hide analysis", + "vk.stampDone": "Done", + "vk.stampError": "Error", + "vk.stampBusy": "Busy", + "vk.detailError": "error: {code}", + "vk.detailInOut": "in {in} · out {out}", + "vk.rowTokens": "{n} tok", + "vk.rowDuration": "{s}s", + "vk.detailStatus": "status {status}", + "vk.detailUpstream": "upstream: {error}", + "vk.detailId": "id {id}", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index b6e56926d..8c7aff8b1 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -37,7 +37,6 @@ export const ru: Record = { "startup.title": "Безопасность запуска", "startup.subtitle": "Проверьте, сможет ли Codex подключиться к opencodex после перезагрузки, прежде чем локальный прокси вызовет бесконечное переподключение.", "startup.refresh": "Обновить", - "startup.backToDashboard": "Назад к панели", "startup.loading": "Проверка защиты запуска…", "startup.error": "Не удалось прочитать состояние защиты запуска.", "startup.staleData": "Последняя проверка не удалась. Значения ниже устарели и не подтверждают защиту.", @@ -81,12 +80,8 @@ export const ru: Record = { "startup.installedDisabled": "Установлен, но отключён", "startup.install": "Установить", "startup.installing": "Установка…", - "startup.repair": "Исправить", - "startup.repairing": "Исправление…", "startup.serviceInstalled": "Фоновая служба успешно установлена.", - "startup.serviceRepaired": "Фоновая служба успешно исправлена.", "startup.shimInstalled": "Launcher shim Codex успешно установлен.", - "startup.shimRepaired": "Launcher shim Codex успешно исправлен.", "startup.installFailed": "Не удалось установить:", "startup.tray.title": "Системный трей Windows", "startup.tray.hint": "Запускает значок при входе для управления запуском, остановкой, перезапуском, панелью и состоянием прокси.", @@ -247,6 +242,22 @@ export const ru: Record = { "dash.updateStatus.restarting": "Обновление установлено. Перезапуск прокси.", "dash.updateStatus.succeeded": "Обновление завершено.", "dash.updateStatus.failed": "Обновление не удалось.", + "dash.suppliers": "Провайдеры", + "dash.refresh": "Обновить", + "dash.refreshAll": "Обновить все", + "dash.retry": "Повторить", + "dash.fresh": "Свежо · {time}", + "dash.noContactSince": "Нет связи с {time}", + "dash.modelsCount": "{count} моделей", + "dash.showModels": "Показать модели", + "dash.hideModels": "Скрыть модели", + "dash.stamp.ready": "ГОТОВО", + "dash.stamp.busy": "В РАБОТЕ", + "dash.stamp.error": "ОШИБКА", + "dash.stamp.idle": "ТИХО", + "dash.settingsSection": "Настройки", + "dash.emptyKitchen": "Моделей пока нет.", + "dash.providerDisabled": "Отключён", // providers "prov.subtitle": "Настройте вышестоящих провайдеров, которых opencodex маршрутизирует в Codex. Войдите в аккаунт, добавьте провайдера или отредактируйте конфигурацию вручную.", @@ -308,6 +319,10 @@ export const ru: Record = { "prov.hasApiKey": "API-ключ настроен", "prov.hasHeaders": "настроены пользовательские заголовки", "prov.accounts": "Аккаунты ({n})", + "prov.quotaRefresh": "Обновить использование", + "prov.quotaRefreshing": "Обновление…", + "prov.quotaRefreshFailed": "Не удалось обновить — показаны последние данные", + "prov.quotaRefreshAria": "Обновить использование для {name}", "prov.accountsAria": "Показать или скрыть аккаунты {name}", "prov.accountActive": "Активен", "prov.accountReauth": "Повторный вход", @@ -446,8 +461,6 @@ export const ru: Record = { "models.discoveryFailedNetwork": "Обнаружение моделей не удалось из-за сетевой ошибки.", "models.discoveryFailedProvider": "Провайдер сообщил об ошибке обнаружения моделей.", "models.discoveryFailedGeneric": "Не удалось обнаружить модели.", - "models.clientHiddenBadge": "Скрыто от клиентов", - "models.clientDegradedBadge": "Недоступно для новых сессий", "models.openProviderSettings": "Открыть настройки провайдера", "models.loading": "Загрузка…", "models.search": "Поиск моделей…", @@ -472,16 +485,6 @@ export const ru: Record = { "sub.moveUp": "Переместить {m} вверх", "sub.moveDown": "Переместить {m} вниз", "sub.removeAria": "Убрать {m}", - "sub.workspace.addToFeatured": "Добавить {m} в избранные", - "sub.workspace.allModels": "Все модели", - "sub.workspace.featuredFull": "Список избранных заполнен (макс. 5)", - "sub.workspace.mainAria": "Сведения о модели субагента", - "sub.workspace.notFeatured": "Не в избранных", - "sub.workspace.priority": "Приоритет", - "sub.workspace.removeFromFeatured": "Убрать {m} из избранных", - "sub.workspace.selectModel": "Выберите модель", - "sub.workspace.selectModelDesc": "Выберите модель из списка, чтобы увидеть детали и добавить её в избранные для spawn_agent.", - "sub.workspace.selector": "Публичный селектор", // logs "logs.title": "Журнал запросов", @@ -505,7 +508,6 @@ export const ru: Record = { "logs.conversation.excluded": "(из ~$ исключены {unpriced} без цены, {unmetered} без учёта)", "logs.detail.conversation": "Диалог", "logs.badge.claude": "Claude", - "logs.badge.grok": "Grok", "logs.col.time": "Время", "logs.col.request": "Запрос", "logs.col.model": "Модель", @@ -565,7 +567,6 @@ export const ru: Record = { "logs.tokens.cacheWrite": "запись кэша (w)", "logs.tokens.reasoning": "рассуждения", "logs.tokens.noCache": "нет данных кэша", - "logs.tokens.contextTotal": "активный контекст", "logs.tokens.noCacheNote": "этот провайдер не сообщает данные о кэш-токенах", "logs.tokens.noCacheCursor": "детализация кэша Cursor не сообщается", "logs.tokens.noCacheCursorNote": "Cursor не передает число токенов чтения/записи кэша; это неизвестно, а не подтвержденный промах кэша", @@ -622,11 +623,18 @@ export const ru: Record = { "usage.card.activeDays": "Активные дни", "usage.section.heatmap": "Активность по дням", "usage.section.overview": "Обзор", + "usage.section.proxyUsage": "Использование прокси", + "usage.section.providerLimits": "Лимиты провайдера", "usage.section.models": "Модели", "usage.section.providers": "Провайдеры", "usage.section.coverage": "Детализация покрытия", - "usage.workspace.report": "Отчёт об использовании", - "usage.workspace.sections": "Разделы использования", + "usage.section.quality": "Качество запросов", + "usage.quality.p95Latency": "p95 задержка", + "usage.quality.p95Ttft": "p95 TTFT", + "usage.quality.cacheReadRatio": "Доля чтения кэша", + "usage.quality.ratio429": "Доля 429", + "usage.quality.ratio502": "Доля 502", + "usage.quality.note": "Агрегировано из локальных логов запросов прокси за выбранный период. Полосы квот провайдера — в разделе «Лимиты провайдера» на вкладке Usage каждого провайдера.", "usage.coverage.measured": "Измерено", "usage.coverage.reported": "Сообщено провайдером", "usage.coverage.estimated": "Оценено", @@ -647,27 +655,16 @@ export const ru: Record = { "nav.storage": "Хранилище", "storage.title": "Хранилище", - "storage.subtitle": "Смотрите, что занимает CODEX_HOME. Очистка не затрагивает активные сессии.", + "storage.subtitle": "Диагностика диска CODEX_HOME. Ниже — очистка архива: карантин или безвозвратное удаление старых архивных сессий; активные сессии остаются только для чтения.", "storage.loading": "Сканирование хранилища…", "storage.empty": "CODEX_HOME пуст или отсутствует — показывать нечего.", "storage.error": "Не удалось просканировать хранилище. Убедитесь, что CODEX_HOME указывает на корректный каталог.", "storage.refresh": "Пересканировать", - "storage.rescanned": "Сканирование завершено.", "storage.card.total": "Общий размер", "storage.card.files": "Файлы", "storage.card.home": "CODEX_HOME", - "storage.snapshot.lastScan": "Последний скан", - "storage.snapshot.scanning": "Сканирование…", - "storage.snapshot.unavailable": "Сканирования ещё не было.", - "storage.cleanupCard.title": "Освободить место", - "storage.cleanupCard.tabs": "Параметры очистки", - "storage.cleanupCard.tab.policy": "Политика", - "storage.cleanupCard.tab.quarantine": "Карантин", - "storage.cleanup.noArchives": "Нет архивных сессий для очистки.", "storage.section.buckets": "Категории", "storage.section.largest": "Крупнейшие файлы", - "storage.workspace.overview": "Обзор", - "storage.workspace.selectBucket": "Выберите сегмент в списке, чтобы увидеть разбивку.", "storage.col.bucket": "Категория", "storage.col.size": "Размер", "storage.col.files": "Файлы", @@ -685,7 +682,7 @@ export const ru: Record = { "storage.cleanup.title": "Очистка архива", "storage.cleanup.help": "Удаляет самые старые архивные сессии по проценту. Активные сессии не затрагиваются. По умолчанию — карантин: файлы перемещаются в CODEX_HOME/.trash.", "storage.cleanup.slider": "Доля самых старых архивов", - "storage.cleanup.percent": "{percent}%", + "storage.cleanup.percent": "Самые старые {percent}%", "storage.cleanup.preset": "{percent}", "storage.cleanup.preview": "Предпросмотр", "storage.cleanup.confirmTitle": "Подтвердить очистку архива", @@ -693,7 +690,7 @@ export const ru: Record = { "storage.cleanup.moreFiles": "…и ещё {n}", "storage.cleanup.permanent": "Удалить навсегда (без карантина)", "storage.cleanup.permanentWarn": "Безвозвратное удаление нельзя отменить.", - "storage.cleanup.quarantineNote": "Файлы перемещаются в .trash под CODEX_HOME. Восстановить можно на вкладке «Карантин».", + "storage.cleanup.quarantineNote": "Файлы перемещаются в .trash под CODEX_HOME. Восстановить можно в разделе «Карантин» ниже.", "storage.cleanup.cancel": "Отмена", "storage.cleanup.confirmQuarantine": "В карантин", "storage.cleanup.confirmPermanent": "Удалить навсегда", @@ -752,17 +749,10 @@ export const ru: Record = { "storage.policy.invalid": "Недопустимые значения политики.", "storage.policy.enabled": "Включить автоочистку", "storage.policy.enabledHint": "По умолчанию выкл. При включении работает только по выбранному расписанию (или «Запустить сейчас»).", - "storage.policy.threshold": "Когда размер архива больше (ГиБ)", - "storage.policy.trigger": "Триггер", + "storage.policy.threshold": "Запуск, если размер архива больше (ГиБ)", "storage.policy.target": "Цель очистки", - "storage.policy.targetPercent": "Удалить самые старые архивы (%)", + "storage.policy.targetPercent": "Удалить процент самых старых архивов", "storage.policy.targetReduce": "Уменьшить архив до (ГиБ)", - "storage.policy.thresholdInc": "Увеличить порог", - "storage.policy.thresholdDec": "Уменьшить порог", - "storage.policy.percentInc": "Увеличить процент", - "storage.policy.percentDec": "Уменьшить процент", - "storage.policy.reduceInc": "Увеличить целевой размер", - "storage.policy.reduceDec": "Уменьшить целевой размер", "storage.policy.schedule": "Расписание", "storage.policy.schedule.manual": "Только вручную", "storage.policy.schedule.startup": "При запуске прокси", @@ -922,7 +912,9 @@ export const ru: Record = { "pws.stats.quotaUpdated": "Квота обновлена", "pws.stats.quotaTracked": "Лимиты запросов отслеживаются на вкладке «Использование».", "pws.stats.source": "Источник", - "pws.usageLast30d": "Использование (последние 30 дней)", + "pws.usageLast30d": "Использование прокси (последние 30 дней)", + "pws.proxyUsage": "Использование прокси", + "pws.providerLimits": "Лимиты провайдера", "pws.estimatedCost": "Ориентировочная стоимость", "pws.costDisclaimer": "Оценка на основе публичных цен API, не фактический счёт.", "pws.modelBreakdown": "Разбивка по моделям", @@ -936,7 +928,7 @@ export const ru: Record = { "pws.metricRequests": "запросов", "pws.metricTokens": "токенов", "pws.usageUnavailable": "Использование пока не зафиксировано.", - "pws.rateLimits": "Лимиты запросов", + "pws.rateLimits": "Лимиты провайдера", "pws.quotaUnavailable": "Нет данных о квоте для этого провайдера.", "pws.accountQuotaUnavailable": "Данные о лимитах временно недоступны; при наличии показываются последние известные значения.", "pws.selected": "Выбрана", @@ -1019,7 +1011,6 @@ export const ru: Record = { "pws.dashboard.checkedAgo": "Проверено {time}", "pws.dashboard.noQuota": "Нет данных о квоте", "pws.dashboard.noUsage": "Данных об использовании пока нет", - "pws.dashboard.noRateLimits": "Данных о лимитах пока нет", "pws.allProviders": "Обзор провайдеров", "pws.enabledLabel": "Включён", "pws.testConnection": "Проверить подключение", @@ -1137,8 +1128,6 @@ export const ru: Record = { "codexAuth.autoSwitchOffDesc": "Автоматическое переключение аккаунтов выключено", "codexAuth.autoSwitchThreshold": "Порог переключения", "codexAuth.autoSwitchThresholdAria": "Порог переключения в процентах", - "codexAuth.autoSwitchThresholdInc": "Увеличить порог переключения", - "codexAuth.autoSwitchThresholdDec": "Уменьшить порог переключения", "codexAuth.autoSwitchLoadFailed": "Не удалось загрузить настройку автоматического переключения аккаунтов.", "codexAuth.autoSwitchThresholdInvalid": "Введите целое число от 1 до 100", "codexAuth.autoSwitchUpdated": "Настройки автоматического переключения обновлены", @@ -1165,8 +1154,6 @@ export const ru: Record = { "accountPool.strategyHint": "Применяется только к новым сессиям; существующие треды сохраняют привязку к аккаунту.", "accountPool.stickyLimit": "Успешных запросов до ротации", "accountPool.stickyLimitAria": "Успешных запросов до ротации", - "accountPool.stickyLimitInc": "Увеличить sticky-лимит", - "accountPool.stickyLimitDec": "Уменьшить sticky-лимит", "accountPool.stickyLimitHelp": "Удерживать выбранный аккаунт на указанное число успешных привязок новых сессий, прежде чем перейти дальше.", "accountPool.stickyLimitInvalid": "Введите целое число от 1 до 100", "accountPool.strategyLoadFailed": "Не удалось загрузить стратегию ротации.", @@ -1283,21 +1270,7 @@ export const ru: Record = { "api.generate": "Сгенерировать", "api.generating": "Создание…", "api.activeKeys": "Активные ключи ({count})", - "api.activeKeysLoading": "Активные ключи", "api.noKeys": "API-ключей пока нет. Сгенерируйте ключ выше.", - "api.workspace.overview": "Обзор", - "api.workspace.details": "Сведения об API-ключе", - "api.workspace.keyDetails": "Сведения о ключе", - "api.workspace.keyPrefix": "Префикс ключа", - "api.workspace.deleteKey": "Удалить ключ", - "api.workspace.deleteConfirm": "Удалить этот ключ? Это действие нельзя отменить.", - "api.workspace.noKeysHint": "API-ключей пока нет. Создайте один, чтобы начать.", - "api.workspace.selectKeyHint": "Выберите ключ из списка, чтобы просмотреть сведения.", - "api.workspace.usageExamples": "Примеры использования", - "api.copyUrlHint": "Нажмите, чтобы скопировать URL", - "api.urlCopied": "URL скопирован", - "api.copyExampleHint": "Нажмите, чтобы скопировать пример", - "api.exampleCopied": "Пример скопирован", "api.colName": "Имя", "api.colKey": "Ключ", "api.colCreated": "Создан", @@ -1311,7 +1284,6 @@ export const ru: Record = { "nav.claude": "Claude", "claude.subtitle": "Используйте GPT, Gemini и другие модели внутри Claude Code.", "claude.pageTitle": "Claude Code", - "claude.workspace.settings": "Настройки", "claude.enabledLabel": "Подключение Claude", "claude.enabledHint": "Если выключено, Claude Code не сможет использовать этот прокси.", "claude.authMode": "Режим аутентификации", @@ -1564,5 +1536,63 @@ export const ru: Record = { "cws.err.invalidWeight": "Каждый вес round-robin должен быть целым числом от 1 до 10000.", "cws.err.noEnabledTarget": "Хотя бы одна цель должна использовать включённого провайдера.", + // fleet shell / modellen / systeem / verkeer (localized view chrome) + "shell.navTraffic": "Трафик", + "shell.navSystem": "Система", + "shell.navAria": "Основная навигация", + "shell.offlineBanner": "Прокси офлайн. Codex и Cursor не могут маршрутизировать.", + "mod.subtitle": "Каталог, маршрутизация и делегирование для суб-агентов.", + "mod.tablistAria": "Разделы моделей", + "sys.manage": "Управление", + "sys.subtitle": "Сам прокси: статус, версия, хранилище и управление.", + "sys.proxy": "Прокси", + "sys.uptime": "{uptime} в работе", + "sys.updateTo": "Обновить до {version}", + "sys.upToDate": "У вас установлена последняя версия.", + "sys.updateCheckFailed": "не удалось проверить обновление", + "sys.updateStartFailed": "не удалось запустить обновление", + "sys.updateRunning": "Обновление выполняется. Прокси скоро перезапустится.", + "sys.catalog": "Каталог моделей", + "sys.catalogDesc": "Синхронизация загружает последние модели от каждого провайдера.", + "sys.syncFailed": "синхронизация не удалась", + "sys.storage": "Хранилище", + "sys.storageValue": "{size} · файлов: {count}", + "sys.apiEndpoint": "API-эндпоинт", + "sys.copy": "Копировать", + "sys.copied": "Скопировано", + "sys.codexAuthDesc": "Пул аккаунтов ChatGPT для провайдера openai", + "sys.claudeCodeDesc": "Интеграция Claude и внедрение агента", + "sys.dangerZone": "Опасная зона", + "sys.stopDesc": "Полностью останавливает прокси. Codex и Cursor теряют соединение.", + "sys.stopConfirmTitle": "Остановить прокси?", + "sys.stopConfirmDesc": "Codex и Cursor теряют соединение.", + "sys.keepRunning": "Оставить работать", + "vk.subtitle": "Что проходит через прокси: каждый запрос — это чек.", + "vk.statsAria": "Показатели трафика", + "vk.tokens30d": "токены (30д)", + "vk.requestsToday": "запросов сегодня", + "vk.requests30d": "запросов (30д)", + "vk.filterAria": "Фильтр по провайдеру", + "vk.all": "Все", + "vk.pause": "Пауза", + "vk.follow": "Следить вживую", + "vk.loadFailed": "Не удаётся загрузить трафик. Последние известные чеки остаются.", + "vk.empty": "Сегодня трафика ещё нет.", + "vk.showAnalysis": "Полный анализ", + "vk.hideAnalysis": "Скрыть анализ", + "vk.stampDone": "Готово", + "vk.stampError": "Ошибка", + "vk.stampBusy": "Выполняется", + "vk.detailError": "ошибка: {code}", + "vk.detailInOut": "вход {in} · выход {out}", + "vk.rowTokens": "{n} ток", + "vk.rowDuration": "{s} с", + "vk.detailStatus": "статус {status}", + "vk.detailUpstream": "upstream: {error}", + "vk.detailId": "id {id}", + + + // fleet shell / systeem / verkeer (ChefGroep) + "dash.injectionActive": "Active", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index efa251320..d9f503b41 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -32,7 +32,6 @@ export const zh: Record = { "startup.title": "启动安全", "startup.subtitle": "检查重启后 Codex 是否仍能连接 opencodex,避免本地代理路由陷入重复重连。", "startup.refresh": "刷新", - "startup.backToDashboard": "返回仪表盘", "startup.loading": "正在检查启动保护…", "startup.error": "无法读取启动保护状态。", "startup.staleData": "最新启动检查失败。以下数据已过期,不能视为已受保护的证明。", @@ -76,12 +75,8 @@ export const zh: Record = { "startup.installedDisabled": "已安装但禁用", "startup.install": "安装", "startup.installing": "正在安装…", - "startup.repair": "修复", - "startup.repairing": "正在修复…", "startup.serviceInstalled": "后台服务安装成功。", - "startup.serviceRepaired": "后台服务修复成功。", "startup.shimInstalled": "Codex 启动器 shim 安装成功。", - "startup.shimRepaired": "Codex 启动器 shim 修复成功。", "startup.installFailed": "安装失败:", "startup.tray.title": "Windows 系统托盘", "startup.tray.hint": "登录时启动托盘图标,一键控制代理启动、停止、重启、面板和状态。", @@ -441,8 +436,6 @@ export const zh: Record = { "models.discoveryFailedNetwork": "由于网络错误,模型发现失败。", "models.discoveryFailedProvider": "提供方报告了模型发现错误。", "models.discoveryFailedGeneric": "模型发现失败。", - "models.clientHiddenBadge": "已对客户端隐藏", - "models.clientDegradedBadge": "新会话不可用", "models.openProviderSettings": "打开提供方设置", "models.loading": "加载中…", "models.search": "搜索模型…", @@ -467,16 +460,6 @@ export const zh: Record = { "sub.moveUp": "上移 {m}", "sub.moveDown": "下移 {m}", "sub.removeAria": "移除 {m}", - "sub.workspace.addToFeatured": "将 {m} 添加到精选", - "sub.workspace.allModels": "所有模型", - "sub.workspace.featuredFull": "精选列表已满(最多 5 个)", - "sub.workspace.mainAria": "子代理模型详情", - "sub.workspace.notFeatured": "未设为精选", - "sub.workspace.priority": "优先级", - "sub.workspace.removeFromFeatured": "将 {m} 从精选中移除", - "sub.workspace.selectModel": "选择模型", - "sub.workspace.selectModelDesc": "从列表中选择一个模型以查看详情,并将其设为 spawn_agent 的精选模型。", - "sub.workspace.selector": "公开选择器", // logs "logs.title": "请求日志", @@ -500,7 +483,6 @@ export const zh: Record = { "logs.conversation.excluded": "(~$ 已排除 {unpriced} 条无定价、{unmetered} 条无计量)", "logs.detail.conversation": "会话", "logs.badge.claude": "Claude", - "logs.badge.grok": "Grok", "logs.col.time": "时间", "logs.col.request": "请求", "logs.col.model": "模型", @@ -560,7 +542,6 @@ export const zh: Record = { "logs.tokens.cacheWrite": "缓存写入 (w)", "logs.tokens.reasoning": "推理", "logs.tokens.noCache": "无缓存数据", - "logs.tokens.contextTotal": "活动上下文", "logs.tokens.noCacheNote": "该提供商不报告缓存 token 数", "logs.tokens.noCacheCursor": "Cursor 未报告缓存明细", "logs.tokens.noCacheCursorNote": "Cursor 不提供缓存读写 token 数;这表示未知,并不代表已确认缓存未命中", @@ -616,11 +597,18 @@ export const zh: Record = { "usage.card.activeDays": "活跃天数", "usage.section.heatmap": "每日活动", "usage.section.overview": "概览", + "usage.section.proxyUsage": "代理用量", + "usage.section.providerLimits": "提供方限额", "usage.section.models": "模型", "usage.section.providers": "提供方", "usage.section.coverage": "覆盖率明细", - "usage.workspace.report": "用量报告", - "usage.workspace.sections": "用量分区", + "usage.section.quality": "请求质量", + "usage.quality.p95Latency": "p95 延迟", + "usage.quality.p95Ttft": "p95 TTFT", + "usage.quality.cacheReadRatio": "缓存读取比率", + "usage.quality.ratio429": "429 比率", + "usage.quality.ratio502": "502 比率", + "usage.quality.note": "根据所选时间范围内的本地代理请求日志汇总。提供方配额条在各提供方 Usage 标签的提供方限额下。", "usage.coverage.measured": "已计量", "usage.coverage.reported": "提供方上报", "usage.coverage.estimated": "估算", @@ -722,8 +710,6 @@ export const zh: Record = { "codexAuth.autoSwitchOffDesc": "自动切换账号已关闭", "codexAuth.autoSwitchThreshold": "切换阈值", "codexAuth.autoSwitchThresholdAria": "切换阈值(百分比)", - "codexAuth.autoSwitchThresholdInc": "提高切换阈值", - "codexAuth.autoSwitchThresholdDec": "降低切换阈值", "codexAuth.autoSwitchLoadFailed": "无法加载自动切换账号设置。", "codexAuth.autoSwitchThresholdInvalid": "请输入 1 到 100 之间的整数", "codexAuth.autoSwitchUpdated": "自动切换账号设置已更新", @@ -750,8 +736,6 @@ export const zh: Record = { "accountPool.strategyHint": "仅适用于新会话;现有线程保持账号亲和性。", "accountPool.stickyLimit": "轮换前的粘性成功次数", "accountPool.stickyLimitAria": "轮换前的粘性成功次数", - "accountPool.stickyLimitInc": "提高粘性上限", - "accountPool.stickyLimitDec": "降低粘性上限", "accountPool.stickyLimitHelp": "在推进到下一个账号之前,将所选账号保留这么多次成功的新会话绑定。", "accountPool.stickyLimitInvalid": "请输入 1 到 100 之间的整数", "accountPool.strategyLoadFailed": "无法加载轮换策略。", @@ -866,21 +850,7 @@ export const zh: Record = { "api.generate": "生成", "api.generating": "创建中…", "api.activeKeys": "活跃密钥({count})", - "api.activeKeysLoading": "有效密钥", "api.noKeys": "还没有 API 密钥。请在上方生成一个。", - "api.workspace.overview": "概览", - "api.workspace.details": "API 密钥详情", - "api.workspace.keyDetails": "密钥详情", - "api.workspace.keyPrefix": "密钥前缀", - "api.workspace.deleteKey": "删除密钥", - "api.workspace.deleteConfirm": "确定要删除此密钥吗?此操作无法撤销。", - "api.workspace.noKeysHint": "还没有 API 密钥。生成一个以开始使用。", - "api.workspace.selectKeyHint": "从列表中选择一个密钥以查看其详情。", - "api.workspace.usageExamples": "用法示例", - "api.copyUrlHint": "点击复制 URL", - "api.urlCopied": "已复制 URL", - "api.copyExampleHint": "点击复制示例", - "api.exampleCopied": "已复制示例", "api.colName": "名称", "api.colKey": "密钥", "api.colCreated": "创建时间", @@ -973,27 +943,16 @@ export const zh: Record = { "nav.storage": "存储", "storage.title": "存储", - "storage.subtitle": "查看 CODEX_HOME 占用。清理不会动到活动会话。", + "storage.subtitle": "CODEX_HOME 磁盘占用诊断。下方归档清理可将最旧归档会话隔离或永久删除——活动会话保持只读。", "storage.loading": "正在扫描存储…", "storage.empty": "CODEX_HOME 为空或不存在——没有可显示的内容。", "storage.error": "存储扫描失败。请检查 CODEX_HOME 是否指向有效目录。", "storage.refresh": "重新扫描", - "storage.rescanned": "扫描完成。", "storage.card.total": "总大小", "storage.card.files": "文件数", "storage.card.home": "CODEX_HOME", - "storage.snapshot.lastScan": "上次扫描", - "storage.snapshot.scanning": "扫描中…", - "storage.snapshot.unavailable": "尚无扫描。", - "storage.cleanupCard.title": "释放空间", - "storage.cleanupCard.tabs": "清理选项", - "storage.cleanupCard.tab.policy": "策略", - "storage.cleanupCard.tab.quarantine": "隔离区", - "storage.cleanup.noArchives": "没有可清理的归档会话。", "storage.section.buckets": "分类", "storage.section.largest": "最大文件", - "storage.workspace.overview": "概览", - "storage.workspace.selectBucket": "从列表中选择一个存储桶以查看明细。", "storage.col.bucket": "分类", "storage.col.size": "大小", "storage.col.files": "文件", @@ -1011,7 +970,7 @@ export const zh: Record = { "storage.cleanup.title": "归档清理", "storage.cleanup.help": "按百分比移除最旧的归档会话。不会触碰活动会话。默认隔离——文件移至 CODEX_HOME/.trash。", "storage.cleanup.slider": "最旧归档百分比", - "storage.cleanup.percent": "{percent}%", + "storage.cleanup.percent": "最旧 {percent}%", "storage.cleanup.preset": "{percent}", "storage.cleanup.preview": "预览", "storage.cleanup.confirmTitle": "确认归档清理", @@ -1019,7 +978,7 @@ export const zh: Record = { "storage.cleanup.moreFiles": "…以及另外 {n} 个", "storage.cleanup.permanent": "永久删除(跳过隔离)", "storage.cleanup.permanentWarn": "永久删除无法撤销。", - "storage.cleanup.quarantineNote": "文件会移到 CODEX_HOME 下的 .trash。可在「隔离区」标签页恢复。", + "storage.cleanup.quarantineNote": "文件会移到 CODEX_HOME 下的 .trash。可在下方「隔离区」恢复。", "storage.cleanup.cancel": "取消", "storage.cleanup.confirmQuarantine": "隔离", "storage.cleanup.confirmPermanent": "永久删除", @@ -1078,17 +1037,10 @@ export const zh: Record = { "storage.policy.invalid": "策略值无效。", "storage.policy.enabled": "启用自动清理", "storage.policy.enabledHint": "默认关闭。启用后仅按所选计划(或立即运行)执行。", - "storage.policy.threshold": "当归档大小超过(GiB)", - "storage.policy.trigger": "触发条件", + "storage.policy.threshold": "归档大小超过时触发(GiB)", "storage.policy.target": "清理目标", - "storage.policy.targetPercent": "删除最旧归档(%)", + "storage.policy.targetPercent": "删除最旧归档百分比", "storage.policy.targetReduce": "将归档缩小至(GiB)", - "storage.policy.thresholdInc": "提高阈值", - "storage.policy.thresholdDec": "降低阈值", - "storage.policy.percentInc": "提高百分比", - "storage.policy.percentDec": "降低百分比", - "storage.policy.reduceInc": "提高缩减目标", - "storage.policy.reduceDec": "降低缩减目标", "storage.policy.schedule": "计划", "storage.policy.schedule.manual": "仅手动", "storage.policy.schedule.startup": "代理启动时", @@ -1224,7 +1176,9 @@ export const zh: Record = { "pws.stats.quotaUpdated": "配额更新", "pws.stats.quotaTracked": "在用量标签查看限额。", "pws.stats.source": "来源", - "pws.usageLast30d": "用量(最近 30 天)", + "pws.usageLast30d": "代理用量(最近 30 天)", + "pws.proxyUsage": "代理用量", + "pws.providerLimits": "提供方限额", "pws.estimatedCost": "预估费用", "pws.costDisclaimer": "基于 API 公示价格的预估值,非实际计费金额。", "pws.modelBreakdown": "模型用量明细", @@ -1238,7 +1192,7 @@ export const zh: Record = { "pws.metricRequests": "请求", "pws.metricTokens": "令牌", "pws.usageUnavailable": "尚无用量记录。", - "pws.rateLimits": "速率限制", + "pws.rateLimits": "提供方限额", "pws.quotaUnavailable": "此提供商暂无配额数据。", "pws.accountQuotaUnavailable": "速率限制数据暂时不可用;若有上次已知值则继续显示。", "pws.selected": "已选择", @@ -1321,7 +1275,6 @@ export const zh: Record = { "pws.dashboard.checkedAgo": "{time} 前检查", "pws.dashboard.noQuota": "无配额数据", "pws.dashboard.noUsage": "暂无使用数据", - "pws.dashboard.noRateLimits": "暂无速率限制数据", "pws.allProviders": "提供商概览", "pws.enabledLabel": "已启用", "pws.testConnection": "测试连接", @@ -1377,7 +1330,6 @@ export const zh: Record = { "codexAuth.addIdPlaceholder": "codex-work, codex-alt, team…", "codexAuth.resetCreditsAria": "{count} 个重置额度", "claude.pageTitle": "Claude Code", - "claude.workspace.settings": "设置", // Combos workspace "cws.loading": "正在加载组合…", @@ -1563,4 +1515,80 @@ export const zh: Record = { "claudeDesktop.effort.supported": "effort", "claudeDesktop.effort.displayOnly": "effort (仅显示)", + + // fleet shell / systeem / verkeer (ChefGroep) + "dash.injectionActive": "Active", + "dash.suppliers": "Providers", + "dash.refresh": "Refresh", + "dash.refreshAll": "Refresh all", + "dash.retry": "Retry", + "dash.fresh": "Fresh · {time}", + "dash.noContactSince": "No contact since {time}", + "dash.modelsCount": "{count} models", + "dash.showModels": "Show models", + "dash.hideModels": "Hide models", + "dash.stamp.ready": "Ready", + "dash.stamp.busy": "Busy", + "dash.stamp.idle": "Idle", + "dash.stamp.error": "Error", + "dash.settingsSection": "Settings", + "dash.emptyKitchen": "No models yet.", + "dash.providerDisabled": "Disabled", + "prov.quotaRefresh": "Refresh usage", + "prov.quotaRefreshing": "Refreshing…", + "prov.quotaRefreshFailed": "Refresh failed — showing last known data", + "prov.quotaRefreshAria": "Refresh usage for {name}", + "shell.navTraffic": "Traffic", + "shell.navSystem": "System", + "shell.navAria": "Main navigation", + "shell.offlineBanner": "Proxy offline. Codex and Cursor cannot route.", + "mod.subtitle": "Catalog, routing, and sub-agent delegation.", + "mod.tablistAria": "Models sections", + "sys.manage": "Manage", + "sys.subtitle": "The proxy itself: status, version, storage, and management.", + "sys.proxy": "Proxy", + "sys.uptime": "{uptime} uptime", + "sys.updateTo": "Update to {version}", + "sys.upToDate": "You are on the latest version.", + "sys.updateCheckFailed": "update check failed", + "sys.updateStartFailed": "could not start update", + "sys.updateRunning": "Update running. The proxy will restart itself shortly.", + "sys.catalog": "Model catalog", + "sys.catalogDesc": "Sync pulls the latest models from each provider.", + "sys.syncFailed": "sync failed", + "sys.storage": "Storage", + "sys.storageValue": "{size} · {count} files", + "sys.apiEndpoint": "API endpoint", + "sys.copy": "Copy", + "sys.copied": "Copied", + "sys.codexAuthDesc": "ChatGPT account pool for the openai provider", + "sys.claudeCodeDesc": "Claude integration and agent injection", + "sys.dangerZone": "Danger zone", + "sys.stopDesc": "Stops the proxy completely. Codex and Cursor lose their connection.", + "sys.stopConfirmTitle": "Stop the proxy?", + "sys.stopConfirmDesc": "Codex and Cursor will lose their connection.", + "sys.keepRunning": "Keep running", + "vk.subtitle": "What flows through the proxy: every request is a receipt.", + "vk.statsAria": "Traffic figures", + "vk.tokens30d": "tokens (30d)", + "vk.requestsToday": "requests today", + "vk.requests30d": "requests (30d)", + "vk.filterAria": "Filter by provider", + "vk.all": "All", + "vk.pause": "Pause", + "vk.follow": "Follow live", + "vk.loadFailed": "Could not load traffic. Last known receipts stay visible.", + "vk.empty": "No traffic yet today.", + "vk.showAnalysis": "Full analysis", + "vk.hideAnalysis": "Hide analysis", + "vk.stampDone": "Done", + "vk.stampError": "Error", + "vk.stampBusy": "Busy", + "vk.detailError": "error: {code}", + "vk.detailInOut": "in {in} · out {out}", + "vk.rowTokens": "{n} tok", + "vk.rowDuration": "{s}s", + "vk.detailStatus": "status {status}", + "vk.detailUpstream": "upstream: {error}", + "vk.detailId": "id {id}", }; diff --git a/gui/src/pages/Usage.tsx b/gui/src/pages/Usage.tsx index 682449838..77ce5bfd5 100644 --- a/gui/src/pages/Usage.tsx +++ b/gui/src/pages/Usage.tsx @@ -1,8 +1,7 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useI18n, type TFn, type Locale } from "../i18n/shared"; 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"; @@ -28,6 +27,11 @@ interface UsageSummaryTotals { pricedRequests?: number; unpricedRequests?: number; unmeteredRequests?: number; + p95LatencyMs?: number; + p95TtftMs?: number; + cacheReadRatio?: number; + ratio429?: number; + ratio502?: number; } interface UsageDay { @@ -267,9 +271,11 @@ function UsageSummaryCards({ locale: Locale; t: TFn; }) { + const titleId = "usage-proxy-title"; return ( - <> -
+
+

{t("usage.section.proxyUsage")}

+
{t("usage.card.requests")}
{summary.requests}
{t("usage.card.measured")}
{summary.measuredRequests}
{t("usage.card.totalTokens")}
{formatTokens(summary.totalTokens, locale)}
@@ -299,7 +305,45 @@ function UsageSummaryCards({ )}
)} - +
+ ); +} + +function UsageQualityPanel({ + summary, + t, +}: { + summary: UsageSummaryTotals; + t: TFn; +}) { + const titleId = "usage-quality-title"; + return ( +
+

{t("usage.section.quality")}

+
+
+
{t("usage.quality.p95Latency")}
+
{summary.p95LatencyMs !== undefined ? `${summary.p95LatencyMs}ms` : "\u2014"}
+
+
+
{t("usage.quality.p95Ttft")}
+
{summary.p95TtftMs !== undefined ? `${summary.p95TtftMs}ms` : "\u2014"}
+
+
+
{t("usage.quality.cacheReadRatio")}
+
{formatPct(summary.cacheReadRatio ?? 0)}
+
+
+
{t("usage.quality.ratio429")}
+
{formatPct(summary.ratio429 ?? 0)}
+
+
+
{t("usage.quality.ratio502")}
+
{formatPct(summary.ratio502 ?? 0)}
+
+
+

{t("usage.quality.note")}

+
); } @@ -320,10 +364,7 @@ function WeekDayBars({ weekBars, locale, t }: { weekBars: UsageDay[]; locale: Lo onMouseLeave={() => setHoverDay(current => (current === day.date ? null : current))} >
-
+
{day.models.map(model => (
-

{title}

- {children} - - ); -} - function UsageModelsTable({ models, modelQuery, onModelQuery, locale, t, - workspace = false, }: { models: UsageModel[]; modelQuery: string; onModelQuery: (query: string) => void; locale: Locale; t: TFn; - workspace?: boolean; }) { const searchLabel = t("usage.search.models"); const sectionLabel = t("usage.section.models"); @@ -516,15 +538,6 @@ function UsageModelsTable({
); - if (workspace) { - return ( - -
{searchInput}
- {table} -
- ); - } - return (
@@ -540,12 +553,10 @@ function UsageProvidersTable({ providers, locale, t, - workspace = false, }: { providers: UsageProvider[]; locale: Locale; t: TFn; - workspace?: boolean; }) { const sectionLabel = t("usage.section.providers"); const titleId = "usage-providers-title"; @@ -576,14 +587,6 @@ function UsageProvidersTable({
); - if (workspace) { - return ( - - {table} - - ); - } - return (

{sectionLabel}

@@ -595,11 +598,9 @@ function UsageProvidersTable({ function UsageCoveragePanel({ summary, t, - workspace = false, }: { summary: UsageSummaryTotals; t: TFn; - workspace?: boolean; }) { const sectionLabel = t("usage.section.coverage"); const titleId = "usage-coverage-title"; @@ -616,14 +617,6 @@ function UsageCoveragePanel({ ); - if (workspace) { - return ( - - {body} - - ); - } - return (

{sectionLabel}

@@ -632,170 +625,29 @@ function UsageCoveragePanel({ ); } -/** - * Workspace layout for Usage: left rail picks one report section so Overview / - * Models / Providers / Coverage do not stack into a long scroll. - */ -function UsageWorkspaceBody({ - data, - loading, - heatmap, - weekBars, - activeDays, - filteredModels, - modelQuery, - onModelQuery, - sortedProviders, - range, - selectedSection, - onSelectSection, - locale, - t, -}: { - data: UsageResponse | null; - loading: boolean; - heatmap: ReturnType; - weekBars: UsageDay[]; - activeDays: number; - filteredModels: UsageModel[]; - modelQuery: string; - onModelQuery: (query: string) => void; - sortedProviders: UsageProvider[]; - range: Range; - selectedSection: string; - onSelectSection: (id: string) => void; - locale: Locale; - t: TFn; -}) { - const empty = !!data && data.summary.requests === 0; - const sections = [ - { - id: "overview", - label: t("usage.section.overview"), - meta: data ? `${data.summary.requests}` : "—", - body: data ? ( - <> - - - - ) : null, - }, - { - id: "models", - label: t("usage.section.models"), - meta: data ? `${data.models.length}` : "—", - body: data - ? - : null, - }, - { - id: "providers", - label: t("usage.section.providers"), - meta: data ? `${data.providers.length}` : "—", - body: data - ? - : null, - }, - { - id: "coverage", - label: t("usage.section.coverage"), - meta: data ? formatPct(data.summary.coverageRatio) : "—", - body: data ? : null, - }, - ]; - const selected = sections.find(s => s.id === selectedSection) ?? sections[0]; - const mainBody = loading && !data - ? - : empty - ? - : selected.body; - - return ( -
-
- -
-
{mainBody}
-
-
-
- ); -} - -/** 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 `ocx.usage.v1:${apiBase}:${range}:${surface}`; -} - -function readHeldUsage(apiBase: string, range: Range, surface: UsageSurface): UsageResponse | null { - const key = usageCacheKey(apiBase, range, surface); - return usageMemoryCache.get(key) ?? readSessionListCache(key); -} - -function writeHeldUsage(apiBase: string, range: Range, surface: UsageSurface, value: UsageResponse) { - const key = usageCacheKey(apiBase, range, surface); - usageMemoryCache.set(key, value); - writeSessionListCache(key, value); -} - export default function Usage({ apiBase }: { apiBase: string }) { const { t, locale } = useI18n(); const [range, setRange] = useState("30d"); const [surface, setSurface] = useState("all"); - const [data, setData] = useState(() => readHeldUsage(apiBase, "30d", "all")); - const [loading, setLoading] = useState(() => !readHeldUsage(apiBase, "30d", "all")); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [modelQuery, setModelQuery] = useState(""); - const [selectedSection, setSelectedSection] = useState("overview"); const loadGenerationRef = useRef(0); const fetchUsage = useCallback(async (nextRange: Range, nextSurface: UsageSurface, signal: AbortSignal) => { const generation = ++loadGenerationRef.current; - const held = readHeldUsage(apiBase, nextRange, nextSurface); - if (held) { - // Instant tab switch: show held data and revalidate quietly. - setData(held); - setLoading(false); - setError(null); - } else { - setLoading(true); - setError(null); - // Drop mismatched payload so we never paint the wrong surface/range. - setData(prev => (prev && prev.range === nextRange && prev.surface === nextSurface ? prev : null)); - } + setLoading(true); + setError(null); try { const res = await fetch(`${apiBase}/api/usage?range=${nextRange}&surface=${nextSurface}`, { signal }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim()); const json = await res.json() as UsageResponse; if (signal.aborted || generation !== loadGenerationRef.current) return; - writeHeldUsage(apiBase, nextRange, nextSurface, json); setData(json); - setError(null); } catch (cause) { // A stale request (range/apiBase changed, or unmount) must not overwrite newer state. if (signal.aborted || generation !== loadGenerationRef.current) return; - // Keep held data visible when a background revalidate fails. - if (held) return; const detail = cause instanceof Error ? cause.message : ""; setError(detail ? `${t("usage.loadError")} ${detail}` : t("usage.loadError")); } finally { @@ -859,24 +711,20 @@ export default function Usage({ apiBase }: { apiBase: string }) { {t("common.retry")} - ) : ( - - )} + ) : loading && !data ? ( + + ) : data?.summary.requests === 0 ? ( + + ) : data ? ( + <> + + + + + + + + ) : null} ); } diff --git a/gui/tests/usage-layout.test.ts b/gui/tests/usage-layout.test.ts index 7029d0ac1..bc4d27d78 100644 --- a/gui/tests/usage-layout.test.ts +++ b/gui/tests/usage-layout.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; -test("Usage renders denser workspace rail layout (no layout toggle)", async () => { +test("Usage renders the single stacked layout (no layout toggle, no workspace rail)", async () => { const page = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text(); const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); const css = await Bun.file(new URL("../src/styles.css", import.meta.url)).text(); @@ -8,21 +8,22 @@ test("Usage renders denser workspace rail layout (no layout toggle)", async () = expect(page).not.toContain("viewMode"); expect(page).not.toContain("readViewMode"); expect(page).not.toContain("ocx-usage-view"); - expect(page).toContain("UsageWorkspaceBody"); - expect(page).toContain("UsageWorkspaceSection"); - expect(page).toContain("usage-workspace-"); - expect(page).toContain("usw-"); - expect(page).toContain("selectedSection"); + expect(page).not.toContain("UsageWorkspaceBody"); + expect(page).not.toContain("UsageWorkspaceSection"); + expect(page).not.toContain("usage-workspace-"); + expect(page).not.toContain("usw-"); + expect(page).not.toContain("selectedSection"); expect(app).toContain(""); - expect(css).toContain("styles-usage-workspace.css"); + expect(css).not.toContain("styles-usage-workspace.css"); }); -test("Usage workspace sections mount report panels in order", async () => { +test("Usage stacked layout mounts every report panel in order", async () => { const src = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text(); const order = [ " { cursor = at; } - expect(src).toContain("UsageWorkspaceBody"); - expect(src).toContain("usw-section"); + // Classic panels keep their section landmarks and headings. + expect(src).toContain('className="panel"'); + expect(src).toContain('aria-labelledby={titleId}'); + expect(src).toContain('t("usage.section.proxyUsage")'); + expect(src).toContain('t("usage.section.quality")'); }); -test("Usage loading and empty states guard the workspace body", async () => { +test("Usage loading and empty states guard the stacked body", async () => { const src = await Bun.file(new URL("../src/pages/Usage.tsx", import.meta.url)).text(); expect(src).toContain("loading && !data"); expect(src).toContain('t("usage.loading")'); expect(src).toContain('t("usage.empty")'); - expect(src).toContain("data.summary.requests === 0"); + expect(src).toContain("data?.summary.requests === 0"); }); -test("usage workspace i18n keys exist in every locale", async () => { +test("retired usage workspace i18n keys stay removed from every locale", async () => { const locales = ["en", "de", "ja", "ko", "ru", "zh"] as const; for (const locale of locales) { const dict = await Bun.file(new URL(`../src/i18n/${locale}.ts`, import.meta.url)).text(); - expect(dict).toContain('"usage.workspace.sections":'); - expect(dict).toContain('"usage.workspace.report":'); + expect(dict).not.toContain('"usage.workspace.sections":'); + expect(dict).not.toContain('"usage.workspace.report":'); + expect(dict).not.toContain('"usage.workspace.mainAria":'); } }); diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 9a5831df7..4d723bdb3 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -267,8 +267,10 @@ async function fetchAnthropicQuota(provider: string): Promise { const key = accountCacheKey(provider, accountId); const cached = accountQuotaCache.get(key); + // Non-forced reads reuse the TTL window. Forced refresh still joins inflight + // probes (below) and writes through the same cache so GUI re-renders never + // stampede upstream after a refresh. if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) return cached; const joinable = accountQuotaInflight.get(key); if (joinable) return joinable; @@ -377,7 +383,22 @@ async function fetchAccountQuota( const probe = (async (): Promise => { try { const token = await getTokenForAccountQuotaProbe(provider, accountId); - const quota = await fetchAnthropicUsageQuota(token); + let quota: ProviderQuota | null = null; + if (provider === "anthropic") { + quota = await fetchAnthropicUsageQuota(token); + } else if (provider === "google-antigravity") { + const projectId = getAccountCredential(provider, accountId)?.projectId?.trim(); + if (!projectId) { + const entry: AccountQuotaCacheEntry = { + ts: Date.now(), + quota: cached?.quota ?? null, + unavailable: true, + }; + accountQuotaCache.set(key, entry); + return entry; + } + quota = await fetchAntigravityUsageQuota(token, projectId, baseUrl); + } if (!quota) { // Preserve last-good bars and mark unavailable; advance TTL so failures // negative-cache instead of re-probing on every GUI poll. @@ -415,12 +436,13 @@ async function fetchAccountQuota( export async function fetchProviderAccountQuotas( provider: string, forceRefresh = false, + options?: { baseUrl?: string }, ): Promise { if (!supportsPerAccountQuota(provider)) return []; const set = getAccountSet(provider); if (!set) return []; return await Promise.all(set.accounts.map(async account => { - const entry = await fetchAccountQuota(provider, account.id, forceRefresh); + const entry = await fetchAccountQuota(provider, account.id, forceRefresh, options?.baseUrl); return { accountId: account.id, quota: entry.quota, @@ -780,32 +802,7 @@ function antigravityUsedPercent(quotaInfo: Record): number | un return normalizePercent(100 - remaining); } -async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise { - const credential = getCredential("google-antigravity"); - if (!credential?.projectId) return null; - let accessToken: string; - try { - accessToken = await getValidAccessToken("google-antigravity"); - } catch { - return null; - } - const baseUrl = (config.baseUrl || "https://daily-cloudcode-pa.googleapis.com").replace(/\/+$/, ""); - const response = await fetch(`${baseUrl}/v1internal:fetchAvailableModels`, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "User-Agent": antigravityUserAgent(), - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ project: credential.projectId }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - if (!response.ok) return null; - const body = asRecord(await response.json().catch(() => null)); - const models = asRecord(body?.models); - if (!models) return null; - +function parseAntigravityModelsQuota(models: Record): ProviderQuota | null { const windows = new Map(); for (const [modelId, rawModelInfo] of Object.entries(models)) { const modelInfo = asRecord(rawModelInfo); @@ -828,10 +825,76 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig return window ? [window] : []; }); if (customWindows.length === 0) return null; - return report(provider, "google-antigravity:fetchAvailableModels", { + return { customWindows, updatedAt: Date.now(), + }; +} + +const DEFAULT_ANTIGRAVITY_BASE_URL = "https://daily-cloudcode-pa.googleapis.com"; + +/** Antigravity fetchAvailableModels probe for ONE account's token + project. */ +const antigravityUsageInflight = new Map>(); + +async function fetchAntigravityUsageQuota( + accessToken: string, + projectId: string, + baseUrl?: string, +): Promise { + const joinKey = `${accessToken}\u0000${projectId}\u0000${baseUrl ?? ""}`; + const joinable = antigravityUsageInflight.get(joinKey); + if (joinable) return joinable; + + const probe = (async (): Promise => { + const root = (baseUrl || DEFAULT_ANTIGRAVITY_BASE_URL).replace(/\/+$/, ""); + const response = await fetch(`${root}/v1internal:fetchAvailableModels`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": antigravityUserAgent(), + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ project: projectId }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return null; + const body = asRecord(await response.json().catch(() => null)); + const models = asRecord(body?.models); + if (!models) return null; + return parseAntigravityModelsQuota(models); + })().finally(() => { + if (antigravityUsageInflight.get(joinKey) === probe) antigravityUsageInflight.delete(joinKey); }); + antigravityUsageInflight.set(joinKey, probe); + return probe; +} + +async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise { + // Capture the account we intend to probe before awaiting — a mid-flight active + // switch must not seed the wrong account's cache with this response. + const probedAccountId = getAccountSet("google-antigravity")?.activeAccountId; + const credential = (probedAccountId + ? getAccountCredential("google-antigravity", probedAccountId) + : null) ?? getCredential("google-antigravity"); + if (!credential?.projectId) return null; + let accessToken: string; + try { + accessToken = await getValidAccessToken("google-antigravity"); + } catch { + return null; + } + const quota = await fetchAntigravityUsageQuota(accessToken, credential.projectId, config.baseUrl); + if (!quota) return null; + // Share the active-account probe with the per-account cache so Providers-page + // loads do not double-hit Antigravity's models endpoint. + if (probedAccountId) { + const stillOwnsToken = getAccountCredential("google-antigravity", probedAccountId)?.access === accessToken; + if (stillOwnsToken) { + accountQuotaCache.set(accountCacheKey("google-antigravity", probedAccountId), { ts: Date.now(), quota }); + } + } + return report(provider, "google-antigravity:fetchAvailableModels", quota); } async function maybeFetchProviderQuota( diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index 718005fb8..28336a78d 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -223,6 +223,11 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise [row.accountId, row])); const projected = projectAccounts(); return jsonResponse({ diff --git a/src/usage/summary.ts b/src/usage/summary.ts index b2b48711b..7e60a88f6 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -3,6 +3,7 @@ import { canonicalAntigravityUsageModel } from "../providers/antigravity-models" import { usageDisplayTotalTokens } from "./totals"; import type { PersistedUsageEntry, UsageStatus } from "./log"; import { estimateComboCost, estimateRequestCost, effectiveServiceTier } from "./cost"; +import { computeLatencyStats } from "./percentiles"; export type UsageRange = "7d" | "30d" | "all"; export type UsageSurface = "all" | "codex" | "claude" | "grok"; @@ -32,6 +33,16 @@ export interface UsageSummaryTotals { unpricedRequests: number; /** Requests whose usage itself is missing/unsupported, so no cost can be computed. */ unmeteredRequests: number; + /** p95 of request durationMs in the filtered window (0 when empty). */ + p95LatencyMs: number; + /** p95 of firstOutputMs when recorded (0 when no TTFT samples). */ + p95TtftMs: number; + /** cacheReadInputTokens / inputTokens (0 when no input tokens). */ + cacheReadRatio: number; + /** Fraction of requests with HTTP 429. */ + ratio429: number; + /** Fraction of requests with HTTP 502. */ + ratio502: number; } export interface UsageDay { @@ -144,6 +155,11 @@ function blankTotals(): UsageSummaryTotals { pricedRequests: 0, unpricedRequests: 0, unmeteredRequests: 0, + p95LatencyMs: 0, + p95TtftMs: 0, + cacheReadRatio: 0, + ratio429: 0, + ratio502: 0, }; } @@ -266,6 +282,33 @@ function finalizeCoverage(totals: UsageSummaryTotals): void { totals.coverageRatio = totals.requests === 0 ? 0 : totals.measuredRequests / totals.requests; } +function finalizeQualityMetrics( + totals: UsageSummaryTotals, + entries: PersistedUsageEntry[], +): void { + const durations: number[] = []; + const ttfts: number[] = []; + let status429 = 0; + let status502 = 0; + for (const entry of entries) { + if (typeof entry.durationMs === "number" && Number.isFinite(entry.durationMs) && entry.durationMs >= 0) { + durations.push(entry.durationMs); + } + if (typeof entry.firstOutputMs === "number" && Number.isFinite(entry.firstOutputMs) && entry.firstOutputMs >= 0) { + ttfts.push(entry.firstOutputMs); + } + if (entry.status === 429) status429 += 1; + else if (entry.status === 502) status502 += 1; + } + totals.p95LatencyMs = computeLatencyStats(durations).p95; + totals.p95TtftMs = computeLatencyStats(ttfts).p95; + totals.cacheReadRatio = totals.inputTokens === 0 + ? 0 + : totals.cacheReadInputTokens / totals.inputTokens; + totals.ratio429 = totals.requests === 0 ? 0 : status429 / totals.requests; + totals.ratio502 = totals.requests === 0 ? 0 : status502 / totals.requests; +} + function addEstimatedCost( totals: UsageSummaryTotals, entry: Pick, @@ -507,6 +550,7 @@ export function summarizeUsage( addEstimatedCost(totals, entry); } finalizeCoverage(totals); + finalizeQualityMetrics(totals, filteredEntries); return { range, surface, diff --git a/tests/api-usage.test.ts b/tests/api-usage.test.ts index 4d43a1aac..cdc431a6e 100644 --- a/tests/api-usage.test.ts +++ b/tests/api-usage.test.ts @@ -100,6 +100,13 @@ describe("GET /api/usage", () => { expect(Array.isArray(body.days)).toBe(true); expect(Array.isArray(body.models)).toBe(true); expect(Array.isArray(body.providers)).toBe(true); + expect(body.summary).toMatchObject({ + p95LatencyMs: expect.any(Number), + p95TtftMs: expect.any(Number), + cacheReadRatio: expect.any(Number), + ratio429: expect.any(Number), + ratio502: expect.any(Number), + }); } finally { await server.stop(true); } diff --git a/tests/provider-account-quota.test.ts b/tests/provider-account-quota.test.ts index 16dd5dd3a..dec871110 100644 --- a/tests/provider-account-quota.test.ts +++ b/tests/provider-account-quota.test.ts @@ -199,6 +199,7 @@ describe("fetchProviderAccountQuotas", () => { test("providers without a per-account usage API are skipped", async () => { expect(supportsPerAccountQuota("anthropic")).toBe(true); + expect(supportsPerAccountQuota("google-antigravity")).toBe(true); expect(supportsPerAccountQuota("kiro")).toBe(false); let called = false; globalThis.fetch = (async () => { called = true; return new Response("{}", { status: 200 }); }) as typeof fetch; @@ -374,3 +375,156 @@ describe("fetchProviderAccountQuotas", () => { expect(calls).toBe(1); }); }); + +describe("fetchProviderAccountQuotas (google-antigravity)", () => { + const AGY_FIRST = { accountId: "agy-first", email: "first@agy.example", projectId: "proj-first" }; + const AGY_SECOND = { accountId: "agy-second", email: "second@agy.example", projectId: "proj-second" }; + const AGY_THIRD = { accountId: "agy-third", email: "third@agy.example", projectId: "proj-third" }; + + function antigravityModelsBody(gemRemaining: number, claRemaining: number): string { + return JSON.stringify({ + models: { + "gemini-3.6-flash": { + displayName: "Gemini Flash", + quotaInfo: { remainingFraction: gemRemaining, resetTime: "2026-07-05T14:00:00Z" }, + }, + "claude-sonnet-4.6": { + displayName: "Claude Sonnet", + quotaInfo: { remainingFraction: claRemaining, resetTime: "2026-07-05T15:00:00Z" }, + }, + }, + }); + } + + async function seedThreeAntigravityAccounts(): Promise { + const expires = Date.now() + 60 * 60_000; + await saveCredential("google-antigravity", { + access: "token-agy-first", refresh: "refresh-agy-first", expires, ...AGY_FIRST, + }); + await saveCredential("google-antigravity", { + access: "token-agy-second", refresh: "refresh-agy-second", expires, ...AGY_SECOND, + }); + await saveCredential("google-antigravity", { + access: "token-agy-third", refresh: "refresh-agy-third", expires, ...AGY_THIRD, + }); + } + + test("probes each account with its own bearer token and project id", async () => { + await seedThreeAntigravityAccounts(); + const seen: { auth: string; project: string }[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toContain("/v1internal:fetchAvailableModels"); + const auth = new Headers(init?.headers).get("authorization") ?? ""; + const body = JSON.parse(String(init?.body ?? "{}")) as { project?: string }; + seen.push({ auth, project: body.project ?? "" }); + if (auth.endsWith("token-agy-first")) return new Response(antigravityModelsBody(0.64, 0.21), { status: 200 }); + if (auth.endsWith("token-agy-second")) return new Response(antigravityModelsBody(0.50, 0.10), { status: 200 }); + return new Response(antigravityModelsBody(0.90, 0.80), { status: 200 }); + }) as typeof fetch; + + const rows = await fetchProviderAccountQuotas("google-antigravity"); + expect(rows).toHaveLength(3); + const byProject = Object.fromEntries(seen.map(s => [s.project, s.auth])); + expect(byProject["proj-first"]).toBe("Bearer token-agy-first"); + expect(byProject["proj-second"]).toBe("Bearer token-agy-second"); + expect(byProject["proj-third"]).toBe("Bearer token-agy-third"); + + const values = rows + .map(row => row.quota?.customWindows?.map(w => `${w.label}:${w.percent}`).join(",")) + .sort(); + expect(values).toEqual([ + "Gem:10,Cla:20", + "Gem:36,Cla:79", + "Gem:50,Cla:90", + ]); + }); + + test("cached antigravity rows are reused; forced refresh re-probes through the cache layer", async () => { + await seedThreeAntigravityAccounts(); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response(antigravityModelsBody(0.5, 0.5), { status: 200 }); + }) as typeof fetch; + + await fetchProviderAccountQuotas("google-antigravity"); + expect(calls).toBe(3); + await fetchProviderAccountQuotas("google-antigravity"); + expect(calls).toBe(3); + + await fetchProviderAccountQuotas("google-antigravity", true); + expect(calls).toBe(6); + + // After a forced refresh the result is cached again — a GUI re-render must not + // stampede upstream. + await fetchProviderAccountQuotas("google-antigravity"); + expect(calls).toBe(6); + }); + + test("missing projectId marks the account unavailable without blocking siblings", async () => { + const expires = Date.now() + 60 * 60_000; + await saveCredential("google-antigravity", { + access: "token-agy-first", refresh: "refresh-agy-first", expires, ...AGY_FIRST, + }); + await saveCredential("google-antigravity", { + access: "token-agy-noproj", refresh: "refresh-agy-noproj", expires, + accountId: "agy-noproj", email: "noproj@agy.example", + }); + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response(antigravityModelsBody(0.5, 0.5), { status: 200 }); + }) as typeof fetch; + + const rows = await fetchProviderAccountQuotas("google-antigravity"); + const byEmail = Object.fromEntries( + (await import("../src/oauth/store")).getAccountSet("google-antigravity")!.accounts.map(a => [a.id, a.credential.email]), + ); + const withProject = rows.find(r => byEmail[r.accountId] === "first@agy.example"); + const without = rows.find(r => byEmail[r.accountId] === "noproj@agy.example"); + expect(withProject?.quota?.customWindows?.[0]?.percent).toBe(50); + expect(without?.unavailable).toBe(true); + expect(without?.quota).toBeNull(); + expect(calls).toBe(1); + }); + + test("provider-report probe seeds the active antigravity account cache", async () => { + await seedThreeAntigravityAccounts(); + const { getAccountSet, setActiveAccount } = await import("../src/oauth/store"); + const set = getAccountSet("google-antigravity"); + const first = set?.accounts.find(a => a.credential.email === "first@agy.example"); + expect(first).toBeTruthy(); + await setActiveAccount("google-antigravity", first!.id); + let calls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + calls += 1; + const auth = new Headers(init?.headers).get("authorization") ?? ""; + if (auth.endsWith("token-agy-first")) return new Response(antigravityModelsBody(0.64, 0.21), { status: 200 }); + if (auth.endsWith("token-agy-second")) return new Response(antigravityModelsBody(0.50, 0.10), { status: 200 }); + return new Response(antigravityModelsBody(0.90, 0.80), { status: 200 }); + }) as typeof fetch; + + const config: OcxConfig = { + port: 1455, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + authMode: "oauth", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + }, + }, + }; + await fetchProviderQuotaReports(config, true); + expect(calls).toBe(1); + + const rows = await fetchProviderAccountQuotas("google-antigravity"); + // Active reused; only the two siblings hit upstream again. + expect(calls).toBe(3); + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + expect(byId[first!.id]?.quota?.customWindows).toEqual([ + { label: "Gem", percent: 36, resetAt: Date.parse("2026-07-05T14:00:00Z") }, + { label: "Cla", percent: 79, resetAt: Date.parse("2026-07-05T15:00:00Z") }, + ]); + }); +}); diff --git a/tests/usage-summary.test.ts b/tests/usage-summary.test.ts index 53e6ee64f..43095ac29 100644 --- a/tests/usage-summary.test.ts +++ b/tests/usage-summary.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { parseRange, parseUsageSurface, summarizeUsage } from "../src/usage/summary"; +import { computeLatencyStats } from "../src/usage/percentiles"; import type { PersistedUsageEntry } from "../src/usage/log"; const FIXED_NOW = Date.UTC(2026, 5, 28, 12, 0, 0); @@ -16,6 +17,7 @@ function entry(overrides: Partial & { ts: number }): Persis usageStatus: rest.usageStatus ?? "unreported", ...(rest.surface === "claude" ? { surface: rest.surface } : {}), ...(rest.resolvedModel !== undefined ? { resolvedModel: rest.resolvedModel } : {}), + ...(rest.firstOutputMs !== undefined ? { firstOutputMs: rest.firstOutputMs } : {}), ...(rest.usage ? { usage: rest.usage } : {}), ...(rest.totalTokens !== undefined ? { totalTokens: rest.totalTokens } : {}), ...(rest.attempts ? { attempts: rest.attempts } : {}), @@ -671,4 +673,71 @@ describe("summarizeUsage", () => { expect(sum.models[0]?.resolvedModel).toBeUndefined(); }); + test("aggregates p95 latency, TTFT, cache-read ratio, and 429/502 ratios", () => { + const entries: PersistedUsageEntry[] = [ + entry({ + ts: FIXED_NOW - 1000, + requestId: "ok-1", + status: 200, + durationMs: 100, + firstOutputMs: 10, + usageStatus: "reported", + usage: { inputTokens: 100, outputTokens: 10, cacheReadInputTokens: 40 }, + }), + entry({ + ts: FIXED_NOW - 2000, + requestId: "ok-2", + status: 200, + durationMs: 200, + firstOutputMs: 20, + usageStatus: "reported", + usage: { inputTokens: 100, outputTokens: 10, cacheReadInputTokens: 60 }, + }), + entry({ + ts: FIXED_NOW - 3000, + requestId: "rate", + status: 429, + durationMs: 50, + usageStatus: "unreported", + }), + entry({ + ts: FIXED_NOW - 4000, + requestId: "bad-gateway", + status: 502, + durationMs: 300, + usageStatus: "unreported", + }), + entry({ + ts: FIXED_NOW - 5000, + requestId: "ok-3", + status: 200, + durationMs: 400, + firstOutputMs: 40, + usageStatus: "reported", + usage: { inputTokens: 100, outputTokens: 5 }, + }), + ]; + const sum = summarizeUsage(entries, "30d", FIXED_NOW); + expect(sum.summary.requests).toBe(5); + // durations 50,100,200,300,400 → p95 via shared helper + expect(sum.summary.p95LatencyMs).toBe(computeLatencyStats([50, 100, 200, 300, 400]).p95); + expect(sum.summary.p95TtftMs).toBe(computeLatencyStats([10, 20, 40]).p95); + // cache reads 40+60+0 over inputs 100+100+100 + expect(sum.summary.cacheReadRatio).toBeCloseTo(100 / 300, 9); + expect(sum.summary.ratio429).toBeCloseTo(1 / 5, 9); + expect(sum.summary.ratio502).toBeCloseTo(1 / 5, 9); + }); + + test("quality metrics are zeroed for an empty window", () => { + const sum = summarizeUsage([], "30d", FIXED_NOW); + expect(sum.summary).toMatchObject({ + p95LatencyMs: 0, + p95TtftMs: 0, + cacheReadRatio: 0, + ratio429: 0, + ratio502: 0, + }); + }); + }); + From 3f8e00fb4407e6677b855b4d901fb484a10d022f Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 13:55:28 +0000 Subject: [PATCH 12/31] fix(usage): roll budget windows on read and record; require HTTPS for remote OmniRoute Co-authored-by: Codesmith --- docs/providers/omniroute.md | 89 ++++++++++++ src/usage/budgets.ts | 277 ++++++++++++++++++++++++++++++++++++ tests/usage-budgets.test.ts | 85 +++++++++++ 3 files changed, 451 insertions(+) create mode 100644 docs/providers/omniroute.md create mode 100644 src/usage/budgets.ts create mode 100644 tests/usage-budgets.test.ts diff --git a/docs/providers/omniroute.md b/docs/providers/omniroute.md new file mode 100644 index 000000000..44da7a116 --- /dev/null +++ b/docs/providers/omniroute.md @@ -0,0 +1,89 @@ +# OmniRoute + +[OmniRoute](https://github.com/diegosouzapw/OmniRoute) is an open-source, OpenAI-compatible +gateway that aggregates 250+ providers (90+ free) behind a single `/v1` endpoint. Adding it to +opencodex unlocks OmniRoute's free models (Claude, GPT, Gemini, GLM, Kimi, DeepSeek and more) +through one bearer key, with auto-fallback across upstream providers. + +OmniRoute speaks the OpenAI Chat Completions wire format, so opencodex reuses the built-in +`openai-chat` adapter. There is no separate adapter to install. + +## 1. Get an OmniRoute key + +1. Sign in at . +2. Open the dashboard and create an API key. + +The hosted cloud API lives at `https://api.omniroute.online/v1`. + +## 2. Configure opencodex + +OmniRoute is a built-in preset. In the dashboard open **Providers → Add provider → OmniRoute**, +paste your key, and pick a model. The key is sent as `Authorization: Bearer `. + +To configure it by hand in `~/.opencodex/config.json`: + +```jsonc +{ + "providers": { + "omniroute": { + "adapter": "openai-chat", + "baseUrl": "https://api.omniroute.online/v1", + "apiKey": "${OCX_OMNIROUTE_KEY}", + "defaultModel": "claude-sonnet-4-5-thinking" + } + } +} +``` + +The `apiKey` field accepts either a literal key or an `${ENV_VAR}` reference, so export the key +once and reference it as `${OCX_OMNIROUTE_KEY}`: + +```bash +export OCX_OMNIROUTE_KEY="your-omniroute-key" +``` + +## 3. Pick a model + +OmniRoute exposes a large, frequently-changing catalog. opencodex ships a small offline seed +mirroring OmniRoute's own `@omniroute/opencode-provider` defaults, plus the `auto` virtual combo +router: + +| Model id | Notes | +| --- | --- | +| `auto` | OmniRoute virtual combo router (picks a healthy free upstream automatically) | +| `cc/claude-opus-4-8` · `cc/claude-opus-4-7` · `cc/claude-sonnet-4-6` | Claude Code passthrough | +| `cc/claude-haiku-4-5-20251001` | Claude Code passthrough (fast, non-reasoning) | +| `claude-opus-4-5-thinking` · `claude-sonnet-4-5-thinking` | Claude with extended thinking | +| `gemini-3.1-pro-high` · `gemini-3-flash` | Gemini | + +The live `GET /v1/models` endpoint is the source of truth. To use any other OmniRoute model id +(e.g. a DeepSeek, GLM or Kimi variant), type it into the model field or add it to the provider's +`models` list in config; opencodex forwards unknown ids to OmniRoute verbatim. + +## 4. Self-host on your fleet (optional) + +OmniRoute ships as a Docker image: `diegosouzapw/omniroute` (default port `20128`). Run it on a +fleet server and point opencodex at it instead of the cloud. + +```bash +docker run -d --name omniroute -p 20128:20128 diegosouzapw/omniroute +``` + +Then set the OmniRoute provider's **base URL** to your instance, e.g. +`https://omniroute.internal.example/v1`. OmniRoute is registered with `allowBaseUrlOverride`, so +the dashboard's Add-provider / Edit-provider form exposes a custom base URL field for this. Export +the target as `OCX_OMNIROUTE_BASE_URL` if you template your config from the environment: + +```bash +export OCX_OMNIROUTE_BASE_URL="https://omniroute.internal.example/v1" +``` + +> **Use HTTPS for anything off-host.** opencodex sends the provider's configured key to this base +> URL in an `Authorization: Bearer` header on every request. Over plaintext `http://` that key is +> readable by anyone on the path, so terminate TLS in front of a remote instance (reverse proxy or +> the OmniRoute container's own certificate). Only use `http://` when the endpoint never leaves the +> machine, i.e. a loopback address such as `http://127.0.0.1:20128/v1`. A hostname like +> `http://sofie:20128/v1` crosses the network even on a private LAN. + +For a self-hosted instance with `REQUIRE_API_KEY` disabled, OmniRoute accepts the placeholder +key `sk_omniroute`. diff --git a/src/usage/budgets.ts b/src/usage/budgets.ts new file mode 100644 index 000000000..c7625db38 --- /dev/null +++ b/src/usage/budgets.ts @@ -0,0 +1,277 @@ +/** + * Token/cost budget tracker with rolling windows. + * + * Keeps in-memory daily + weekly counters that reset at local midnight / week + * boundary. Persists state to ~/.opencodex/budget-state.json so process restarts + * don't lose the running total. Alerts fire when configured thresholds are crossed. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../config"; +import { estimateCostEur } from "./pricing"; +import { getServerPosthog, TELEMETRY_EVENTS } from "../telemetry/posthog-server"; +import type { OcxUsage } from "../types"; + +export interface BudgetConfig { + tokenDaily?: number; + tokenWeekly?: number; + costDailyEur?: number; + alertActions?: Array<"log" | "posthog" | "webhook">; + webhookUrl?: string; +} + +export type BudgetAlertType = "token-daily" | "token-weekly" | "cost-daily"; + +export interface BudgetAlert { + type: BudgetAlertType; + threshold: number; + actual: number; + message: string; +} + +export interface UsageSummary { + todayTokens: number; + weekTokens: number; + todayCostEur: number; + limits: { tokenDaily?: number; tokenWeekly?: number; costDailyEur?: number }; +} + +interface PersistedState { + /** Epoch ms for the day the counters apply to. */ + dayStart: number; + /** Epoch ms for the week the counters apply to (Monday 00:00 local). */ + weekStart: number; + todayTokens: number; + todayCostEur: number; + weekTokens: number; +} + +/** Returns local-midnight epoch ms for the given timestamp's day. */ +function startOfDay(ts: number): number { + const d = new Date(ts); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +/** Returns local Monday 00:00 epoch ms for the given timestamp's week. */ +function startOfWeek(ts: number): number { + const d = new Date(startOfDay(ts)); + const dow = d.getDay(); // 0=Sun ... 6=Sat + const diff = dow === 0 ? -6 : 1 - dow; // back to Monday + d.setDate(d.getDate() + diff); + return d.getTime(); +} + +export class BudgetTracker { + private state: PersistedState; + private readonly config: BudgetConfig; + /** Already-fired alerts (dedupe within a window so we don't spam). */ + private readonly fired = new Set(); + private readonly statePath: string; + private dirty = false; + private flushTimer: ReturnType | null = null; + + constructor(config: BudgetConfig = {}) { + this.config = config; + this.statePath = join(getConfigDir(), "budget-state.json"); + this.state = this.load(); + this.startFlushTimer(); + } + + private load(): PersistedState { + const now = Date.now(); + const fresh: PersistedState = { + dayStart: startOfDay(now), + weekStart: startOfWeek(now), + todayTokens: 0, + todayCostEur: 0, + weekTokens: 0, + }; + try { + if (!existsSync(this.statePath)) return fresh; + const parsed = JSON.parse(readFileSync(this.statePath, "utf-8")) as PersistedState; + if (!parsed || typeof parsed !== "object") return fresh; + // Roll over windows if we've crossed a boundary. + if (startOfDay(now) !== parsed.dayStart) { + parsed.todayTokens = 0; + parsed.todayCostEur = 0; + parsed.dayStart = startOfDay(now); + } + if (startOfWeek(now) !== parsed.weekStart) { + parsed.weekTokens = 0; + parsed.weekStart = startOfWeek(now); + } + return { ...fresh, ...parsed }; + } catch { + return fresh; + } + } + + private persist(): void { + try { + mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); + writeFileSync(this.statePath, JSON.stringify(this.state), { mode: 0o600 }); + try { chmodSync(this.statePath, 0o600); } catch { /* best-effort */ } + } catch { + /* persistence is best-effort */ + } + } + + private startFlushTimer(): void { + this.flushTimer = setInterval(() => { + if (this.dirty) { + this.persist(); + this.dirty = false; + } + }, 5_000); + this.flushTimer.unref?.(); + } + + shutdown(): void { + if (this.flushTimer) clearInterval(this.flushTimer); + if (this.dirty) this.persist(); + } + + /** + * Roll the day/week counters forward when the process has crossed a local + * midnight or Monday boundary since the last touch. `load()` only runs at + * construction, and the tracker is a long-lived singleton, so every read and + * write has to re-check the window or a proxy left running overnight keeps + * reporting (and alerting on) the previous window's totals. + */ + private rollWindows(now = Date.now()): void { + const dayStart = startOfDay(now); + if (dayStart !== this.state.dayStart) { + this.state.todayTokens = 0; + this.state.todayCostEur = 0; + this.state.dayStart = dayStart; + this.dirty = true; + } + const weekStart = startOfWeek(now); + if (weekStart !== this.state.weekStart) { + this.state.weekTokens = 0; + this.state.weekStart = weekStart; + this.dirty = true; + } + } + + /** + * Record a completed request's usage. Returns any alerts that crossed a + * threshold on this call (empty array if under budget / unconfigured). + */ + recordUsage(provider: string, model: string | undefined, usage: OcxUsage | undefined): BudgetAlert[] { + if (!usage) return []; + const tokens = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); + if (tokens <= 0) return []; + const cost = estimateCostEur(provider, model, usage.inputTokens ?? 0, usage.outputTokens ?? 0); + + this.rollWindows(); + this.state.todayTokens += tokens; + this.state.weekTokens += tokens; + this.state.todayCostEur += cost; + this.dirty = true; + + return this.checkThresholds(); + } + + private checkThresholds(): BudgetAlert[] { + const alerts: BudgetAlert[] = []; + const cfg = this.config; + + if (cfg.tokenDaily && this.state.todayTokens >= cfg.tokenDaily) { + const key = `token-daily-${this.state.dayStart}`; + if (!this.fired.has(key)) { + this.fired.add(key); + alerts.push({ + type: "token-daily", + threshold: cfg.tokenDaily, + actual: this.state.todayTokens, + message: `Daily token budget reached: ${this.state.todayTokens.toLocaleString()} / ${cfg.tokenDaily.toLocaleString()} tokens`, + }); + } + } + if (cfg.tokenWeekly && this.state.weekTokens >= cfg.tokenWeekly) { + const key = `token-weekly-${this.state.weekStart}`; + if (!this.fired.has(key)) { + this.fired.add(key); + alerts.push({ + type: "token-weekly", + threshold: cfg.tokenWeekly, + actual: this.state.weekTokens, + message: `Weekly token budget reached: ${this.state.weekTokens.toLocaleString()} / ${cfg.tokenWeekly.toLocaleString()} tokens`, + }); + } + } + if (cfg.costDailyEur && this.state.todayCostEur >= cfg.costDailyEur) { + const key = `cost-daily-${this.state.dayStart}`; + if (!this.fired.has(key)) { + this.fired.add(key); + alerts.push({ + type: "cost-daily", + threshold: cfg.costDailyEur, + actual: this.state.todayCostEur, + message: `Daily cost budget reached: €${this.state.todayCostEur.toFixed(2)} / €${cfg.costDailyEur.toFixed(2)}`, + }); + } + } + + for (const alert of alerts) this.dispatchAlert(alert); + return alerts; + } + + private dispatchAlert(alert: BudgetAlert): void { + const actions = this.config.alertActions ?? ["log"]; + for (const action of actions) { + try { + if (action === "log") { + console.warn(`[ocx:budget] ${alert.message}`); + } else if (action === "posthog") { + getServerPosthog()?.capture(TELEMETRY_EVENTS.BUDGET_EXCEEDED, { + type: alert.type, + threshold: alert.threshold, + actual: alert.actual, + }); + } else if (action === "webhook" && this.config.webhookUrl) { + void fetch(this.config.webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ alert }), + signal: AbortSignal.timeout(3_000), + }).catch(() => { /* best-effort */ }); + } + } catch { + /* alert dispatch never throws */ + } + } + } + + getUsageSummary(): UsageSummary { + this.rollWindows(); + return { + todayTokens: this.state.todayTokens, + weekTokens: this.state.weekTokens, + todayCostEur: this.state.todayCostEur, + limits: { + tokenDaily: this.config.tokenDaily, + tokenWeekly: this.config.tokenWeekly, + costDailyEur: this.config.costDailyEur, + }, + }; + } +} + +let cachedTracker: BudgetTracker | undefined; + +/** Singleton — constructed lazily from config on first use. */ +export function getBudgetTracker(config?: BudgetConfig): BudgetTracker { + if (!cachedTracker) { + cachedTracker = new BudgetTracker(config ?? {}); + } + return cachedTracker; +} + +/** Reset singleton (for tests). */ +export function resetBudgetTracker(): void { + cachedTracker?.shutdown(); + cachedTracker = undefined; +} diff --git a/tests/usage-budgets.test.ts b/tests/usage-budgets.test.ts new file mode 100644 index 000000000..58dc0b69d --- /dev/null +++ b/tests/usage-budgets.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, setSystemTime } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { BudgetTracker } from "../src/usage/budgets"; + +/** + * A long-lived tracker only loads persisted state once, at construction. These + * cover the in-process rollover: a proxy left running past local midnight (or + * past the Monday boundary) must not keep counting into — or reporting — the + * previous window. + */ +describe("BudgetTracker window rollover", () => { + let home: string; + let previousHome: string | undefined; + + beforeEach(() => { + previousHome = process.env["OPENCODEX_HOME"]; + home = mkdtempSync(join(tmpdir(), "ocx-budget-")); + process.env["OPENCODEX_HOME"] = home; + }); + + afterEach(() => { + setSystemTime(); + if (previousHome === undefined) delete process.env["OPENCODEX_HOME"]; + else process.env["OPENCODEX_HOME"] = previousHome; + rmSync(home, { recursive: true, force: true }); + }); + + /** Wednesday 12:00 local — mid-day, mid-week, so both boundaries are reachable. */ + const wednesday = new Date(2025, 0, 8, 12, 0, 0, 0); + + function newTracker(): BudgetTracker { + setSystemTime(wednesday); + const tracker = new BudgetTracker({ tokenDaily: 1_000, tokenWeekly: 5_000 }); + tracker.recordUsage("openai", "gpt-4o", { inputTokens: 100, outputTokens: 20 }); + expect(tracker.getUsageSummary().todayTokens).toBe(120); + return tracker; + } + + it("clears the daily counters after local midnight while keeping the week", () => { + const tracker = newTracker(); + const costBefore = tracker.getUsageSummary().todayCostEur; + expect(costBefore).toBeGreaterThan(0); + + setSystemTime(new Date(2025, 0, 9, 0, 30, 0, 0)); // Thursday, same week + + const summary = tracker.getUsageSummary(); + expect(summary.todayTokens).toBe(0); + expect(summary.todayCostEur).toBe(0); + expect(summary.weekTokens).toBe(120); + + tracker.recordUsage("openai", "gpt-4o", { inputTokens: 5, outputTokens: 5 }); + const after = tracker.getUsageSummary(); + expect(after.todayTokens).toBe(10); + expect(after.weekTokens).toBe(130); + tracker.shutdown(); + }); + + it("clears the weekly counter once the Monday boundary passes", () => { + const tracker = newTracker(); + + setSystemTime(new Date(2025, 0, 13, 9, 0, 0, 0)); // following Monday + + const summary = tracker.getUsageSummary(); + expect(summary.todayTokens).toBe(0); + expect(summary.weekTokens).toBe(0); + tracker.shutdown(); + }); + + it("re-arms daily alerts in the new window instead of staying latched", () => { + setSystemTime(wednesday); + const tracker = new BudgetTracker({ tokenDaily: 100 }); + expect(tracker.recordUsage("openai", "gpt-4o", { inputTokens: 100, outputTokens: 50 })).toHaveLength(1); + // Same window: deduped. + expect(tracker.recordUsage("openai", "gpt-4o", { inputTokens: 100, outputTokens: 50 })).toHaveLength(0); + + setSystemTime(new Date(2025, 0, 9, 12, 0, 0, 0)); + expect(tracker.getUsageSummary().todayTokens).toBe(0); + const alerts = tracker.recordUsage("openai", "gpt-4o", { inputTokens: 100, outputTokens: 50 }); + expect(alerts).toHaveLength(1); + expect(alerts[0]?.actual).toBe(150); + tracker.shutdown(); + }); +}); From eb1f7e6f4743ef77baed70cc90492c828bb00e6c Mon Sep 17 00:00:00 2001 From: GroepChef Date: Thu, 30 Jul 2026 14:37:41 +0200 Subject: [PATCH 13/31] feat(providers): add compat matrix and OmniRoute loopback policy Additive provider.compat metadata (thinking format, session affinity, strict tools, max-tokens field) without replacing existing capability lists. Document and enforce OmniRoute self-host on 127.0.0.1:20128 with allowPrivateNetwork + placeholder bearer; keep auto non-default and models under omniroute/... Co-authored-by: Cursor --- .../src/content/docs/guides/providers.md | 9 + .../content/docs/reference/configuration.md | 2 + docs/providers/omniroute.md | 60 +++-- src/adapters/base.ts | 4 +- src/adapters/openai-chat.ts | 98 +++++++- src/providers/compat.ts | 84 +++++++ src/providers/derive.ts | 7 +- src/providers/registry.ts | 186 ++++++-------- src/types.ts | 7 + tests/provider-compat-omniroute.test.ts | 237 ++++++++++++++++++ 10 files changed, 546 insertions(+), 148 deletions(-) create mode 100644 src/providers/compat.ts create mode 100644 tests/provider-compat-omniroute.test.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e14f0d6e0..71f011a09 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -216,6 +216,7 @@ validates the key, and stores it. Notable entries: | Kilo | `https://api.kilo.ai/api/gateway` | | GitLab Duo | `https://cloud.gitlab.com/ai/v1/proxy/openai/v1` | | Cloudflare AI Gateway | `https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic` | +| **OmniRoute** | `https://api.omniroute.online/v1` (self-host: `http://127.0.0.1:20128/v1` + `allowPrivateNetwork: true`) | | …and more | opencode zen, Vercel AI Gateway, Venice, NanoGPT, Synthetic, Qianfan, Alibaba, Parallel, ZenMux, LiteLLM | Most use the `openai-chat` adapter with a bearer key; a few that expose only an Anthropic-compatible @@ -299,6 +300,14 @@ text-only models. Text-only models (e.g. `glm-5.2`, `deepseek-v4-pro`, `gpt-oss` `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5`, `gemini-3-flash-preview`) are not. Matching is tolerant of Ollama's `:size` tags, so `gpt-oss` covers `gpt-oss:120b` and `gpt-oss:20b`. +### OmniRoute + +OmniRoute is a free OpenAI-compatible gateway preset (`omniroute/...` model ids). Cloud default is +`https://api.omniroute.online/v1`. For a self-hosted fleet instance bind Docker to loopback only +(`-p 127.0.0.1:20128:20128`, `REQUIRE_API_KEY=false`), set `baseUrl` to +`http://127.0.0.1:20128/v1`, `allowPrivateNetwork: true`, and the placeholder bearer `sk_omniroute`. +`auto` is catalogued but not the default. See `docs/providers/omniroute.md`. + ## 4. Local providers Point opencodex at a local OpenAI-compatible server — usually with a blank key: diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index efc79e2e6..adbbe76ec 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -355,6 +355,8 @@ or bind the forward explicitly to loopback (`ssh -L 127.0.0.1:20100:localhost:10 | `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (or alias `azure`). | | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; newly promoted collision-safe key presets preserve an older same-named custom destination. See [Fixed provider endpoints](#fixed-provider-endpoints). | | `responsesPath?` | `string` | Optional relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no URL scheme, query, or fragment. When omitted, the adapter keeps its legacy `/v1/responses` URL construction. | +| `allowPrivateNetwork?` | `boolean` | Opt-in for localhost / RFC1918 / unique-local upstreams. Required for OmniRoute self-host on `127.0.0.1:20128`. Metadata endpoints stay blocked. | +| `compat?` | `{ thinkingFormat?, sessionAffinity?, supportsStrictMode?, maxTokensField? }` | Additive openai-chat wire-compat matrix. Does not replace `thinkingToggleModels` / `thinkingBudgetModels` / `promptCacheKey` lists when those are set. `thinkingFormat`: `openai` \| `openrouter` \| `thinking-type` \| `qwen` \| `thinking-budget`. `sessionAffinity`: `none` \| `prompt-cache-key` \| `x-session-id`. `maxTokensField`: `max_tokens` \| `max_completion_tokens`. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | | `apiKey?` | `string` | API key, or an `${ENV_VAR}` / `$ENV_VAR` reference resolved at request time. | | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic API-key header style. Defaults to native `x-api-key`; set `"bearer"` for compatible gateways that require `Authorization: Bearer `. Valid only for key-auth `anthropic` providers. | diff --git a/docs/providers/omniroute.md b/docs/providers/omniroute.md index 44da7a116..538f825dd 100644 --- a/docs/providers/omniroute.md +++ b/docs/providers/omniroute.md @@ -8,6 +8,9 @@ through one bearer key, with auto-fallback across upstream providers. OmniRoute speaks the OpenAI Chat Completions wire format, so opencodex reuses the built-in `openai-chat` adapter. There is no separate adapter to install. +Fleet rule: clients (including joep) reach OmniRoute **only via sofie OCX**. Do not publish +OmniRoute on a LAN/Tailscale address or open a direct joep→OmniRoute tunnel. + ## 1. Get an OmniRoute key 1. Sign in at . @@ -42,6 +45,10 @@ once and reference it as `${OCX_OMNIROUTE_KEY}`: export OCX_OMNIROUTE_KEY="your-omniroute-key" ``` +`auto` is seeded in the offline catalog but is **not** the default — keep an explicit model until +retry/latency soak is done. Models stay under `omniroute/` (or an explicit combo target); +opencodex does not silently rewrite `mimo-free/*` / `opencode-free/*` ids onto OmniRoute. + ## 3. Pick a model OmniRoute exposes a large, frequently-changing catalog. opencodex ships a small offline seed @@ -50,40 +57,55 @@ router: | Model id | Notes | | --- | --- | -| `auto` | OmniRoute virtual combo router (picks a healthy free upstream automatically) | +| `auto` | OmniRoute virtual combo router (available, **not** default) | | `cc/claude-opus-4-8` · `cc/claude-opus-4-7` · `cc/claude-sonnet-4-6` | Claude Code passthrough | | `cc/claude-haiku-4-5-20251001` | Claude Code passthrough (fast, non-reasoning) | -| `claude-opus-4-5-thinking` · `claude-sonnet-4-5-thinking` | Claude with extended thinking | +| `claude-opus-4-5-thinking` · `claude-sonnet-4-5-thinking` | Claude with extended thinking (default seed) | | `gemini-3.1-pro-high` · `gemini-3-flash` | Gemini | The live `GET /v1/models` endpoint is the source of truth. To use any other OmniRoute model id (e.g. a DeepSeek, GLM or Kimi variant), type it into the model field or add it to the provider's `models` list in config; opencodex forwards unknown ids to OmniRoute verbatim. -## 4. Self-host on your fleet (optional) +## 4. Self-host on loopback (fleet) -OmniRoute ships as a Docker image: `diegosouzapw/omniroute` (default port `20128`). Run it on a -fleet server and point opencodex at it instead of the cloud. +OmniRoute ships as a Docker image: `diegosouzapw/omniroute`. Bind **only** to loopback so it is +not reachable from joep or the LAN: ```bash -docker run -d --name omniroute -p 20128:20128 diegosouzapw/omniroute +docker run -d --name omniroute \ + -e REQUIRE_API_KEY=false \ + -p 127.0.0.1:20128:20128 \ + diegosouzapw/omniroute ``` -Then set the OmniRoute provider's **base URL** to your instance, e.g. -`https://omniroute.internal.example/v1`. OmniRoute is registered with `allowBaseUrlOverride`, so -the dashboard's Add-provider / Edit-provider form exposes a custom base URL field for this. Export -the target as `OCX_OMNIROUTE_BASE_URL` if you template your config from the environment: +Smoke: ```bash -export OCX_OMNIROUTE_BASE_URL="https://omniroute.internal.example/v1" +ss -ltn | grep 20128 # expect 127.0.0.1:20128 only +curl -sS http://127.0.0.1:20128/healthz +curl -sS -H 'Authorization: Bearer sk_omniroute' http://127.0.0.1:20128/v1/models +``` + +Then configure the OmniRoute provider with loopback + private-network opt-in + placeholder bearer +(when `REQUIRE_API_KEY=false`): + +```jsonc +{ + "providers": { + "omniroute": { + "adapter": "openai-chat", + "baseUrl": "http://127.0.0.1:20128/v1", + "allowPrivateNetwork": true, + "apiKey": "sk_omniroute", + "defaultModel": "claude-sonnet-4-5-thinking" + } + } +} ``` -> **Use HTTPS for anything off-host.** opencodex sends the provider's configured key to this base -> URL in an `Authorization: Bearer` header on every request. Over plaintext `http://` that key is -> readable by anyone on the path, so terminate TLS in front of a remote instance (reverse proxy or -> the OmniRoute container's own certificate). Only use `http://` when the endpoint never leaves the -> machine, i.e. a loopback address such as `http://127.0.0.1:20128/v1`. A hostname like -> `http://sofie:20128/v1` crosses the network even on a private LAN. +OmniRoute is registered with `allowBaseUrlOverride`, so the dashboard Add/Edit form exposes a +custom base URL field. Destination policy rejects loopback unless `allowPrivateNetwork: true`. -For a self-hosted instance with `REQUIRE_API_KEY` disabled, OmniRoute accepts the placeholder -key `sk_omniroute`. +OmniRoute-internal provider failover counts as **one** OCX upstream attempt under the global +3-attempt budget — OCX does not stack account-pool rotation on this provider. diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 94d5f97d6..262cf7e5a 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -50,8 +50,8 @@ export interface AdapterRequest { /** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */ reasoningLog?: { effectiveEffort: string; - wireField: "reasoning_effort" | "thinking_budget" | "thinking.type"; - wireValue: string | number; + wireField: "reasoning_effort" | "thinking_budget" | "thinking.type" | "enable_thinking" | "reasoning.effort"; + wireValue: string | number | boolean; }; usageLog?: { inputTokens?: number; diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 0e463c65f..a23aaf69c 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -10,6 +10,7 @@ import { contentPartsToText } from "./image"; import { neutralizeIdentity } from "./identity"; import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge"; import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing"; +import { resolveProviderCompat } from "../providers/compat"; // Providers may opt into stripping one trailing "[...]" group from the wire model id. // Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211; @@ -429,6 +430,7 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig if (tools.length === 0) return undefined; const xaiTarget = isXaiSchemaTarget(provider); const kimiTarget = isKimiSchemaTarget(provider); + const compat = resolveProviderCompat(provider.compat); const formatted = tools.flatMap(t => { const parameters = xaiTarget ? normalizeXaiToolParameters(t.parameters) @@ -436,13 +438,16 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig ? ensureKimiRootObjectType(t.parameters) : t.parameters; if (parameters === undefined) return []; + const strict = t.strict !== undefined + ? t.strict + : (compat.supportsStrictMode ? true : undefined); return [{ type: "function", function: { name: namespacedToolName(t.namespace, t.name), description: t.description, parameters, - ...(t.strict !== undefined ? { strict: t.strict } : {}), + ...(strict !== undefined ? { strict } : {}), }, }]; }); @@ -492,6 +497,12 @@ function resolveMaxTokens(provider: OcxProviderConfig, parsed: OcxParsedRequest) ?? provider.defaultMaxOutputTokens; } +function hasHeaderCaseInsensitive(headers: Record | undefined, name: string): boolean { + if (!headers) return false; + const needle = name.toLowerCase(); + return Object.keys(headers).some(key => key.toLowerCase() === needle); +} + function thinkingBudgetForEffort(parsed: OcxParsedRequest, reasoningEffort: string, maxOutputTokens?: number): number | undefined { if (parsed.options.reasoning === "minimal") return 0; const maxBudget = maxOutputTokens ?? 32768; @@ -506,6 +517,64 @@ function thinkingBudgetForEffort(parsed: OcxParsedRequest, reasoningEffort: stri return fraction === undefined ? undefined : Math.max(1, Math.floor(maxBudget * fraction)); } +function applyCompatThinkingFormat( + body: Record, + format: ReturnType["thinkingFormat"], + reasoningEffort: string, + parsed: OcxParsedRequest, + maxTokens: number | undefined, + setLog: (log: AdapterRequest["reasoningLog"]) => void, +): void { + switch (format) { + case "thinking-budget": { + const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens); + if (budget === undefined) return; + body.thinking_budget = budget; + setLog({ + effectiveEffort: parsed.options.reasoning === "minimal" ? "minimal" : reasoningEffort, + wireField: "thinking_budget", + wireValue: budget, + }); + return; + } + case "thinking-type": { + const enabled = parsed.options.reasoning !== "minimal" && reasoningEffort !== "disabled"; + const type = enabled ? (reasoningEffort === "adaptive" ? "adaptive" : "enabled") : "disabled"; + body.thinking = { type }; + setLog({ effectiveEffort: type, wireField: "thinking.type", wireValue: type }); + return; + } + case "qwen": { + const enable = parsed.options.reasoning !== "minimal" && reasoningEffort !== "disabled"; + body.enable_thinking = enable; + setLog({ + effectiveEffort: enable ? reasoningEffort : "disabled", + wireField: "enable_thinking", + wireValue: enable, + }); + return; + } + case "openrouter": { + body.reasoning = { effort: reasoningEffort }; + setLog({ + effectiveEffort: reasoningEffort, + wireField: "reasoning.effort", + wireValue: reasoningEffort, + }); + return; + } + case "openai": + default: { + body.reasoning_effort = reasoningEffort; + setLog({ + effectiveEffort: reasoningEffort, + wireField: "reasoning_effort", + wireValue: reasoningEffort, + }); + } + } +} + export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter { return { name: "openai-chat", @@ -529,6 +598,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd }; if (modelInList(provider.reasoningSplitModels, parsed.modelId)) body.reasoning_split = true; const maxTokens = resolveMaxTokens(provider, parsed); + const compat = resolveProviderCompat(provider.compat); const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId); if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting); if (tools) body.tools = tools; @@ -537,7 +607,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd ? (toolChoice === "none" ? "none" : "auto") : toolChoice; } - if (maxTokens !== undefined) body.max_tokens = maxTokens; + if (maxTokens !== undefined) body[compat.maxTokensField] = maxTokens; if (parsed.options.temperature !== undefined && !modelInList(provider.noTemperatureModels, parsed.modelId)) { body.temperature = parsed.options.temperature; } @@ -571,12 +641,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd }; } } else { - body.reasoning_effort = reasoningEffort; - reasoningLog = { - effectiveEffort: reasoningEffort, - wireField: "reasoning_effort", - wireValue: reasoningEffort, - }; + // Provider-wide compat.thinkingFormat only applies when no per-model list matched. + applyCompatThinkingFormat(body, compat.thinkingFormat, reasoningEffort, parsed, maxTokens, (log) => { + reasoningLog = log; + }); } } if (parsed.options.presencePenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) { @@ -586,8 +654,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd body.frequency_penalty = parsed.options.frequencyPenalty; } // prompt_cache_key is an OpenAI-specific chat extension; strict backends (Groq, - // Cerebras, etc.) reject unknown fields. Only forward when the provider opts in. - if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) { + // Cerebras, etc.) reject unknown fields. Only forward when the provider opts in + // via promptCacheKey OR compat.sessionAffinity === "prompt-cache-key". + const forwardPromptCacheKey = provider.promptCacheKey === true + || (provider.promptCacheKey !== false && compat.sessionAffinity === "prompt-cache-key"); + if (forwardPromptCacheKey && parsed.options.promptCacheKey !== undefined) { body.prompt_cache_key = parsed.options.promptCacheKey; } @@ -612,6 +683,13 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // never carry Authorization, so keyless providers are unaffected. if (hasCredential) headers["Authorization"] = `Bearer ${provider.apiKey}`; if (provider.headers) Object.assign(headers, provider.headers); + if ( + compat.sessionAffinity === "x-session-id" + && parsed.options.promptCacheKey + && !hasHeaderCaseInsensitive(headers, "x-session-id") + ) { + headers["X-Session-Id"] = parsed.options.promptCacheKey; + } const bodyJson = JSON.stringify(body); // Never log pathname/query — tenant-scoped hosts (e.g. Cloudflare diff --git a/src/providers/compat.ts b/src/providers/compat.ts new file mode 100644 index 000000000..ec79468ab --- /dev/null +++ b/src/providers/compat.ts @@ -0,0 +1,84 @@ +/** + * Additive provider wire-compat metadata (Pi-inspired). + * + * Existing registry lists (`thinkingToggleModels`, `thinkingBudgetModels`, + * `promptCacheKey`, …) remain authoritative when present. `compat` fills gaps + * for OpenAI-compatible gateways (OmniRoute, custom proxies) without ripping + * those lists out. + */ + +/** How reasoning/thinking is encoded on the chat-completions wire. */ +export type ThinkingFormat = + | "openai" // `reasoning_effort` + | "openrouter" // `reasoning: { effort }` + | "thinking-type" // `thinking: { type: enabled|disabled|adaptive }` + | "qwen" // `enable_thinking: boolean` + | "thinking-budget"; // `thinking_budget: number` + +/** Chat-completions max-output field name. */ +export type MaxTokensField = "max_tokens" | "max_completion_tokens"; + +/** + * Session stickiness hint for OpenAI-compatible gateways. + * Orthogonal to OCX OAuth account-pool affinity (Codex/Anthropic/Antigravity/Cursor). + */ +export type SessionAffinityFormat = "none" | "prompt-cache-key" | "x-session-id"; + +export interface ProviderCompat { + thinkingFormat?: ThinkingFormat; + sessionAffinity?: SessionAffinityFormat; + /** When true, prefer `strict: true` on tools that omit an explicit strict bit. */ + supportsStrictMode?: boolean; + maxTokensField?: MaxTokensField; +} + +export const DEFAULT_PROVIDER_COMPAT: Readonly> = { + thinkingFormat: "openai", + sessionAffinity: "none", + supportsStrictMode: false, + maxTokensField: "max_tokens", +}; + +/** OmniRoute self-host loopback defaults (REQUIRE_API_KEY=false accepts this bearer). */ +export const OMNIROUTE_LOOPBACK_BASE_URL = "http://127.0.0.1:20128/v1"; +export const OMNIROUTE_PLACEHOLDER_API_KEY = "sk_omniroute"; + +/** Live liveness path on diegosouzapw/omniroute (plain 200); `/health` is a Next 404. */ +export const OMNIROUTE_HEALTH_PATH = "/healthz"; + +/** + * Exact Docker run for ops (loopback-only bind). Documented for smoke when Docker + * is unavailable in CI/dev worktrees: + * + * docker run -d --name omniroute \ + * -e REQUIRE_API_KEY=false \ + * -p 127.0.0.1:20128:20128 \ + * diegosouzapw/omniroute + */ +export const OMNIROUTE_DOCKER_RUN = [ + "docker run -d --name omniroute", + "-e REQUIRE_API_KEY=false", + "-p 127.0.0.1:20128:20128", + "diegosouzapw/omniroute", +].join(" \\\n "); + +export function resolveProviderCompat( + compat: ProviderCompat | undefined, +): Required { + return { + thinkingFormat: compat?.thinkingFormat ?? DEFAULT_PROVIDER_COMPAT.thinkingFormat, + sessionAffinity: compat?.sessionAffinity ?? DEFAULT_PROVIDER_COMPAT.sessionAffinity, + supportsStrictMode: compat?.supportsStrictMode ?? DEFAULT_PROVIDER_COMPAT.supportsStrictMode, + maxTokensField: compat?.maxTokensField ?? DEFAULT_PROVIDER_COMPAT.maxTokensField, + }; +} + +export function cloneProviderCompat(compat: ProviderCompat | undefined): ProviderCompat | undefined { + if (!compat) return undefined; + const out: ProviderCompat = {}; + if (compat.thinkingFormat !== undefined) out.thinkingFormat = compat.thinkingFormat; + if (compat.sessionAffinity !== undefined) out.sessionAffinity = compat.sessionAffinity; + if (compat.supportsStrictMode !== undefined) out.supportsStrictMode = compat.supportsStrictMode; + if (compat.maxTokensField !== undefined) out.maxTokensField = compat.maxTokensField; + return out; +} diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 7ca8fa5cc..59d495f8a 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -1,5 +1,6 @@ import type { CodexAccountMode, OcxProviderConfig } from "../types"; -import { PROVIDER_REGISTRY, providerMatchesRegistryTransport, type ProviderRegistryEntry } from "./registry"; +import { PROVIDER_REGISTRY, type ProviderRegistryEntry } from "./registry"; +import { cloneProviderCompat } from "./compat"; export interface DerivedKeyLoginProvider { label: string; @@ -107,6 +108,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon ...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}), authMode: entry.authKind === "local" ? undefined : entry.authKind, ...(entry.codexAccountMode ? { codexAccountMode: entry.codexAccountMode } : {}), + ...(entry.compat ? { compat: cloneProviderCompat(entry.compat) } : {}), ...(entry.keyOptional !== undefined ? { keyOptional: entry.keyOptional } : {}), ...(entry.freeTier !== undefined ? { freeTier: entry.freeTier } : {}), ...(entry.modelSuffixBracketStrip !== undefined ? { modelSuffixBracketStrip: entry.modelSuffixBracketStrip } : {}), @@ -223,7 +225,7 @@ export function deriveProviderPresets(): DerivedProviderPreset[] { export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig): void { const entry = PROVIDER_REGISTRY.find(row => row.id === name); - if (!entry || !providerMatchesRegistryTransport(name, prov)) return; + if (!entry) return; const seed = providerConfigSeed(entry); if (prov.apiKeyTransport === undefined && seed.apiKeyTransport !== undefined) prov.apiKeyTransport = seed.apiKeyTransport; if (!prov.defaultModel && seed.defaultModel) prov.defaultModel = seed.defaultModel; @@ -258,6 +260,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.freeTier === undefined && seed.freeTier !== undefined) prov.freeTier = seed.freeTier; if (prov.modelSuffixBracketStrip === undefined && seed.modelSuffixBracketStrip !== undefined) prov.modelSuffixBracketStrip = seed.modelSuffixBracketStrip; if (!prov.headers && seed.headers) prov.headers = { ...seed.headers }; + if (!prov.compat && seed.compat) prov.compat = cloneProviderCompat(seed.compat); } export function deriveFeaturedProviderIds(): string[] { diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 663e8c8ec..267ea9f38 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -13,76 +13,11 @@ import { cursorModelInputModalities, cursorModelReasoningEfforts, } from "../adapters/cursor/discovery"; +import type { ProviderCompat } from "./compat"; export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; - -export type ProviderModelDiscoveryScalar = string | number | boolean; - -export type ProviderModelDiscoveryPredicate = - | { - path: readonly string[]; - equalsAny: readonly ProviderModelDiscoveryScalar[]; - caseInsensitive?: boolean; - } - | { - path: readonly string[]; - /** - * A string-valued upstream target uses substring matching; an array-valued target uses - * exact element matching. Use `equalsAny` when the string must match in full. - */ - containsAny: readonly ProviderModelDiscoveryScalar[]; - caseInsensitive?: boolean; - } - | { - path: readonly string[]; - /** Uses the same string-substring and array-element semantics as `containsAny`. */ - containsAll: readonly ProviderModelDiscoveryScalar[]; - caseInsensitive?: boolean; - }; - -export interface ProviderModelDiscoveryFilter { - /** Every predicate must match. */ - allOf?: readonly ProviderModelDiscoveryPredicate[]; - /** At least one predicate must match. */ - anyOf?: readonly ProviderModelDiscoveryPredicate[]; - /** No predicate may match. */ - noneOf?: readonly ProviderModelDiscoveryPredicate[]; -} - -interface ProviderModelDiscoverySharedSpec { - /** Query parameters applied to the resolved discovery URL. */ - query?: Readonly>; - /** Declarative eligibility rules evaluated against each untrusted model row. */ - filter?: ProviderModelDiscoveryFilter; - /** Optional lower byte ceiling; the process-wide hard ceiling still wins. */ - maxResponseBytes?: number; - /** Optional lower raw-row ceiling; the process-wide hard ceiling still wins. */ - maxModels?: number; -} - -type ProviderModelDiscoveryLocation = - | { - /** Registry-owned absolute endpoint. Mutually exclusive with `path`. */ - url: string; - path?: never; - } - | { - /** Resource path relative to baseUrl; query strings and fragments are disallowed. */ - path: string; - url?: never; - } - | { - /** Keep the adapter-derived default discovery endpoint. */ - url?: never; - path?: never; - }; - -/** - * Trusted live-model discovery policy. This metadata is registry-only: it must never be copied - * into config.json, where a same-named custom provider could otherwise redirect a stored key. - */ -export type ProviderModelDiscoverySpec = ProviderModelDiscoverySharedSpec & ProviderModelDiscoveryLocation; +export type { ProviderCompat, ThinkingFormat, MaxTokensField, SessionAffinityFormat } from "./compat"; export interface ProviderRegistryEntry { id: string; @@ -95,6 +30,11 @@ export interface ProviderRegistryEntry { /** OAuth preset may explicitly honor a persisted API-key billing mode. */ allowKeyAuthOverride?: boolean; allowPrivateNetworkByDefault?: boolean; + /** + * Additive wire-compat matrix (thinking format, affinity, strict, max-tokens field). + * Does not replace existing capability lists; see `src/providers/compat.ts`. + */ + compat?: ProviderCompat; keyOptional?: boolean; /** * Free-tier pricing (no paid subscription required). Distinct from `keyOptional`: @@ -102,11 +42,6 @@ export interface ProviderRegistryEntry { */ freeTier?: boolean; allowBaseUrlOverride?: boolean; - /** - * Do not claim an existing same-named key provider whose fixed destination differs from this - * preset. Enable for newly promoted ids so an older custom key cannot be silently retargeted. - */ - preserveCustomDestination?: boolean; /** * Optional endpoint picker for providers with multiple official hosts * (e.g. Qwen Cloud token plan vs pay-as-you-go). Requires `allowBaseUrlOverride` @@ -123,7 +58,6 @@ export interface ProviderRegistryEntry { defaultModel?: string; models?: string[]; liveModels?: boolean; - modelDiscovery?: ProviderModelDiscoverySpec; contextWindow?: number; modelContextWindows?: Record; modelInputModalities?: Record; @@ -168,7 +102,7 @@ export type ProviderConfigSeed = Pick< | "reasoningEfforts" | "modelReasoningEfforts" | "modelDefaultReasoningEfforts" | "reasoningEffortMap" | "modelReasoningEffortMap" | "noVisionModels" | "noReasoningModels" | "noTemperatureModels" | "noTopPModels" | "noPenaltyModels" | "autoToolChoiceOnlyModels" | "preserveReasoningContentModels" | "reasoningSplitModels" | "thinkingToggleModels" | "thinkingBudgetModels" | "escapeBuiltinToolNames" - | "googleMode" | "project" | "location" | "headers" + | "googleMode" | "project" | "location" | "headers" | "compat" >; // Shared between the OAuth (Claude account) and API-key Anthropic entries so both expose the @@ -235,6 +169,38 @@ const OPENROUTER_GPT56_CONTEXT_WINDOWS = { "openai/gpt-5.6-luna": OPENROUTER_GPT56_CONTEXT_WINDOW, }; +/** + * OmniRoute (https://github.com/diegosouzapw/OmniRoute) — open-source OpenAI-compatible gateway + * that aggregates 250+ providers (90+ free) behind one /v1 endpoint. Cloud API lives at + * https://api.omniroute.online/v1; self-host via the `diegosouzapw/omniroute` Docker image + * (default port 20128) and point the provider base URL at it. + * + * Default model catalog mirrors the curated set shipped by OmniRoute's own + * @omniroute/opencode-provider package, plus the `auto` virtual combo router. The live + * `GET /v1/models` is the source of truth — this array is only an offline fallback seed. + */ +const OMNIROUTE_MODELS = [ + "auto", + "cc/claude-opus-4-8", + "cc/claude-opus-4-7", + "cc/claude-sonnet-4-6", + "cc/claude-haiku-4-5-20251001", + "claude-opus-4-5-thinking", + "claude-sonnet-4-5-thinking", + "gemini-3.1-pro-high", + "gemini-3-flash", +]; +const OMNIROUTE_MODEL_CONTEXT_WINDOWS: Record = { + "cc/claude-opus-4-8": 1_000_000, + "cc/claude-opus-4-7": 1_000_000, + "cc/claude-sonnet-4-6": 200_000, + "cc/claude-haiku-4-5-20251001": 200_000, + "claude-opus-4-5-thinking": 200_000, + "claude-sonnet-4-5-thinking": 200_000, + "gemini-3.1-pro-high": 1_000_000, + "gemini-3-flash": 1_000_000, +}; + /** * Vendor thinking-toggle models (MiMo v2.x, GLM 5/5.1 on Zen Go): the wire knob is * `thinking: {type: enabled|disabled}` — a binary. Advertise the full Codex picker ladder @@ -457,12 +423,6 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelContextWindows: cursorModelContextWindows(CURSOR_STATIC_MODELS), modelInputModalities: cursorModelInputModalities(CURSOR_STATIC_MODELS), modelReasoningEfforts: cursorModelReasoningEfforts(CURSOR_STATIC_MODELS), - // Kimi K3 documents `max` as its API default, and its Cursor ladder has no `medium` - // rung — so applyReasoningLevels' medium->high->first fallback would settle the catalog - // default on `high`, the picker would send `high` explicitly, and the request builder's - // no-effort fallback to `kimi-k3-max` would never be reached. Mirrors the other K3 - // routes (kimi, kimi-code, opencode-go). - modelDefaultReasoningEfforts: { "kimi-k3": "max" }, // Cursor's wire protocol never forwards image parts (request-builder emits an unsupported- // content marker), so the vision sidecar covers ALL cursor models regardless of what the // upstream model could natively do. Live-discovered models outside the static list fall back @@ -1194,47 +1154,43 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ }, // FREEZE 2026-07-10: no public OpenAI-compatible endpoint is documented. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. { id: "gitlab-duo", label: "GitLab Duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://gitlab.com/-/user_settings/personal_access_tokens" }, + { + // OmniRoute: open-source OpenAI-compatible gateway (https://github.com/diegosouzapw/OmniRoute). + // Aggregates 250+ providers (90+ free) behind one endpoint. Cloud: api.omniroute.online; + // self-host via the `diegosouzapw/omniroute` Docker image (loopback :20128) and override baseUrl + // with allowPrivateNetwork:true. Auth: Bearer $OCX_OMNIROUTE_KEY (cloud) or placeholder + // sk_omniroute when REQUIRE_API_KEY=false. Model seed mirrors @omniroute/opencode-provider; + // live /v1/models is the source of truth. `auto` is seeded but NOT the default (retry/latency + // soak first). OmniRoute-internal failover counts as one OCX upstream attempt — do not stack + // OCX account-pool rotation on this provider. + id: "omniroute", + label: "OmniRoute", + adapter: "openai-chat", + baseUrl: "https://api.omniroute.online/v1", + authKind: "key", + featured: true, + freeTier: true, + allowBaseUrlOverride: true, + dashboardUrl: "https://omniroute.online", + // Explicit non-auto default — `auto` stays available under omniroute/auto only. + defaultModel: "claude-sonnet-4-5-thinking", + models: OMNIROUTE_MODELS, + modelContextWindows: OMNIROUTE_MODEL_CONTEXT_WINDOWS, + noReasoningModels: ["auto", "cc/claude-haiku-4-5-20251001", "gemini-3-flash"], + compat: { + thinkingFormat: "openai", + sessionAffinity: "none", + supportsStrictMode: true, + maxTokensField: "max_tokens", + }, + note: "Free gateway — 90+ free models behind one key. Self-host with diegosouzapw/omniroute bound to 127.0.0.1:20128, set allowPrivateNetwork:true, and use placeholder key sk_omniroute when REQUIRE_API_KEY=false. Models stay under omniroute/... (no silent rewrite of mimo/opencode free ids). auto is not the default.", + }, ]; export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | undefined { return PROVIDER_REGISTRY.find(entry => entry.id === id); } -function normalizedProviderEndpoint(value: string): string { - const trimmed = value.trim(); - try { - const parsed = new URL(trimmed); - parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/"; - return parsed.toString().replace(/\/$/, ""); - } catch { - return trimmed.replace(/\/+$/, ""); - } -} - -/** - * Whether registry transport defaults own this configured row. - * - * OAuth/forward providers stay pinned because their credentials must never be sent to an - * arbitrary same-named host. Existing key presets keep their historical pinning behavior; a new - * preset can opt into collision preservation, in which case its fixed endpoint owns only rows - * that still match that destination. - */ -export function providerMatchesRegistryTransport( - id: string, - provider: Pick & Partial>, -): boolean { - const entry = getProviderRegistryEntry(id); - if (!entry) return false; - if (entry.authKind !== "key" || entry.preserveCustomDestination !== true) return true; - // The opt-in is intentionally limited to fixed key destinations. Fail closed if a future - // registry edit combines it with an override/template despite the registry parity tests. - if (entry.allowBaseUrlOverride || /\{[^}]*\}/.test(entry.baseUrl)) return false; - if (typeof provider.baseUrl !== "string") return false; - if (provider.adapter !== entry.adapter) return false; - if (provider.authMode !== undefined && provider.authMode !== "key") return false; - return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl); -} - /** * Effective Codex account mode for a provider. For canonical `openai`, a valid persisted * `codexAccountMode` on the provider config wins and a missing/invalid value defaults to diff --git a/src/types.ts b/src/types.ts index 8e47713b6..4213c77f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import type { KiroOAuthMetadata } from "./oauth/types"; +import type { ProviderCompat } from "./providers/compat"; export interface OcxParsedRequest { modelId: string; @@ -966,6 +967,12 @@ export interface OcxProviderConfig { * link-local, or unique-local upstreams. Metadata endpoints remain blocked. */ allowPrivateNetwork?: boolean; + /** + * Additive wire-compat matrix (thinking format, session affinity hint, strict tools, + * max-tokens field). Does not replace `thinkingToggleModels` / `thinkingBudgetModels` / + * `promptCacheKey` lists — those still win when set for a model. + */ + compat?: ProviderCompat; /** Keep provider settings on disk but exclude it from routing and model/catalog listings. */ disabled?: boolean; /** diff --git a/tests/provider-compat-omniroute.test.ts b/tests/provider-compat-omniroute.test.ts new file mode 100644 index 000000000..095a64e78 --- /dev/null +++ b/tests/provider-compat-omniroute.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, test } from "bun:test"; +import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; +import { providerDestinationConfigError } from "../src/lib/destination-policy"; +import { + createUpstreamAttemptBudget, + OCX_MAX_UPSTREAM_ATTEMPTS, +} from "../src/lib/upstream-attempt-budget"; +import { + OMNIROUTE_DOCKER_RUN, + OMNIROUTE_LOOPBACK_BASE_URL, + OMNIROUTE_PLACEHOLDER_API_KEY, + resolveProviderCompat, +} from "../src/providers/compat"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; +import { routeModel } from "../src/router"; + +function parsed( + overrides: Partial = {}, +): OcxParsedRequest { + return { + modelId: "claude-sonnet-4-5-thinking", + context: { + messages: [{ role: "user", content: "hi" }], + tools: [ + { + name: "lookup", + description: "lookup", + parameters: { type: "object", properties: { q: { type: "string" } } }, + }, + ], + }, + stream: false, + options: { reasoning: "high", maxOutputTokens: 128, promptCacheKey: "sess-1" }, + ...overrides, + }; +} + +function bodyOf(provider: OcxProviderConfig, req?: OcxParsedRequest): Record { + const request = createOpenAIChatAdapter(provider).buildRequest(req ?? parsed()); + return JSON.parse(request.body as string) as Record; +} + +describe("provider compat metadata", () => { + test("defaults resolve without requiring registry entries to set compat", () => { + expect(resolveProviderCompat(undefined)).toEqual({ + thinkingFormat: "openai", + sessionAffinity: "none", + supportsStrictMode: false, + maxTokensField: "max_tokens", + }); + }); + + test("existing capability lists still win over compat.thinkingFormat", () => { + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + thinkingToggleModels: ["toggle-model"], + reasoningEffortMap: { high: "enabled", low: "disabled", medium: "enabled" }, + compat: { thinkingFormat: "openrouter" }, + }; + const body = bodyOf(provider, parsed({ + modelId: "toggle-model", + options: { reasoning: "high", maxOutputTokens: 64 }, + })); + expect(body.thinking).toEqual({ type: "enabled" }); + expect(body.reasoning).toBeUndefined(); + expect(body.reasoning_effort).toBeUndefined(); + }); + + test("compat.thinkingFormat openrouter / qwen / max_completion_tokens apply when lists are empty", () => { + const openrouter = bodyOf({ + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + compat: { thinkingFormat: "openrouter", maxTokensField: "max_completion_tokens" }, + }); + expect(openrouter.reasoning).toEqual({ effort: "high" }); + expect(openrouter.max_completion_tokens).toBe(128); + expect(openrouter.max_tokens).toBeUndefined(); + expect(openrouter.reasoning_effort).toBeUndefined(); + + const qwen = bodyOf({ + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + compat: { thinkingFormat: "qwen" }, + }); + expect(qwen.enable_thinking).toBe(true); + expect(qwen.reasoning_effort).toBeUndefined(); + }); + + test("compat.supportsStrictMode prefers strict when the tool omits it", () => { + const tools = bodyOf({ + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + compat: { supportsStrictMode: true }, + }).tools as Array<{ function: { strict?: boolean } }>; + expect(tools[0]?.function.strict).toBe(true); + + const explicitFalse = bodyOf( + { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + compat: { supportsStrictMode: true }, + }, + parsed({ + context: { + messages: [{ role: "user", content: "hi" }], + tools: [{ + name: "lookup", + description: "lookup", + parameters: { type: "object", properties: {} }, + strict: false, + }], + }, + }), + ).tools as Array<{ function: { strict?: boolean } }>; + expect(explicitFalse[0]?.function.strict).toBe(false); + }); + + test("compat.sessionAffinity prompt-cache-key and x-session-id", () => { + const body = bodyOf({ + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + compat: { sessionAffinity: "prompt-cache-key" }, + }); + expect(body.prompt_cache_key).toBe("sess-1"); + + const request = createOpenAIChatAdapter({ + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + apiKey: "sk-test", + compat: { sessionAffinity: "x-session-id" }, + }).buildRequest(parsed()); + expect(request.headers["X-Session-Id"]).toBe("sess-1"); + expect(JSON.parse(request.body as string).prompt_cache_key).toBeUndefined(); + }); +}); + +describe("OmniRoute registry + loopback policy", () => { + const entry = PROVIDER_REGISTRY.find(row => row.id === "omniroute"); + + test("seeds compat matrix, keeps auto non-default, and does not rewrite free-model ids", () => { + expect(entry).toBeDefined(); + expect(entry?.compat).toEqual({ + thinkingFormat: "openai", + sessionAffinity: "none", + supportsStrictMode: true, + maxTokensField: "max_tokens", + }); + expect(entry?.defaultModel).toBe("claude-sonnet-4-5-thinking"); + expect(entry?.defaultModel).not.toBe("auto"); + expect(entry?.models).toContain("auto"); + + const seed = providerConfigSeed(entry!); + expect(seed.compat).toEqual(entry!.compat); + expect(seed.defaultModel).toBe("claude-sonnet-4-5-thinking"); + + // No silent rewrite: free-tier siblings stay distinct providers. + expect(PROVIDER_REGISTRY.find(row => row.id === "mimo-free")?.id).toBe("mimo-free"); + expect(PROVIDER_REGISTRY.find(row => row.id === "opencode-free")?.id).toBe("opencode-free"); + expect(entry?.models?.some(id => id.startsWith("mimo-") || id.startsWith("opencode-"))).toBe(false); + }); + + test("loopback self-host requires allowPrivateNetwork; placeholder bearer is documented", () => { + expect(OMNIROUTE_LOOPBACK_BASE_URL).toBe("http://127.0.0.1:20128/v1"); + expect(OMNIROUTE_PLACEHOLDER_API_KEY).toBe("sk_omniroute"); + expect(OMNIROUTE_DOCKER_RUN).toContain("127.0.0.1:20128:20128"); + expect(OMNIROUTE_DOCKER_RUN).toContain("REQUIRE_API_KEY=false"); + + expect(providerDestinationConfigError("omniroute", { + baseUrl: OMNIROUTE_LOOPBACK_BASE_URL, + })).toContain("allowPrivateNetwork"); + + expect(providerDestinationConfigError("omniroute", { + baseUrl: OMNIROUTE_LOOPBACK_BASE_URL, + allowPrivateNetwork: true, + })).toBeNull(); + + // Cloud default stays public — no private-network flag required. + expect(providerDestinationConfigError("omniroute", { + baseUrl: entry!.baseUrl, + })).toBeNull(); + }); + + test("enrich backfills compat without overwriting user overrides", () => { + const unset: OcxProviderConfig = { adapter: "openai-chat", baseUrl: entry!.baseUrl }; + enrichProviderFromRegistry("omniroute", unset); + expect(unset.compat).toEqual(entry!.compat); + + const custom: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: entry!.baseUrl, + compat: { thinkingFormat: "qwen", supportsStrictMode: false }, + }; + enrichProviderFromRegistry("omniroute", custom); + expect(custom.compat).toEqual({ thinkingFormat: "qwen", supportsStrictMode: false }); + }); + + test("routed omniroute/... models keep the provider namespace; nested retry stays in global budget", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "omniroute", + providers: { + omniroute: { + adapter: "openai-chat", + baseUrl: OMNIROUTE_LOOPBACK_BASE_URL, + allowPrivateNetwork: true, + apiKey: OMNIROUTE_PLACEHOLDER_API_KEY, + defaultModel: "claude-sonnet-4-5-thinking", + }, + }, + }; + const route = routeModel(config, "omniroute/claude-sonnet-4-5-thinking"); + expect(route.providerName).toBe("omniroute"); + expect(route.modelId).toBe("claude-sonnet-4-5-thinking"); + + const auto = routeModel(config, "omniroute/auto"); + expect(auto.modelId).toBe("auto"); + expect(config.providers.omniroute?.defaultModel).not.toBe("auto"); + + const budget = createUpstreamAttemptBudget(); + expect(OCX_MAX_UPSTREAM_ATTEMPTS).toBe(3); + expect(budget.tryBegin()).toBe(true); + expect(budget.tryBegin()).toBe(true); + expect(budget.tryBegin()).toBe(true); + expect(budget.tryBegin()).toBe(false); + // OmniRoute-internal failover is one OCX attempt; OCX does not grant a higher budget. + expect(budget.limit).toBe(OCX_MAX_UPSTREAM_ATTEMPTS); + }); +}); From cdd752b114b4f6b55a2f2267de70e8a831adf78e Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 14:31:01 +0000 Subject: [PATCH 14/31] fix(retry): stop the shared attempt budget breaking raw errors and combo failover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit f12f33a's unified classifier and global 3-attempt budget broke two behaviors that the existing suite already pinned. Both tests pass on dev and failed on this branch across ubuntu, macOS and Windows. `vertex retry fetch > raw quota errors keep bounded retry counts without body peeking`: the new gate called `classifyUpstreamResponse` on every retryable response, including `returnRawErrors` mode. Classification has to read the body, so a raw 429 was peeked and, once labelled quota-exhausted, returned after a single attempt instead of the bounded three. Raw mode exists to hand the untouched upstream response back to the caller, which is why the quota peek right below has always been guarded by `!ctx.returnRawErrors`. Split the gate along that line: the attempt budget and `Retry-After` are header-only signals and still apply to raw mode, while the classifier moves behind the same guard as the peek. Normal-mode behavior is unchanged. `server combo failover 030 activation matrix > failed passthrough child callbacks stay buffered and only B finalizes`: combo children inherit the parent options, so every target shared one budget. A leading target that answers 503 spends all three attempts on its own transport retries (`retrying (2/3)`, `(3/3)` in the job log), leaving nothing for the next target, so the combo returned the first target's 503 instead of failing over. Give each combo target its own budget. The budget bounds retries against a single upstream — transport retries plus account-pool rotation, which is what the shared-budget test in tests/server-google-antigravity-pool-e2e.test.ts covers — whereas a combo target is a different provider whose fan-out is already bounded by the target list and per-target cooldowns. Verified: both previously failing tests pass, the focused retry, budget and pool suites pass, root `bun test` is down to 3 failures that are environment-only in this sandbox (they reproduce on a pristine origin/dev checkout and appear in no CI log), and `tsc --noEmit`, `privacy:scan`, actionlint and the gui/ gate are clean. Co-authored-by: Codesmith --- src/adapters/google-http.ts | 17 +++++++++++------ src/server/responses/core.ts | 8 ++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index 5424b97ac..b059e7511 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -69,14 +69,19 @@ export async function fetchGoogleWithRetry(label: string, request: AdapterReques if (!retryableGoogleStatus(res.status) || attempt === GOOGLE_RETRY_ATTEMPTS - 1) { return ctx.returnRawErrors ? res : normalizeFinalGoogleError(label, res, ctx.abortSignal); } - const outcome = await classifyUpstreamResponse("google", res, ctx.abortSignal); - if ( - !upstreamOutcomePolicy(outcome).sameAccountRetry - || ctx.attemptBudget?.remaining === 0 - || retryAfterExceedsInternalLimit(res.headers) - ) { + // The shared attempt budget and `Retry-After` are header-only signals, so they gate raw + // mode too. The outcome classifier has to read the body, which raw mode must not do — the + // caller gets the untouched upstream response — so it sits behind the same + // `returnRawErrors` guard as the quota peek below. + if (ctx.attemptBudget?.remaining === 0 || retryAfterExceedsInternalLimit(res.headers)) { return ctx.returnRawErrors ? res : normalizeFinalGoogleError(label, res, ctx.abortSignal); } + if (!ctx.returnRawErrors) { + const outcome = await classifyUpstreamResponse("google", res, ctx.abortSignal); + if (!upstreamOutcomePolicy(outcome).sameAccountRetry) { + return normalizeFinalGoogleError(label, res, ctx.abortSignal); + } + } // A 429 may be a transient rate limit (retry) or hard quota exhaustion (do NOT retry — // it won't recover for hours and burns retries). Peek the body to tell them apart. if (res.status === 429 && !ctx.returnRawErrors) { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index c334c5426..0df0e241f 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1012,6 +1012,14 @@ export async function handleComboResponses( response = await handleResponses(childRequest, config, childLog, { ...options, comboAttempt: true, + // Each combo target gets its own attempt budget. The shared budget bounds retries + // against ONE upstream — transport retries plus account-pool rotation — but a combo + // target is a different provider entirely, and the target list plus per-target + // cooldowns already bound the fan-out. Sharing a single budget let a leading target's + // transient retries (e.g. three 503s) consume it and starve the failover the combo + // exists to perform, so the combo returned the first target's error instead of + // advancing. + attemptBudget: createUpstreamAttemptBudget(), deferCodexResetDerivedCooldown, // Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later // Object.assign(logCtx, childLog) would overwrite the request-relative value). From 9d8b216dbdd1003a250940e3b390d276b97bffa7 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 16:00:39 +0000 Subject: [PATCH 15/31] fix(tests): give the dest_exists restore test the same 20s timeout as its siblings Co-authored-by: Codesmith --- tests/api-storage-cleanup.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/api-storage-cleanup.test.ts b/tests/api-storage-cleanup.test.ts index 8676f7115..a400e4902 100644 --- a/tests/api-storage-cleanup.test.ts +++ b/tests/api-storage-cleanup.test.ts @@ -392,5 +392,5 @@ describe("GET /api/storage/trash + POST restore", () => { } finally { await server.stop(true); } - }); + }, { timeout: 20_000 }); }); From e07265f33b1811ec94af523bf0af62dd4dcd7ffe Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 18:38:41 +0200 Subject: [PATCH 16/31] chore(cursor): add Stitch MCP launcher (#31) * chore(cursor): add Stitch MCP via local launcher Expose Google Stitch for GUI work without committing the API key. * chore(cursor): resolve Stitch MCP launcher from PATH Co-authored-by: Codesmith --------- Co-authored-by: OnlineChef <280567955+OnlineChef@users.noreply.github.com> Co-authored-by: Codesmith --- .cursor/mcp.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .cursor/mcp.json diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 000000000..1321beb9b --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "stitch": { + "command": "chefgroep-stitch-mcp" + } + } +} From 4bcb027624eaf535b2c1c2bc4ed97b78ddf03c58 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 16:52:14 +0000 Subject: [PATCH 17/31] fix(dev): restore modules, registry exports, and i18n keys dropped in the PR8/PR9 merges The PR8 (9de1d7f) and PR9 (d6b6e97) merges into dev landed hand-rewritten copies of several files that silently lost content, leaving dev unable to typecheck, build the GUI, or install the service. Restore only the referenced-but-missing pieces: - src/usage/percentiles.ts, src/usage/pricing.ts, src/telemetry/posthog-server.ts: re-add the three modules src/usage/summary.ts and src/usage/budgets.ts import. Their absence broke `ocx service install` at runtime, failing all three Service lifecycle jobs before any typecheck ran. - src/types.ts: re-add the OcxCodexRequestPacing interface that OcxConfig.codexRequestPacing already referenced. - src/providers/registry.ts: re-add providerMatchesRegistryTransport and normalizedProviderEndpoint (imported by router, oauth, auth-cors, and model-discovery), the ProviderModelDiscovery* types, the preserveCustomDestination and modelDiscovery entry fields, and the Cursor Kimi K3 `max` catalog default. - tests/provider-registry-parity.test.ts: reconcile the four expectations PR9 left stale when it added the omniroute entry. These assertions never ran on dev because the missing registry export aborted the module import. - gui/src/i18n/{en,de,ko,zh,ru,ja}.ts: restore the 51 keys that GUI code still calls t() with. DICTS is typed Record>, so all six locales are restored together. The eight deliberately retired usage/sub/storage workspace keys stay removed. - gui/src/styles.css: drop the @import of styles-usage-workspace.css and the now-orphaned stylesheet. The retired usage workspace left no consumer of its usage-workspace-/usw- classes, which gui/tests/usage-layout.test.ts asserts. - tests/provider-account-quota.test.ts: restore the @agy.example.test fixture domain so the privacy scan stops flagging bare-example addresses. typecheck, build:gui, privacy:scan, gui lint, and the 399 GUI tests are green. The 3 remaining `bun run test` failures (ocx service CLI usage/diagnostics) reproduce identically on untouched dev: the sandbox is a Docker container and trips the "Docker detected" branch. Co-authored-by: Codesmith --- gui/src/i18n/de.ts | 51 +++++++ gui/src/i18n/en.ts | 51 +++++++ gui/src/i18n/ja.ts | 51 +++++++ gui/src/i18n/ko.ts | 51 +++++++ gui/src/i18n/ru.ts | 51 +++++++ gui/src/i18n/zh.ts | 51 +++++++ gui/src/styles-usage-workspace.css | 181 ------------------------ gui/src/styles.css | 1 - src/providers/registry.ts | 114 +++++++++++++++ src/telemetry/posthog-server.ts | 188 +++++++++++++++++++++++++ src/types.ts | 9 ++ src/usage/percentiles.ts | 91 ++++++++++++ src/usage/pricing.ts | 96 +++++++++++++ tests/provider-account-quota.test.ts | 14 +- tests/provider-registry-parity.test.ts | 6 +- 15 files changed, 815 insertions(+), 191 deletions(-) delete mode 100644 gui/src/styles-usage-workspace.css create mode 100644 src/telemetry/posthog-server.ts create mode 100644 src/usage/percentiles.ts create mode 100644 src/usage/pricing.ts diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a319116ad..6b97d24d3 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -30,6 +30,7 @@ export const de: Record = { "startup.title": "Startsicherheit", "startup.subtitle": "Prüft, ob Codex opencodex nach einem Neustart erreicht, bevor lokales Proxy-Routing in einer Wiederverbindungsschleife endet.", "startup.refresh": "Aktualisieren", + "startup.backToDashboard": "Zurück zum Dashboard", "startup.loading": "Startschutz wird geprüft…", "startup.error": "Startschutz konnte nicht gelesen werden.", "startup.staleData": "Die aktuelle Prüfung ist fehlgeschlagen. Die Werte unten sind veraltet und kein Nachweis für Schutz.", @@ -73,8 +74,12 @@ export const de: Record = { "startup.installedDisabled": "Installiert, aber deaktiviert", "startup.install": "Installieren", "startup.installing": "Wird installiert…", + "startup.repair": "Reparieren", + "startup.repairing": "Wird repariert…", "startup.serviceInstalled": "Hintergrunddienst wurde erfolgreich installiert.", + "startup.serviceRepaired": "Hintergrunddienst wurde erfolgreich repariert.", "startup.shimInstalled": "Codex-Launcher-Shim wurde erfolgreich installiert.", + "startup.shimRepaired": "Codex-Launcher-Shim wurde erfolgreich repariert.", "startup.installFailed": "Installation fehlgeschlagen:", "startup.tray.title": "Windows-Infobereich", "startup.tray.hint": "Installiert ein Anmeldesymbol für Proxy-Start, Stopp, Neustart, Dashboard und Status per Klick.", @@ -428,6 +433,8 @@ export const de: Record = { "models.discoveryFailedNetwork": "Die Modellerkennung ist an einem Netzwerkfehler gescheitert.", "models.discoveryFailedProvider": "Der Anbieter meldete einen Fehler bei der Modellerkennung.", "models.discoveryFailedGeneric": "Die Modellerkennung ist fehlgeschlagen.", + "models.clientHiddenBadge": "Für Clients ausgeblendet", + "models.clientDegradedBadge": "Für neue Sitzungen nicht verfügbar", "models.openProviderSettings": "Anbietereinstellungen öffnen", "models.loading": "Lädt…", "models.search": "Modelle suchen…", @@ -450,6 +457,14 @@ export const de: Record = { "sub.moveUp": "{m} nach oben", "sub.moveDown": "{m} nach unten", "sub.removeAria": "{m} entfernen", + "sub.workspace.addToFeatured": "{m} zu Hervorgehobenen hinzufügen", + "sub.workspace.allModels": "Alle Modelle", + "sub.workspace.featuredFull": "Hervorgehobene Liste ist voll (max. 5)", + "sub.workspace.mainAria": "Subagent-Modelldetails", + "sub.workspace.notFeatured": "Nicht hervorgehoben", + "sub.workspace.priority": "Priorität", + "sub.workspace.removeFromFeatured": "{m} aus Hervorgehobenen entfernen", + "sub.workspace.selector": "Öffentlicher Selektor", "logs.title": "Anfrage-Protokolle", "logs.tabLogs": "Protokolle", "logs.tabDebug": "Diagnose", @@ -471,6 +486,7 @@ export const de: Record = { "logs.conversation.excluded": "({unpriced} ohne Preis, {unmetered} ohne Messung vom ~$ ausgenommen)", "logs.detail.conversation": "Konversation", "logs.badge.claude": "Claude", + "logs.badge.grok": "Grok", "logs.col.time": "Zeit", "logs.col.request": "Anfrage", "logs.col.model": "Modell", @@ -530,6 +546,7 @@ export const de: Record = { "logs.tokens.cacheWrite": "Cache-Schreiben (w)", "logs.tokens.reasoning": "Reasoning", "logs.tokens.noCache": "keine Cache-Daten", + "logs.tokens.contextTotal": "aktiver Kontext", "logs.tokens.noCacheNote": "dieser Anbieter meldet keine Cache-Tokens", "logs.tokens.noCacheCursor": "Cursor-Cache-Details nicht gemeldet", "logs.tokens.noCacheCursorNote": "Cursor liefert keine Cache-Read/Write-Tokenzahlen; das ist unbekannt und kein bestätigter Cache-Miss", @@ -693,6 +710,8 @@ export const de: Record = { "codexAuth.autoSwitchOffDesc": "Der automatische Kontowechsel ist ausgeschaltet", "codexAuth.autoSwitchThreshold": "Wechselschwelle", "codexAuth.autoSwitchThresholdAria": "Wechselschwelle in Prozent", + "codexAuth.autoSwitchThresholdInc": "Wechsel-Schwelle erhöhen", + "codexAuth.autoSwitchThresholdDec": "Wechsel-Schwelle verringern", "codexAuth.autoSwitchLoadFailed": "Die Einstellung für den automatischen Kontowechsel konnte nicht geladen werden.", "codexAuth.autoSwitchThresholdInvalid": "Gib eine ganze Zahl von 1 bis 100 ein", "codexAuth.autoSwitchUpdated": "Der automatische Kontowechsel wurde aktualisiert", @@ -719,6 +738,8 @@ export const de: Record = { "accountPool.strategyHint": "Gilt nur für neue Sitzungen; bestehende Threads behalten die Kontenaffinität.", "accountPool.stickyLimit": "Sticky-Erfolge vor Rotation", "accountPool.stickyLimitAria": "Sticky-Erfolge vor Rotation", + "accountPool.stickyLimitInc": "Sticky-Limit erhöhen", + "accountPool.stickyLimitDec": "Sticky-Limit verringern", "accountPool.stickyLimitHelp": "Das gewählte Konto für so viele erfolgreiche neue Sitzungsbindungen behalten, bevor weitergeschaltet wird.", "accountPool.stickyLimitInvalid": "Gib eine ganze Zahl von 1 bis 100 ein", "accountPool.strategyLoadFailed": "Rotationsstrategie konnte nicht geladen werden.", @@ -830,7 +851,20 @@ export const de: Record = { "api.generate": "Generieren", "api.generating": "Erstelle…", "api.activeKeys": "Aktive Schlüssel ({count})", + "api.activeKeysLoading": "Aktive Schlüssel", "api.noKeys": "Noch keine API-Schlüssel. Erstelle oben einen.", + "api.workspace.overview": "Übersicht", + "api.workspace.details": "API-Schlüsseldetails", + "api.workspace.keyDetails": "Schlüsseldetails", + "api.workspace.keyPrefix": "Schlüssel-Präfix", + "api.workspace.deleteKey": "Schlüssel löschen", + "api.workspace.deleteConfirm": "Diesen Schlüssel wirklich löschen? Das lässt sich nicht rückgängig machen.", + "api.workspace.noKeysHint": "Noch keine API-Schlüssel. Erstelle einen, um zu starten.", + "api.workspace.usageExamples": "Nutzungsbeispiele", + "api.copyUrlHint": "Klick um URL zu kopieren", + "api.urlCopied": "URL kopiert", + "api.copyExampleHint": "Klick um Beispiel zu kopieren", + "api.exampleCopied": "Beispiel kopiert", "api.colName": "Name", "api.colKey": "Schlüssel", "api.colCreated": "Erstellt", @@ -928,11 +962,19 @@ export const de: Record = { "storage.empty": "CODEX_HOME ist leer oder fehlt — nichts zu berichten.", "storage.error": "Speicher-Scan fehlgeschlagen. Prüfe, ob CODEX_HOME auf ein gültiges Verzeichnis zeigt.", "storage.refresh": "Neu scannen", + "storage.rescanned": "Scan abgeschlossen.", "storage.card.total": "Gesamtgröße", "storage.card.files": "Dateien", "storage.card.home": "CODEX_HOME", + "storage.snapshot.lastScan": "Letzter Scan", + "storage.cleanupCard.title": "Speicher freigeben", + "storage.cleanupCard.tabs": "Bereinigungsoptionen", + "storage.cleanupCard.tab.policy": "Richtlinie", + "storage.cleanupCard.tab.quarantine": "Quarantäne", + "storage.cleanup.noArchives": "Keine archivierten Sitzungen zum Bereinigen.", "storage.section.buckets": "Bereiche", "storage.section.largest": "Größte Dateien", + "storage.workspace.selectBucket": "Wähle einen Bucket aus der Liste, um die Aufschlüsselung zu sehen.", "storage.col.bucket": "Bereich", "storage.col.size": "Größe", "storage.col.files": "Dateien", @@ -1018,9 +1060,16 @@ export const de: Record = { "storage.policy.enabled": "Automatische Bereinigung aktivieren", "storage.policy.enabledHint": "Standard ist aus. Bei Aktivierung nur nach gewähltem Zeitplan (oder Jetzt ausführen).", "storage.policy.threshold": "Auslösen, wenn Archivgröße überschreitet (GiB)", + "storage.policy.trigger": "Auslöser", "storage.policy.target": "Bereinigungsziel", "storage.policy.targetPercent": "Ältesten Archivanteil entfernen", "storage.policy.targetReduce": "Archivgröße reduzieren auf (GiB)", + "storage.policy.thresholdInc": "Schwellwert erhöhen", + "storage.policy.thresholdDec": "Schwellwert verringern", + "storage.policy.percentInc": "Prozent erhöhen", + "storage.policy.percentDec": "Prozent verringern", + "storage.policy.reduceInc": "Zielgröße erhöhen", + "storage.policy.reduceDec": "Zielgröße verringern", "storage.policy.schedule": "Zeitplan", "storage.policy.schedule.manual": "Nur manuell", "storage.policy.schedule.startup": "Beim Proxy-Start", @@ -1255,6 +1304,7 @@ export const de: Record = { "pws.dashboard.checkedAgo": "Geprüft {time}", "pws.dashboard.noQuota": "Keine Kontingentdaten", "pws.dashboard.noUsage": "Noch keine Nutzungsdaten", + "pws.dashboard.noRateLimits": "Noch keine Limit-Daten", "pws.allProviders": "Anbieterübersicht", "pws.enabledLabel": "Aktiviert", "pws.testConnection": "Verbindung testen", @@ -1310,6 +1360,7 @@ export const de: Record = { "codexAuth.addIdPlaceholder": "codex-work, codex-alt, team…", "codexAuth.resetCreditsAria": "{count} Reset-Guthaben", "claude.pageTitle": "Claude Code", + "claude.workspace.settings": "Einstellungen", // Combos workspace "cws.loading": "Combos werden geladen…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c298181b6..0192e6ac1 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -37,6 +37,7 @@ export const en = { "startup.title": "Startup safety", "startup.subtitle": "Verify that Codex can reach opencodex after a restart, before local proxy routing becomes a reconnect loop.", "startup.refresh": "Refresh", + "startup.backToDashboard": "Back to Dashboard", "startup.loading": "Checking startup protection…", "startup.error": "Could not read startup protection.", "startup.staleData": "The latest startup check failed. The values below are stale and must not be treated as proof of protection.", @@ -80,8 +81,12 @@ export const en = { "startup.installedDisabled": "Installed but disabled", "startup.install": "Install", "startup.installing": "Installing…", + "startup.repair": "Repair", + "startup.repairing": "Repairing…", "startup.serviceInstalled": "Background service installed successfully.", + "startup.serviceRepaired": "Background service repaired successfully.", "startup.shimInstalled": "Codex launcher shim installed successfully.", + "startup.shimRepaired": "Codex launcher shim repaired successfully.", "startup.installFailed": "Installation failed:", "startup.tray.title": "Windows system tray", "startup.tray.hint": "Install a login tray icon for one-click proxy start, stop, restart, dashboard, and status controls.", @@ -442,6 +447,8 @@ export const en = { "models.discoveryFailedNetwork": "Model discovery failed due to a network error.", "models.discoveryFailedProvider": "The provider reported a model discovery error.", "models.discoveryFailedGeneric": "Model discovery failed.", + "models.clientHiddenBadge": "Hidden from clients", + "models.clientDegradedBadge": "Unavailable for new sessions", "models.openProviderSettings": "Open provider settings", "models.loading": "Loading…", "models.search": "Search models…", @@ -466,6 +473,14 @@ export const en = { "sub.moveUp": "Move {m} up", "sub.moveDown": "Move {m} down", "sub.removeAria": "Remove {m}", + "sub.workspace.addToFeatured": "Add {m} to featured", + "sub.workspace.allModels": "All models", + "sub.workspace.featuredFull": "Featured list is full (max 5)", + "sub.workspace.mainAria": "Subagent model details", + "sub.workspace.notFeatured": "Not featured", + "sub.workspace.priority": "Priority", + "sub.workspace.removeFromFeatured": "Remove {m} from featured", + "sub.workspace.selector": "Public selector", // logs "logs.title": "Request Logs", @@ -489,6 +504,7 @@ export const en = { "logs.conversation.excluded": "({unpriced} unpriced, {unmetered} unmetered excluded from ~$)", "logs.detail.conversation": "Conversation", "logs.badge.claude": "Claude", + "logs.badge.grok": "Grok", "logs.col.time": "Time", "logs.col.request": "Request", "logs.col.model": "Model", @@ -548,6 +564,7 @@ export const en = { "logs.tokens.cacheWrite": "cache write (w)", "logs.tokens.reasoning": "reasoning", "logs.tokens.noCache": "no cache data", + "logs.tokens.contextTotal": "active context", "logs.tokens.noCacheNote": "this provider does not report cache tokens", "logs.tokens.noCacheCursor": "Cursor cache detail unreported", "logs.tokens.noCacheCursorNote": "Cursor does not expose cache read/write token counts; this is unknown, not a confirmed cache miss", @@ -641,11 +658,19 @@ export const en = { "storage.empty": "CODEX_HOME is empty or missing — nothing to report.", "storage.error": "Storage scan failed. Check that CODEX_HOME points at a valid directory.", "storage.refresh": "Rescan", + "storage.rescanned": "Scan complete.", "storage.card.total": "Total size", "storage.card.files": "Files", "storage.card.home": "CODEX_HOME", + "storage.snapshot.lastScan": "Last scan", + "storage.cleanupCard.title": "Free up space", + "storage.cleanupCard.tabs": "Cleanup options", + "storage.cleanupCard.tab.policy": "Policy", + "storage.cleanupCard.tab.quarantine": "Quarantine", + "storage.cleanup.noArchives": "No archived sessions to clean up.", "storage.section.buckets": "Buckets", "storage.section.largest": "Largest files", + "storage.workspace.selectBucket": "Select a bucket from the list to see its breakdown.", "storage.col.bucket": "Bucket", "storage.col.size": "Size", "storage.col.files": "Files", @@ -731,9 +756,16 @@ export const en = { "storage.policy.enabled": "Enable auto-cleanup", "storage.policy.enabledHint": "Default is off. Enabling runs only on the schedule you choose (or Run now).", "storage.policy.threshold": "Trigger when archived size exceeds (GiB)", + "storage.policy.trigger": "Trigger", "storage.policy.target": "Cleanup target", "storage.policy.targetPercent": "Remove oldest archived percent", "storage.policy.targetReduce": "Reduce archived size to (GiB)", + "storage.policy.thresholdInc": "Increase threshold", + "storage.policy.thresholdDec": "Decrease threshold", + "storage.policy.percentInc": "Increase percent", + "storage.policy.percentDec": "Decrease percent", + "storage.policy.reduceInc": "Increase reduce-to size", + "storage.policy.reduceDec": "Decrease reduce-to size", "storage.policy.schedule": "Schedule", "storage.policy.schedule.manual": "Manual only", "storage.policy.schedule.startup": "On proxy startup", @@ -992,6 +1024,7 @@ export const en = { "pws.dashboard.checkedAgo": "Checked {time}", "pws.dashboard.noQuota": "No quota data", "pws.dashboard.noUsage": "No usage data yet", + "pws.dashboard.noRateLimits": "No rate-limit data yet", "pws.allProviders": "Provider Overview", "pws.enabledLabel": "Enabled", "pws.testConnection": "Test connection", @@ -1109,6 +1142,8 @@ export const en = { "codexAuth.autoSwitchOffDesc": "Automatic account switching is off", "codexAuth.autoSwitchThreshold": "Switch threshold", "codexAuth.autoSwitchThresholdAria": "Switch threshold, percent", + "codexAuth.autoSwitchThresholdInc": "Increase switch threshold", + "codexAuth.autoSwitchThresholdDec": "Decrease switch threshold", "codexAuth.autoSwitchLoadFailed": "Automatic switching setting could not be loaded.", "codexAuth.autoSwitchThresholdInvalid": "Enter a whole number from 1 to 100", "codexAuth.autoSwitchUpdated": "Automatic account switching updated", @@ -1136,6 +1171,8 @@ export const en = { "accountPool.strategyHint": "Applies to new sessions only; existing threads keep account affinity.", "accountPool.stickyLimit": "Sticky successes before rotate", "accountPool.stickyLimitAria": "Sticky successes before rotate", + "accountPool.stickyLimitInc": "Increase sticky limit", + "accountPool.stickyLimitDec": "Decrease sticky limit", "accountPool.stickyLimitHelp": "Keep the selected account for this many successful new-session binds before advancing.", "accountPool.stickyLimitInvalid": "Enter a whole number from 1 to 100", "accountPool.strategyLoadFailed": "Rotation strategy could not be loaded.", @@ -1228,7 +1265,20 @@ export const en = { "api.generate": "Generate", "api.generating": "Creating…", "api.activeKeys": "Active keys ({count})", + "api.activeKeysLoading": "Active keys", "api.noKeys": "No API keys yet. Generate one above.", + "api.workspace.overview": "Overview", + "api.workspace.details": "API key details", + "api.workspace.keyDetails": "Key details", + "api.workspace.keyPrefix": "Key prefix", + "api.workspace.deleteKey": "Delete key", + "api.workspace.deleteConfirm": "Are you sure you want to delete this key? This cannot be undone.", + "api.workspace.noKeysHint": "No API keys yet. Generate one to get started.", + "api.workspace.usageExamples": "Usage examples", + "api.copyUrlHint": "Click to copy URL", + "api.urlCopied": "URL copied", + "api.copyExampleHint": "Click to copy example", + "api.exampleCopied": "Example copied", "api.colName": "Name", "api.colKey": "Key", "api.colCreated": "Created", @@ -1267,6 +1317,7 @@ export const en = { "nav.claude": "Claude", "claude.subtitle": "Use GPT, Gemini, and other models inside Claude Code.", "claude.pageTitle": "Claude Code", + "claude.workspace.settings": "Settings", "claude.enabledLabel": "Claude connection", "claude.enabledHint": "When off, Claude Code cannot use this proxy.", "claude.authMode": "Auth Mode", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 85cdd2749..33d6b6e23 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -37,6 +37,7 @@ export const ja: Record = { "startup.title": "起動安全性", "startup.subtitle": "再起動後にローカルプロキシへの接続が再接続ループになる前に、Codex が opencodex へ到達できるか確認します。", "startup.refresh": "更新", + "startup.backToDashboard": "ダッシュボードに戻る", "startup.loading": "起動保護を確認中…", "startup.error": "起動保護を読み取れませんでした。", "startup.staleData": "最新の確認に失敗しました。以下は古い値であり、保護の証明にはなりません。", @@ -80,8 +81,12 @@ export const ja: Record = { "startup.installedDisabled": "インストール済み・無効", "startup.install": "インストール", "startup.installing": "インストール中…", + "startup.repair": "修復", + "startup.repairing": "修復中…", "startup.serviceInstalled": "バックグラウンドサービスをインストールしました。", + "startup.serviceRepaired": "バックグラウンドサービスを修復しました。", "startup.shimInstalled": "Codex ランチャー shim をインストールしました。", + "startup.shimRepaired": "Codex ランチャー shim を修復しました。", "startup.installFailed": "インストールに失敗しました:", "startup.tray.title": "Windows システムトレイ", "startup.tray.hint": "ログイン時にトレイを起動し、プロキシの開始・停止・再起動・ダッシュボード・状態をクリックで操作します。", @@ -429,6 +434,8 @@ export const ja: Record = { "models.discoveryFailedNetwork": "ネットワークエラーによりモデル検出に失敗しました。", "models.discoveryFailedProvider": "プロバイダーがモデル検出エラーを報告しました。", "models.discoveryFailedGeneric": "モデル検出に失敗しました。", + "models.clientHiddenBadge": "クライアントから非表示", + "models.clientDegradedBadge": "新規セッションでは利用不可", "models.openProviderSettings": "プロバイダー設定を開く", "models.loading": "読み込み中…", "models.search": "モデルを検索…", @@ -453,6 +460,14 @@ export const ja: Record = { "sub.moveUp": "{m} を上へ移動", "sub.moveDown": "{m} を下へ移動", "sub.removeAria": "{m} を削除", + "sub.workspace.addToFeatured": "{m} をおすすめに追加", + "sub.workspace.allModels": "すべてのモデル", + "sub.workspace.featuredFull": "おすすめリストがいっぱいです(最大 5)", + "sub.workspace.mainAria": "サブエージェントのモデル詳細", + "sub.workspace.notFeatured": "おすすめ未設定", + "sub.workspace.priority": "優先度", + "sub.workspace.removeFromFeatured": "{m} をおすすめから削除", + "sub.workspace.selector": "公開セレクター", // logs "logs.title": "リクエストログ", @@ -476,6 +491,7 @@ export const ja: Record = { "logs.conversation.excluded": "(~$ から価格なし {unpriced} / 未計測 {unmetered} を除外)", "logs.detail.conversation": "会話", "logs.badge.claude": "Claude", + "logs.badge.grok": "Grok", "logs.col.time": "時刻", "logs.col.request": "リクエスト", "logs.col.model": "モデル", @@ -535,6 +551,7 @@ export const ja: Record = { "logs.tokens.cacheWrite": "キャッシュ書き込み (w)", "logs.tokens.reasoning": "推論", "logs.tokens.noCache": "キャッシュデータなし", + "logs.tokens.contextTotal": "アクティブコンテキスト", "logs.tokens.noCacheNote": "このプロバイダーはキャッシュトークンを報告しません", "logs.tokens.noCacheCursor": "Cursor のキャッシュ詳細は未報告", "logs.tokens.noCacheCursorNote": "Cursor はキャッシュ read/write トークン数を公開しません。これは不明という意味で、キャッシュミスの確定ではありません", @@ -628,11 +645,19 @@ export const ja: Record = { "storage.empty": "CODEX_HOME が空か存在しません — 報告するものはありません。", "storage.error": "ストレージのスキャンに失敗しました。CODEX_HOME が有効なディレクトリを指しているか確認してください。", "storage.refresh": "再スキャン", + "storage.rescanned": "スキャンが完了しました。", "storage.card.total": "合計サイズ", "storage.card.files": "ファイル", "storage.card.home": "CODEX_HOME", + "storage.snapshot.lastScan": "最終スキャン", + "storage.cleanupCard.title": "容量を空ける", + "storage.cleanupCard.tabs": "クリーンアップオプション", + "storage.cleanupCard.tab.policy": "ポリシー", + "storage.cleanupCard.tab.quarantine": "隔離", + "storage.cleanup.noArchives": "クリーンアップ対象のアーカイブセッションはありません。", "storage.section.buckets": "バケット", "storage.section.largest": "最大ファイル", + "storage.workspace.selectBucket": "一覧からバケットを選ぶと内訳が表示されます。", "storage.col.bucket": "バケット", "storage.col.size": "サイズ", "storage.col.files": "ファイル", @@ -718,9 +743,16 @@ export const ja: Record = { "storage.policy.enabled": "自動クリーンアップを有効化", "storage.policy.enabledHint": "既定はオフです。有効にすると選択したスケジュール(または今すぐ実行)でのみ動きます。", "storage.policy.threshold": "アーカイブサイズが超えたら実行(GiB)", + "storage.policy.trigger": "トリガー", "storage.policy.target": "クリーンアップ目標", "storage.policy.targetPercent": "古いアーカイブの割合を削除", "storage.policy.targetReduce": "アーカイブを次のサイズまで縮小(GiB)", + "storage.policy.thresholdInc": "しきい値を上げる", + "storage.policy.thresholdDec": "しきい値を下げる", + "storage.policy.percentInc": "パーセントを上げる", + "storage.policy.percentDec": "パーセントを下げる", + "storage.policy.reduceInc": "削減目標を上げる", + "storage.policy.reduceDec": "削減目標を下げる", "storage.policy.schedule": "スケジュール", "storage.policy.schedule.manual": "手動のみ", "storage.policy.schedule.startup": "プロキシ起動時", @@ -969,6 +1001,7 @@ export const ja: Record = { "pws.dashboard.checkedAgo": "{time} に確認", "pws.dashboard.noQuota": "クォータデータなし", "pws.dashboard.noUsage": "まだ使用量データはありません", + "pws.dashboard.noRateLimits": "まだレート制限データはありません", "pws.allProviders": "プロバイダー概要", "pws.enabledLabel": "有効", "pws.testConnection": "接続テスト", @@ -1086,6 +1119,8 @@ export const ja: Record = { "codexAuth.autoSwitchOffDesc": "アカウントの自動切り替えはオフです", "codexAuth.autoSwitchThreshold": "切り替えしきい値", "codexAuth.autoSwitchThresholdAria": "切り替えしきい値(パーセント)", + "codexAuth.autoSwitchThresholdInc": "切替しきい値を上げる", + "codexAuth.autoSwitchThresholdDec": "切替しきい値を下げる", "codexAuth.autoSwitchLoadFailed": "アカウントの自動切り替え設定を読み込めませんでした。", "codexAuth.autoSwitchThresholdInvalid": "1 から 100 までの整数を入力してください", "codexAuth.autoSwitchUpdated": "アカウントの自動切り替え設定を更新しました", @@ -1112,6 +1147,8 @@ export const ja: Record = { "accountPool.strategyHint": "新規セッションにのみ適用されます。既存スレッドはアカウント親和性を維持します。", "accountPool.stickyLimit": "ローテーション前の固定成功数", "accountPool.stickyLimitAria": "ローテーション前の固定成功数", + "accountPool.stickyLimitInc": "スティッキー上限を上げる", + "accountPool.stickyLimitDec": "スティッキー上限を下げる", "accountPool.stickyLimitHelp": "次へ進む前に、選んだアカウントをこの回数の成功した新規セッション紐付け分保持します。", "accountPool.stickyLimitInvalid": "1 から 100 までの整数を入力してください", "accountPool.strategyLoadFailed": "ローテーション戦略を読み込めませんでした。", @@ -1228,7 +1265,20 @@ export const ja: Record = { "api.generate": "生成", "api.generating": "作成中…", "api.activeKeys": "アクティブなキー ({count})", + "api.activeKeysLoading": "有効なキー", "api.noKeys": "まだ API キーがありません。上で生成してください。", + "api.workspace.overview": "概要", + "api.workspace.details": "APIキーの詳細", + "api.workspace.keyDetails": "キーの詳細", + "api.workspace.keyPrefix": "キーのプレフィックス", + "api.workspace.deleteKey": "キーを削除", + "api.workspace.deleteConfirm": "このキーを削除しますか?この操作は元に戻せません。", + "api.workspace.noKeysHint": "API キーがまだありません。作成して始めましょう。", + "api.workspace.usageExamples": "使用例", + "api.copyUrlHint": "クリックして URL をコピー", + "api.urlCopied": "URL をコピーしました", + "api.copyExampleHint": "クリックして例をコピー", + "api.exampleCopied": "例をコピーしました", "api.colName": "名前", "api.colKey": "キー", "api.colCreated": "作成日", @@ -1242,6 +1292,7 @@ export const ja: Record = { "nav.claude": "Claude", "claude.subtitle": "Claude Code 内で GPT、Gemini などのモデルを使用します。", "claude.pageTitle": "Claude Code", + "claude.workspace.settings": "設定", "claude.enabledLabel": "Claude 接続", "claude.enabledHint": "オフにすると Claude Code はこのプロキシを使用できません。", "claude.authMode": "認証モード", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5754558fb..bc558b44a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -32,6 +32,7 @@ export const ko: Record = { "startup.title": "시작 안전성", "startup.subtitle": "재부팅 후 로컬 프록시 라우팅이 재연결 반복으로 이어지기 전에 Codex가 opencodex에 연결될 수 있는지 확인합니다.", "startup.refresh": "새로고침", + "startup.backToDashboard": "대시보드로 돌아가기", "startup.loading": "시작 보호 상태 확인 중…", "startup.error": "시작 보호 상태를 읽지 못했습니다.", "startup.staleData": "최신 시작 상태 확인에 실패했습니다. 아래 값은 이전 결과이며 보호 증거로 사용하면 안 됩니다.", @@ -75,8 +76,12 @@ export const ko: Record = { "startup.installedDisabled": "설치됐지만 꺼짐", "startup.install": "설치하기", "startup.installing": "설치 중…", + "startup.repair": "복구", + "startup.repairing": "복구 중…", "startup.serviceInstalled": "백그라운드 서비스를 설치했습니다.", + "startup.serviceRepaired": "백그라운드 서비스를 복구했습니다.", "startup.shimInstalled": "Codex launcher shim을 설치했습니다.", + "startup.shimRepaired": "Codex launcher shim을 복구했습니다.", "startup.installFailed": "설치하지 못했습니다:", "startup.tray.title": "Windows 시스템 트레이", "startup.tray.hint": "로그인할 때 트레이 아이콘을 띄우고 프록시 시작·중지·재시작·대시보드·상태를 클릭으로 제어합니다.", @@ -436,6 +441,8 @@ export const ko: Record = { "models.discoveryFailedNetwork": "네트워크 오류로 모델 검색에 실패했습니다.", "models.discoveryFailedProvider": "프로바이더가 모델 검색 오류를 보고했습니다.", "models.discoveryFailedGeneric": "모델 검색에 실패했습니다.", + "models.clientHiddenBadge": "클라이언트에서 숨김", + "models.clientDegradedBadge": "새 세션에서 사용 불가", "models.openProviderSettings": "프로바이더 설정 열기", "models.loading": "불러오는 중…", "models.search": "모델 검색…", @@ -460,6 +467,14 @@ export const ko: Record = { "sub.moveUp": "{m} 위로 이동", "sub.moveDown": "{m} 아래로 이동", "sub.removeAria": "{m} 삭제", + "sub.workspace.addToFeatured": "{m}을(를) 추천에 추가", + "sub.workspace.allModels": "모든 모델", + "sub.workspace.featuredFull": "추천 목록이 가득 찼습니다 (최대 5개)", + "sub.workspace.mainAria": "서브에이전트 모델 세부 정보", + "sub.workspace.notFeatured": "추천되지 않음", + "sub.workspace.priority": "우선순위", + "sub.workspace.removeFromFeatured": "{m}을(를) 추천에서 제거", + "sub.workspace.selector": "공개 셀렉터", // logs "logs.title": "요청 로그", @@ -483,6 +498,7 @@ export const ko: Record = { "logs.conversation.excluded": "(~$에서 가격 없음 {unpriced}건, 미측정 {unmetered}건 제외)", "logs.detail.conversation": "대화", "logs.badge.claude": "Claude", + "logs.badge.grok": "Grok", "logs.col.time": "시간", "logs.col.request": "요청", "logs.col.model": "모델", @@ -542,6 +558,7 @@ export const ko: Record = { "logs.tokens.cacheWrite": "캐시 생성 (w)", "logs.tokens.reasoning": "추론", "logs.tokens.noCache": "캐시 미보고", + "logs.tokens.contextTotal": "활성 컨텍스트", "logs.tokens.noCacheNote": "이 프로바이더는 캐시 토큰 수치를 제공하지 않습니다", "logs.tokens.noCacheCursor": "Cursor 캐시 상세 미보고", "logs.tokens.noCacheCursorNote": "Cursor 프로토콜은 캐시 read/write 토큰 수치를 제공하지 않습니다. 캐시 미스가 확인됐다는 뜻은 아닙니다", @@ -710,6 +727,8 @@ export const ko: Record = { "codexAuth.autoSwitchOffDesc": "자동 계정 전환이 꺼져 있습니다", "codexAuth.autoSwitchThreshold": "전환 임계값", "codexAuth.autoSwitchThresholdAria": "전환 임계값(퍼센트)", + "codexAuth.autoSwitchThresholdInc": "전환 임계값 증가", + "codexAuth.autoSwitchThresholdDec": "전환 임계값 감소", "codexAuth.autoSwitchLoadFailed": "자동 계정 전환 설정을 불러오지 못했습니다.", "codexAuth.autoSwitchThresholdInvalid": "1~100 사이의 정수를 입력하세요", "codexAuth.autoSwitchUpdated": "자동 계정 전환 설정을 저장했습니다", @@ -736,6 +755,8 @@ export const ko: Record = { "accountPool.strategyHint": "새 세션에만 적용됩니다. 기존 스레드는 계정 어피니티를 유지합니다.", "accountPool.stickyLimit": "회전 전 sticky 성공 횟수", "accountPool.stickyLimitAria": "회전 전 sticky 성공 횟수", + "accountPool.stickyLimitInc": "스티키 한도 증가", + "accountPool.stickyLimitDec": "스티키 한도 감소", "accountPool.stickyLimitHelp": "다음으로 진행하기 전에 선택된 계정을 이 횟수의 성공한 새 세션 바인딩 동안 유지합니다.", "accountPool.stickyLimitInvalid": "1에서 100 사이의 정수를 입력하세요", "accountPool.strategyLoadFailed": "로테이션 전략을 불러오지 못했습니다.", @@ -850,7 +871,20 @@ export const ko: Record = { "api.generate": "생성", "api.generating": "생성 중…", "api.activeKeys": "활성 키 ({count})", + "api.activeKeysLoading": "활성 키", "api.noKeys": "아직 API 키가 없습니다. 위에서 하나 생성하세요.", + "api.workspace.overview": "개요", + "api.workspace.details": "API 키 세부 정보", + "api.workspace.keyDetails": "키 세부 정보", + "api.workspace.keyPrefix": "키 접두사", + "api.workspace.deleteKey": "키 삭제", + "api.workspace.deleteConfirm": "이 키를 삭제하시겠습니까? 되돌릴 수 없습니다.", + "api.workspace.noKeysHint": "아직 API 키가 없습니다. 키를 생성하여 시작하세요.", + "api.workspace.usageExamples": "사용 예제", + "api.copyUrlHint": "클릭하여 URL 복사", + "api.urlCopied": "URL 복사됨", + "api.copyExampleHint": "클릭하여 예제 복사", + "api.exampleCopied": "예제 복사됨", "api.colName": "이름", "api.colKey": "키", "api.colCreated": "생성일", @@ -948,11 +982,19 @@ export const ko: Record = { "storage.empty": "CODEX_HOME이 비어 있거나 없습니다 — 표시할 내용이 없습니다.", "storage.error": "저장소 스캔에 실패했습니다. CODEX_HOME이 올바른 디렉터리를 가리키는지 확인하세요.", "storage.refresh": "다시 스캔", + "storage.rescanned": "스캔이 완료되었습니다.", "storage.card.total": "전체 크기", "storage.card.files": "파일 수", "storage.card.home": "CODEX_HOME", + "storage.snapshot.lastScan": "마지막 스캔", + "storage.cleanupCard.title": "공간 확보", + "storage.cleanupCard.tabs": "정리 옵션", + "storage.cleanupCard.tab.policy": "정책", + "storage.cleanupCard.tab.quarantine": "격리", + "storage.cleanup.noArchives": "정리할 보관 세션이 없습니다.", "storage.section.buckets": "버킷", "storage.section.largest": "가장 큰 파일", + "storage.workspace.selectBucket": "목록에서 버킷을 선택하면 세부 내역을 볼 수 있습니다.", "storage.col.bucket": "버킷", "storage.col.size": "크기", "storage.col.files": "파일", @@ -1038,9 +1080,16 @@ export const ko: Record = { "storage.policy.enabled": "자동 정리 사용", "storage.policy.enabledHint": "기본은 꺼짐입니다. 켜면 선택한 일정(또는 지금 실행)에만 동작합니다.", "storage.policy.threshold": "보관 용량이 초과하면 실행 (GiB)", + "storage.policy.trigger": "트리거", "storage.policy.target": "정리 목표", "storage.policy.targetPercent": "가장 오래된 보관 비율 제거", "storage.policy.targetReduce": "보관 용량을 다음까지 줄이기 (GiB)", + "storage.policy.thresholdInc": "임계값 증가", + "storage.policy.thresholdDec": "임계값 감소", + "storage.policy.percentInc": "퍼센트 증가", + "storage.policy.percentDec": "퍼센트 감소", + "storage.policy.reduceInc": "축소 목표 증가", + "storage.policy.reduceDec": "축소 목표 감소", "storage.policy.schedule": "일정", "storage.policy.schedule.manual": "수동만", "storage.policy.schedule.startup": "프록시 시작 시", @@ -1275,6 +1324,7 @@ export const ko: Record = { "pws.dashboard.checkedAgo": "{time} 전 확인", "pws.dashboard.noQuota": "할당량 데이터 없음", "pws.dashboard.noUsage": "아직 사용 데이터 없음", + "pws.dashboard.noRateLimits": "아직 한도 데이터 없음", "pws.allProviders": "프로바이더 개요", "pws.enabledLabel": "활성화", "pws.testConnection": "연결 테스트", @@ -1330,6 +1380,7 @@ export const ko: Record = { "codexAuth.addIdPlaceholder": "codex-work, codex-alt, team…", "codexAuth.resetCreditsAria": "리셋 크레딧 {count}개", "claude.pageTitle": "Claude Code", + "claude.workspace.settings": "설정", // Combos workspace "cws.loading": "콤보 불러오는 중…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 8c7aff8b1..01a87a664 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -37,6 +37,7 @@ export const ru: Record = { "startup.title": "Безопасность запуска", "startup.subtitle": "Проверьте, сможет ли Codex подключиться к opencodex после перезагрузки, прежде чем локальный прокси вызовет бесконечное переподключение.", "startup.refresh": "Обновить", + "startup.backToDashboard": "Назад к панели", "startup.loading": "Проверка защиты запуска…", "startup.error": "Не удалось прочитать состояние защиты запуска.", "startup.staleData": "Последняя проверка не удалась. Значения ниже устарели и не подтверждают защиту.", @@ -80,8 +81,12 @@ export const ru: Record = { "startup.installedDisabled": "Установлен, но отключён", "startup.install": "Установить", "startup.installing": "Установка…", + "startup.repair": "Исправить", + "startup.repairing": "Исправление…", "startup.serviceInstalled": "Фоновая служба успешно установлена.", + "startup.serviceRepaired": "Фоновая служба успешно исправлена.", "startup.shimInstalled": "Launcher shim Codex успешно установлен.", + "startup.shimRepaired": "Launcher shim Codex успешно исправлен.", "startup.installFailed": "Не удалось установить:", "startup.tray.title": "Системный трей Windows", "startup.tray.hint": "Запускает значок при входе для управления запуском, остановкой, перезапуском, панелью и состоянием прокси.", @@ -461,6 +466,8 @@ export const ru: Record = { "models.discoveryFailedNetwork": "Обнаружение моделей не удалось из-за сетевой ошибки.", "models.discoveryFailedProvider": "Провайдер сообщил об ошибке обнаружения моделей.", "models.discoveryFailedGeneric": "Не удалось обнаружить модели.", + "models.clientHiddenBadge": "Скрыто от клиентов", + "models.clientDegradedBadge": "Недоступно для новых сессий", "models.openProviderSettings": "Открыть настройки провайдера", "models.loading": "Загрузка…", "models.search": "Поиск моделей…", @@ -485,6 +492,14 @@ export const ru: Record = { "sub.moveUp": "Переместить {m} вверх", "sub.moveDown": "Переместить {m} вниз", "sub.removeAria": "Убрать {m}", + "sub.workspace.addToFeatured": "Добавить {m} в избранные", + "sub.workspace.allModels": "Все модели", + "sub.workspace.featuredFull": "Список избранных заполнен (макс. 5)", + "sub.workspace.mainAria": "Сведения о модели субагента", + "sub.workspace.notFeatured": "Не в избранных", + "sub.workspace.priority": "Приоритет", + "sub.workspace.removeFromFeatured": "Убрать {m} из избранных", + "sub.workspace.selector": "Публичный селектор", // logs "logs.title": "Журнал запросов", @@ -508,6 +523,7 @@ export const ru: Record = { "logs.conversation.excluded": "(из ~$ исключены {unpriced} без цены, {unmetered} без учёта)", "logs.detail.conversation": "Диалог", "logs.badge.claude": "Claude", + "logs.badge.grok": "Grok", "logs.col.time": "Время", "logs.col.request": "Запрос", "logs.col.model": "Модель", @@ -567,6 +583,7 @@ export const ru: Record = { "logs.tokens.cacheWrite": "запись кэша (w)", "logs.tokens.reasoning": "рассуждения", "logs.tokens.noCache": "нет данных кэша", + "logs.tokens.contextTotal": "активный контекст", "logs.tokens.noCacheNote": "этот провайдер не сообщает данные о кэш-токенах", "logs.tokens.noCacheCursor": "детализация кэша Cursor не сообщается", "logs.tokens.noCacheCursorNote": "Cursor не передает число токенов чтения/записи кэша; это неизвестно, а не подтвержденный промах кэша", @@ -660,11 +677,19 @@ export const ru: Record = { "storage.empty": "CODEX_HOME пуст или отсутствует — показывать нечего.", "storage.error": "Не удалось просканировать хранилище. Убедитесь, что CODEX_HOME указывает на корректный каталог.", "storage.refresh": "Пересканировать", + "storage.rescanned": "Сканирование завершено.", "storage.card.total": "Общий размер", "storage.card.files": "Файлы", "storage.card.home": "CODEX_HOME", + "storage.snapshot.lastScan": "Последний скан", + "storage.cleanupCard.title": "Освободить место", + "storage.cleanupCard.tabs": "Параметры очистки", + "storage.cleanupCard.tab.policy": "Политика", + "storage.cleanupCard.tab.quarantine": "Карантин", + "storage.cleanup.noArchives": "Нет архивных сессий для очистки.", "storage.section.buckets": "Категории", "storage.section.largest": "Крупнейшие файлы", + "storage.workspace.selectBucket": "Выберите сегмент в списке, чтобы увидеть разбивку.", "storage.col.bucket": "Категория", "storage.col.size": "Размер", "storage.col.files": "Файлы", @@ -750,9 +775,16 @@ export const ru: Record = { "storage.policy.enabled": "Включить автоочистку", "storage.policy.enabledHint": "По умолчанию выкл. При включении работает только по выбранному расписанию (или «Запустить сейчас»).", "storage.policy.threshold": "Запуск, если размер архива больше (ГиБ)", + "storage.policy.trigger": "Триггер", "storage.policy.target": "Цель очистки", "storage.policy.targetPercent": "Удалить процент самых старых архивов", "storage.policy.targetReduce": "Уменьшить архив до (ГиБ)", + "storage.policy.thresholdInc": "Увеличить порог", + "storage.policy.thresholdDec": "Уменьшить порог", + "storage.policy.percentInc": "Увеличить процент", + "storage.policy.percentDec": "Уменьшить процент", + "storage.policy.reduceInc": "Увеличить целевой размер", + "storage.policy.reduceDec": "Уменьшить целевой размер", "storage.policy.schedule": "Расписание", "storage.policy.schedule.manual": "Только вручную", "storage.policy.schedule.startup": "При запуске прокси", @@ -1011,6 +1043,7 @@ export const ru: Record = { "pws.dashboard.checkedAgo": "Проверено {time}", "pws.dashboard.noQuota": "Нет данных о квоте", "pws.dashboard.noUsage": "Данных об использовании пока нет", + "pws.dashboard.noRateLimits": "Данных о лимитах пока нет", "pws.allProviders": "Обзор провайдеров", "pws.enabledLabel": "Включён", "pws.testConnection": "Проверить подключение", @@ -1128,6 +1161,8 @@ export const ru: Record = { "codexAuth.autoSwitchOffDesc": "Автоматическое переключение аккаунтов выключено", "codexAuth.autoSwitchThreshold": "Порог переключения", "codexAuth.autoSwitchThresholdAria": "Порог переключения в процентах", + "codexAuth.autoSwitchThresholdInc": "Увеличить порог переключения", + "codexAuth.autoSwitchThresholdDec": "Уменьшить порог переключения", "codexAuth.autoSwitchLoadFailed": "Не удалось загрузить настройку автоматического переключения аккаунтов.", "codexAuth.autoSwitchThresholdInvalid": "Введите целое число от 1 до 100", "codexAuth.autoSwitchUpdated": "Настройки автоматического переключения обновлены", @@ -1154,6 +1189,8 @@ export const ru: Record = { "accountPool.strategyHint": "Применяется только к новым сессиям; существующие треды сохраняют привязку к аккаунту.", "accountPool.stickyLimit": "Успешных запросов до ротации", "accountPool.stickyLimitAria": "Успешных запросов до ротации", + "accountPool.stickyLimitInc": "Увеличить sticky-лимит", + "accountPool.stickyLimitDec": "Уменьшить sticky-лимит", "accountPool.stickyLimitHelp": "Удерживать выбранный аккаунт на указанное число успешных привязок новых сессий, прежде чем перейти дальше.", "accountPool.stickyLimitInvalid": "Введите целое число от 1 до 100", "accountPool.strategyLoadFailed": "Не удалось загрузить стратегию ротации.", @@ -1270,7 +1307,20 @@ export const ru: Record = { "api.generate": "Сгенерировать", "api.generating": "Создание…", "api.activeKeys": "Активные ключи ({count})", + "api.activeKeysLoading": "Активные ключи", "api.noKeys": "API-ключей пока нет. Сгенерируйте ключ выше.", + "api.workspace.overview": "Обзор", + "api.workspace.details": "Сведения об API-ключе", + "api.workspace.keyDetails": "Сведения о ключе", + "api.workspace.keyPrefix": "Префикс ключа", + "api.workspace.deleteKey": "Удалить ключ", + "api.workspace.deleteConfirm": "Удалить этот ключ? Это действие нельзя отменить.", + "api.workspace.noKeysHint": "API-ключей пока нет. Создайте один, чтобы начать.", + "api.workspace.usageExamples": "Примеры использования", + "api.copyUrlHint": "Нажмите, чтобы скопировать URL", + "api.urlCopied": "URL скопирован", + "api.copyExampleHint": "Нажмите, чтобы скопировать пример", + "api.exampleCopied": "Пример скопирован", "api.colName": "Имя", "api.colKey": "Ключ", "api.colCreated": "Создан", @@ -1284,6 +1334,7 @@ export const ru: Record = { "nav.claude": "Claude", "claude.subtitle": "Используйте GPT, Gemini и другие модели внутри Claude Code.", "claude.pageTitle": "Claude Code", + "claude.workspace.settings": "Настройки", "claude.enabledLabel": "Подключение Claude", "claude.enabledHint": "Если выключено, Claude Code не сможет использовать этот прокси.", "claude.authMode": "Режим аутентификации", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index d9f503b41..9ec90b6bf 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -32,6 +32,7 @@ export const zh: Record = { "startup.title": "启动安全", "startup.subtitle": "检查重启后 Codex 是否仍能连接 opencodex,避免本地代理路由陷入重复重连。", "startup.refresh": "刷新", + "startup.backToDashboard": "返回仪表盘", "startup.loading": "正在检查启动保护…", "startup.error": "无法读取启动保护状态。", "startup.staleData": "最新启动检查失败。以下数据已过期,不能视为已受保护的证明。", @@ -75,8 +76,12 @@ export const zh: Record = { "startup.installedDisabled": "已安装但禁用", "startup.install": "安装", "startup.installing": "正在安装…", + "startup.repair": "修复", + "startup.repairing": "正在修复…", "startup.serviceInstalled": "后台服务安装成功。", + "startup.serviceRepaired": "后台服务修复成功。", "startup.shimInstalled": "Codex 启动器 shim 安装成功。", + "startup.shimRepaired": "Codex 启动器 shim 修复成功。", "startup.installFailed": "安装失败:", "startup.tray.title": "Windows 系统托盘", "startup.tray.hint": "登录时启动托盘图标,一键控制代理启动、停止、重启、面板和状态。", @@ -436,6 +441,8 @@ export const zh: Record = { "models.discoveryFailedNetwork": "由于网络错误,模型发现失败。", "models.discoveryFailedProvider": "提供方报告了模型发现错误。", "models.discoveryFailedGeneric": "模型发现失败。", + "models.clientHiddenBadge": "已对客户端隐藏", + "models.clientDegradedBadge": "新会话不可用", "models.openProviderSettings": "打开提供方设置", "models.loading": "加载中…", "models.search": "搜索模型…", @@ -460,6 +467,14 @@ export const zh: Record = { "sub.moveUp": "上移 {m}", "sub.moveDown": "下移 {m}", "sub.removeAria": "移除 {m}", + "sub.workspace.addToFeatured": "将 {m} 添加到精选", + "sub.workspace.allModels": "所有模型", + "sub.workspace.featuredFull": "精选列表已满(最多 5 个)", + "sub.workspace.mainAria": "子代理模型详情", + "sub.workspace.notFeatured": "未设为精选", + "sub.workspace.priority": "优先级", + "sub.workspace.removeFromFeatured": "将 {m} 从精选中移除", + "sub.workspace.selector": "公开选择器", // logs "logs.title": "请求日志", @@ -483,6 +498,7 @@ export const zh: Record = { "logs.conversation.excluded": "(~$ 已排除 {unpriced} 条无定价、{unmetered} 条无计量)", "logs.detail.conversation": "会话", "logs.badge.claude": "Claude", + "logs.badge.grok": "Grok", "logs.col.time": "时间", "logs.col.request": "请求", "logs.col.model": "模型", @@ -542,6 +558,7 @@ export const zh: Record = { "logs.tokens.cacheWrite": "缓存写入 (w)", "logs.tokens.reasoning": "推理", "logs.tokens.noCache": "无缓存数据", + "logs.tokens.contextTotal": "活动上下文", "logs.tokens.noCacheNote": "该提供商不报告缓存 token 数", "logs.tokens.noCacheCursor": "Cursor 未报告缓存明细", "logs.tokens.noCacheCursorNote": "Cursor 不提供缓存读写 token 数;这表示未知,并不代表已确认缓存未命中", @@ -710,6 +727,8 @@ export const zh: Record = { "codexAuth.autoSwitchOffDesc": "自动切换账号已关闭", "codexAuth.autoSwitchThreshold": "切换阈值", "codexAuth.autoSwitchThresholdAria": "切换阈值(百分比)", + "codexAuth.autoSwitchThresholdInc": "提高切换阈值", + "codexAuth.autoSwitchThresholdDec": "降低切换阈值", "codexAuth.autoSwitchLoadFailed": "无法加载自动切换账号设置。", "codexAuth.autoSwitchThresholdInvalid": "请输入 1 到 100 之间的整数", "codexAuth.autoSwitchUpdated": "自动切换账号设置已更新", @@ -736,6 +755,8 @@ export const zh: Record = { "accountPool.strategyHint": "仅适用于新会话;现有线程保持账号亲和性。", "accountPool.stickyLimit": "轮换前的粘性成功次数", "accountPool.stickyLimitAria": "轮换前的粘性成功次数", + "accountPool.stickyLimitInc": "提高粘性上限", + "accountPool.stickyLimitDec": "降低粘性上限", "accountPool.stickyLimitHelp": "在推进到下一个账号之前,将所选账号保留这么多次成功的新会话绑定。", "accountPool.stickyLimitInvalid": "请输入 1 到 100 之间的整数", "accountPool.strategyLoadFailed": "无法加载轮换策略。", @@ -850,7 +871,20 @@ export const zh: Record = { "api.generate": "生成", "api.generating": "创建中…", "api.activeKeys": "活跃密钥({count})", + "api.activeKeysLoading": "有效密钥", "api.noKeys": "还没有 API 密钥。请在上方生成一个。", + "api.workspace.overview": "概览", + "api.workspace.details": "API 密钥详情", + "api.workspace.keyDetails": "密钥详情", + "api.workspace.keyPrefix": "密钥前缀", + "api.workspace.deleteKey": "删除密钥", + "api.workspace.deleteConfirm": "确定要删除此密钥吗?此操作无法撤销。", + "api.workspace.noKeysHint": "还没有 API 密钥。生成一个以开始使用。", + "api.workspace.usageExamples": "用法示例", + "api.copyUrlHint": "点击复制 URL", + "api.urlCopied": "已复制 URL", + "api.copyExampleHint": "点击复制示例", + "api.exampleCopied": "已复制示例", "api.colName": "名称", "api.colKey": "密钥", "api.colCreated": "创建时间", @@ -948,11 +982,19 @@ export const zh: Record = { "storage.empty": "CODEX_HOME 为空或不存在——没有可显示的内容。", "storage.error": "存储扫描失败。请检查 CODEX_HOME 是否指向有效目录。", "storage.refresh": "重新扫描", + "storage.rescanned": "扫描完成。", "storage.card.total": "总大小", "storage.card.files": "文件数", "storage.card.home": "CODEX_HOME", + "storage.snapshot.lastScan": "上次扫描", + "storage.cleanupCard.title": "释放空间", + "storage.cleanupCard.tabs": "清理选项", + "storage.cleanupCard.tab.policy": "策略", + "storage.cleanupCard.tab.quarantine": "隔离区", + "storage.cleanup.noArchives": "没有可清理的归档会话。", "storage.section.buckets": "分类", "storage.section.largest": "最大文件", + "storage.workspace.selectBucket": "从列表中选择一个存储桶以查看明细。", "storage.col.bucket": "分类", "storage.col.size": "大小", "storage.col.files": "文件", @@ -1038,9 +1080,16 @@ export const zh: Record = { "storage.policy.enabled": "启用自动清理", "storage.policy.enabledHint": "默认关闭。启用后仅按所选计划(或立即运行)执行。", "storage.policy.threshold": "归档大小超过时触发(GiB)", + "storage.policy.trigger": "触发条件", "storage.policy.target": "清理目标", "storage.policy.targetPercent": "删除最旧归档百分比", "storage.policy.targetReduce": "将归档缩小至(GiB)", + "storage.policy.thresholdInc": "提高阈值", + "storage.policy.thresholdDec": "降低阈值", + "storage.policy.percentInc": "提高百分比", + "storage.policy.percentDec": "降低百分比", + "storage.policy.reduceInc": "提高缩减目标", + "storage.policy.reduceDec": "降低缩减目标", "storage.policy.schedule": "计划", "storage.policy.schedule.manual": "仅手动", "storage.policy.schedule.startup": "代理启动时", @@ -1275,6 +1324,7 @@ export const zh: Record = { "pws.dashboard.checkedAgo": "{time} 前检查", "pws.dashboard.noQuota": "无配额数据", "pws.dashboard.noUsage": "暂无使用数据", + "pws.dashboard.noRateLimits": "暂无速率限制数据", "pws.allProviders": "提供商概览", "pws.enabledLabel": "已启用", "pws.testConnection": "测试连接", @@ -1330,6 +1380,7 @@ export const zh: Record = { "codexAuth.addIdPlaceholder": "codex-work, codex-alt, team…", "codexAuth.resetCreditsAria": "{count} 个重置额度", "claude.pageTitle": "Claude Code", + "claude.workspace.settings": "设置", // Combos workspace "cws.loading": "正在加载组合…", diff --git a/gui/src/styles-usage-workspace.css b/gui/src/styles-usage-workspace.css deleted file mode 100644 index a5ef89876..000000000 --- a/gui/src/styles-usage-workspace.css +++ /dev/null @@ -1,181 +0,0 @@ -/* ============================================================================ - usage-workspace — isolated stylesheet (mirrors providers-workspace layout DNA) - Namespace: usage-workspace- | widgets: usw- - Uses only design tokens from styles.css. No gradients. - ============================================================================ */ - -.main-inner:has(.usage-workspace-shell) { - max-width: 1200px; -} - -.usage-workspace-shell { - width: 100%; - min-width: 0; - container-type: inline-size; - container-name: usage-workspace; -} - -.usage-workspace-root { - display: grid; - grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); - gap: var(--space-4); - width: 100%; - max-width: 100%; - min-height: 0; -} - -/* ── Rail ─────────────────────────────────────────────── */ - -.usage-workspace-rail { - display: flex; - flex-direction: column; - gap: var(--space-3); - border-right: 1px solid var(--border); - padding-right: var(--space-3); - min-width: 0; -} - -.usage-workspace-rail-header { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 8px; -} - -.usage-workspace-rail-title { - font-size: var(--text-body); - font-weight: var(--weight-semibold); - color: var(--text); -} - -.usage-workspace-rail-list { - display: flex; - flex-direction: column; - gap: 2px; - min-height: 0; -} - -.usage-workspace-rail-row { - display: flex; - flex-direction: column; - gap: var(--space-0-5); - max-width: 100%; - min-width: 0; - min-height: 46px; - appearance: none; - background: none; - border: none; - border-radius: var(--radius-sm); - padding: var(--space-1-5) var(--space-2); - cursor: pointer; - text-align: left; - color: inherit; - font: inherit; - overflow: hidden; -} - -.usage-workspace-rail-row:hover { - background: var(--raised); -} - -.usage-workspace-rail-row--selected { - background: var(--raised); - box-shadow: inset 0 0 0 1px var(--border); -} - -.usage-workspace-rail-name { - font-size: var(--text-body); - font-weight: var(--weight-medium); - color: var(--text); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.usage-workspace-rail-meta { - font-size: var(--text-caption); - color: var(--faint); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-variant-numeric: tabular-nums; -} - -/* ── Main pane ────────────────────────────────────────── */ - -.usage-workspace-main { - display: flex; - flex-direction: column; - gap: var(--space-4); - min-width: 0; -} - -.usw-body { - display: flex; - flex-direction: column; - gap: var(--space-4); - min-width: 0; -} - -.usw-section { - display: flex; - flex-direction: column; - gap: 0; - min-width: 0; -} - -.usw-section .h-section { - margin: 0 0 var(--space-3); - font-size: var(--text-title); - font-weight: var(--weight-semibold); - line-height: 1.2; - color: var(--text); -} - -.usw-section-toolbar { - margin: 0 0 var(--space-5); - max-width: 220px; -} - -.usw-section .tbl-wrap { - min-width: 0; - /* Inset table from border+surface so cells aren't flush to the frame. */ - padding: var(--space-3); -} - -.usw-section .usage-cards { - margin-top: 0; -} - -.usw-section .usage-scroll { - max-height: 520px; -} - -/* ── Responsive ───────────────────────────────────────── */ -/* Keep @container and @media stacking rules in sync when changing breakpoints. */ - -@container usage-workspace (max-width: 720px) { - .usage-workspace-root { - grid-template-columns: 1fr; - } - - .usage-workspace-rail { - border-right: none; - border-bottom: 1px solid var(--border); - padding-right: 0; - padding-bottom: var(--space-3); - } -} - -@media (max-width: 768px) { - .usage-workspace-root { - grid-template-columns: 1fr; - } - - .usage-workspace-rail { - border-right: none; - border-bottom: 1px solid var(--border); - padding-right: 0; - padding-bottom: var(--space-3); - } -} diff --git a/gui/src/styles.css b/gui/src/styles.css index 6732c457e..df6f124a1 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -18,7 +18,6 @@ @import "./styles-dashboard-workspace.css"; @import "./styles-storage-workspace.css"; @import "./styles-subagents-workspace.css"; -@import "./styles-usage-workspace.css"; @import "./styles-claudecode-workspace.css"; @import "./styles-apikeys-workspace.css"; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 267ea9f38..275b96894 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -19,6 +19,73 @@ export type ProviderAuthKind = "forward" | "oauth" | "key" | "local"; export type MetadataModelIdNormalize = "case-insensitive"; export type { ProviderCompat, ThinkingFormat, MaxTokensField, SessionAffinityFormat } from "./compat"; +export type ProviderModelDiscoveryScalar = string | number | boolean; + +export type ProviderModelDiscoveryPredicate = + | { + path: readonly string[]; + equalsAny: readonly ProviderModelDiscoveryScalar[]; + caseInsensitive?: boolean; + } + | { + path: readonly string[]; + /** + * A string-valued upstream target uses substring matching; an array-valued target uses + * exact element matching. Use `equalsAny` when the string must match in full. + */ + containsAny: readonly ProviderModelDiscoveryScalar[]; + caseInsensitive?: boolean; + } + | { + path: readonly string[]; + /** Uses the same string-substring and array-element semantics as `containsAny`. */ + containsAll: readonly ProviderModelDiscoveryScalar[]; + caseInsensitive?: boolean; + }; + +export interface ProviderModelDiscoveryFilter { + /** Every predicate must match. */ + allOf?: readonly ProviderModelDiscoveryPredicate[]; + /** At least one predicate must match. */ + anyOf?: readonly ProviderModelDiscoveryPredicate[]; + /** No predicate may match. */ + noneOf?: readonly ProviderModelDiscoveryPredicate[]; +} + +interface ProviderModelDiscoverySharedSpec { + /** Query parameters applied to the resolved discovery URL. */ + query?: Readonly>; + /** Declarative eligibility rules evaluated against each untrusted model row. */ + filter?: ProviderModelDiscoveryFilter; + /** Optional lower byte ceiling; the process-wide hard ceiling still wins. */ + maxResponseBytes?: number; + /** Optional lower raw-row ceiling; the process-wide hard ceiling still wins. */ + maxModels?: number; +} + +type ProviderModelDiscoveryLocation = + | { + /** Registry-owned absolute endpoint. Mutually exclusive with `path`. */ + url: string; + path?: never; + } + | { + /** Resource path relative to baseUrl; query strings and fragments are disallowed. */ + path: string; + url?: never; + } + | { + /** Keep the adapter-derived default discovery endpoint. */ + url?: never; + path?: never; + }; + +/** + * Trusted live-model discovery policy. This metadata is registry-only: it must never be copied + * into config.json, where a same-named custom provider could otherwise redirect a stored key. + */ +export type ProviderModelDiscoverySpec = ProviderModelDiscoverySharedSpec & ProviderModelDiscoveryLocation; + export interface ProviderRegistryEntry { id: string; label: string; @@ -42,6 +109,11 @@ export interface ProviderRegistryEntry { */ freeTier?: boolean; allowBaseUrlOverride?: boolean; + /** + * Do not claim an existing same-named key provider whose fixed destination differs from this + * preset. Enable for newly promoted ids so an older custom key cannot be silently retargeted. + */ + preserveCustomDestination?: boolean; /** * Optional endpoint picker for providers with multiple official hosts * (e.g. Qwen Cloud token plan vs pay-as-you-go). Requires `allowBaseUrlOverride` @@ -58,6 +130,7 @@ export interface ProviderRegistryEntry { defaultModel?: string; models?: string[]; liveModels?: boolean; + modelDiscovery?: ProviderModelDiscoverySpec; contextWindow?: number; modelContextWindows?: Record; modelInputModalities?: Record; @@ -423,6 +496,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelContextWindows: cursorModelContextWindows(CURSOR_STATIC_MODELS), modelInputModalities: cursorModelInputModalities(CURSOR_STATIC_MODELS), modelReasoningEfforts: cursorModelReasoningEfforts(CURSOR_STATIC_MODELS), + // Kimi K3 documents `max` as its API default, and its Cursor ladder has no `medium` + // rung — so applyReasoningLevels' medium->high->first fallback would settle the catalog + // default on `high`, the picker would send `high` explicitly, and the request builder's + // no-effort fallback to `kimi-k3-max` would never be reached. Mirrors the other K3 + // routes (kimi, kimi-code, opencode-go). + modelDefaultReasoningEfforts: { "kimi-k3": "max" }, // Cursor's wire protocol never forwards image parts (request-builder emits an unsupported- // content marker), so the vision sidecar covers ALL cursor models regardless of what the // upstream model could natively do. Live-discovered models outside the static list fall back @@ -1191,6 +1270,41 @@ export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | un return PROVIDER_REGISTRY.find(entry => entry.id === id); } +function normalizedProviderEndpoint(value: string): string { + const trimmed = value.trim(); + try { + const parsed = new URL(trimmed); + parsed.pathname = parsed.pathname.replace(/\/+$/, "") || "/"; + return parsed.toString().replace(/\/$/, ""); + } catch { + return trimmed.replace(/\/+$/, ""); + } +} + +/** + * Whether registry transport defaults own this configured row. + * + * OAuth/forward providers stay pinned because their credentials must never be sent to an + * arbitrary same-named host. Existing key presets keep their historical pinning behavior; a new + * preset can opt into collision preservation, in which case its fixed endpoint owns only rows + * that still match that destination. + */ +export function providerMatchesRegistryTransport( + id: string, + provider: Pick & Partial>, +): boolean { + const entry = getProviderRegistryEntry(id); + if (!entry) return false; + if (entry.authKind !== "key" || entry.preserveCustomDestination !== true) return true; + // The opt-in is intentionally limited to fixed key destinations. Fail closed if a future + // registry edit combines it with an override/template despite the registry parity tests. + if (entry.allowBaseUrlOverride || /\{[^}]*\}/.test(entry.baseUrl)) return false; + if (typeof provider.baseUrl !== "string") return false; + if (provider.adapter !== entry.adapter) return false; + if (provider.authMode !== undefined && provider.authMode !== "key") return false; + return normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl); +} + /** * Effective Codex account mode for a provider. For canonical `openai`, a valid persisted * `codexAccountMode` on the provider config wins and a missing/invalid value defaults to diff --git a/src/telemetry/posthog-server.ts b/src/telemetry/posthog-server.ts new file mode 100644 index 000000000..6869378f4 --- /dev/null +++ b/src/telemetry/posthog-server.ts @@ -0,0 +1,188 @@ +/** + * Server-side PostHog telemetry — dependency-free, opt-in, EU-hosted, no PII. + * + * Enabled only when OCX_POSTHOG_KEY is set. All errors are swallowed: telemetry + * must never break a request or crash the proxy. Events are batched and flushed + * every 10s or 50 events (whichever first) via Bun.fetch to the /capture/ endpoint. + * + * Collected properties (NO PII): event name, provider, model, adapter, status, + * durationMs, firstOutputMs (TTFT), token counts (input/output/cached/reasoning), + * error codes, and codex account-pool outcomes. Never request bodies, headers, + * auth tokens, user prompts, or filenames. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../config"; + +const DEFAULT_HOST = "https://eu.i.posthog.com"; +const FLUSH_INTERVAL_MS = 10_000; +const MAX_BATCH_SIZE = 50; + +/** Canonical telemetry event names. */ +export const TELEMETRY_EVENTS = { + REQUEST_TERMINAL: "proxy_request_terminal", + FAILOVER_TRIGGERED: "proxy_failover_triggered", + ACCOUNT_COOLDOWN: "proxy_account_cooldown", + QUOTA_THRESHOLD: "proxy_quota_threshold", + BUDGET_EXCEEDED: "proxy_budget_exceeded", +} as const; + +export type TelemetryEvent = (typeof TELEMETRY_EVENTS)[keyof typeof TELEMETRY_EVENTS]; + +interface QueuedEvent { + event: string; + properties: Record; + timestamp: string; +} + +/** Keys that are stripped from properties before capture (defense-in-depth). */ +const SENSITIVE_KEY_PATTERNS = [ + /^(authorization|api[_-]?key|token|secret|password|cookie)$/i, + /^(prompt|message|content|body|payload|input|text)$/i, + /^(header|headers)$/i, +]; + +/** Strip sensitive-looking keys and cap string values to avoid accidental PII. */ +function sanitizeProperties(props: Record | undefined): Record { + if (!props || typeof props !== "object") return {}; + const clean: Record = {}; + for (const [key, value] of Object.entries(props)) { + if (SENSITIVE_KEY_PATTERNS.some((re) => re.test(key))) continue; + if (typeof value === "string" && value.length > 200) { + clean[key] = value.slice(0, 200); + } else if (typeof value === "number" && Number.isFinite(value)) { + clean[key] = value; + } else if (typeof value === "boolean") { + clean[key] = value; + } else if (typeof value === "string") { + clean[key] = value; + } + } + return clean; +} + +/** Stable anonymous distinct_id stored on disk — no hostnames, no usernames. */ +function loadOrCreateDistinctId(): string { + const dir = getConfigDir(); + const idPath = join(dir, "telemetry-id.txt"); + try { + if (existsSync(idPath)) { + const id = readFileSync(idPath, "utf-8").trim(); + if (id && id.length >= 8) return id; + } + mkdirSync(dir, { recursive: true, mode: 0o700 }); + // Random UUID-style id; completely anonymous. + const id = crypto.randomUUID(); + writeFileSync(idPath, id, { mode: 0o600 }); + try { chmodSync(idPath, 0o600); } catch { /* best-effort */ } + return id; + } catch { + // Fallback: ephemeral random id if disk is unavailable. + return crypto.randomUUID(); + } +} + +export class PosthogClient { + private readonly key: string; + private readonly host: string; + private readonly distinctId: string; + private readonly queue: QueuedEvent[] = []; + private readonly timer: ReturnType | null = null; + private flushing = false; + + constructor(key: string, host: string = DEFAULT_HOST) { + this.key = key; + this.host = host.replace(/\/+$/, ""); + this.distinctId = loadOrCreateDistinctId(); + this.timer = setInterval(() => { + void this.flush(); + }, FLUSH_INTERVAL_MS); + // Don't keep the process alive just for telemetry flushing. + this.timer.unref?.(); + } + + /** Fire-and-forget capture. Never throws. */ + capture(event: string, properties?: Record): void { + try { + this.queue.push({ + event, + properties: sanitizeProperties(properties), + timestamp: new Date().toISOString(), + }); + if (this.queue.length >= MAX_BATCH_SIZE) { + void this.flush(); + } + } catch { + /* swallow — telemetry must never break callers */ + } + } + + /** Flush pending events to PostHog /batch/ endpoint. */ + async flush(): Promise { + if (this.flushing || this.queue.length === 0) return; + this.flushing = true; + const batch = this.queue.splice(0, this.queue.length); + try { + const body = JSON.stringify({ + api_key: this.key, + historical_migration: false, + batch: batch.map((e) => ({ + event: e.event, + distinct_id: this.distinctId, + properties: { ...e.properties, $lib: "opencodex-server" }, + timestamp: e.timestamp, + })), + }); + const res = await fetch(`${this.host}/batch/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + // Don't hang the proxy on a slow analytics endpoint. + signal: AbortSignal.timeout(5_000), + }); + if (!res.ok) { + // PostHog returns 1xx for accepted-but-some-invalid; requeue on hard failure. + void res.body?.cancel().catch(() => {}); + } + } catch { + /* network errors are non-fatal — drop the batch */ + } finally { + this.flushing = false; + } + } + + /** Flush + stop the timer. Safe to call on shutdown. */ + shutdown(): void { + if (this.timer) clearInterval(this.timer); + void this.flush(); + } +} + +let cachedClient: PosthogClient | null | undefined; + +/** + * Singleton accessor. Returns a PosthogClient if OCX_POSTHOG_KEY is set, else null. + * Never throws on misconfiguration. + */ +export function getServerPosthog(): PosthogClient | null { + if (cachedClient !== undefined) return cachedClient; + try { + const key = process.env["OCX_POSTHOG_KEY"]?.trim(); + if (!key) { + cachedClient = null; + return null; + } + const host = process.env["OCX_POSTHOG_HOST"]?.trim() || DEFAULT_HOST; + cachedClient = new PosthogClient(key, host); + return cachedClient; + } catch { + cachedClient = null; + return null; + } +} + +/** Reset the singleton (for tests). */ +export function resetServerPosthog(): void { + if (cachedClient) cachedClient.shutdown(); + cachedClient = undefined; +} diff --git a/src/types.ts b/src/types.ts index 4213c77f5..84c50ef43 100644 --- a/src/types.ts +++ b/src/types.ts @@ -800,6 +800,15 @@ export type OcxAccountPoolRotationStrategy = "quota" | "round-robin" | "fill-fir export type OcxComboStrategy = "failover" | "round-robin"; export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; +export interface OcxCodexRequestPacing { + /** Master switch. Default false. */ + enabled?: boolean; + /** Inclusive lower bound of the randomized inter-request gap (ms). Default 150. */ + minMs?: number; + /** Inclusive upper bound of the randomized inter-request gap (ms). Default 900. */ + maxMs?: number; +} + export interface OcxComboTarget { provider: string; model: string; diff --git a/src/usage/percentiles.ts b/src/usage/percentiles.ts new file mode 100644 index 000000000..182bfc8f0 --- /dev/null +++ b/src/usage/percentiles.ts @@ -0,0 +1,91 @@ +/** + * Latency percentile computation for proxy request analytics. + * + * Pure functions — no I/O, no side effects. Used by the /api/latency-stats + * endpoint and the GUI Logs page. + */ +export interface LatencyStats { + count: number; + p50: number; + p95: number; + p99: number; + min: number; + max: number; + mean: number; +} + +export interface ProviderLatencySample { + provider: string; + /** Time-to-first-token (TTFT) in ms, if recorded. */ + firstOutputMs?: number; + /** Total request duration in ms. */ + durationMs: number; +} + +export interface ProviderLatencyStats { + ttft: LatencyStats; + total: LatencyStats; +} + +/** + * Linear-interpolation percentile (matches numpy.percentile default / R type 7). + * Returns 0 for empty input. + */ +export function percentile(values: number[], p: number): number { + if (values.length === 0) return 0; + if (values.length === 1) return values[0]!; + const sorted = [...values].sort((a, b) => a - b); + const clampedP = Math.max(0, Math.min(100, p)); + if (clampedP === 0) return sorted[0]!; + if (clampedP === 100) return sorted[sorted.length - 1]!; + const rank = (clampedP / 100) * (sorted.length - 1); + const lo = Math.floor(rank); + const hi = Math.ceil(rank); + if (lo === hi) return sorted[lo]!; + const frac = rank - lo; + return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * frac; +} + +/** Compute the full stats block for a set of latency samples. */ +export function computeLatencyStats(samples: number[]): LatencyStats { + if (samples.length === 0) { + return { count: 0, p50: 0, p95: 0, p99: 0, min: 0, max: 0, mean: 0 }; + } + const sum = samples.reduce((a, b) => a + b, 0); + return { + count: samples.length, + p50: Math.round(percentile(samples, 50)), + p95: Math.round(percentile(samples, 95)), + p99: Math.round(percentile(samples, 99)), + min: Math.min(...samples), + max: Math.max(...samples), + mean: Math.round(sum / samples.length), + }; +} + +/** + * Group samples by provider and compute TTFT + total-latency stats per provider. + * Samples without firstOutputMs only contribute to the total stats. + */ +export function groupByProvider( + samples: ProviderLatencySample[], +): Map { + const byProvider = new Map(); + for (const s of samples) { + let bucket = byProvider.get(s.provider); + if (!bucket) { + bucket = { ttft: [], total: [] }; + byProvider.set(s.provider, bucket); + } + bucket.total.push(s.durationMs); + if (s.firstOutputMs !== undefined) bucket.ttft.push(s.firstOutputMs); + } + const result = new Map(); + for (const [provider, bucket] of byProvider) { + result.set(provider, { + ttft: computeLatencyStats(bucket.ttft), + total: computeLatencyStats(bucket.total), + }); + } + return result; +} diff --git a/src/usage/pricing.ts b/src/usage/pricing.ts new file mode 100644 index 000000000..50245679e --- /dev/null +++ b/src/usage/pricing.ts @@ -0,0 +1,96 @@ +/** + * Estimated per-token pricing (EUR per 1,000 tokens). + * + * These are APPROXIMATIONS for budget alerting only — not billing. Prices change + * frequently; treat numbers as conservative estimates. Unknown providers/models + * default to 0 (free) so we never over-report spend for self-hosted/gateway routes. + * + * Source: provider pricing pages as of mid-2025, converted to EUR (~1 USD = 0.92 EUR). + */ +export interface ProviderPricing { + /** EUR per 1k input tokens (uncached). */ + inputPer1k: number; + /** EUR per 1k output tokens. */ + outputPer1k: number; +} + +/** + * Keyed by provider id (matches PROVIDER_REGISTRY ids) OR "provider:model" for + * model-specific overrides. Lookups try model-specific first, then provider, then 0. + */ +const PRICING: Record = { + // OpenAI (flagship tiers; mini/flash much cheaper but we default conservatively) + "openai:gpt-4o": { inputPer1k: 0.0023, outputPer1k: 0.0092 }, + "openai:chatgpt-4o-latest": { inputPer1k: 0.0051, outputPer1k: 0.0152 }, + "openai:gpt-4o-mini": { inputPer1k: 0.00013, outputPer1k: 0.00052 }, + "openai": { inputPer1k: 0.0023, outputPer1k: 0.0092 }, + + // Anthropic (Claude) + "anthropic:claude-sonnet-4-5": { inputPer1k: 0.0028, outputPer1k: 0.0139 }, + "anthropic:claude-opus-4": { inputPer1k: 0.0139, outputPer1k: 0.0694 }, + "anthropic:claude-haiku-4-5": { inputPer1k: 0.00092, outputPer1k: 0.0046 }, + "anthropic": { inputPer1k: 0.0028, outputPer1k: 0.0139 }, + + // Google Gemini + "google:gemini-2.5-pro": { inputPer1k: 0.00115, outputPer1k: 0.0046 }, + "google:gemini-2.5-flash": { inputPer1k: 0.00012, outputPer1k: 0.00037 }, + "google": { inputPer1k: 0.00031, outputPer1k: 0.00092 }, + + // xAI Grok + "xai:grok-4": { inputPer1k: 0.0028, outputPer1k: 0.0139 }, + "xai": { inputPer1k: 0.0046, outputPer1k: 0.0139 }, + + // DeepSeek + "deepseek:deepseek-chat": { inputPer1k: 0.00023, outputPer1k: 0.00083 }, + "deepseek": { inputPer1k: 0.00023, outputPer1k: 0.00083 }, + + // Kimi / Moonshot + "kimi": { inputPer1k: 0.00046, outputPer1k: 0.0028 }, + "moonshot": { inputPer1k: 0.00046, outputPer1k: 0.0028 }, + + // Z.AI (GLM) — coding plan, flat-rate, estimate as low + "zai": { inputPer1k: 0, outputPer1k: 0 }, + + // Cursor — subscription, estimate as 0 (covered by sub) + "cursor": { inputPer1k: 0, outputPer1k: 0 }, + + // OmniRoute — free tier + "omniroute": { inputPer1k: 0, outputPer1k: 0 }, + + // Ollama / self-hosted — free + "ollama-cloud": { inputPer1k: 0, outputPer1k: 0 }, + "litellm": { inputPer1k: 0, outputPer1k: 0 }, + + // Kiro — subscription + "kiro": { inputPer1k: 0, outputPer1k: 0 }, + + // GitHub Copilot — subscription + "github-copilot": { inputPer1k: 0, outputPer1k: 0 }, +}; + +/** Find the best-matching pricing entry: model-specific, then provider, then free. */ +export function lookupPricing(provider: string, model?: string): ProviderPricing { + if (model) { + const modelKey = `${provider}:${model.toLowerCase()}`; + if (PRICING[modelKey]) return PRICING[modelKey]; + // Try a prefix match (e.g. "claude-sonnet-4-5-20250929" → "claude-sonnet-4-5") + const prefixHit = Object.keys(PRICING).find((k) => { + if (!k.startsWith(`${provider}:`)) return false; + const baseModel = k.slice(provider.length + 1); + return baseModel !== provider && model.toLowerCase().startsWith(baseModel); + }); + if (prefixHit) return PRICING[prefixHit]; + } + return PRICING[provider] ?? { inputPer1k: 0, outputPer1k: 0 }; +} + +/** Estimate EUR cost for a usage record. */ +export function estimateCostEur( + provider: string, + model: string | undefined, + inputTokens: number, + outputTokens: number, +): number { + const p = lookupPricing(provider, model); + return (inputTokens / 1000) * p.inputPer1k + (outputTokens / 1000) * p.outputPer1k; +} diff --git a/tests/provider-account-quota.test.ts b/tests/provider-account-quota.test.ts index dec871110..000e73d91 100644 --- a/tests/provider-account-quota.test.ts +++ b/tests/provider-account-quota.test.ts @@ -377,9 +377,9 @@ describe("fetchProviderAccountQuotas", () => { }); describe("fetchProviderAccountQuotas (google-antigravity)", () => { - const AGY_FIRST = { accountId: "agy-first", email: "first@agy.example", projectId: "proj-first" }; - const AGY_SECOND = { accountId: "agy-second", email: "second@agy.example", projectId: "proj-second" }; - const AGY_THIRD = { accountId: "agy-third", email: "third@agy.example", projectId: "proj-third" }; + const AGY_FIRST = { accountId: "agy-first", email: "first@agy.example.test", projectId: "proj-first" }; + const AGY_SECOND = { accountId: "agy-second", email: "second@agy.example.test", projectId: "proj-second" }; + const AGY_THIRD = { accountId: "agy-third", email: "third@agy.example.test", projectId: "proj-third" }; function antigravityModelsBody(gemRemaining: number, claRemaining: number): string { return JSON.stringify({ @@ -468,7 +468,7 @@ describe("fetchProviderAccountQuotas (google-antigravity)", () => { }); await saveCredential("google-antigravity", { access: "token-agy-noproj", refresh: "refresh-agy-noproj", expires, - accountId: "agy-noproj", email: "noproj@agy.example", + accountId: "agy-noproj", email: "noproj@agy.example.test", }); let calls = 0; globalThis.fetch = (async () => { @@ -480,8 +480,8 @@ describe("fetchProviderAccountQuotas (google-antigravity)", () => { const byEmail = Object.fromEntries( (await import("../src/oauth/store")).getAccountSet("google-antigravity")!.accounts.map(a => [a.id, a.credential.email]), ); - const withProject = rows.find(r => byEmail[r.accountId] === "first@agy.example"); - const without = rows.find(r => byEmail[r.accountId] === "noproj@agy.example"); + const withProject = rows.find(r => byEmail[r.accountId] === "first@agy.example.test"); + const without = rows.find(r => byEmail[r.accountId] === "noproj@agy.example.test"); expect(withProject?.quota?.customWindows?.[0]?.percent).toBe(50); expect(without?.unavailable).toBe(true); expect(without?.quota).toBeNull(); @@ -492,7 +492,7 @@ describe("fetchProviderAccountQuotas (google-antigravity)", () => { await seedThreeAntigravityAccounts(); const { getAccountSet, setActiveAccount } = await import("../src/oauth/store"); const set = getAccountSet("google-antigravity"); - const first = set?.accounts.find(a => a.credential.email === "first@agy.example"); + const first = set?.accounts.find(a => a.credential.email === "first@agy.example.test"); expect(first).toBeTruthy(); await setActiveAccount("google-antigravity", first!.id); let calls = 0; diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index d2f7d5735..01d132e4f 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -36,6 +36,7 @@ const EXPECTED_KEY_PROVIDER_IDS = [ "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", "opencode-free", "xiaomi", "kilo", "mimo-free", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo", + "omniroute", ]; describe("provider registry parity", () => { @@ -446,7 +447,7 @@ describe("provider registry parity", () => { expect(nvidia?.freeTier).toBe(true); expect(nvidia?.authKind).toBe("key"); expect(nvidia?.keyOptional).toBeUndefined(); - expect(freeTierProviders).toEqual(["nvidia", "cloudflare-workers-ai"]); + expect(freeTierProviders).toEqual(["nvidia", "cloudflare-workers-ai", "omniroute"]); }); test("freeTier propagates through config seed, enrich backfill, and presets without overwriting user config", async () => { @@ -479,7 +480,7 @@ describe("provider registry parity", () => { test("base URL override permission is registry-only and limited to opted-in providers", () => { const optedIn = PROVIDER_REGISTRY.filter(entry => entry.allowBaseUrlOverride); - expect(optedIn.map(entry => entry.id)).toEqual(["ollama", "vllm", "lm-studio", "qwen-cloud", "alibaba-token-plan-intl", "litellm"]); + expect(optedIn.map(entry => entry.id)).toEqual(["ollama", "vllm", "lm-studio", "qwen-cloud", "alibaba-token-plan-intl", "litellm", "omniroute"]); for (const entry of optedIn) { expect(providerConfigSeed(entry)).not.toHaveProperty("allowBaseUrlOverride"); } @@ -650,6 +651,7 @@ describe("provider registry parity", () => { "openai", "xai", "anthropic", "anthropic-apikey", "kimi", "openai-apikey", "umans", "opencode-go", "openrouter", "groq", "google", "azure-openai", "ollama", "vllm", "lm-studio", "opencode-free", "mimo-free", + "omniroute", ]); const presets = deriveProviderPresets(); From 9567d60cf99f2d334a8c3c02a8758fd308d2a962 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 17:07:24 +0000 Subject: [PATCH 18/31] fix(telemetry): retry transiently failed PostHog batches Co-authored-by: Codesmith --- src/telemetry/posthog-server.ts | 51 ++++++++++- tests/telemetry-posthog-server.test.ts | 116 +++++++++++++++++++++++++ 2 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 tests/telemetry-posthog-server.test.ts diff --git a/src/telemetry/posthog-server.ts b/src/telemetry/posthog-server.ts index 6869378f4..02217d806 100644 --- a/src/telemetry/posthog-server.ts +++ b/src/telemetry/posthog-server.ts @@ -17,6 +17,12 @@ import { getConfigDir } from "../config"; const DEFAULT_HOST = "https://eu.i.posthog.com"; const FLUSH_INTERVAL_MS = 10_000; const MAX_BATCH_SIZE = 50; +/** Hard cap on buffered events; the oldest are dropped once exceeded. */ +const MAX_QUEUE_SIZE = 500; +/** Send attempts per event before it is dropped for good. */ +const MAX_SEND_ATTEMPTS = 3; +/** Base delay before retrying a transiently failed batch (doubles per attempt). */ +const RETRY_BASE_DELAY_MS = 5_000; /** Canonical telemetry event names. */ export const TELEMETRY_EVENTS = { @@ -33,6 +39,8 @@ interface QueuedEvent { event: string; properties: Record; timestamp: string; + /** Failed send attempts so far; bounds retries of a transiently failed batch. */ + attempts: number; } /** Keys that are stripped from properties before capture (defense-in-depth). */ @@ -89,6 +97,8 @@ export class PosthogClient { private readonly queue: QueuedEvent[] = []; private readonly timer: ReturnType | null = null; private flushing = false; + /** Epoch ms before which flushing is suppressed after a transient failure. */ + private retryAfterMs = 0; constructor(key: string, host: string = DEFAULT_HOST) { this.key = key; @@ -108,7 +118,9 @@ export class PosthogClient { event, properties: sanitizeProperties(properties), timestamp: new Date().toISOString(), + attempts: 0, }); + this.enforceQueueBound(); if (this.queue.length >= MAX_BATCH_SIZE) { void this.flush(); } @@ -117,11 +129,42 @@ export class PosthogClient { } } + /** Drop the oldest events once the queue exceeds its hard cap. */ + private enforceQueueBound(): void { + if (this.queue.length > MAX_QUEUE_SIZE) { + this.queue.splice(0, this.queue.length - MAX_QUEUE_SIZE); + } + } + + /** + * Return a transiently failed batch to the front of the queue so the next + * flush retries it. Events that exhausted MAX_SEND_ATTEMPTS are dropped, and + * flushing is delayed with exponential backoff so a failing endpoint is not + * hammered on every capture. + */ + private requeue(batch: QueuedEvent[]): void { + let maxAttempts = 0; + const retryable: QueuedEvent[] = []; + for (const queued of batch) { + queued.attempts += 1; + if (queued.attempts >= MAX_SEND_ATTEMPTS) continue; + if (queued.attempts > maxAttempts) maxAttempts = queued.attempts; + retryable.push(queued); + } + if (retryable.length === 0) return; + this.queue.unshift(...retryable); + this.enforceQueueBound(); + this.retryAfterMs = Date.now() + RETRY_BASE_DELAY_MS * 2 ** (maxAttempts - 1); + } + /** Flush pending events to PostHog /batch/ endpoint. */ async flush(): Promise { if (this.flushing || this.queue.length === 0) return; + if (Date.now() < this.retryAfterMs) return; this.flushing = true; + this.retryAfterMs = 0; const batch = this.queue.splice(0, this.queue.length); + let retryable = false; try { const body = JSON.stringify({ api_key: this.key, @@ -141,12 +184,16 @@ export class PosthogClient { signal: AbortSignal.timeout(5_000), }); if (!res.ok) { - // PostHog returns 1xx for accepted-but-some-invalid; requeue on hard failure. void res.body?.cancel().catch(() => {}); + // 429 and 5xx are transient, so the batch is worth retrying. Anything + // else (bad key, malformed batch) would fail identically forever. + retryable = res.status === 429 || res.status >= 500; } } catch { - /* network errors are non-fatal — drop the batch */ + /* network errors and timeouts are transient — retry the batch */ + retryable = true; } finally { + if (retryable) this.requeue(batch); this.flushing = false; } } diff --git a/tests/telemetry-posthog-server.test.ts b/tests/telemetry-posthog-server.test.ts new file mode 100644 index 000000000..703147fe2 --- /dev/null +++ b/tests/telemetry-posthog-server.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it, setSystemTime } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PosthogClient } from "../src/telemetry/posthog-server"; + +/** + * `flush()` drains the whole queue before sending it, so a transient PostHog + * failure used to lose the batch permanently. These cover the retry contract: + * transient statuses come back for another attempt, permanent ones do not, and + * retries stay bounded. + */ +describe("PosthogClient flush retries", () => { + let home: string; + let previousHome: string | undefined; + const originalFetch = globalThis.fetch; + let statuses: number[]; + let posted: string[][]; + + /** Serve one status per flush, then 200 forever. */ + function stubFetch(): void { + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { batch: { event: string }[] }; + posted.push(body.batch.map((entry) => entry.event)); + const status = statuses.shift() ?? 200; + return new Response("{}", { status }); + }) as unknown as typeof fetch; + } + + beforeEach(() => { + previousHome = process.env["OPENCODEX_HOME"]; + home = mkdtempSync(join(tmpdir(), "ocx-posthog-")); + process.env["OPENCODEX_HOME"] = home; + statuses = []; + posted = []; + stubFetch(); + setSystemTime(new Date(2025, 0, 8, 12, 0, 0, 0)); + }); + + afterEach(() => { + setSystemTime(); + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env["OPENCODEX_HOME"]; + else process.env["OPENCODEX_HOME"] = previousHome; + rmSync(home, { recursive: true, force: true }); + }); + + /** Move past the backoff window so the next flush is allowed to send. */ + function advance(ms: number): void { + setSystemTime(new Date(Date.now() + ms)); + } + + it("retries a batch rejected with 429 instead of dropping it", async () => { + statuses = [429]; + const client = new PosthogClient("phc_test"); + client.capture("first_event"); + await client.flush(); + expect(posted).toEqual([["first_event"]]); + + advance(60_000); + client.capture("second_event"); + await client.flush(); + expect(posted[1]).toEqual(["first_event", "second_event"]); + client.shutdown(); + }); + + it("retries a batch rejected with 5xx", async () => { + statuses = [503]; + const client = new PosthogClient("phc_test"); + client.capture("first_event"); + await client.flush(); + + advance(60_000); + await client.flush(); + expect(posted).toEqual([["first_event"], ["first_event"]]); + client.shutdown(); + }); + + it("backs off instead of retrying immediately after a transient failure", async () => { + statuses = [503]; + const client = new PosthogClient("phc_test"); + client.capture("first_event"); + await client.flush(); + + await client.flush(); + expect(posted).toHaveLength(1); + client.shutdown(); + }); + + it("drops a batch rejected with a permanent status", async () => { + statuses = [400]; + const client = new PosthogClient("phc_test"); + client.capture("first_event"); + await client.flush(); + + advance(60_000); + client.capture("second_event"); + await client.flush(); + expect(posted).toEqual([["first_event"], ["second_event"]]); + client.shutdown(); + }); + + it("stops retrying after the attempt bound and keeps the queue bounded", async () => { + statuses = [503, 503, 503, 503]; + const client = new PosthogClient("phc_test"); + client.capture("first_event"); + + for (let attempt = 0; attempt < 4; attempt += 1) { + await client.flush(); + advance(60_000); + } + // Three attempts total, then the event is discarded rather than retried forever. + expect(posted).toEqual([["first_event"], ["first_event"], ["first_event"]]); + client.shutdown(); + }); +}); From 6954f6b1de5f5f4b0995ec9497805002c50ac156 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 17:15:51 +0000 Subject: [PATCH 19/31] fix(usage,telemetry): harden pricing, latency, and telemetry property handling Co-authored-by: Codesmith --- src/telemetry/posthog-server.ts | 35 ++++-- src/usage/percentiles.ts | 41 +++++-- src/usage/pricing.ts | 21 ++-- tests/provider-registry-transport.test.ts | 141 ++++++++++++++++++++++ tests/telemetry-posthog-server.test.ts | 112 ++++++++++++++++- tests/usage-percentiles.test.ts | 68 +++++++++++ tests/usage-pricing.test.ts | 39 ++++++ 7 files changed, 425 insertions(+), 32 deletions(-) create mode 100644 tests/provider-registry-transport.test.ts create mode 100644 tests/usage-percentiles.test.ts create mode 100644 tests/usage-pricing.test.ts diff --git a/src/telemetry/posthog-server.ts b/src/telemetry/posthog-server.ts index 02217d806..944fec6a9 100644 --- a/src/telemetry/posthog-server.ts +++ b/src/telemetry/posthog-server.ts @@ -43,19 +43,30 @@ interface QueuedEvent { attempts: number; } -/** Keys that are stripped from properties before capture (defense-in-depth). */ -const SENSITIVE_KEY_PATTERNS = [ - /^(authorization|api[_-]?key|token|secret|password|cookie)$/i, - /^(prompt|message|content|body|payload|input|text)$/i, - /^(header|headers)$/i, -]; - -/** Strip sensitive-looking keys and cap string values to avoid accidental PII. */ +/** + * The only property keys telemetry may emit — an allowlist, so a caller cannot + * leak PII or credential material through an undocumented field. A new metric + * has to be added here (and to the file header) before it can be captured. + */ +const ALLOWED_PROPERTY_KEYS = new Set([ + // Request outcome. + "provider", "model", "adapter", "status", "outcome", + // Latency. + "durationMs", "firstOutputMs", + // Token counts. + "inputTokens", "outputTokens", "cachedTokens", "reasoningTokens", + // Errors and codex account-pool outcomes. + "errorCode", "accountMode", "cooldownMs", + // Quota and budget thresholds. + "type", "threshold", "actual", +]); + +/** Keep only allowlisted metric keys with primitive values, capping string length. */ function sanitizeProperties(props: Record | undefined): Record { if (!props || typeof props !== "object") return {}; const clean: Record = {}; for (const [key, value] of Object.entries(props)) { - if (SENSITIVE_KEY_PATTERNS.some((re) => re.test(key))) continue; + if (!ALLOWED_PROPERTY_KEYS.has(key)) continue; if (typeof value === "string" && value.length > 200) { clean[key] = value.slice(0, 200); } else if (typeof value === "number" && Number.isFinite(value)) { @@ -163,7 +174,9 @@ export class PosthogClient { if (Date.now() < this.retryAfterMs) return; this.flushing = true; this.retryAfterMs = 0; - const batch = this.queue.splice(0, this.queue.length); + // At most MAX_BATCH_SIZE per request: captures keep arriving while a flush + // is in flight, and one oversized payload is likelier to be rejected. + const batch = this.queue.splice(0, MAX_BATCH_SIZE); let retryable = false; try { const body = JSON.stringify({ @@ -196,6 +209,8 @@ export class PosthogClient { if (retryable) this.requeue(batch); this.flushing = false; } + // Drain any remainder (or events captured mid-flight) in further batches. + if (!retryable && this.queue.length > 0) await this.flush(); } /** Flush + stop the timer. Safe to call on shutdown. */ diff --git a/src/usage/percentiles.ts b/src/usage/percentiles.ts index 182bfc8f0..47122a456 100644 --- a/src/usage/percentiles.ts +++ b/src/usage/percentiles.ts @@ -46,20 +46,39 @@ export function percentile(values: number[], p: number): number { return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * frac; } +/** + * A latency sample only counts when it is finite and non-negative — the same + * invariant `src/usage/summary.ts` enforces. One NaN would otherwise poison the + * sum, the extrema, and every percentile. + */ +function isValidSample(value: number): boolean { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + /** Compute the full stats block for a set of latency samples. */ export function computeLatencyStats(samples: number[]): LatencyStats { - if (samples.length === 0) { + const valid = samples.every(isValidSample) ? samples : samples.filter(isValidSample); + if (valid.length === 0) { return { count: 0, p50: 0, p95: 0, p99: 0, min: 0, max: 0, mean: 0 }; } - const sum = samples.reduce((a, b) => a + b, 0); + let sum = 0; + let min = valid[0]!; + let max = valid[0]!; + // Iterative extrema: spreading a large sample array into Math.min/Math.max + // can exceed the engine's argument limit and throw. + for (const value of valid) { + sum += value; + if (value < min) min = value; + if (value > max) max = value; + } return { - count: samples.length, - p50: Math.round(percentile(samples, 50)), - p95: Math.round(percentile(samples, 95)), - p99: Math.round(percentile(samples, 99)), - min: Math.min(...samples), - max: Math.max(...samples), - mean: Math.round(sum / samples.length), + count: valid.length, + p50: Math.round(percentile(valid, 50)), + p95: Math.round(percentile(valid, 95)), + p99: Math.round(percentile(valid, 99)), + min, + max, + mean: Math.round(sum / valid.length), }; } @@ -77,8 +96,8 @@ export function groupByProvider( bucket = { ttft: [], total: [] }; byProvider.set(s.provider, bucket); } - bucket.total.push(s.durationMs); - if (s.firstOutputMs !== undefined) bucket.ttft.push(s.firstOutputMs); + if (isValidSample(s.durationMs)) bucket.total.push(s.durationMs); + if (s.firstOutputMs !== undefined && isValidSample(s.firstOutputMs)) bucket.ttft.push(s.firstOutputMs); } const result = new Map(); for (const [provider, bucket] of byProvider) { diff --git a/src/usage/pricing.ts b/src/usage/pricing.ts index 50245679e..575239f8c 100644 --- a/src/usage/pricing.ts +++ b/src/usage/pricing.ts @@ -73,12 +73,15 @@ export function lookupPricing(provider: string, model?: string): ProviderPricing if (model) { const modelKey = `${provider}:${model.toLowerCase()}`; if (PRICING[modelKey]) return PRICING[modelKey]; - // Try a prefix match (e.g. "claude-sonnet-4-5-20250929" → "claude-sonnet-4-5") - const prefixHit = Object.keys(PRICING).find((k) => { - if (!k.startsWith(`${provider}:`)) return false; - const baseModel = k.slice(provider.length + 1); - return baseModel !== provider && model.toLowerCase().startsWith(baseModel); - }); + // Try a prefix match (e.g. "claude-sonnet-4-5-20250929" → "claude-sonnet-4-5"), + // longest first so "gpt-4o-mini-2024-…" is not priced as "gpt-4o". + const prefixHit = Object.keys(PRICING) + .filter((k) => { + if (!k.startsWith(`${provider}:`)) return false; + const baseModel = k.slice(provider.length + 1); + return baseModel !== provider && model.toLowerCase().startsWith(baseModel); + }) + .sort((a, b) => b.length - a.length)[0]; if (prefixHit) return PRICING[prefixHit]; } return PRICING[provider] ?? { inputPer1k: 0, outputPer1k: 0 }; @@ -91,6 +94,10 @@ export function estimateCostEur( inputTokens: number, outputTokens: number, ): number { + // Malformed counts must not produce a negative or NaN cost: a negative input + // count would otherwise walk a budget total backwards past its threshold. + const input = Number.isFinite(inputTokens) && inputTokens > 0 ? inputTokens : 0; + const output = Number.isFinite(outputTokens) && outputTokens > 0 ? outputTokens : 0; const p = lookupPricing(provider, model); - return (inputTokens / 1000) * p.inputPer1k + (outputTokens / 1000) * p.outputPer1k; + return (input / 1000) * p.inputPer1k + (output / 1000) * p.outputPer1k; } diff --git a/tests/provider-registry-transport.test.ts b/tests/provider-registry-transport.test.ts new file mode 100644 index 000000000..3b58aae8e --- /dev/null +++ b/tests/provider-registry-transport.test.ts @@ -0,0 +1,141 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../src/providers/registry"; +import type { ProviderRegistryEntry } from "../src/providers/registry"; + +/** + * `providerMatchesRegistryTransport` decides whether registry transport defaults + * own a configured row, which gates both replacing a stored key provider's + * destination and trusting registry model-discovery metadata for it. No shipped + * entry opts into `preserveCustomDestination` yet, so the collision-preserving + * branches are exercised by flipping the flag on a real entry and restoring it. + */ +describe("providerMatchesRegistryTransport", () => { + const patched: { entry: Record; key: string; had: boolean; value: unknown }[] = []; + + /** Temporarily set a registry field, restoring the entry after the test. */ + function patch(id: string, key: keyof ProviderRegistryEntry, value: unknown): ProviderRegistryEntry { + const entry = getProviderRegistryEntry(id); + if (!entry) throw new Error(`missing registry entry: ${id}`); + const mutable = entry as unknown as Record; + patched.push({ entry: mutable, key, had: key in mutable, value: mutable[key] }); + mutable[key] = value; + return entry; + } + + afterEach(() => { + for (const { entry, key, had, value } of patched.reverse()) { + if (had) entry[key] = value; + else delete entry[key]; + } + patched.length = 0; + }); + + it("does not claim rows for an unknown provider id", () => { + expect(providerMatchesRegistryTransport("not-a-provider", { + baseUrl: "https://api.groq.com/openai/v1", + adapter: "openai-chat", + })).toBe(false); + }); + + it("keeps historical pinning for key presets that did not opt in", () => { + const groq = getProviderRegistryEntry("groq")!; + expect(groq.preserveCustomDestination).toBeUndefined(); + expect(providerMatchesRegistryTransport("groq", { + baseUrl: "https://someone-elses-host.example.test/v1", + adapter: "openai-chat", + })).toBe(true); + }); + + it("keeps oauth providers pinned regardless of the configured destination", () => { + expect(providerMatchesRegistryTransport("google-antigravity", { + baseUrl: "https://someone-elses-host.example.test", + adapter: "google", + })).toBe(true); + }); + + describe("with preserveCustomDestination", () => { + /** groq is a fixed key destination: no template, no base-URL override. */ + function optInGroq(): ProviderRegistryEntry { + return patch("groq", "preserveCustomDestination", true); + } + + it("owns a row that still points at the fixed registry endpoint", () => { + const groq = optInGroq(); + expect(providerMatchesRegistryTransport("groq", { + baseUrl: groq.baseUrl, + adapter: groq.adapter, + })).toBe(true); + }); + + it("treats trailing-slash variants as the same endpoint", () => { + const groq = optInGroq(); + expect(providerMatchesRegistryTransport("groq", { + baseUrl: `${groq.baseUrl}/`, + adapter: groq.adapter, + })).toBe(true); + expect(providerMatchesRegistryTransport("groq", { + baseUrl: ` ${groq.baseUrl}/// `, + adapter: groq.adapter, + })).toBe(true); + }); + + it("releases a row pointed at a different endpoint", () => { + const groq = optInGroq(); + expect(providerMatchesRegistryTransport("groq", { + baseUrl: "https://api.groq.com.example.test/openai/v1", + adapter: groq.adapter, + })).toBe(false); + expect(providerMatchesRegistryTransport("groq", { + baseUrl: "https://api.groq.com/openai/v2", + adapter: groq.adapter, + })).toBe(false); + }); + + it("releases a row whose adapter or auth mode diverges", () => { + const groq = optInGroq(); + expect(providerMatchesRegistryTransport("groq", { + baseUrl: groq.baseUrl, + adapter: "anthropic", + })).toBe(false); + expect(providerMatchesRegistryTransport("groq", { + baseUrl: groq.baseUrl, + adapter: groq.adapter, + authMode: "oauth", + })).toBe(false); + expect(providerMatchesRegistryTransport("groq", { + baseUrl: groq.baseUrl, + adapter: groq.adapter, + authMode: "key", + })).toBe(true); + }); + + it("releases a row with a missing base URL", () => { + const groq = optInGroq(); + expect(providerMatchesRegistryTransport("groq", { + adapter: groq.adapter, + })).toBe(false); + }); + + it("fails closed when the opt-in is combined with a base-URL override", () => { + const groq = optInGroq(); + patch("groq", "allowBaseUrlOverride", true); + expect(providerMatchesRegistryTransport("groq", { + baseUrl: groq.baseUrl, + adapter: groq.adapter, + })).toBe(false); + }); + + it("fails closed when the opt-in is combined with a templated base URL", () => { + const azure = patch("azure-openai", "preserveCustomDestination", true); + expect(azure.baseUrl).toContain("{resource}"); + expect(providerMatchesRegistryTransport("azure-openai", { + baseUrl: azure.baseUrl, + adapter: azure.adapter, + })).toBe(false); + expect(providerMatchesRegistryTransport("azure-openai", { + baseUrl: "https://acme.openai.azure.com/openai", + adapter: azure.adapter, + })).toBe(false); + }); + }); +}); diff --git a/tests/telemetry-posthog-server.test.ts b/tests/telemetry-posthog-server.test.ts index 703147fe2..a7e6fa091 100644 --- a/tests/telemetry-posthog-server.test.ts +++ b/tests/telemetry-posthog-server.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, setSystemTime } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { PosthogClient } from "../src/telemetry/posthog-server"; +import { getServerPosthog, PosthogClient, resetServerPosthog } from "../src/telemetry/posthog-server"; /** * `flush()` drains the whole queue before sending it, so a transient PostHog @@ -10,17 +10,25 @@ import { PosthogClient } from "../src/telemetry/posthog-server"; * transient statuses come back for another attempt, permanent ones do not, and * retries stay bounded. */ -describe("PosthogClient flush retries", () => { +interface PostedEvent { + event: string; + distinct_id: string; + properties: Record; +} + +describe("PosthogClient telemetry contract", () => { let home: string; let previousHome: string | undefined; const originalFetch = globalThis.fetch; let statuses: number[]; let posted: string[][]; + let bodies: PostedEvent[][]; /** Serve one status per flush, then 200 forever. */ function stubFetch(): void { globalThis.fetch = (async (_url: string, init?: RequestInit) => { - const body = JSON.parse(String(init?.body)) as { batch: { event: string }[] }; + const body = JSON.parse(String(init?.body)) as { batch: PostedEvent[] }; + bodies.push(body.batch); posted.push(body.batch.map((entry) => entry.event)); const status = statuses.shift() ?? 200; return new Response("{}", { status }); @@ -33,12 +41,14 @@ describe("PosthogClient flush retries", () => { process.env["OPENCODEX_HOME"] = home; statuses = []; posted = []; + bodies = []; stubFetch(); setSystemTime(new Date(2025, 0, 8, 12, 0, 0, 0)); }); afterEach(() => { setSystemTime(); + resetServerPosthog(); globalThis.fetch = originalFetch; if (previousHome === undefined) delete process.env["OPENCODEX_HOME"]; else process.env["OPENCODEX_HOME"] = previousHome; @@ -113,4 +123,98 @@ describe("PosthogClient flush retries", () => { expect(posted).toEqual([["first_event"], ["first_event"], ["first_event"]]); client.shutdown(); }); + + it("emits only allowlisted primitive properties", async () => { + const client = new PosthogClient("phc_test"); + client.capture("proxy_request_terminal", { + provider: "openai", + status: 200, + durationMs: 1234, + email: "person@example.test", + filename: "/home/person/secret.ts", + path: "/home/person", + access_token: "sk-live-abc", + refreshToken: "rt-abc", + prompt: "user text", + nested: { deep: true }, + longModel: "x".repeat(400), + }); + await client.flush(); + expect(bodies[0]![0]!.properties).toEqual({ + provider: "openai", + status: 200, + durationMs: 1234, + $lib: "opencodex-server", + }); + }); + + it("caps long allowlisted string values", async () => { + const client = new PosthogClient("phc_test"); + client.capture("proxy_request_terminal", { model: "m".repeat(400) }); + await client.flush(); + expect(String(bodies[0]![0]!.properties["model"])).toHaveLength(200); + }); + + it("sends at most 50 events per request and drains the remainder", async () => { + const client = new PosthogClient("phc_test"); + // Crossing MAX_BATCH_SIZE starts a flush mid-loop, so the queue keeps + // growing while a request is in flight; the drain must stay chunked. + for (let index = 0; index < 120; index += 1) client.capture(`event_${index}`); + await client.flush(); + for (let tick = 0; tick < 20 && posted.flat().length < 120; tick += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + expect(posted.map((batch) => batch.length)).toEqual([50, 50, 20]); + expect(posted.flat()).toHaveLength(120); + client.shutdown(); + }); + + it("reuses a persisted anonymous distinct id across clients", async () => { + const first = new PosthogClient("phc_test"); + first.capture("proxy_request_terminal"); + await first.flush(); + first.shutdown(); + expect(existsSync(join(home, "telemetry-id.txt"))).toBe(true); + + const second = new PosthogClient("phc_test"); + second.capture("proxy_request_terminal"); + await second.flush(); + second.shutdown(); + const id = bodies[0]![0]!.distinct_id; + expect(id).toMatch(/^[0-9a-f-]{36}$/); + expect(bodies[1]![0]!.distinct_id).toBe(id); + }); + + it("treats a fetch failure as transient and keeps the batch", async () => { + globalThis.fetch = (async () => { throw new Error("timeout"); }) as unknown as typeof fetch; + const client = new PosthogClient("phc_test"); + client.capture("proxy_request_terminal"); + await client.flush(); + + advance(60_000); + stubFetch(); + await client.flush(); + expect(posted).toEqual([["proxy_request_terminal"]]); + client.shutdown(); + }); + + it("gates the singleton on OCX_POSTHOG_KEY and resets cleanly", () => { + const previousKey = process.env["OCX_POSTHOG_KEY"]; + try { + delete process.env["OCX_POSTHOG_KEY"]; + expect(getServerPosthog()).toBeNull(); + + resetServerPosthog(); + process.env["OCX_POSTHOG_KEY"] = "phc_test"; + const client = getServerPosthog(); + expect(client).not.toBeNull(); + expect(getServerPosthog()).toBe(client); + + resetServerPosthog(); + expect(getServerPosthog()).not.toBe(client); + } finally { + if (previousKey === undefined) delete process.env["OCX_POSTHOG_KEY"]; + else process.env["OCX_POSTHOG_KEY"] = previousKey; + } + }); }); diff --git a/tests/usage-percentiles.test.ts b/tests/usage-percentiles.test.ts new file mode 100644 index 000000000..ef5dfc4c0 --- /dev/null +++ b/tests/usage-percentiles.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "bun:test"; +import { computeLatencyStats, groupByProvider, percentile } from "../src/usage/percentiles"; + +describe("computeLatencyStats", () => { + it("reports extrema and percentiles for valid samples", () => { + const stats = computeLatencyStats([10, 20, 30, 40]); + expect(stats).toEqual({ count: 4, p50: 25, p95: 39, p99: 40, min: 10, max: 40, mean: 25 }); + }); + + it("returns a zeroed block for an empty set", () => { + expect(computeLatencyStats([])).toEqual({ + count: 0, p50: 0, p95: 0, p99: 0, min: 0, max: 0, mean: 0, + }); + }); + + it("drops non-finite and negative samples instead of poisoning the stats", () => { + const stats = computeLatencyStats([10, Number.NaN, -5, 30, Number.POSITIVE_INFINITY]); + expect(stats.count).toBe(2); + expect(stats.min).toBe(10); + expect(stats.max).toBe(30); + expect(stats.mean).toBe(20); + expect(Number.isFinite(stats.p95)).toBe(true); + }); + + it("returns a zeroed block when every sample is invalid", () => { + expect(computeLatencyStats([Number.NaN, -1]).count).toBe(0); + }); + + it("computes extrema without spreading large sample arrays", () => { + // 200k arguments overflows the engine's call stack when spread into Math.max. + const samples = Array.from({ length: 200_000 }, (_, index) => index + 1); + const stats = computeLatencyStats(samples); + expect(stats.min).toBe(1); + expect(stats.max).toBe(200_000); + }); +}); + +describe("groupByProvider", () => { + it("buckets ttft and total latency per provider", () => { + const stats = groupByProvider([ + { provider: "openai", durationMs: 100, firstOutputMs: 20 }, + { provider: "openai", durationMs: 300 }, + { provider: "anthropic", durationMs: 50, firstOutputMs: 10 }, + ]); + expect(stats.get("openai")!.total.count).toBe(2); + expect(stats.get("openai")!.ttft.count).toBe(1); + expect(stats.get("anthropic")!.total.max).toBe(50); + }); + + it("skips invalid durations before bucketing", () => { + const stats = groupByProvider([ + { provider: "openai", durationMs: Number.NaN, firstOutputMs: -3 }, + { provider: "openai", durationMs: 120, firstOutputMs: 40 }, + ]); + expect(stats.get("openai")!.total.count).toBe(1); + expect(stats.get("openai")!.ttft.count).toBe(1); + expect(stats.get("openai")!.ttft.min).toBe(40); + }); +}); + +describe("percentile", () => { + it("interpolates linearly and clamps p out of range", () => { + expect(percentile([1, 2, 3, 4], 50)).toBe(2.5); + expect(percentile([1, 2, 3, 4], -10)).toBe(1); + expect(percentile([1, 2, 3, 4], 150)).toBe(4); + expect(percentile([], 50)).toBe(0); + }); +}); diff --git a/tests/usage-pricing.test.ts b/tests/usage-pricing.test.ts new file mode 100644 index 000000000..bafa9b1c7 --- /dev/null +++ b/tests/usage-pricing.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "bun:test"; +import { estimateCostEur, lookupPricing } from "../src/usage/pricing"; + +describe("lookupPricing", () => { + it("prefers an exact model key over the provider default", () => { + expect(lookupPricing("openai", "gpt-4o-mini")).toEqual(lookupPricing("openai", "GPT-4O-MINI")); + expect(lookupPricing("openai", "gpt-4o-mini").inputPer1k) + .toBeLessThan(lookupPricing("openai").inputPer1k); + }); + + it("picks the longest matching model prefix", () => { + // "openai:gpt-4o" is declared first, so a first-match lookup would price the + // dated mini snapshot at the flagship rate. + expect(lookupPricing("openai", "gpt-4o-mini-2024-07-18")) + .toEqual(lookupPricing("openai", "gpt-4o-mini")); + expect(lookupPricing("anthropic", "claude-sonnet-4-5-20250929")) + .toEqual(lookupPricing("anthropic", "claude-sonnet-4-5")); + }); + + it("falls back to the provider entry, then to free", () => { + expect(lookupPricing("openai", "some-unlisted-model")).toEqual(lookupPricing("openai")); + expect(lookupPricing("not-a-provider")).toEqual({ inputPer1k: 0, outputPer1k: 0 }); + }); +}); + +describe("estimateCostEur", () => { + it("scales cost per 1k tokens", () => { + const p = lookupPricing("openai", "gpt-4o"); + expect(estimateCostEur("openai", "gpt-4o", 1000, 2000)) + .toBeCloseTo(p.inputPer1k + 2 * p.outputPer1k, 10); + }); + + it("ignores malformed token counts instead of returning a negative or NaN cost", () => { + expect(estimateCostEur("openai", "gpt-4o", -100, 200)) + .toBe(estimateCostEur("openai", "gpt-4o", 0, 200)); + expect(estimateCostEur("openai", "gpt-4o", Number.NaN, Number.POSITIVE_INFINITY)).toBe(0); + expect(estimateCostEur("openai", "gpt-4o", -100, -200)).toBe(0); + }); +}); From 09fbc26910d6dcf1a0310561e7fe9f1c27da1be8 Mon Sep 17 00:00:00 2001 From: chefgroep Date: Sat, 25 Jul 2026 12:49:17 +0200 Subject: [PATCH 20/31] chore: update package.json to reflect fork identity and repo URLs --- package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index e38d51b0a..32fd5cdcf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@bitkyc08/opencodex", "version": "2.7.43", - "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", + "description": "OnlineChefGroep fork — Universal provider proxy for OpenAI Codex & Claude Code. Use any LLM with Codex CLI/App/SDK and Claude Code.", "type": "module", "main": "./bin/package-main.mjs", "exports": { @@ -88,11 +88,11 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/lidge-jun/opencodex.git" + "url": "git+https://github.com/OnlineChefGroep/opencodex.git" }, - "homepage": "https://lidge-jun.github.io/opencodex/", + "homepage": "https://github.com/OnlineChefGroep/opencodex", "bugs": { - "url": "https://github.com/lidge-jun/opencodex/issues" + "url": "https://github.com/OnlineChefGroep/opencodex/issues" }, "license": "MIT" } From 9ea9316e9699cf104ede18d0d6289d510e7184e5 Mon Sep 17 00:00:00 2001 From: chefgroep Date: Sat, 25 Jul 2026 13:38:19 +0200 Subject: [PATCH 21/31] chore: update release workflow for fork branch model + add ROADMAP - Release workflow now allows any main-branch release (stable or alpha/preview) - Preview releases can use any prerelease suffix (e.g. -alpha.1, -beta.1) - Removed preview branch requirement (we use main for everything) - Added ROADMAP.md with short/medium/long term plans --- .github/workflows/release.yml | 32 ++++++++++++++--------------- ROADMAP.md | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 17 deletions(-) create mode 100644 ROADMAP.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9767d931..0a830a626 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,32 +111,30 @@ jobs: run: | set -euo pipefail + # All releases run from main. Stable versions (no pre-release suffix) get dist-tag "latest". + # Pre-release versions (e.g. 1.0.0-alpha.1) get dist-tag "preview". case "$GITHUB_REF" in refs/heads/main) - expected_tag="latest" if [[ "$RELEASE_VERSION" == *-* ]]; then - echo "::error::main releases must use a stable semver version; got ${RELEASE_VERSION}" - exit 1 - fi - ;; - refs/heads/preview) - expected_tag="preview" - if [[ "$RELEASE_VERSION" != *-preview.* ]]; then - echo "::error::preview releases must use a preview prerelease version; got ${RELEASE_VERSION}" - exit 1 + # Pre-release version — must use "preview" dist-tag + if [ "$NPM_DIST_TAG" != "preview" ]; then + echo "::error::Pre-release versions (${RELEASE_VERSION}) must use dist-tag 'preview', got '${NPM_DIST_TAG}'" + exit 1 + fi + else + # Stable version — must use "latest" dist-tag + if [ "$NPM_DIST_TAG" != "latest" ]; then + echo "::error::Stable releases (${RELEASE_VERSION}) must use dist-tag 'latest', got '${NPM_DIST_TAG}'" + exit 1 + fi fi ;; *) - echo "::error::Release must run from main or preview; got ${GITHUB_REF}" + echo "::error::Release must run from main; got ${GITHUB_REF}" exit 1 ;; esac - if [ "$NPM_DIST_TAG" != "$expected_tag" ]; then - echo "::error::${GITHUB_REF#refs/heads/} releases must publish with npm dist-tag '${expected_tag}', got '${NPM_DIST_TAG}'" - exit 1 - fi - ci_url="$( gh run list \ --workflow ci.yml \ @@ -306,7 +304,7 @@ jobs: # Preview builds must be marked prerelease so GitHub "latest" keeps pointing at the # stable channel (matching npm dist-tags); see issue #64. prerelease_flag="" - if [[ "$RELEASE_VERSION" == *-preview.* ]]; then + if [[ "$RELEASE_VERSION" == *-* ]]; then prerelease_flag="--prerelease" fi diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..1d11b6d7f --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,38 @@ +# Roadmap + +**OnlineChefGroep fork** of [opencodex](https://github.com/lidge-jun/opencodex) + +## Vision + +A fully self-sufficient fork with our own release cadence, feature set, and quality bar — +no longer dependent on upstream decisions or timelines. + +## Short term (done) + +- [x] **Independent versioning** — fully detached from upstream, own semver scheme +- [x] **All closed PRs merged** — Dutch GUI, Claude Desktop, combo/alias, cursor fixes, etc. +- [x] **Enhanced CI** — CodeQL, dependabot, security audit, workflow linting +- [x] **Release process** — VERSIONING.md, RELEASE_PROCESS.md, CHANGELOG.md + +## Near term (next) + +- [ ] **First fork release (v1.0.0-alpha.1)** — tag and publish to npm +- [ ] **Update READMEs** — ensure all translated READMEs (ko, zh, ru, ja) reflect fork status +- [ ] **Clean up old tags** — remove stale upstream tags that don't point to our commits +- [ ] **Dependency audit** — review and update all dependencies (gui + root) +- [ ] **TypeScript strict mode** — enable `strict` in tsconfig and fix all violations + +## Medium term + +- [ ] **Custom provider: OnlineChef AI gateway** — first-party provider integration +- [ ] **Performance benchmarks** — proxy latency regression tests in CI +- [ ] **Improved documentation** — deploy docs to GitHub Pages for our fork +- [ ] **Automated dependency upgrades** — Dependabot auto-merge for non-breaking updates +- [ ] **Smoke test suite** — end-to-end tests that spin up the proxy and make real API calls + +## Long term + +- [ ] **Own GUI theme** — custom branding for the dashboard +- [ ] **Plugin system** — third-party provider adapters +- [ ] **Service mode improvements** — better systemd/launchd integration +- [ ] **Multi-host fleet management** — centralized config across machines From 1801fdc4dcd9024d1953aedc6580b9cd87a8af85 Mon Sep 17 00:00:00 2001 From: chefgroep Date: Sat, 25 Jul 2026 13:47:59 +0200 Subject: [PATCH 22/31] fix: replace all upstream (lidge-jun) references with OnlineChefGroep URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Source code repo constants (star-prompt, update/job, update/notify) - PR target enforcement workflow (dev→main, docs links) - Issue quality workflow (docs links) - Issue templates (security, contributing docs URLs) - Test scripts (expected URLs, closed_by user) --- .github/ISSUE_TEMPLATE/config.yml | 7 ++----- .github/ISSUE_TEMPLATE/documentation.yml | 2 +- .github/scripts/issue-quality.test.cjs | 4 ++-- .github/workflows/enforce-issue-quality.yml | 2 +- src/cli/star-prompt.ts | 2 +- src/update/job.ts | 2 +- src/update/notify.ts | 2 +- 7 files changed, 9 insertions(+), 12 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 07fd1d1cf..258b24467 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,11 +1,8 @@ blank_issues_enabled: false contact_links: - - name: Report a security vulnerability (private) - url: https://github.com/lidge-jun/opencodex/security/advisories/new - about: Report undisclosed vulnerabilities privately to the maintainers. Do not open a public issue. - name: Security policy - url: https://github.com/lidge-jun/opencodex/blob/main/SECURITY.md + url: https://github.com/OnlineChefGroep/opencodex/blob/main/SECURITY.md about: Read the supported-version and reporting guidance before sharing security-sensitive details. - name: Contributing guide - url: https://opencodex.me/contributing/ + url: https://github.com/OnlineChefGroep/opencodex about: Review setup, build, and verification guidance for contributors. diff --git a/.github/ISSUE_TEMPLATE/documentation.yml b/.github/ISSUE_TEMPLATE/documentation.yml index 358e5e889..98a40322c 100644 --- a/.github/ISSUE_TEMPLATE/documentation.yml +++ b/.github/ISSUE_TEMPLATE/documentation.yml @@ -28,7 +28,7 @@ body: attributes: label: Documentation location description: Public documentation URL or repository path. - placeholder: "https://opencodex.me/providers/ or docs/providers.md" + placeholder: "https://github.com/OnlineChefGroep/opencodex or docs/providers.md" validations: required: true diff --git a/.github/scripts/issue-quality.test.cjs b/.github/scripts/issue-quality.test.cjs index c629a3d78..71fda9a3d 100644 --- a/.github/scripts/issue-quality.test.cjs +++ b/.github/scripts/issue-quality.test.cjs @@ -1003,7 +1003,7 @@ describe("validateIssue - documentation", () => { "### Documentation problem type", "Incorrect documentation", "### Documentation location", - "https://lidge-jun.github.io/opencodex/providers/", + "https://github.com/OnlineChefGroep/opencodex", "### What is wrong or missing?", "The page says kimi uses /v1/chat/completions but it actually uses /v1/responses.", "### What should the documentation explain instead?", @@ -1164,7 +1164,7 @@ describe("shouldReopen", () => { state: "closed", closed_at: "2026-07-20T10:00:00Z", state_reason: "not_planned", - closed_by: "lidge-jun", + closed_by: "OnlineChefGroep", }; assert.equal(shouldReopen(baseBotState, issue, false), false); }); diff --git a/.github/workflows/enforce-issue-quality.yml b/.github/workflows/enforce-issue-quality.yml index 2ef8ddc59..164397207 100644 --- a/.github/workflows/enforce-issue-quality.yml +++ b/.github/workflows/enforce-issue-quality.yml @@ -1040,7 +1040,7 @@ jobs: "", guidanceList, "", - "See the [Contributing guide](https://lidge-jun.github.io/opencodex/contributing/) for details.", + "See the [README](https://github.com/OnlineChefGroep/opencodex) for details.", "", "Once the report passes the automated checks, it will be reopened automatically unless a maintainer has changed its state.", ].join("\n")); diff --git a/src/cli/star-prompt.ts b/src/cli/star-prompt.ts index 7a251fc21..9927a12c2 100644 --- a/src/cli/star-prompt.ts +++ b/src/cli/star-prompt.ts @@ -6,7 +6,7 @@ import { recordOwnedConfigPath } from "../lib/config-ownership"; import { isAgentDriven } from "./agent-driven"; import { interactiveConfirm } from "./interactive-confirm"; -const REPO = "lidge-jun/opencodex"; +const REPO = "OnlineChefGroep/opencodex"; /** Fires exactly once from the first interactive `ocx start`. */ const MARKER = ".star-prompted"; diff --git a/src/update/job.ts b/src/update/job.ts index e16b80240..2570ed5bf 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -22,7 +22,7 @@ import { import { isNewer } from "./notify"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; -const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest"; +const RELEASE_NOTES_URL = "https://github.com/OnlineChefGroep/opencodex/releases/latest"; const UPDATE_JOB_FILENAME = "update-job.json"; const UPDATE_TIMEOUT_MS = 180_000; const RESTART_TIMEOUT_MS = 60_000; diff --git a/src/update/notify.ts b/src/update/notify.ts index 6a6ad9863..9602ebcd3 100644 --- a/src/update/notify.ts +++ b/src/update/notify.ts @@ -16,7 +16,7 @@ import { const VERSION_FILENAME = "version.json"; const REFRESH_INTERVAL_MS = 20 * 60 * 60 * 1000; // 20h, matching codex-rs -const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest"; +const RELEASE_NOTES_URL = "https://github.com/OnlineChefGroep/opencodex/releases/latest"; export interface VersionCache { latest_version: string; From 501665946eba12aaf09d0fb8f2d7132277f3771c Mon Sep 17 00:00:00 2001 From: chefgroep Date: Sat, 25 Jul 2026 13:50:08 +0200 Subject: [PATCH 23/31] chore: remove all upstream (lidge-jun) references from codebase - All READMEs (en, ko, zh-CN, ru, ja): replace lidge-jun URLs with OnlineChefGroep - GUI (Dashboard.tsx, Models.tsx): fix docs links - docs-site: astro.config, robots.txt, Landing.astro, all translated docs - CONTRIBUTING.md: replace upstream contributing link - tests/update-job.test.ts: fix expected release URL --- CONTRIBUTING.md | 2 +- MAINTAINERS.md | 2 +- README.md | 8 ++--- SECURITY.md | 2 +- docs-site/astro.config.mjs | 4 +-- docs-site/src/components/Landing.astro | 4 +-- docs-site/src/components/SiteJsonLd.astro | 2 +- docs-site/src/content/docs/contributing.md | 6 ++-- .../docs/getting-started/installation.md | 2 +- .../content/docs/guides/sub-agent-surface.md | 6 ++-- docs-site/src/content/docs/ja/contributing.md | 4 +-- .../docs/ja/getting-started/installation.md | 2 +- .../docs/ja/guides/sub-agent-surface.md | 6 ++-- .../src/content/docs/ja/reference/cli.md | 2 +- docs-site/src/content/docs/ko/contributing.md | 4 +-- .../docs/ko/getting-started/installation.md | 2 +- .../docs/ko/guides/sub-agent-surface.md | 6 ++-- .../src/content/docs/ko/reference/cli.md | 2 +- docs-site/src/content/docs/reference/cli.md | 2 +- .../content/docs/reference/configuration.md | 2 +- docs-site/src/content/docs/ru/contributing.md | 4 +-- .../docs/ru/getting-started/installation.md | 2 +- .../docs/ru/guides/sub-agent-surface.md | 6 ++-- .../src/content/docs/ru/reference/cli.md | 2 +- .../docs/troubleshooting/windows-memory.md | 4 +-- .../src/content/docs/zh-cn/contributing.md | 4 +-- .../zh-cn/getting-started/installation.md | 2 +- .../docs/zh-cn/guides/sub-agent-surface.md | 6 ++-- .../src/content/docs/zh-cn/reference/cli.md | 2 +- gui/src/App.tsx | 2 +- readme/README.ja.md | 6 ++-- readme/README.ko.md | 6 ++-- readme/README.ru.md | 6 ++-- readme/README.zh-CN.md | 6 ++-- tests/ci-workflows.test.ts | 36 +++++++++---------- tests/helpers/enforce-pr-target-harness.ts | 20 +++++------ tests/release-notes.test.ts | 22 ++++++------ tests/update-job.test.ts | 8 ++--- 38 files changed, 107 insertions(+), 107 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index eb3fdab11..e2c0ba992 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ Thanks for helping with opencodex. - `preview` — prerelease train. The `dev2-go` Go native-port line has been retired. Its history is archived at -[lidge-jun/opencodex-go-archive](https://github.com/lidge-jun/opencodex-go-archive), +[OnlineChefGroep/opencodex-go-archive](https://github.com/OnlineChefGroep/opencodex-go-archive), and everything now goes to `dev`. See [`MAINTAINERS.md`](./MAINTAINERS.md) for the reasoning. diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 867e7d6aa..f4bcd9e4b 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -60,7 +60,7 @@ defects. Bun-native TypeScript on `dev` is the single runtime line again. - The branch has been deleted from this repository. Its full history is published at - [lidge-jun/opencodex-go-archive](https://github.com/lidge-jun/opencodex-go-archive), + [OnlineChefGroep/opencodex-go-archive](https://github.com/OnlineChefGroep/opencodex-go-archive), and its final tip stays reachable here as the `archive/dev2-go` tag. - A merge into `dev` carries no port obligation. The nine open `needs-go-port` issues (#661, #663, #666, #670, #674, #678, #680, #685, #703) were closed as diff --git a/README.md b/README.md index 86b30f082..e0762e081 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Two commands, and every one of them runs any LLM you point it at.

Follow @claudeebum on X npm version - license + license node version

@@ -263,7 +263,7 @@ next Codex session. opencodex keeps these behaviors: - **Log in once, skip the API key.** OAuth support for xAI, Anthropic, and Kimi means you can authenticate with your existing account. Tokens auto-refresh. Or forward your `codex login`, paste an API key, or use `${ENV_VAR}` references — your call. - **Works everywhere Codex does.** Injects into Codex CLI, TUI, App, and SDK automatically. Routed models show up in Codex's model picker just like native ones. - **History-safe injection.** On local installs the proxy points Codex's own built-in `openai` provider at itself via a single `openai_base_url` line — new threads keep their native provider tag, so ongoing chat history is never remapped and an unclean shutdown can't hide it. (Threads re-tagged by older versions are migrated back once on the first start; remote/LAN binds use a dedicated provider entry instead, since they need an API-key header.) -- **Delegate to the right model.** Feature up to five routed or native models in Codex's subagent picker from the dashboard or config — route complex tasks to a reasoning model, fast tasks to a cheap one. On the v2 multi-agent surface (GPT-5.6 Sol/Terra) the proxy injects compact, schema-agnostic delegation guidance: an eligible preferred sub-agent model and effort (`injectionModel` / `injectionEffort`), the configured intersection of Codex's picker-visible, v2-compatible, priority-sorted first five with available effort ladders, and the `fork_turns` rules that let cross-model `spawn_agent` calls apply their overrides. Known limitation: when a native parent spawns a routed child, the task body can currently arrive backend-encrypted and be lost ([#92](https://github.com/lidge-jun/opencodex/issues/92)) — use the v1 surface for reliable cross-provider delegation. Want your own wording? Set `injectionPrompt` with `{{model}}` / `{{effort}}` / `{{roster}}` placeholders. +- **Delegate to the right model.** Feature up to five routed or native models in Codex's subagent picker from the dashboard or config — route complex tasks to a reasoning model, fast tasks to a cheap one. On the v2 multi-agent surface (GPT-5.6 Sol/Terra) the proxy injects compact, schema-agnostic delegation guidance: an eligible preferred sub-agent model and effort (`injectionModel` / `injectionEffort`), the configured intersection of Codex's picker-visible, v2-compatible, priority-sorted first five with available effort ladders, and the `fork_turns` rules that let cross-model `spawn_agent` calls apply their overrides. Known limitation: when a native parent spawns a routed child, the task body can currently arrive backend-encrypted and be lost ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)) — use the v1 surface for reliable cross-provider delegation. Want your own wording? Set `injectionPrompt` with `{{model}}` / `{{effort}}` / `{{roster}}` placeholders. - **Prepare for preview-gated OpenAI rollouts.** GPT-5.6 Sol/Terra/Luna entries preserve the upstream effort ladders. Direct/Multi use the 372k Codex contract; OpenAI API and OpenRouter use 1.05M metadata when upstream access is available. - **Give any model superpowers.** Non-OpenAI models get real web search and image understanding via a `gpt-5.4-mini` sidecar over your ChatGPT login. - **Generate images natively.** Codex's standalone `image_gen` tool uses `POST /v1/images/generations` for generation and `POST /v1/images/edits` for edits; it is separate from the hosted Responses `image_generation` tool. @@ -510,7 +510,7 @@ Maintainer source-of-truth notes live under [`structure/`](./structure). Histori Contributor setup lives in [`CONTRIBUTING.md`](./CONTRIBUTING.md), and security reporting guidance lives in [`SECURITY.md`](./SECURITY.md). Report undisclosed vulnerabilities privately through -[GitHub private vulnerability reporting](https://github.com/lidge-jun/opencodex/security/advisories/new), +[GitHub private vulnerability reporting](https://github.com/OnlineChefGroep/opencodex/security/advisories/new), not a public issue. ## Development @@ -519,7 +519,7 @@ Source development requires the `bun` CLI on your `PATH`. This is separate from package's bundled Bun runtime, which is used only by installed `ocx` commands. ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # start the proxy API in dev mode diff --git a/SECURITY.md b/SECURITY.md index b39af65d2..b73decd56 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,7 +20,7 @@ Please avoid posting undisclosed vulnerabilities as public GitHub issues. Report privately through GitHub private vulnerability reporting, which is enabled on this repository: -**** +**** The same form is reachable from the repository's **Security** tab under **Report a vulnerability**. It is private between you and the maintainers, and it is the only channel this project offers for diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index f86b7ea19..97d62b5e6 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -52,10 +52,10 @@ export default defineConfig({ { tag: "meta", attrs: { name: "theme-color", media: "(prefers-color-scheme: dark)", content: "#212121" } }, ], social: [ - { icon: "github", label: "GitHub", href: "https://github.com/lidge-jun/opencodex" }, + { icon: "github", label: "GitHub", href: "https://github.com/OnlineChefGroep/opencodex" }, ], editLink: { - baseUrl: "https://github.com/lidge-jun/opencodex/edit/main/docs-site/", + baseUrl: "https://github.com/OnlineChefGroep/opencodex/edit/main/docs-site/", }, lastUpdated: true, // English at the site root; Korean under /ko, Simplified Chinese under /zh-cn, Russian under /ru, Japanese under /ja. diff --git a/docs-site/src/components/Landing.astro b/docs-site/src/components/Landing.astro index d9e2965f2..105e4ed4a 100644 --- a/docs-site/src/components/Landing.astro +++ b/docs-site/src/components/Landing.astro @@ -112,7 +112,7 @@ const docsMap = [ icon: 'folder', links: [ { label: t('Contributing', '기여하기', '贡献', 'Участие в проекте', 'コントリビュート'), href: `${prefix}contributing/` }, - { label: 'GitHub', href: 'https://github.com/lidge-jun/opencodex' }, + { label: 'GitHub', href: 'https://github.com/OnlineChefGroep/opencodex' }, { label: 'npm', href: 'https://www.npmjs.com/package/@bitkyc08/opencodex' }, ], }, @@ -158,7 +158,7 @@ const docsMap = [ {t('Get Started', '시작하기', '快速开始', 'Начать', 'はじめに')} - GitHub + GitHub
    {providers.map((p) => ( diff --git a/docs-site/src/components/SiteJsonLd.astro b/docs-site/src/components/SiteJsonLd.astro index b6043033c..3e0307274 100644 --- a/docs-site/src/components/SiteJsonLd.astro +++ b/docs-site/src/components/SiteJsonLd.astro @@ -71,7 +71,7 @@ if (!locale) { offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }, softwareHelp: { '@type': 'CreativeWork', url: `${SITE_URL}/` }, downloadUrl: 'https://www.npmjs.com/package/@bitkyc08/opencodex', - url: 'https://github.com/lidge-jun/opencodex', + url: 'https://github.com/OnlineChefGroep/opencodex', }); } diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index bc285f7a7..bc888ab58 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -9,7 +9,7 @@ Source development requires the `bun` CLI on your `PATH`. The published npm pack Bun runtime for users, but this checkout's scripts run through your local Bun installation. ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # proxy API in dev mode @@ -93,7 +93,7 @@ bun run release:watch # watch the newest Release workflow run The `dev2-go` line that carried the Go native port has been retired, and the dual-track carry policy with it. Its history is published read-only at -[lidge-jun/opencodex-go-archive](https://github.com/lidge-jun/opencodex-go-archive). +[OnlineChefGroep/opencodex-go-archive](https://github.com/OnlineChefGroep/opencodex-go-archive). Bun-native TypeScript on `dev` is the single runtime line. Rebase pull requests are welcome. Bringing a stale branch onto the current head @@ -110,7 +110,7 @@ description. ## Project maintainers The current maintainers, their responsibilities, and the review and merge policy are documented in -[`MAINTAINERS.md`](https://github.com/lidge-jun/opencodex/blob/main/MAINTAINERS.md). GitHub review +[`MAINTAINERS.md`](https://github.com/OnlineChefGroep/opencodex/blob/main/MAINTAINERS.md). GitHub review ownership for the repository and security-sensitive paths is declared in `.github/CODEOWNERS`. ## Conventions diff --git a/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index 7c68e1b84..f0a38dd31 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -60,7 +60,7 @@ ocx update --tag preview To hack on opencodex itself: ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # starts the proxy API in dev mode (src/cli/index.ts start) 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 ae9738ad9..ecaebb411 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -6,7 +6,7 @@ description: Control how Codex spawns and manages sub-agents across all models. opencodex lets you choose the multi-agent collaboration surface for every model in the catalog. The **Sub-agent** toggle in the dashboard and Models page controls this globally. :::note -On the v2 surface (`multi_agent_v2`), a spawned sub-agent inherits the parent model **by default**: `fork_turns` defaults to `all`, and full-history forks reject overrides. Since v2.7.2 opencodex injects guidance that teaches the model how to break inheritance — a `spawn_agent` call that sets `fork_turns` to `"none"` (or a partial fork such as `"3"`) can pass `model` / `reasoning_effort` arguments, which the Codex runtime parses and applies even though the published tool schema hides them. Known transport limitation: when a **native** parent spawns a child routed to a **non-native** provider, the Codex client may send the `NEW_TASK` payload only as backend-encrypted `encrypted_content` ([#92](https://github.com/lidge-jun/opencodex/issues/92)). opencodex does not forward that unreadable task to an external provider: a direct route fails with HTTP 400 and code `unreadable_encrypted_agent_task`, while a combo skips non-decrypting targets and selects a canonical native ChatGPT target when one is available. Use v1 for heterogeneous-provider delegation, select a native ChatGPT child, or resend the task as plaintext v2 `agent_message` content. +On the v2 surface (`multi_agent_v2`), a spawned sub-agent inherits the parent model **by default**: `fork_turns` defaults to `all`, and full-history forks reject overrides. Since v2.7.2 opencodex injects guidance that teaches the model how to break inheritance — a `spawn_agent` call that sets `fork_turns` to `"none"` (or a partial fork such as `"3"`) can pass `model` / `reasoning_effort` arguments, which the Codex runtime parses and applies even though the published tool schema hides them. Known transport limitation: when a **native** parent spawns a child routed to a **non-native** provider, the Codex client may send the `NEW_TASK` payload only as backend-encrypted `encrypted_content` ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). opencodex does not forward that unreadable task to an external provider: a direct route fails with HTTP 400 and code `unreadable_encrypted_agent_task`, while a combo skips non-decrypting targets and selects a canonical native ChatGPT target when one is available. Use v1 for heterogeneous-provider delegation, select a native ChatGPT child, or resend the task as plaintext v2 `agent_message` content. ::: ## Modes @@ -15,7 +15,7 @@ On the v2 surface (`multi_agent_v2`), a spawned sub-agent inherits the parent mo | --- | --- | --- | | **v1** | `multi_agent_v1` | Classic namespaced agent tools with `send_input` / `close_agent` / `resume_agent`. A `spawn_agent` model override can start a sub-agent on a different model. | | **base** (default) | Upstream pins | Restores upstream model pins: gpt-5.6-sol and gpt-5.6-terra use v2, gpt-5.6-luna uses v1, and unpinned models follow the Codex `multi_agent_v2` feature flag. Spawn behavior follows the surface that resolves for that model. | -| **v2** | `multi_agent_v2` | Flat `spawn_agent` tools with concurrent sessions and `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`. Children inherit the parent model on full-history forks; `fork_turns: "none"` (or a partial fork) accepts `model` / `reasoning_effort` overrides. If a native→routed child receives only backend-encrypted task content, external routes return `unreadable_encrypted_agent_task`; mixed combos prefer a decrypt-capable native target ([#92](https://github.com/lidge-jun/opencodex/issues/92)). | +| **v2** | `multi_agent_v2` | Flat `spawn_agent` tools with concurrent sessions and `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`. Children inherit the parent model on full-history forks; `fork_turns: "none"` (or a partial fork) accepts `model` / `reasoning_effort` overrides. If a native→routed child receives only backend-encrypted task content, external routes return `unreadable_encrypted_agent_task`; mixed combos prefer a decrypt-capable native target ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). | ### Encrypted v2 task delivery @@ -58,7 +58,7 @@ To replace the built-in v2 guidance, set `injectionPrompt` (config key, or `PUT - **Dashboard** → first stat cell: click **v1**, **base**, or **v2**. - **Models** page → top-row segmented control. - Both pages have a **?** button that opens a help modal with a link back here. -- **Dashboard** → **Sub-agent delegation**: choose a preferred model and optional reasoning effort. Enable **OpenCodex multi-agent guidance** for delegation instructions, or independently enable **Use as native Codex subagent defaults** to apply the selection to new Codex tasks after sync/restart. The defaults switch does not cause delegation, and existing user-owned `[agents]` defaults are preserved rather than overwritten. On v2 the injected guidance instructs the agent to spawn with `fork_turns: "none"` so the model override applies. If a native→routed child receives only encrypted task content, use a native target or v1; external-only delivery now fails explicitly with `unreadable_encrypted_agent_task` ([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **Dashboard** → **Sub-agent delegation**: choose a preferred model and optional reasoning effort. Enable **OpenCodex multi-agent guidance** for delegation instructions, or independently enable **Use as native Codex subagent defaults** to apply the selection to new Codex tasks after sync/restart. The defaults switch does not cause delegation, and existing user-owned `[agents]` defaults are preserved rather than overwritten. On v2 the injected guidance instructs the agent to spawn with `fork_turns: "none"` so the model override applies. If a native→routed child receives only encrypted task content, use a native target or v1; external-only delivery now fails explicitly with `unreadable_encrypted_agent_task` ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). ### CLI diff --git a/docs-site/src/content/docs/ja/contributing.md b/docs-site/src/content/docs/ja/contributing.md index 2d74d52f7..df57fef35 100644 --- a/docs-site/src/content/docs/ja/contributing.md +++ b/docs-site/src/content/docs/ja/contributing.md @@ -6,7 +6,7 @@ description: opencodex の開発環境、構成、規約、プロバイダーと ## セットアップ ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # 開発モードのプロキシ API @@ -87,7 +87,7 @@ bun run release:watch # 直近の Release ワークフロー run Go ネイティブポートを担っていた `dev2-go` は廃止し、2 本の統合ラインを維持する方針も 終了しました。履歴は -[lidge-jun/opencodex-go-archive](https://github.com/lidge-jun/opencodex-go-archive) +[OnlineChefGroep/opencodex-go-archive](https://github.com/OnlineChefGroep/opencodex-go-archive) に読み取り専用で残しています。現在は `dev` の Bun ネイティブ TypeScript が単一のランタイム ラインです。 diff --git a/docs-site/src/content/docs/ja/getting-started/installation.md b/docs-site/src/content/docs/ja/getting-started/installation.md index 52ff9d178..9ade20744 100644 --- a/docs-site/src/content/docs/ja/getting-started/installation.md +++ b/docs-site/src/content/docs/ja/getting-started/installation.md @@ -59,7 +59,7 @@ ocx update --tag preview opencodex 自体を直接修正しながら作業するには: ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # 開発モードでプロキシ API を起動 (src/cli/index.ts start) diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md index 59dce3ccb..9ef906da1 100644 --- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md @@ -6,7 +6,7 @@ description: すべてのモデルの Codex サブエージェント生成・管 opencodex ではカタログの全モデルが使うマルチエージェントコラボサーフェスを選択できます。ダッシュボードとモデルページの **サブエージェント** トグルがこの値をグローバルに制御します。 :::note -v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォルトで**親モデルを継承します。`fork_turns` のデフォルトが `all` で、全体履歴 fork がオーバーライドを拒否するためです。v2.7.2 から opencodex が継承を破る方法をガイドとして注入します。`fork_turns` を `"none"`(または `"3"` のような部分 fork)に指定した `spawn_agent` 呼び出しは `model` / `reasoning_effort` 引数を渡せ、公開されたツールスキーマにこの引数が見えなくても Codex ランタイムはパースして適用します。既知の転送制限:**ネイティブ**の親が**非ネイティブ**(ルーティング)プロバイダーの子をスポーンすると、Codex クライアントは `NEW_TASK` ペイロードをバックエンド暗号化の `encrypted_content` でのみ送ることがあります([#92](https://github.com/lidge-jun/opencodex/issues/92))。opencodex は読み取れないタスクを外部プロバイダーへ転送しません。直接ルートは HTTP 400 とコード `unreadable_encrypted_agent_task` で失敗し、コンボは復号できないターゲットを除外して、可能なら正規のネイティブ ChatGPT ターゲットを選択します。異種プロバイダー委任には v1 を使うか、ネイティブ ChatGPT の子を選ぶか、タスクを平文の v2 `agent_message` コンテンツとして送り直してください。 +v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォルトで**親モデルを継承します。`fork_turns` のデフォルトが `all` で、全体履歴 fork がオーバーライドを拒否するためです。v2.7.2 から opencodex が継承を破る方法をガイドとして注入します。`fork_turns` を `"none"`(または `"3"` のような部分 fork)に指定した `spawn_agent` 呼び出しは `model` / `reasoning_effort` 引数を渡せ、公開されたツールスキーマにこの引数が見えなくても Codex ランタイムはパースして適用します。既知の転送制限:**ネイティブ**の親が**非ネイティブ**(ルーティング)プロバイダーの子をスポーンすると、Codex クライアントは `NEW_TASK` ペイロードをバックエンド暗号化の `encrypted_content` でのみ送ることがあります([#92](https://github.com/OnlineChefGroep/opencodex/issues/92))。opencodex は読み取れないタスクを外部プロバイダーへ転送しません。直接ルートは HTTP 400 とコード `unreadable_encrypted_agent_task` で失敗し、コンボは復号できないターゲットを除外して、可能なら正規のネイティブ ChatGPT ターゲットを選択します。異種プロバイダー委任には v1 を使うか、ネイティブ ChatGPT の子を選ぶか、タスクを平文の v2 `agent_message` コンテンツとして送り直してください。 ::: ## モード @@ -15,7 +15,7 @@ v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォル --- | --- | --- | | **v1** | `multi_agent_v1` | 名前空間方式のクラシックエージェントツールと `send_input` / `close_agent` / `resume_agent` を使います。`spawn_agent` モデルオーバーライドで別モデルのサブエージェントを起動できます。 | | **base**(デフォルト) | 上流 pin | 上流モデル pin を復元します。gpt-5.6-sol と gpt-5.6-terra は v2、gpt-5.6-luna は v1 を使い、pin のないモデルは Codex `multi_agent_v2` 機能フラグに従います。実際のスポーン動作は各モデルに決定されたサーフェスに従います。 | -| **v2** | `multi_agent_v2` | フラット `spawn_agent` ツールと同時セッション、`send_message` / `followup_task` / `wait_agent` / `interrupt_agent` を使います。全体履歴 fork では子が親モデルを継承し、`fork_turns: "none"`(または部分 fork)では `model` / `reasoning_effort` オーバーライドが適用されます。ネイティブ→ルーティングの子がバックエンド暗号化のタスク内容しか受け取れない場合、外部ルートは `unreadable_encrypted_agent_task` を返し、混成コンボは復号可能なネイティブターゲットを優先します([#92](https://github.com/lidge-jun/opencodex/issues/92))。 | +| **v2** | `multi_agent_v2` | フラット `spawn_agent` ツールと同時セッション、`send_message` / `followup_task` / `wait_agent` / `interrupt_agent` を使います。全体履歴 fork では子が親モデルを継承し、`fork_turns: "none"`(または部分 fork)では `model` / `reasoning_effort` オーバーライドが適用されます。ネイティブ→ルーティングの子がバックエンド暗号化のタスク内容しか受け取れない場合、外部ルートは `unreadable_encrypted_agent_task` を返し、混成コンボは復号可能なネイティブターゲットを優先します([#92](https://github.com/OnlineChefGroep/opencodex/issues/92))。 | ### 暗号化 v2 タスクの配信 @@ -58,7 +58,7 @@ v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォル - **ダッシュボード** → 最初のスタットセルで **v1**、**base**、**v2** を選択します。 - **モデル** ページ → 上部セグメントコントロールで選択します。 - 両ページとも **?** ボタンを押すとこのドキュメントに繋がるヘルプモーダルが開きます。 -- **ダッシュボード** → **サブエージェント委任** で推奨モデルとオプションの推論強度を選びます。**ネイティブ Codex サブエージェント既定値として使用**を有効にすると、OpenCodex が有効な Codex ルーティングを管理している場合、次回の sync または restart から新しい Codex タスクにも同じ選択値が適用されます。外部のユーザー管理 provider 設定は変更しません。このトグルは委任ガイダンスのトグルとは独立しています。v2 では注入ガイドが `fork_turns: "none"` スポーンを指示しモデルオーバーライドを適用させます — ただしネイティブ→ルーティング子はタスク本文が暗号化状態で到着する可能性があります([#92](https://github.com/lidge-jun/opencodex/issues/92))。 +- **ダッシュボード** → **サブエージェント委任** で推奨モデルとオプションの推論強度を選びます。**ネイティブ Codex サブエージェント既定値として使用**を有効にすると、OpenCodex が有効な Codex ルーティングを管理している場合、次回の sync または restart から新しい Codex タスクにも同じ選択値が適用されます。外部のユーザー管理 provider 設定は変更しません。このトグルは委任ガイダンスのトグルとは独立しています。v2 では注入ガイドが `fork_turns: "none"` スポーンを指示しモデルオーバーライドを適用させます — ただしネイティブ→ルーティング子はタスク本文が暗号化状態で到着する可能性があります([#92](https://github.com/OnlineChefGroep/opencodex/issues/92))。 ### CLI diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index d54e2f63c..973def9e3 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -438,7 +438,7 @@ ocx update ocx update --tag preview ``` -[Release ワークフロー](https://github.com/lidge-jun/opencodex/actions/workflows/release.yml) が npm に +[Release ワークフロー](https://github.com/OnlineChefGroep/opencodex/actions/workflows/release.yml) が npm に 公開した直後に新しいバージョンが使えるようになります。 ## ヘルプ diff --git a/docs-site/src/content/docs/ko/contributing.md b/docs-site/src/content/docs/ko/contributing.md index 285445d8c..38c09bba9 100644 --- a/docs-site/src/content/docs/ko/contributing.md +++ b/docs-site/src/content/docs/ko/contributing.md @@ -6,7 +6,7 @@ description: opencodex 개발 환경, 구조, 컨벤션, 프로바이더와 어 ## 설정 ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # 개발 모드 프록시 API @@ -87,7 +87,7 @@ bun run release:watch # 가장 최근 Release workflow run 감시 Go 네이티브 포트를 담당했던 `dev2-go`는 정리했고, 두 라인을 동시에 유지하던 정책도 함께 끝났습니다. 히스토리는 -[lidge-jun/opencodex-go-archive](https://github.com/lidge-jun/opencodex-go-archive)에 +[OnlineChefGroep/opencodex-go-archive](https://github.com/OnlineChefGroep/opencodex-go-archive)에 읽기 전용으로 남아 있습니다. 지금은 `dev`의 Bun 네이티브 TypeScript가 단일 런타임입니다. 리베이스 PR은 환영합니다. 오래된 브랜치를 현재 head 위로 리베이스하는 것은 잡음이 아니라 diff --git a/docs-site/src/content/docs/ko/getting-started/installation.md b/docs-site/src/content/docs/ko/getting-started/installation.md index 1dfff4b15..ce63d0291 100644 --- a/docs-site/src/content/docs/ko/getting-started/installation.md +++ b/docs-site/src/content/docs/ko/getting-started/installation.md @@ -59,7 +59,7 @@ ocx update --tag preview opencodex 자체를 직접 수정하며 작업하려면: ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # 개발 모드로 프록시 API 시작 (src/cli/index.ts start) diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index 5623a6ae6..3c2e90108 100644 --- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md @@ -6,7 +6,7 @@ description: 모든 모델의 Codex 서브에이전트 생성·관리 방식을 opencodex에서는 카탈로그의 모든 모델이 사용할 멀티에이전트 협업 서피스를 선택할 수 있습니다. 대시보드와 모델 페이지의 **서브에이전트** 토글이 이 값을 전역으로 제어합니다. :::note -v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부모 모델을 상속합니다. `fork_turns` 기본값이 `all`이고, 전체 히스토리 fork는 오버라이드를 거부하기 때문입니다. v2.7.2부터 opencodex가 상속을 깨는 방법을 가이드로 주입합니다. `fork_turns`를 `"none"`(또는 `"3"` 같은 부분 fork)으로 지정한 `spawn_agent` 호출은 `model` / `reasoning_effort` 인자를 전달할 수 있고, 공개된 툴 스키마에 이 인자가 안 보여도 Codex 런타임은 파싱해서 적용합니다. 알려진 전송 제한: **네이티브** 부모가 **비네이티브**(라우팅) 프로바이더의 자식을 스폰하면 Codex 클라이언트가 `NEW_TASK` 페이로드를 백엔드 암호화된 `encrypted_content`로만 볼 수 있습니다([#92](https://github.com/lidge-jun/opencodex/issues/92)). opencodex는 읽을 수 없는 작업을 외부 프로바이더로 전달하지 않습니다. 직접 라우팅은 HTTP 400과 `unreadable_encrypted_agent_task` 코드로 실패하고, 콤보는 복호화할 수 없는 대상은 제외하고 가능하면 정규 네이티브 ChatGPT 대상을 선택합니다. 이종 프로바이더 위임에는 v1을 쓰거나, 네이티브 ChatGPT 자식을 선택하거나, 작업을 평문 v2 `agent_message` 콘텐츠로 다시 볼 수 있습니다. +v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부모 모델을 상속합니다. `fork_turns` 기본값이 `all`이고, 전체 히스토리 fork는 오버라이드를 거부하기 때문입니다. v2.7.2부터 opencodex가 상속을 깨는 방법을 가이드로 주입합니다. `fork_turns`를 `"none"`(또는 `"3"` 같은 부분 fork)으로 지정한 `spawn_agent` 호출은 `model` / `reasoning_effort` 인자를 전달할 수 있고, 공개된 툴 스키마에 이 인자가 안 보여도 Codex 런타임은 파싱해서 적용합니다. 알려진 전송 제한: **네이티브** 부모가 **비네이티브**(라우팅) 프로바이더의 자식을 스폰하면 Codex 클라이언트가 `NEW_TASK` 페이로드를 백엔드 암호화된 `encrypted_content`로만 볼 수 있습니다([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). opencodex는 읽을 수 없는 작업을 외부 프로바이더로 전달하지 않습니다. 직접 라우팅은 HTTP 400과 `unreadable_encrypted_agent_task` 코드로 실패하고, 콤보는 복호화할 수 없는 대상은 제외하고 가능하면 정규 네이티브 ChatGPT 대상을 선택합니다. 이종 프로바이더 위임에는 v1을 쓰거나, 네이티브 ChatGPT 자식을 선택하거나, 작업을 평문 v2 `agent_message` 콘텐츠로 다시 볼 수 있습니다. ::: ## 모드 @@ -15,7 +15,7 @@ v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부 | --- | --- | --- | | **v1** | `multi_agent_v1` | 네임스페이스 방식의 클래식 에이전트 툴과 `send_input` / `close_agent` / `resume_agent`를 사용합니다. `spawn_agent` 모델 오버라이드로 다른 모델의 서브에이전트를 띄울 수 있습니다. | | **base** (기본값) | 업스트림 핀 | 업스트림 모델 핀을 복원합니다. gpt-5.6-sol과 gpt-5.6-terra는 v2, gpt-5.6-luna는 v1을 쓰고, 핀이 없는 모델은 Codex `multi_agent_v2` 기능 플래그를 따릅니다. 실제 스폰 동작은 각 모델에 결정된 서피스를 따릅니다. | -| **v2** | `multi_agent_v2` | 플랫 `spawn_agent` 툴과 동시 세션, `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`를 사용합니다. 전체 히스토리 fork에서는 자식이 부모 모델을 상속하고, `fork_turns: "none"`(또는 부분 fork)에서는 `model` / `reasoning_effort` 오버라이드가 적용됩니다. 네이티브→라우팅 자식이 백엔드 암호화된 작업 콘텐츠만 받으면 외부 라우팅은 `unreadable_encrypted_agent_task`를 반환하고, 혼합 콤보는 복호화 가능한 네이티브 대상을 우선합니다([#92](https://github.com/lidge-jun/opencodex/issues/92)). | +| **v2** | `multi_agent_v2` | 플랫 `spawn_agent` 툴과 동시 세션, `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`를 사용합니다. 전체 히스토리 fork에서는 자식이 부모 모델을 상속하고, `fork_turns: "none"`(또는 부분 fork)에서는 `model` / `reasoning_effort` 오버라이드가 적용됩니다. 네이티브→라우팅 자식이 백엔드 암호화된 작업 콘텐츠만 받으면 외부 라우팅은 `unreadable_encrypted_agent_task`를 반환하고, 혼합 콤보는 복호화 가능한 네이티브 대상을 우선합니다([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). | ### 암호화된 v2 작업 전달 @@ -58,7 +58,7 @@ v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부 - **대시보드** → 첫 번째 스탯 셀에서 **v1**, **base**, **v2**를 선택합니다. - **모델** 페이지 → 상단 세그먼트 컨트롤에서 선택합니다. - 두 페이지 모두 **?** 버튼을 누르면 이 문서로 연결되는 도움말 모달이 열립니다. -- **대시보드** → **서브에이전트 위임**에서 선호 모델과 선택 사항인 추론 강도를 고릅니다. **Codex 네이티브 서브에이전트 기본값으로 사용**을 켜면 OpenCodex가 활성 Codex 라우팅을 관리할 때 다음 sync 또는 restart부터 새 Codex task에도 같은 선택값을 적용합니다. 외부 사용자 관리 provider 설정은 그대로 유지합니다. 이 토글은 위임 가이드 토글과 별개입니다. v2에서는 주입된 가이드가 `fork_turns: "none"` 스폰을 지시해 모델 오버라이드가 적용되게 합니다 — 다만 네이티브→라우팅 자식은 작업 본문이 암호화 상태로 도착할 수 있습니다([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **대시보드** → **서브에이전트 위임**에서 선호 모델과 선택 사항인 추론 강도를 고릅니다. **Codex 네이티브 서브에이전트 기본값으로 사용**을 켜면 OpenCodex가 활성 Codex 라우팅을 관리할 때 다음 sync 또는 restart부터 새 Codex task에도 같은 선택값을 적용합니다. 외부 사용자 관리 provider 설정은 그대로 유지합니다. 이 토글은 위임 가이드 토글과 별개입니다. v2에서는 주입된 가이드가 `fork_turns: "none"` 스폰을 지시해 모델 오버라이드가 적용되게 합니다 — 다만 네이티브→라우팅 자식은 작업 본문이 암호화 상태로 도착할 수 있습니다([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). ### CLI diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index db267493e..4d264dbef 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -456,7 +456,7 @@ ocx update ocx update --tag preview ``` -[Release 워크플로](https://github.com/lidge-jun/opencodex/actions/workflows/release.yml)가 npm에 +[Release 워크플로](https://github.com/OnlineChefGroep/opencodex/actions/workflows/release.yml)가 npm에 게시하는 즉시 새 버전을 사용할 수 있습니다. ## 도움말 diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 88b308ab5..990831ea4 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -523,7 +523,7 @@ ocx update ocx update --tag preview ``` -New versions become available the moment the [Release workflow](https://github.com/lidge-jun/opencodex/actions/workflows/release.yml) +New versions become available the moment the [Release workflow](https://github.com/OnlineChefGroep/opencodex/actions/workflows/release.yml) publishes them to npm. ## Help diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index adbbe76ec..b2caee0f8 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -169,7 +169,7 @@ provider terms of service. ### anthropicAccountPool (experimental) Opt-in routing across **multiple Anthropic OAuth accounts** already stored in `auth.json` -(issue [#294](https://github.com/lidge-jun/opencodex/issues/294)). **Default off.** This is +(issue [#294](https://github.com/OnlineChefGroep/opencodex/issues/294)). **Default off.** This is experimental and not battle-tested — enable only if you accept the risk that Anthropic may restrict accounts that look like automated multi-account rotation. Accounts under the same organization can share quota; pooling those will not help. diff --git a/docs-site/src/content/docs/ru/contributing.md b/docs-site/src/content/docs/ru/contributing.md index 6f71de7f8..988203a70 100644 --- a/docs-site/src/content/docs/ru/contributing.md +++ b/docs-site/src/content/docs/ru/contributing.md @@ -6,7 +6,7 @@ description: Разработка opencodex — настройка окруже ## Настройка окружения ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # прокси-API в режиме разработки @@ -87,7 +87,7 @@ bun run release:watch # наблюдение за последни Ветка `dev2-go`, которая несла нативный порт на Go, закрыта, и вместе с ней закончилась политика двух линий интеграции. Её история опубликована только для чтения в -[lidge-jun/opencodex-go-archive](https://github.com/lidge-jun/opencodex-go-archive). +[OnlineChefGroep/opencodex-go-archive](https://github.com/OnlineChefGroep/opencodex-go-archive). Теперь единственная линия рантайма — Bun-нативный TypeScript в `dev`. Pull request'ы с ребейзом приветствуются: ребейз устаревшей ветки на текущий head — это diff --git a/docs-site/src/content/docs/ru/getting-started/installation.md b/docs-site/src/content/docs/ru/getting-started/installation.md index 837178f7e..b8af34eb2 100644 --- a/docs-site/src/content/docs/ru/getting-started/installation.md +++ b/docs-site/src/content/docs/ru/getting-started/installation.md @@ -62,7 +62,7 @@ ocx update --tag preview Чтобы работать над самим opencodex: ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # запускает API прокси в режиме разработки (src/cli/index.ts start) diff --git a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md index 27510501d..356a1e3e7 100644 --- a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md @@ -6,7 +6,7 @@ description: Управление тем, как Codex порождает под opencodex позволяет выбрать поверхность мультиагентного взаимодействия для каждой модели в каталоге. Переключатель **Sub-agent** в дашборде и на странице Models управляет этим глобально. :::note -На поверхности v2 (`multi_agent_v2`) порождённый подагент **по умолчанию** наследует модель родителя: `fork_turns` по умолчанию равен `all`, а форки с полной историей отклоняют переопределения. Начиная с v2.7.2 opencodex внедряет инструкцию, которая учит модель обходить наследование: вызов `spawn_agent`, устанавливающий `fork_turns` в `"none"` (или частичный форк, например `"3"`), может передать аргументы `model` / `reasoning_effort`, которые рантайм Codex разбирает и применяет, хотя опубликованная схема инструмента их скрывает. Известное ограничение транспорта: когда **нативный** родитель порождает потомка, маршрутизируемого на **ненативного** провайдера, клиент Codex может отправить полезную нагрузку `NEW_TASK` только как зашифрованный бэкендом `encrypted_content` ([#92](https://github.com/lidge-jun/opencodex/issues/92)). opencodex не пересылает нечитаемую задачу внешнему провайдеру: прямой маршрут завершается с HTTP 400 и кодом `unreadable_encrypted_agent_task`, а комбо пропускает цели без возможности дешифрования и при наличии выбирает каноническую нативную цель ChatGPT. Для делегирования между разнородными провайдерами используйте v1, выберите нативного потомка ChatGPT или отправьте задачу повторно как открытый v2 `agent_message` контент. +На поверхности v2 (`multi_agent_v2`) порождённый подагент **по умолчанию** наследует модель родителя: `fork_turns` по умолчанию равен `all`, а форки с полной историей отклоняют переопределения. Начиная с v2.7.2 opencodex внедряет инструкцию, которая учит модель обходить наследование: вызов `spawn_agent`, устанавливающий `fork_turns` в `"none"` (или частичный форк, например `"3"`), может передать аргументы `model` / `reasoning_effort`, которые рантайм Codex разбирает и применяет, хотя опубликованная схема инструмента их скрывает. Известное ограничение транспорта: когда **нативный** родитель порождает потомка, маршрутизируемого на **ненативного** провайдера, клиент Codex может отправить полезную нагрузку `NEW_TASK` только как зашифрованный бэкендом `encrypted_content` ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). opencodex не пересылает нечитаемую задачу внешнему провайдеру: прямой маршрут завершается с HTTP 400 и кодом `unreadable_encrypted_agent_task`, а комбо пропускает цели без возможности дешифрования и при наличии выбирает каноническую нативную цель ChatGPT. Для делегирования между разнородными провайдерами используйте v1, выберите нативного потомка ChatGPT или отправьте задачу повторно как открытый v2 `agent_message` контент. ::: ## Режимы @@ -15,7 +15,7 @@ opencodex позволяет выбрать поверхность мульти | --- | --- | --- | | **v1** | `multi_agent_v1` | Классические агентные инструменты с пространством имён: `send_input` / `close_agent` / `resume_agent`. Переопределение модели в `spawn_agent` может запустить подагента на другой модели. | | **base** (по умолчанию) | Вышестоящие закрепления | Восстанавливает вышестоящие закрепления моделей: gpt-5.6-sol и gpt-5.6-terra используют v2, gpt-5.6-luna — v1, а незакреплённые модели следуют фиче-флагу Codex `multi_agent_v2`. Поведение порождения следует поверхности, которая определяется для данной модели. | -| **v2** | `multi_agent_v2` | Плоские инструменты `spawn_agent` с параллельными сессиями и `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`. Потомки наследуют модель родителя при форках с полной историей; `fork_turns: "none"` (или частичный форк) принимает переопределения `model` / `reasoning_effort`. Если потомок native→routed получает только зашифрованное бэкендом содержимое задачи, внешние маршруты возвращают `unreadable_encrypted_agent_task`, а смешанные комбо предпочитают цель с возможностью дешифрования ([#92](https://github.com/lidge-jun/opencodex/issues/92)). | +| **v2** | `multi_agent_v2` | Плоские инструменты `spawn_agent` с параллельными сессиями и `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`. Потомки наследуют модель родителя при форках с полной историей; `fork_turns: "none"` (или частичный форк) принимает переопределения `model` / `reasoning_effort`. Если потомок native→routed получает только зашифрованное бэкендом содержимое задачи, внешние маршруты возвращают `unreadable_encrypted_agent_task`, а смешанные комбо предпочитают цель с возможностью дешифрования ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). | ### Доставка зашифрованных v2-задач @@ -58,7 +58,7 @@ opencodex позволяет выбрать поверхность мульти - **Dashboard** → первая ячейка статистики: нажмите **v1**, **base** или **v2**. - Страница **Models** → сегментированный переключатель в верхнем ряду. - На обеих страницах есть кнопка **?**, открывающая модальное окно справки со ссылкой на эту страницу. -- **Dashboard** → **Sub-agent delegation**: выберите предпочтительную модель и, при желании, уровень рассуждений. Включите **Использовать как нативные значения по умолчанию для подагентов Codex**, чтобы после следующей синхронизации или перезапуска применять тот же выбор к новым задачам Codex, когда активной маршрутизацией управляет OpenCodex. Внешняя пользовательская конфигурация провайдера остаётся неизменной. Этот переключатель не зависит от переключателя руководства по делегированию. На v2 внедрённая инструкция велит агенту порождать с `fork_turns: "none"`, чтобы переопределение модели сработало, — хотя для потомков native→routed тело задачи сейчас может приходить зашифрованным ([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **Dashboard** → **Sub-agent delegation**: выберите предпочтительную модель и, при желании, уровень рассуждений. Включите **Использовать как нативные значения по умолчанию для подагентов Codex**, чтобы после следующей синхронизации или перезапуска применять тот же выбор к новым задачам Codex, когда активной маршрутизацией управляет OpenCodex. Внешняя пользовательская конфигурация провайдера остаётся неизменной. Этот переключатель не зависит от переключателя руководства по делегированию. На v2 внедрённая инструкция велит агенту порождать с `fork_turns: "none"`, чтобы переопределение модели сработало, — хотя для потомков native→routed тело задачи сейчас может приходить зашифрованным ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). ### CLI diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index 0c1456354..7b6529afb 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -485,7 +485,7 @@ ocx update ocx update --tag preview ``` -Новые версии становятся доступны в момент, когда [workflow Release](https://github.com/lidge-jun/opencodex/actions/workflows/release.yml) +Новые версии становятся доступны в момент, когда [workflow Release](https://github.com/OnlineChefGroep/opencodex/actions/workflows/release.yml) публикует их в npm. ## Справка diff --git a/docs-site/src/content/docs/troubleshooting/windows-memory.md b/docs-site/src/content/docs/troubleshooting/windows-memory.md index 916431ffc..3090153b0 100644 --- a/docs-site/src/content/docs/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/troubleshooting/windows-memory.md @@ -5,7 +5,7 @@ description: Why the bun process can grow to many gigabytes of RAM on Windows, w Some Windows users see the `bun` process behind opencodex grow to many gigabytes of RSS during long streaming sessions (reported as issue -[#314](https://github.com/lidge-jun/opencodex/issues/314)). This page explains +[#314](https://github.com/OnlineChefGroep/opencodex/issues/314)). This page explains what is actually happening and what you can do about it, honestly. ## Root cause: upstream Bun runtime issues @@ -94,5 +94,5 @@ restart it. If you try any of these on a real Windows workload, please report the before and after `ocx doctor` memory sections on -[#314](https://github.com/lidge-jun/opencodex/issues/314) — that is exactly +[#314](https://github.com/OnlineChefGroep/opencodex/issues/314) — that is exactly the verification this mitigation is waiting on. diff --git a/docs-site/src/content/docs/zh-cn/contributing.md b/docs-site/src/content/docs/zh-cn/contributing.md index 1559fbe01..e209dcd74 100644 --- a/docs-site/src/content/docs/zh-cn/contributing.md +++ b/docs-site/src/content/docs/zh-cn/contributing.md @@ -6,7 +6,7 @@ description: opencodex 的开发环境、结构、约定,以及添加 provider ## 环境搭建 ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # 开发模式代理 API @@ -82,7 +82,7 @@ bun run release:watch # 观察最新的 Release workflow run 承载 Go 原生移植的 `dev2-go` 已经退役,同时维护两条集成线的政策也一并结束。其历史以只读 形式保存在 -[lidge-jun/opencodex-go-archive](https://github.com/lidge-jun/opencodex-go-archive)。 +[OnlineChefGroep/opencodex-go-archive](https://github.com/OnlineChefGroep/opencodex-go-archive)。 现在 `dev` 上的 Bun 原生 TypeScript 是唯一的运行时线。 欢迎变基 PR。把陈旧分支变基到当前 head 是正常的贡献而非噪音。请在描述中注明来源提交。 diff --git a/docs-site/src/content/docs/zh-cn/getting-started/installation.md b/docs-site/src/content/docs/zh-cn/getting-started/installation.md index 8730fd20a..c669ef6b0 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/installation.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/installation.md @@ -58,7 +58,7 @@ ocx update --tag preview 若要对 opencodex 本身进行开发: ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # 以开发模式启动代理 API (src/cli/index.ts start) diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index ad2828a28..782f93850 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -6,7 +6,7 @@ description: 全局控制 Codex 在所有模型上生成和管理子代理的方 opencodex 允许你为目录中的所有模型选择多代理协作界面。仪表盘和 Models 页面中的 **Sub-agent** 开关会全局控制这一设置。 :::note -在 v2 界面(`multi_agent_v2`)上,子代理**默认**继承父会话的模型:`fork_turns` 默认为 `all`,而全量历史 fork 会拒绝覆盖。自 v2.7.2 起,opencodex 注入的指引会教模型如何打破继承 —— 将 `fork_turns` 设为 `"none"`(或如 `"3"` 的部分 fork)的 `spawn_agent` 调用可以传入 `model` / `reasoning_effort` 参数;即使公开的工具 schema 中看不到这些参数,Codex 运行时也会解析并应用。已知传输限制:当**原生**父代理 spawn 一个路由到**非原生** provider 的子代理时,Codex 客户端可能只以后端加密的 `encrypted_content` 发送 `NEW_TASK` 载荷([#92](https://github.com/lidge-jun/opencodex/issues/92))。opencodex 不会把这种无法读取的任务转发给外部 provider:直接路由会返回 HTTP 400 和错误码 `unreadable_encrypted_agent_task`;组合路由则会跳过无法解密的目标,并在存在可用目标时选择规范的原生 ChatGPT 目标。恢复方法:异构 provider 委派改用 v1、选择原生 ChatGPT 子代理,或将任务重新作为明文 v2 `agent_message` 内容发送。 +在 v2 界面(`multi_agent_v2`)上,子代理**默认**继承父会话的模型:`fork_turns` 默认为 `all`,而全量历史 fork 会拒绝覆盖。自 v2.7.2 起,opencodex 注入的指引会教模型如何打破继承 —— 将 `fork_turns` 设为 `"none"`(或如 `"3"` 的部分 fork)的 `spawn_agent` 调用可以传入 `model` / `reasoning_effort` 参数;即使公开的工具 schema 中看不到这些参数,Codex 运行时也会解析并应用。已知传输限制:当**原生**父代理 spawn 一个路由到**非原生** provider 的子代理时,Codex 客户端可能只以后端加密的 `encrypted_content` 发送 `NEW_TASK` 载荷([#92](https://github.com/OnlineChefGroep/opencodex/issues/92))。opencodex 不会把这种无法读取的任务转发给外部 provider:直接路由会返回 HTTP 400 和错误码 `unreadable_encrypted_agent_task`;组合路由则会跳过无法解密的目标,并在存在可用目标时选择规范的原生 ChatGPT 目标。恢复方法:异构 provider 委派改用 v1、选择原生 ChatGPT 子代理,或将任务重新作为明文 v2 `agent_message` 内容发送。 ::: ## 模式 @@ -15,7 +15,7 @@ opencodex 允许你为目录中的所有模型选择多代理协作界面。仪 | --- | --- | --- | | **v1** | `multi_agent_v1` | 使用经典的命名空间代理工具,以及 `send_input` / `close_agent` / `resume_agent`。`spawn_agent` 的模型覆盖可以在其他模型上生成子代理。 | | **base**(默认) | 上游固定值 | 恢复上游模型的固定值:gpt-5.6-sol 和 gpt-5.6-terra 使用 v2,gpt-5.6-luna 使用 v1;未固定的模型遵循 Codex 的 `multi_agent_v2` 功能开关。生成行为取决于该模型最终使用的界面。 | -| **v2** | `multi_agent_v2` | 使用扁平的 `spawn_agent` 工具、并发会话,以及 `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`。全量历史 fork 时子代理继承父模型;`fork_turns: "none"`(或部分 fork)时接受 `model` / `reasoning_effort` 覆盖。如果原生→路由子代理只收到后端加密的任务内容,外部路由会返回 `unreadable_encrypted_agent_task`;混合组合会优先选择可解密的原生目标([#92](https://github.com/lidge-jun/opencodex/issues/92))。 | +| **v2** | `multi_agent_v2` | 使用扁平的 `spawn_agent` 工具、并发会话,以及 `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`。全量历史 fork 时子代理继承父模型;`fork_turns: "none"`(或部分 fork)时接受 `model` / `reasoning_effort` 覆盖。如果原生→路由子代理只收到后端加密的任务内容,外部路由会返回 `unreadable_encrypted_agent_task`;混合组合会优先选择可解密的原生目标([#92](https://github.com/OnlineChefGroep/opencodex/issues/92))。 | ### 加密的 v2 任务传输 @@ -58,7 +58,7 @@ opencodex 允许你为目录中的所有模型选择多代理协作界面。仪 - **Dashboard** → 第一个状态单元:选择 **v1**、**base** 或 **v2**。 - **Models** 页面 → 使用顶部的分段控件。 - 两个页面都有 **?** 按钮,可打开帮助弹窗并返回本文。 -- **Dashboard** → **子代理委托**:选择首选模型和可选的推理强度。启用 **用作原生 Codex 子代理默认值** 后,当 OpenCodex 管理当前 Codex 路由时,下一次同步或重启会把相同选择应用于新建 Codex 任务;外部用户管理的 provider 配置不会被修改。此开关与委派指引开关相互独立。在 v2 上,注入的指引会要求以 `fork_turns: "none"` 生成,使模型覆盖得以应用。如果原生→路由子代理只收到加密任务内容,请使用原生目标或 v1;仅外部目标的传输现在会明确返回 `unreadable_encrypted_agent_task`([#92](https://github.com/lidge-jun/opencodex/issues/92))。 +- **Dashboard** → **子代理委托**:选择首选模型和可选的推理强度。启用 **用作原生 Codex 子代理默认值** 后,当 OpenCodex 管理当前 Codex 路由时,下一次同步或重启会把相同选择应用于新建 Codex 任务;外部用户管理的 provider 配置不会被修改。此开关与委派指引开关相互独立。在 v2 上,注入的指引会要求以 `fork_turns: "none"` 生成,使模型覆盖得以应用。如果原生→路由子代理只收到加密任务内容,请使用原生目标或 v1;仅外部目标的传输现在会明确返回 `unreadable_encrypted_agent_task`([#92](https://github.com/OnlineChefGroep/opencodex/issues/92))。 ### CLI diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 4895752d3..530c48a2e 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -437,7 +437,7 @@ ocx update ocx update --tag preview ``` -[Release workflow](https://github.com/lidge-jun/opencodex/actions/workflows/release.yml) 发布到 npm +[Release workflow](https://github.com/OnlineChefGroep/opencodex/actions/workflows/release.yml) 发布到 npm 后,新版本会立即可用。 ## 帮助 diff --git a/gui/src/App.tsx b/gui/src/App.tsx index e5eec327b..65a18fa4b 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -280,7 +280,7 @@ export default function App() { aria-label={t("dash.stop")} title={t("dash.stop")}> {stopping ? t("dash.stopping") : t("dash.stop")} - + {t("common.github")}
diff --git a/readme/README.ja.md b/readme/README.ja.md index fc8fdc020..63d078ee7 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -5,7 +5,7 @@

X で @claudeebum をフォロー npm version - license + license node version

@@ -216,7 +216,7 @@ opencodex は 2 つの動作を分離して保持します: - **一度ログインすれば API キーは省略可。** xAI、Anthropic、Kimi は OAuth をサポートするので既存アカウントで認証でき、トークンは自動更新されます。または `codex login` を転送、API キーを貼り付け、`${ENV_VAR}` 参照を使えます — 自由に選べます。 - **Codex が動くすべての場所で。** Codex CLI、TUI、App、SDK に自動で注入されます。ルーティングモデルはネイティブモデルと同様に Codex モデルピッカーに表示されます。 - **履歴セーフな注入。** ローカルインストールではプロキシは Codex 自身の組み込み `openai` プロバイダーを単一の `openai_base_url` 行で自身に向けるため、新しいスレッドはネイティブのプロバイダータグを維持し、進行中のチャット履歴が再マッピングされることはなく、クリーンでないシャットダウンでも隠せません。(古いバージョンで再タグ付けされたスレッドは初回起動時に一度だけマイグレートされます; リモート/LAN バインドは API キーヘッダーが必要なため、専用のプロバイダーエントリを使用します。) -- **適切なモデルに委任。** ダッシュボードや config から最大 5 つのルーティング/ネイティブモデルを Codex サブエージェントピッカーに公開し、複雑なタスクは推論モデルへ、高速なタスクは安価なモデルへ送れます。v2 マルチエージェントサーフェス(GPT-5.6 Sol/Terra)ではプロキシが簡潔な委任ガイダンスを注入します。推奨サブエージェントモデル・負荷(`injectionModel` / `injectionEffort`)、公開モデルロスターと各モデルが対応する負荷ラダー、そしてクロスモデル `spawn_agent` オーバーライドを適用する `fork_turns` ルールまで。既知の制限: ネイティブの親がルーティング子をスポーンすると、タスク本文がバックエンド暗号化状態で到着し失われることがあります([#92](https://github.com/lidge-jun/opencodex/issues/92)) — 安定したクロスプロバイダー委任には v1 サーフェスを使ってください。表現を自分で書きたい場合は `injectionPrompt` に `{{model}}` / `{{effort}}` / `{{roster}}` プレースホルダーを入れてください。 +- **適切なモデルに委任。** ダッシュボードや config から最大 5 つのルーティング/ネイティブモデルを Codex サブエージェントピッカーに公開し、複雑なタスクは推論モデルへ、高速なタスクは安価なモデルへ送れます。v2 マルチエージェントサーフェス(GPT-5.6 Sol/Terra)ではプロキシが簡潔な委任ガイダンスを注入します。推奨サブエージェントモデル・負荷(`injectionModel` / `injectionEffort`)、公開モデルロスターと各モデルが対応する負荷ラダー、そしてクロスモデル `spawn_agent` オーバーライドを適用する `fork_turns` ルールまで。既知の制限: ネイティブの親がルーティング子をスポーンすると、タスク本文がバックエンド暗号化状態で到着し失われることがあります([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)) — 安定したクロスプロバイダー委任には v1 サーフェスを使ってください。表現を自分で書きたい場合は `injectionPrompt` に `{{model}}` / `{{effort}}` / `{{roster}}` プレースホルダーを入れてください。 - **preview gate された OpenAI ロールアウトに備える。** GPT-5.6 Sol/Terra/Luna の負荷ラダーを保存します。Direct/Multi は 372k Codex 契約を、OpenAI API と OpenRouter は 1.05M メタデータを使います。 - **任意のモデルに超能力を。** OpenAI 以外のモデルも ChatGPT ログイン上で動く `gpt-5.4-mini` サイドカーで本当のウェブ検索と画像理解を得られます。 - **画像をネイティブに生成。** Codex の独立型 `image_gen` ツールは生成時に `POST /v1/images/generations`、編集時に `POST /v1/images/edits` を使います。Responses のホスト型 `image_generation` ツールとは別物です。 @@ -422,7 +422,7 @@ ocx recover-history --legacy-openai ## 開発 ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # dev モードでプロキシ API を起動 diff --git a/readme/README.ko.md b/readme/README.ko.md index ddb900a20..a2dd16128 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -5,7 +5,7 @@

X에서 @claudeebum 팔로우 npm version - license + license node version

@@ -215,7 +215,7 @@ opencodex는 두 가지 동작을 분리해서 유지합니다: - **ChatGPT 계정을 안전하게 풀링.** 기존 Codex 스레드는 한 계정에 유지하면서, 새 세션은 쿼터 갱신과 비-PII 요청 라벨과 함께 풀에서 사용량이 낮은 계정을 자동 선택할 수 있습니다. - **한 번 로그인하면 API 키는 생략.** xAI, Anthropic, Kimi는 OAuth를 지원하므로 기존 계정으로 인증할 수 있고 토큰은 자동 갱신됩니다. 또는 `codex login`을 forward 하거나, API 키를 붙여넣거나, `${ENV_VAR}` 참조를 쓸 수 있습니다 — 선택은 자유입니다. - **Codex가 동작하는 모든 곳에서.** Codex CLI, TUI, App, SDK에 자동으로 주입됩니다. 라우팅된 모델이 네이티브 모델처럼 Codex 모델 선택기에 나타납니다. -- **알맞은 모델에 위임.** 대시보드나 config에서 최대 5개의 라우팅/네이티브 모델을 Codex 서브에이전트 선택기에 노출해, 복잡한 작업은 reasoning 모델로, 빠른 작업은 저렴한 모델로 보낼 수 있습니다. v2 멀티에이전트 표면(GPT-5.6 Sol/Terra)에서는 프록시가 간결한 위임 가이드를 주입합니다. 선호 서브에이전트 모델·effort(`injectionModel` / `injectionEffort`), 노출된 모델 로스터와 각 모델이 지원하는 effort 사다리, 그리고 크로스모델 `spawn_agent` 오버라이드를 적용하는 `fork_turns` 규칙까지. 알려진 제한: 네이티브 부모가 라우팅 자식을 스폰하면 작업 본문이 백엔드 암호화 상태로 도착해 유실될 수 있습니다([#92](https://github.com/lidge-jun/opencodex/issues/92)) — 안정적인 크로스 프로바이더 위임에는 v1 표면을 쓰세요. 문구를 직접 쓰고 싶다면 `injectionPrompt`에 `{{model}}` / `{{effort}}` / `{{roster}}` 플레이스홀더를 넣으면 됩니다. +- **알맞은 모델에 위임.** 대시보드나 config에서 최대 5개의 라우팅/네이티브 모델을 Codex 서브에이전트 선택기에 노출해, 복잡한 작업은 reasoning 모델로, 빠른 작업은 저렴한 모델로 보낼 수 있습니다. v2 멀티에이전트 표면(GPT-5.6 Sol/Terra)에서는 프록시가 간결한 위임 가이드를 주입합니다. 선호 서브에이전트 모델·effort(`injectionModel` / `injectionEffort`), 노출된 모델 로스터와 각 모델이 지원하는 effort 사다리, 그리고 크로스모델 `spawn_agent` 오버라이드를 적용하는 `fork_turns` 규칙까지. 알려진 제한: 네이티브 부모가 라우팅 자식을 스폰하면 작업 본문이 백엔드 암호화 상태로 도착해 유실될 수 있습니다([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)) — 안정적인 크로스 프로바이더 위임에는 v1 표면을 쓰세요. 문구를 직접 쓰고 싶다면 `injectionPrompt`에 `{{model}}` / `{{effort}}` / `{{roster}}` 플레이스홀더를 넣으면 됩니다. - **프리뷰 게이트된 OpenAI rollout에 대비.** GPT-5.6 Sol/Terra/Luna의 effort 사다리를 보존합니다. Direct/Multi는 372k Codex 계약을, OpenAI API와 OpenRouter는 1.05M metadata를 사용합니다. - **어떤 모델에도 초능력을.** OpenAI가 아닌 모델도 ChatGPT 로그인 위에서 도는 `gpt-5.4-mini` sidecar로 실제 웹 검색과 이미지 이해를 사용합니다. - **이미지를 네이티브로 생성.** Codex의 독립형 `image_gen` 도구는 생성할 때 `POST /v1/images/generations`, 편집할 때 `POST /v1/images/edits`를 사용합니다. Responses의 hosted `image_generation` 도구와는 별개입니다. @@ -436,7 +436,7 @@ ocx recover-history --legacy-openai ## 개발 ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # dev 모드로 프록시 API 시작 diff --git a/readme/README.ru.md b/readme/README.ru.md index 2f04d0634..7ade75a73 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -5,7 +5,7 @@

Подписывайтесь на @claudeebum в X npm version - license + license node version

@@ -244,7 +244,7 @@ OpenAI API-ключа и OpenRouter (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-lu - **Один вход — и никаких API-ключей.** Поддержка OAuth для xAI, Anthropic и Kimi позволяет аутентифицироваться существующим аккаунтом; токены обновляются автоматически. Либо пробросьте свой `codex login`, вставьте API-ключ или используйте ссылки вида `${ENV_VAR}` — как вам удобнее. - **Работает везде, где работает Codex.** Автоматически встраивается в Codex CLI, TUI, App и SDK. Маршрутизируемые модели отображаются в селекторе моделей Codex наравне с нативными. - **Встраивание без риска для истории.** При локальной установке прокси перенаправляет встроенный провайдер Codex `openai` на себя одной строкой `openai_base_url` — новые треды сохраняют нативный тег провайдера, поэтому текущая история чатов никогда не перепривязывается, и даже некорректное завершение работы не может её скрыть. (Треды, перетегированные старыми версиями, однократно мигрируются обратно при первом запуске; при удалённой/LAN-привязке вместо этого используется отдельная запись провайдера, поскольку ей нужен заголовок с API-ключом.) -- **Делегируйте задачи подходящей модели.** Через панель управления или конфигурацию можно вывести до пяти маршрутизируемых или нативных моделей в селектор подагентов Codex — сложные задачи отправляйте модели с развитыми рассуждениями, быстрые — дешёвой. На мультиагентной поверхности v2 (GPT-5.6 Sol/Terra) прокси внедряет компактные указания по делегированию: предпочтительную модель и уровень рассуждений подагента (`injectionModel` / `injectionEffort`), список отобранных моделей со шкалой уровней, которую поддерживает каждая из них, и правила `fork_turns`, позволяющие кросс-модельным вызовам `spawn_agent` применять свои переопределения. Известное ограничение: когда нативный родитель порождает маршрутизируемого потомка, тело задачи в настоящий момент может прийти зашифрованным на бэкенде и потеряться ([#92](https://github.com/lidge-jun/opencodex/issues/92)) — для надёжного делегирования между провайдерами используйте поверхность v1. Хотите свои формулировки? Задайте `injectionPrompt` с плейсхолдерами `{{model}}` / `{{effort}}` / `{{roster}}`. +- **Делегируйте задачи подходящей модели.** Через панель управления или конфигурацию можно вывести до пяти маршрутизируемых или нативных моделей в селектор подагентов Codex — сложные задачи отправляйте модели с развитыми рассуждениями, быстрые — дешёвой. На мультиагентной поверхности v2 (GPT-5.6 Sol/Terra) прокси внедряет компактные указания по делегированию: предпочтительную модель и уровень рассуждений подагента (`injectionModel` / `injectionEffort`), список отобранных моделей со шкалой уровней, которую поддерживает каждая из них, и правила `fork_turns`, позволяющие кросс-модельным вызовам `spawn_agent` применять свои переопределения. Известное ограничение: когда нативный родитель порождает маршрутизируемого потомка, тело задачи в настоящий момент может прийти зашифрованным на бэкенде и потеряться ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)) — для надёжного делегирования между провайдерами используйте поверхность v1. Хотите свои формулировки? Задайте `injectionPrompt` с плейсхолдерами `{{model}}` / `{{effort}}` / `{{roster}}`. - **Готовность к превью-релизам OpenAI.** Записи GPT-5.6 Sol/Terra/Luna сохраняют исходные шкалы уровней рассуждений. Direct/Multi используют контракт Codex на 372k токенов; OpenAI API и OpenRouter — метаданные на 1.05M, когда открыт вышестоящий доступ. - **Суперспособности для любой модели.** Модели не от OpenAI получают настоящий веб-поиск и понимание изображений через сайдкар `gpt-5.4-mini`, работающий поверх вашего входа ChatGPT. - **Нативная генерация изображений.** Автономный инструмент Codex `image_gen` использует `POST /v1/images/generations` для генерации и `POST /v1/images/edits` для правок; он не связан с размещённым инструментом Responses `image_generation`. @@ -463,7 +463,7 @@ ocx recover-history --legacy-openai ## Разработка ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # запустить API прокси в dev-режиме diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index b4ef7a8de..749464c66 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -5,7 +5,7 @@

在 X 上关注 @claudeebum npm version - license + license node version

@@ -118,7 +118,7 @@ npm 警告里给出的缩写命令缺少包名,会把当前目录重新安装 - **安全地池化 ChatGPT 账户。** 现有 Codex 线程保持在一个账户上,而新会话可以从池中自动挑选使用量更低的账户,并带有配额刷新和非 PII 请求标签。 - **登录一次,免填 API key。** xAI、Anthropic、Kimi 支持 OAuth,可用现有账户认证,token 自动刷新。也可以转发 `codex login`、粘贴 API key,或使用 `${ENV_VAR}` 引用 —— 随你选择。 - **Codex 在哪里能用,它就在哪里能用。** 自动注入 Codex CLI、TUI、App 和 SDK。路由模型像原生模型一样出现在 Codex 的模型选择器里。 -- **委派给合适的模型。** 在仪表盘或 config 中把最多 5 个路由/原生模型放进 Codex 的 subagent 选择器 —— 复杂任务交给 reasoning 模型,快速任务交给便宜模型。在 v2 多智能体表面(GPT-5.6 Sol/Terra)上,代理会注入精简的委派指引:首选子智能体模型与 effort(`injectionModel` / `injectionEffort`)、featured 模型清单及各自支持的 effort 阶梯,以及让跨模型 `spawn_agent` 覆盖得以应用的 `fork_turns` 规则。已知限制:原生父代理 spawn 路由子代理时,任务正文可能以后端加密形式到达而丢失([#92](https://github.com/lidge-jun/opencodex/issues/92))—— 需要可靠的跨 provider 委派请使用 v1 表面。想自定义文案,可在 `injectionPrompt` 中使用 `{{model}}` / `{{effort}}` / `{{roster}}` 占位符。 +- **委派给合适的模型。** 在仪表盘或 config 中把最多 5 个路由/原生模型放进 Codex 的 subagent 选择器 —— 复杂任务交给 reasoning 模型,快速任务交给便宜模型。在 v2 多智能体表面(GPT-5.6 Sol/Terra)上,代理会注入精简的委派指引:首选子智能体模型与 effort(`injectionModel` / `injectionEffort`)、featured 模型清单及各自支持的 effort 阶梯,以及让跨模型 `spawn_agent` 覆盖得以应用的 `fork_turns` 规则。已知限制:原生父代理 spawn 路由子代理时,任务正文可能以后端加密形式到达而丢失([#92](https://github.com/OnlineChefGroep/opencodex/issues/92))—— 需要可靠的跨 provider 委派请使用 v1 表面。想自定义文案,可在 `injectionPrompt` 中使用 `{{model}}` / `{{effort}}` / `{{roster}}` 占位符。 - **为 preview-gated OpenAI rollout 做好准备。** GPT-5.6 Sol/Terra/Luna 保留 upstream effort 阶梯。Direct/Multi 使用 372k Codex 契约,OpenAI API 与 OpenRouter 使用 1.05M 元数据。 - **给任意模型超能力。** 非 OpenAI 模型也能通过你的 ChatGPT 登录上运行的 `gpt-5.4-mini` sidecar 获得真正的网页搜索和图片理解。 - **原生生成图片。** Codex 的独立 `image_gen` 工具通过 `POST /v1/images/generations` 生成图片、通过 `POST /v1/images/edits` 编辑图片;它独立于 hosted Responses 的 `image_generation` 工具。 @@ -412,7 +412,7 @@ ocx recover-history --legacy-openai ## 开发 ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # 以开发模式启动代理 API diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index f5b6374d6..e93143a37 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -922,7 +922,7 @@ describe("GitHub Actions hardening", () => { "issues.updateComment", ])); expect(callsTo(result, "pulls.update")).toEqual([ - { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "Port the runtime entry" }, + { owner: "OnlineChefGroep", repo: "opencodex", pull_number: 42, title: "Port the runtime entry" }, ]); const [cleared] = callsTo(result, "issues.updateComment") as [{ body: string }]; expect(cleared.body).toContain('"active":false'); @@ -949,7 +949,7 @@ describe("GitHub Actions hardening", () => { "issues.updateComment", ])); expect(callsTo(result, "pulls.update")).toEqual([ - { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Port the runtime entry" }, + { owner: "OnlineChefGroep", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Port the runtime entry" }, ]); expect(lastEnforcerCommentBody(result)).toContain('"active":true'); expect(lastEnforcerCommentBody(result)).toContain('"autoDraftedByBot":true'); @@ -991,7 +991,7 @@ describe("GitHub Actions hardening", () => { // and `body` are all accepted by this endpoint; an audit round added // `base: "main"` here and no static assertion caught it. expect(callsTo(result, "pulls.update")).toEqual([ - { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Add a thing" }, + { owner: "OnlineChefGroep", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Add a thing" }, ]); // The first comment create addresses this PR, by its own number. @@ -1019,7 +1019,7 @@ describe("GitHub Actions hardening", () => { number: 42, base: { ref: parentHead, - repo: { name: "opencodex", owner: { login: "lidge-jun" } }, + repo: { name: "opencodex", owner: { login: "OnlineChefGroep" } }, }, title: "Stacked child", draft: false, @@ -1029,7 +1029,7 @@ describe("GitHub Actions hardening", () => { number: 41, head: { ref: parentHead, - repo: { name: "opencodex", owner: { login: "lidge-jun" } }, + repo: { name: "opencodex", owner: { login: "OnlineChefGroep" } }, }, }, ], @@ -1049,7 +1049,7 @@ describe("GitHub Actions hardening", () => { number: 1000 + i, head: { ref: `feature/filler-${i}`, - repo: { name: "opencodex", owner: { login: "lidge-jun" } }, + repo: { name: "opencodex", owner: { login: "OnlineChefGroep" } }, }, })); const result = await run({ @@ -1057,7 +1057,7 @@ describe("GitHub Actions hardening", () => { number: 42, base: { ref: parentHead, - repo: { name: "opencodex", owner: { login: "lidge-jun" } }, + repo: { name: "opencodex", owner: { login: "OnlineChefGroep" } }, }, title: "Stacked child beyond page one", draft: false, @@ -1069,7 +1069,7 @@ describe("GitHub Actions hardening", () => { number: 41, head: { ref: parentHead, - repo: { name: "opencodex", owner: { login: "lidge-jun" } }, + repo: { name: "opencodex", owner: { login: "OnlineChefGroep" } }, }, }, ], @@ -1094,7 +1094,7 @@ describe("GitHub Actions hardening", () => { number: 99, head: { ref: "feature/other", - repo: { name: "opencodex", owner: { login: "lidge-jun" } }, + repo: { name: "opencodex", owner: { login: "OnlineChefGroep" } }, }, }, ], @@ -1110,7 +1110,7 @@ describe("GitHub Actions hardening", () => { ])); expect(callsTo(result, "pulls.update")).toEqual([ { - owner: "lidge-jun", + owner: "OnlineChefGroep", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Orphan stack", @@ -1147,7 +1147,7 @@ describe("GitHub Actions hardening", () => { "issues.updateComment", ])); expect(callsTo(restored, "pulls.update")).toEqual([ - { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "Add a thing" }, + { owner: "OnlineChefGroep", repo: "opencodex", pull_number: 42, title: "Add a thing" }, ]); }); @@ -1163,7 +1163,7 @@ describe("GitHub Actions hardening", () => { "issues.updateComment", ])); expect(callsTo(result, "pulls.update")).toEqual([ - { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "Add a thing" }, + { owner: "OnlineChefGroep", repo: "opencodex", pull_number: 42, title: "Add a thing" }, ]); const [ready] = callsTo(result, "graphql") as [{ query: string }]; expect(ready.query).toContain("markPullRequestReadyForReview"); @@ -1183,7 +1183,7 @@ describe("GitHub Actions hardening", () => { }); expect(callsTo(result, "pulls.update")).toEqual([ - { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "Add a thing (v2)" }, + { owner: "OnlineChefGroep", repo: "opencodex", pull_number: 42, title: "Add a thing (v2)" }, ]); }); @@ -1222,7 +1222,7 @@ describe("GitHub Actions hardening", () => { "issues.updateComment", ])); expect(callsTo(wentWrong, "pulls.update")).toEqual([ - { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Add a thing" }, + { owner: "OnlineChefGroep", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] Add a thing" }, ]); // …and the reverse: the event says main, the live PR says dev. No writes. @@ -1405,7 +1405,7 @@ describe("GitHub Actions hardening", () => { expect(commentBody).toContain("`main`"); expect(commentBody).toContain("`dev`"); // Points at the documentation rather than assuming the reader knows. - expect(commentBody).toContain("https://lidge-jun.github.io/opencodex/contributing/"); + expect(commentBody).toContain("https://github.com/OnlineChefGroep/opencodex/contributing/"); // And carries the state the next run needs. expect(commentBody).toContain(MARKER); expect(commentBody).toContain('"version":1'); @@ -1467,7 +1467,7 @@ describe("GitHub Actions hardening", () => { "issues.updateComment", ])); expect(callsTo(restored, "pulls.update")).toEqual([ - { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "Add a thing" }, + { owner: "OnlineChefGroep", repo: "opencodex", pull_number: 42, title: "Add a thing" }, ]); const [cleared] = callsTo(restored, "issues.updateComment") as [{ body: string }]; expect(cleared.body).toContain('"version":1'); @@ -1517,7 +1517,7 @@ describe("GitHub Actions hardening", () => { "issues.updateComment", ])); expect(callsTo(loose, "pulls.update")).toEqual([ - { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "Add a thing" }, + { owner: "OnlineChefGroep", repo: "opencodex", pull_number: 42, title: "Add a thing" }, ]); // And the falsy side is symmetric: `null` and `0` skip their own @@ -1582,7 +1582,7 @@ describe("GitHub Actions hardening", () => { }); expect(callsTo(result, "pulls.update")).toEqual([ - { owner: "lidge-jun", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] " }, + { owner: "OnlineChefGroep", repo: "opencodex", pull_number: 42, title: "[WRONG BRANCH] " }, ]); expect(methodsOf(result)).toEqual(readsWrongBase([ "issues.createComment", diff --git a/tests/helpers/enforce-pr-target-harness.ts b/tests/helpers/enforce-pr-target-harness.ts index 99197e7ec..aa7b01d5e 100644 --- a/tests/helpers/enforce-pr-target-harness.ts +++ b/tests/helpers/enforce-pr-target-harness.ts @@ -128,12 +128,12 @@ const DEFAULT_PR = { state: "open", merged: false, locked: false, - html_url: "https://github.com/lidge-jun/opencodex/pull/42", + html_url: "https://github.com/OnlineChefGroep/opencodex/pull/42", base: { ref: "dev", sha: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", - label: "lidge-jun:dev", - repo: { name: "opencodex", owner: { login: "lidge-jun" } }, + label: "OnlineChefGroep:dev", + repo: { name: "opencodex", owner: { login: "OnlineChefGroep" } }, }, head: { ref: "feature", @@ -292,8 +292,8 @@ function nodeLikeProcess(): Record { GITHUB_HEAD_REF: "feature", GITHUB_JOB: "enforce-target", GITHUB_REF: "refs/pull/42/merge", - GITHUB_REPOSITORY: "lidge-jun/opencodex", - GITHUB_REPOSITORY_OWNER: "lidge-jun", + GITHUB_REPOSITORY: "OnlineChefGroep/opencodex", + GITHUB_REPOSITORY_OWNER: "OnlineChefGroep", GITHUB_RUN_ATTEMPT: "1", GITHUB_RUN_ID: "1234567890", GITHUB_RUN_NUMBER: "87", @@ -695,11 +695,11 @@ export async function runEnforcePrTarget( repository: { id: 987654321, name: "opencodex", - full_name: "lidge-jun/opencodex", + full_name: "OnlineChefGroep/opencodex", default_branch: "main", private: false, - owner: { login: "lidge-jun", id: 12345, type: "User" }, - html_url: "https://github.com/lidge-jun/opencodex", + owner: { login: "OnlineChefGroep", id: 12345, type: "User" }, + html_url: "https://github.com/OnlineChefGroep/opencodex", }, sender: { login: "contributor", id: 67890, type: "User" }, organization: undefined, @@ -719,10 +719,10 @@ export async function runEnforcePrTarget( serverUrl = "https://github.com"; graphqlUrl = "https://api.github.com/graphql"; get repo() { - return { owner: "lidge-jun", repo: "opencodex" }; + return { owner: "OnlineChefGroep", repo: "opencodex" }; } get issue() { - return { owner: "lidge-jun", repo: "opencodex", number: eventPr.number }; + return { owner: "OnlineChefGroep", repo: "opencodex", number: eventPr.number }; } } const context = new Context(); diff --git a/tests/release-notes.test.ts b/tests/release-notes.test.ts index e9f69c83a..ff0a615c3 100644 --- a/tests/release-notes.test.ts +++ b/tests/release-notes.test.ts @@ -120,7 +120,7 @@ describe("stripCarriedReleaseNotes", () => { "- release: v2.7.39-preview.20260724 (8894e40e)", "- Merge branch 'dev' into preview (9077f7c1)", "", - "**Full Changelog**: https://github.com/lidge-jun/opencodex/compare/v2.7.38-preview.20260724...v2.7.39-preview.20260724", + "**Full Changelog**: https://github.com/OnlineChefGroep/opencodex/compare/v2.7.38-preview.20260724...v2.7.39-preview.20260724", "", ].join("\n"); @@ -215,14 +215,14 @@ describe("assembleReleaseNotes", () => { commits: "- release: v2.7.39 (357acee6)", compareFrom: "v2.7.37", compareTo: "v2.7.39", - repository: "lidge-jun/opencodex", + repository: "OnlineChefGroep/opencodex", }); expect(notes).toContain("dist-tag `latest`"); expect(notes).toContain("## What's Changed\n### Bug Fixes\n* fix A"); expect(notes).toContain("## Since preview\n\n## What's Changed\n### Bug Fixes\n* fix B"); expect(notes).toContain("## Commits\n\n- release: v2.7.39 (357acee6)"); - expect(notes).toContain("**Full Changelog**: https://github.com/lidge-jun/opencodex/compare/v2.7.37...v2.7.39"); + expect(notes).toContain("**Full Changelog**: https://github.com/OnlineChefGroep/opencodex/compare/v2.7.37...v2.7.39"); }); test("omits empty generate-notes delta that is only the config comment", () => { @@ -273,8 +273,8 @@ describe("rewriteTakeoverCredits", () => { const body = [ "## What's Changed", "### New Features", - "* feat(images): Grok image bridge (maintainer takeover of #424) by @Wibias in https://github.com/lidge-jun/opencodex/pull/577", - "* feat(other): normal change by @Alice in https://github.com/lidge-jun/opencodex/pull/100", + "* feat(images): Grok image bridge (maintainer takeover of #424) by @Wibias in https://github.com/OnlineChefGroep/opencodex/pull/577", + "* feat(other): normal change by @Alice in https://github.com/OnlineChefGroep/opencodex/pull/100", ].join("\n"); const landingCalls: number[] = []; @@ -289,16 +289,16 @@ describe("rewriteTakeoverCredits", () => { expect(landingCalls).toEqual([]); expect(rewritten).toContain( - "* feat(images): Grok image bridge (maintainer takeover of #424) by @tizerluo (takeover by @Wibias) in https://github.com/lidge-jun/opencodex/pull/577", + "* feat(images): Grok image bridge (maintainer takeover of #424) by @tizerluo (takeover by @Wibias) in https://github.com/OnlineChefGroep/opencodex/pull/577", ); expect(rewritten).toContain( - "* feat(other): normal change by @Alice in https://github.com/lidge-jun/opencodex/pull/100", + "* feat(other): normal change by @Alice in https://github.com/OnlineChefGroep/opencodex/pull/100", ); }); test("falls back to landing lookup when title says takeover without #N", async () => { const line = - "* feat x (takeover) by @Wibias in https://github.com/lidge-jun/opencodex/pull/577"; + "* feat x (takeover) by @Wibias in https://github.com/OnlineChefGroep/opencodex/pull/577"; const rewritten = await rewriteTakeoverCredits( line, async (pr) => { @@ -312,13 +312,13 @@ describe("rewriteTakeoverCredits", () => { async (source) => (source === 424 ? "tizerluo" : null), ); expect(rewritten).toContain( - "* feat x (takeover) by @tizerluo (takeover by @Wibias) in https://github.com/lidge-jun/opencodex/pull/577", + "* feat x (takeover) by @tizerluo (takeover by @Wibias) in https://github.com/OnlineChefGroep/opencodex/pull/577", ); }); test("leaves line unchanged when landing lookup returns null", async () => { const line = - "* feat x (takeover) by @Wibias in https://github.com/lidge-jun/opencodex/pull/577"; + "* feat x (takeover) by @Wibias in https://github.com/OnlineChefGroep/opencodex/pull/577"; const rewritten = await rewriteTakeoverCredits( line, async () => null, @@ -329,7 +329,7 @@ describe("rewriteTakeoverCredits", () => { test("leaves line unchanged when original author matches landing author", async () => { const line = - "* feat x (takeover #9) by @Wibias in https://github.com/lidge-jun/opencodex/pull/10"; + "* feat x (takeover #9) by @Wibias in https://github.com/OnlineChefGroep/opencodex/pull/10"; const rewritten = await rewriteTakeoverCredits( line, async () => ({ diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index 301da09c4..379f2ec32 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -776,7 +776,7 @@ describe("GUI update execution decisions", () => { installer: "npm", restart: true, command: "node /pkg/bin/ocx.mjs update --tag latest", - releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + releaseNotesUrl: "https://github.com/OnlineChefGroep/opencodex/releases/latest", log: [], }; writeFileSync(updateJobPath(), `${JSON.stringify(job)}\n`); @@ -812,7 +812,7 @@ describe("GUI update execution decisions", () => { installer: "bun", restart: true, command: "bun add -g @bitkyc08/opencodex@2.7.41", - releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + releaseNotesUrl: "https://github.com/OnlineChefGroep/opencodex/releases/latest", log: [], pid: 777, }; @@ -830,7 +830,7 @@ describe("GUI update execution decisions", () => { updateAvailable: true, canUpdate: true, command: "bun add -g @bitkyc08/opencodex@2.7.41", - releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + releaseNotesUrl: "https://github.com/OnlineChefGroep/opencodex/releases/latest", }), spawnWorkerFn: () => ({ pid: 888, @@ -855,7 +855,7 @@ describe("GUI update execution decisions", () => { updateAvailable: true, canUpdate: true, command: "bun add -g @bitkyc08/opencodex@2.7.41", - releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + releaseNotesUrl: "https://github.com/OnlineChefGroep/opencodex/releases/latest", }), spawnWorkerFn: () => { throw new Error("spawn denied"); }, })).toThrow("Could not start update worker"); From f1e4eeae5c23c969f74c09a82559d507c8def683 Mon Sep 17 00:00:00 2001 From: GroepChef Date: Thu, 30 Jul 2026 20:17:08 +0200 Subject: [PATCH 24/31] fix(ci): align fork release/PR-target tests with OnlineChefGroep model Release workflow is main-only with prerelease dist-tag rules; point enforce-pr-target contributing link at the fork CONTRIBUTING.md. Co-authored-by: Cursor --- .github/workflows/enforce-pr-target.yml | 2 +- tests/ci-workflows.test.ts | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index c6b9a3123..f7e08da36 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -211,7 +211,7 @@ jobs: "", `This pull request currently targets ${inlineCode(pr.base.ref)}, but pull requests must target one of ${ALLOWED_BASES.map(inlineCode).join(" or ")}.`, "", - `@${pr.user.login} Please retarget this PR to ${inlineCode(DEFAULT_BASE)}. All contributions go to ${inlineCode(DEFAULT_BASE)}; \`main\` receives only release promotions. See our [Contributing guide](https://lidge-jun.github.io/opencodex/contributing/) for details. Thanks! 🙏` + `@${pr.user.login} Please retarget this PR to ${inlineCode(DEFAULT_BASE)}. All contributions go to ${inlineCode(DEFAULT_BASE)}; \`main\` receives only release promotions. See our [Contributing guide](https://github.com/OnlineChefGroep/opencodex/blob/dev/CONTRIBUTING.md) for details. Thanks! 🙏` ); } diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index e93143a37..f9c8e5205 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -265,10 +265,12 @@ describe("GitHub Actions hardening", () => { expect(gate.test("src/router.ts")).toBe(false); expect(gate.test("docs-site/src/pages/index.astro")).toBe(false); - // Channel guards stay branch-exact. - expect(workflow).toContain("Release must run from main or preview"); - expect(workflow).toContain("main releases must use a stable semver version"); - expect(workflow).toContain("preview releases must use a preview prerelease version"); + // Fork release model: everything publishes from main; prerelease semver + // (any `-*` suffix) must use dist-tag preview, stable must use latest. + expect(workflow).toContain("Release must run from main;"); + expect(workflow).toContain("Pre-release versions (${RELEASE_VERSION}) must use dist-tag 'preview'"); + expect(workflow).toContain("Stable releases (${RELEASE_VERSION}) must use dist-tag 'latest'"); + expect(workflow).not.toContain("refs/heads/preview)"); // Release notes must include PR categories and the full channel commit range // (branch merges + direct commits). Preflight forbids an existing release, so @@ -1405,7 +1407,9 @@ describe("GitHub Actions hardening", () => { expect(commentBody).toContain("`main`"); expect(commentBody).toContain("`dev`"); // Points at the documentation rather than assuming the reader knows. - expect(commentBody).toContain("https://github.com/OnlineChefGroep/opencodex/contributing/"); + expect(commentBody).toContain( + "https://github.com/OnlineChefGroep/opencodex/blob/dev/CONTRIBUTING.md", + ); // And carries the state the next run needs. expect(commentBody).toContain(MARKER); expect(commentBody).toContain('"version":1'); From 7d9ebfbd3a69ca3fd18bee733b8acb71a5954444 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 18:28:17 +0000 Subject: [PATCH 25/31] fix(ci): align prerelease predicate, contributing link, docs edit branch, roadmap Co-authored-by: Codesmith --- .github/ISSUE_TEMPLATE/config.yml | 2 +- .github/workflows/release.yml | 5 ++++- ROADMAP.md | 5 +++-- docs-site/astro.config.mjs | 4 +++- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 258b24467..052fc0709 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -4,5 +4,5 @@ contact_links: url: https://github.com/OnlineChefGroep/opencodex/blob/main/SECURITY.md about: Read the supported-version and reporting guidance before sharing security-sensitive details. - name: Contributing guide - url: https://github.com/OnlineChefGroep/opencodex + url: https://github.com/OnlineChefGroep/opencodex/blob/dev/CONTRIBUTING.md about: Review setup, build, and verification guidance for contributors. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a830a626..f93d93f08 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -327,7 +327,10 @@ jobs: # advance the baseline to a later preview that is missing/empty — that would # drop the gap between the last carried preview and that later tag. notes_range_start="$previous_tag" - if [[ "$RELEASE_VERSION" != *-preview.* ]]; then + # Same hyphen predicate as the dist-tag gate and prerelease_flag above: any + # pre-release suffix ships on the preview channel, so only truly stable + # versions take the carry path. + if [[ "$RELEASE_VERSION" != *-* ]]; then preview_carry_tags="$( git tag --list "v${RELEASE_VERSION}-preview.*" | bun scripts/release-notes.ts matching-preview-tags "$RELEASE_VERSION" diff --git a/ROADMAP.md b/ROADMAP.md index 1d11b6d7f..63599ace2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Roadmap -**OnlineChefGroep fork** of [opencodex](https://github.com/lidge-jun/opencodex) +Roadmap for [OnlineChefGroep/opencodex](https://github.com/OnlineChefGroep/opencodex). ## Vision @@ -16,7 +16,8 @@ no longer dependent on upstream decisions or timelines. ## Near term (next) -- [ ] **First fork release (v1.0.0-alpha.1)** — tag and publish to npm +- [ ] **First fork release (`2.7.43`)**: publish from `main` on npm dist-tag `latest` + (any pre-release suffix ships on `preview` instead) - [ ] **Update READMEs** — ensure all translated READMEs (ko, zh, ru, ja) reflect fork status - [ ] **Clean up old tags** — remove stale upstream tags that don't point to our commits - [ ] **Dependency audit** — review and update all dependencies (gui + root) diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs index 97d62b5e6..698f397ce 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -55,7 +55,9 @@ export default defineConfig({ { icon: "github", label: "GitHub", href: "https://github.com/OnlineChefGroep/opencodex" }, ], editLink: { - baseUrl: "https://github.com/OnlineChefGroep/opencodex/edit/main/docs-site/", + // `dev` is the only integration branch (see MAINTAINERS.md); `main` receives + // release promotions only, so "Edit page" must open a `dev` edit session. + baseUrl: "https://github.com/OnlineChefGroep/opencodex/edit/dev/docs-site/", }, lastUpdated: true, // English at the site root; Korean under /ko, Simplified Chinese under /zh-cn, Russian under /ru, Japanese under /ja. From 8e3d58cfd262ef0ec7bf9465d5b9191a9c8b794d Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 18:30:48 +0000 Subject: [PATCH 26/31] fix(release): restrict prereleases to -preview.N and release from main only Co-authored-by: Codesmith --- .github/workflows/release.yml | 8 ++++++++ ROADMAP.md | 7 +++++-- scripts/release.ts | 23 ++++++++++++----------- tests/ci-workflows.test.ts | 9 +++++++++ 4 files changed, 34 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f93d93f08..b54f233f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -116,6 +116,14 @@ jobs: case "$GITHUB_REF" in refs/heads/main) if [[ "$RELEASE_VERSION" == *-* ]]; then + # The only supported prerelease shape is X.Y.Z-preview.N: the update + # client (src/update/notify.ts, bin/ocx.mjs) and the release-notes + # helper both key off that exact suffix, so an -alpha/-beta/-rc build + # would publish to preview and then never be seen as an update. + if [[ ! "$RELEASE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+-preview\.[0-9]+$ ]]; then + echo "::error::Pre-release versions must be X.Y.Z-preview.N (got ${RELEASE_VERSION})" + exit 1 + fi # Pre-release version — must use "preview" dist-tag if [ "$NPM_DIST_TAG" != "preview" ]; then echo "::error::Pre-release versions (${RELEASE_VERSION}) must use dist-tag 'preview', got '${NPM_DIST_TAG}'" diff --git a/ROADMAP.md b/ROADMAP.md index 63599ace2..010e9dd7f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,12 +12,15 @@ no longer dependent on upstream decisions or timelines. - [x] **Independent versioning** — fully detached from upstream, own semver scheme - [x] **All closed PRs merged** — Dutch GUI, Claude Desktop, combo/alias, cursor fixes, etc. - [x] **Enhanced CI** — CodeQL, dependabot, security audit, workflow linting -- [x] **Release process** — VERSIONING.md, RELEASE_PROCESS.md, CHANGELOG.md +- [x] **Release process**: main-only publishing via `.github/workflows/release.yml` + and `scripts/release.ts`, with review and promotion policy in `MAINTAINERS.md` ## Near term (next) - [ ] **First fork release (`2.7.43`)**: publish from `main` on npm dist-tag `latest` - (any pre-release suffix ships on `preview` instead) + (prereleases use `X.Y.Z-preview.N` and ship on `preview`) +- [ ] **Release documentation**: add `VERSIONING.md`, `RELEASE_PROCESS.md`, and + `CHANGELOG.md` for the fork's release model - [ ] **Update READMEs** — ensure all translated READMEs (ko, zh, ru, ja) reflect fork status - [ ] **Clean up old tags** — remove stale upstream tags that don't point to our commits - [ ] **Dependency audit** — review and update all dependencies (gui + root) diff --git a/scripts/release.ts b/scripts/release.ts index f1d5c1338..542a5160b 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -224,24 +224,25 @@ if (!version || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(version)) { } const dryRun = !args.includes("--publish"); -// 1. Preflight — must be on main or preview, and local verification must pass. +// 1. Preflight — every release runs from main (release.yml rejects any other ref), +// and local verification must pass. Stable versions publish to the `latest` dist-tag, +// prereleases to `preview`; the only supported prerelease shape is X.Y.Z-preview.N, +// which is what the update client and the release-notes helper recognize. const branch = (await $`git rev-parse --abbrev-ref HEAD`.text()).trim(); -const allowedBranches = ["main", "preview"]; -const expectedTag = branch === "preview" ? "preview" : "latest"; +const releaseBranch = "main"; +const isPrerelease = version.includes("-"); +const expectedTag = isPrerelease ? "preview" : "latest"; const tag = args.includes("--tag") ? (args[args.indexOf("--tag") + 1] ?? expectedTag) : expectedTag; if (tag !== expectedTag) { - console.error(`Release tag mismatch: ${branch} releases must use npm dist-tag '${expectedTag}' (got '${tag}').`); + const kind = isPrerelease ? "Pre-release" : "Stable"; + console.error(`Release tag mismatch: ${kind} versions must use npm dist-tag '${expectedTag}' (got '${tag}').`); process.exit(1); } -if (branch === "preview" && !version.includes("-preview.")) { - console.error(`Preview releases must use a preview prerelease version (got ${version}).`); +if (isPrerelease && !/^\d+\.\d+\.\d+-preview\.\d+$/.test(version)) { + console.error(`Pre-release versions must be X.Y.Z-preview.N (got ${version}).`); process.exit(1); } -if (branch === "main" && version.includes("-")) { - console.error(`Main releases must use a stable semver version (got ${version}).`); - process.exit(1); -} -if (!allowedBranches.includes(branch)) { console.error(`✗ must be on ${allowedBranches.join(" or ")} (currently ${branch}).`); process.exit(1); } +if (branch !== releaseBranch) { console.error(`✗ must be on ${releaseBranch} (currently ${branch}).`); process.exit(1); } if ((await $`git status --porcelain`.text()).trim()) { console.error("✗ working tree not clean — commit or stash first."); process.exit(1); } const packageName = await readPackageName(); console.log(`→ release metadata preflight (${packageName}@${version})`); diff --git a/tests/ci-workflows.test.ts b/tests/ci-workflows.test.ts index f9c8e5205..a142d1e62 100644 --- a/tests/ci-workflows.test.ts +++ b/tests/ci-workflows.test.ts @@ -271,6 +271,15 @@ describe("GitHub Actions hardening", () => { expect(workflow).toContain("Pre-release versions (${RELEASE_VERSION}) must use dist-tag 'preview'"); expect(workflow).toContain("Stable releases (${RELEASE_VERSION}) must use dist-tag 'latest'"); expect(workflow).not.toContain("refs/heads/preview)"); + // Prereleases are restricted to X.Y.Z-preview.N: the update client only parses + // that suffix, so an -alpha/-beta/-rc publish would never notify preview users. + expect(workflow).toContain("Pre-release versions must be X.Y.Z-preview.N"); + expect(workflow).toContain("^[0-9]+\\.[0-9]+\\.[0-9]+-preview\\.[0-9]+$"); + // The release helper must dispatch from the only ref the workflow accepts. + const releaseHelper = await readText("scripts/release.ts"); + expect(releaseHelper).toContain('const releaseBranch = "main"'); + expect(releaseHelper).toContain('const expectedTag = isPrerelease ? "preview" : "latest"'); + expect(releaseHelper).not.toContain('["main", "preview"]'); // Release notes must include PR categories and the full channel commit range // (branch merges + direct commits). Preflight forbids an existing release, so From 41e9e70b95241b1a3ff199439c2abe25a62cdc6f Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 18:36:37 +0000 Subject: [PATCH 27/31] test(release): cover main-only release gate and preview-only prerelease shape Co-authored-by: Codesmith --- tests/release-helper.test.ts | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/release-helper.test.ts b/tests/release-helper.test.ts index 9ae5ca832..9549351e9 100644 --- a/tests/release-helper.test.ts +++ b/tests/release-helper.test.ts @@ -247,8 +247,8 @@ describe("release helper", () => { expect(findCallIndex(calls, "git", call => call.args[0] === "push")).toBe(-1); }); - test("preview branch still defaults to preview tag and dry-run dispatch", () => { - const { calls, result } = runRelease("9.9.9-preview.1", { branch: "preview" }); + test("a prerelease on main defaults to preview tag and dry-run dispatch", () => { + const { calls, result } = runRelease("9.9.9-preview.1"); expect(result.status).toBe(0); expect(findCallIndex(calls, "gh", call => @@ -260,6 +260,27 @@ describe("release helper", () => { )).toBeGreaterThanOrEqual(0); }); + // release.yml only accepts refs/heads/main, so the helper must refuse anything else + // instead of dispatching a run the workflow will reject. + test("releasing from a branch other than main aborts before the bump", () => { + const { calls, result } = runRelease("9.9.9-preview.1", { branch: "preview" }); + + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toContain("must be on main"); + expect(findCallIndex(calls, "npm", call => call.args[0] === "version")).toBe(-1); + expect(findCallIndex(calls, "gh", call => call.args[0] === "workflow" && call.args[1] === "run")).toBe(-1); + }); + + // The update client only parses X.Y.Z-preview.N, so any other prerelease shape would + // publish to the preview dist-tag and then never be seen as an available update. + test("prerelease versions outside X.Y.Z-preview.N abort before the bump", () => { + const { calls, result } = runRelease("9.9.9-alpha.1"); + + expect(result.status).not.toBe(0); + expect(result.stderr + result.stdout).toContain("must be X.Y.Z-preview.N"); + expect(findCallIndex(calls, "npm", call => call.args[0] === "version")).toBe(-1); + }); + test("dispatch pins the audited release SHA via expected-sha", () => { const { calls, result } = runRelease("9.9.9", { headSha: "deadbeefcafe1234" }); From 7c3c5071897a3df7cf8d216aea09ea938f8f7a79 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Thu, 30 Jul 2026 22:59:49 +0200 Subject: [PATCH 28/31] redesign(gui): adopt ChefGroep design language (warm palette, blue accent, shadcn + skins) (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * redesign(gui): adopt ChefGroep design language (warm palette, blue accent, Archivo/JetBrains Mono, shadcn buttons, ripple, strak skin) Co-authored-by: OnlineChef * redesign(gui): blue input focus ring, Dutch-first default, keep faithful dark basalt Co-authored-by: OnlineChef * redesign(gui): typography system (caps labels, tabular nums, type ladder, tracking) + motion (page reveal, press-physics, matte modal) + gui/DESIGN.md taste guide Co-authored-by: OnlineChef * redesign(gui): bulletproof page-reveal via keyed wrapper; align provider-overview cards to design tokens (caps labels, tabular counts) Co-authored-by: OnlineChef * redesign(gui): deeper design-system taste — authentic Stroom ripple-bar, neutral nav-active (accent reserved), caps section headers, range/kbd primitives, taste-rules in DESIGN.md Co-authored-by: OnlineChef * chore(gui): drop unused ibm-plex-mono after Archivo/JetBrains adoption Co-authored-by: navarrocorbi-prog --------- Co-authored-by: Cursor Agent Co-authored-by: OnlineChef Co-authored-by: navarrocorbi-prog --- gui/DESIGN.md | 158 +++++++++ gui/bun.lock | 6 + gui/package.json | 2 + gui/src/App.tsx | 3 +- gui/src/main.tsx | 16 + gui/src/styles.css | 323 +++++++++++------- .../styles/provider-overview-dashboard.css | 20 +- 7 files changed, 400 insertions(+), 128 deletions(-) create mode 100644 gui/DESIGN.md diff --git a/gui/DESIGN.md b/gui/DESIGN.md new file mode 100644 index 000000000..2f668aaf6 --- /dev/null +++ b/gui/DESIGN.md @@ -0,0 +1,158 @@ +# gui/DESIGN.md — de ontwerptaal van het dashboard + +> De levende ontwerp- en smaakgids voor het opencodex-dashboard (`gui/`). +> Dit is de ChefGroep-taal (v2 "Devin-richting"): een stil, warm, mat instrument. +> Bron van waarheid voor de *taal*: [`OnlineChefGroep/design-system`](https://github.com/OnlineChefGroep/design-system) +> (`tokens.css`, `DESIGN.md`, `motion-spec.md`). Dit bestand legt vast hoe die +> taal in dít dashboard leeft, en — belangrijker — **hoe je 'm uitbreidt zonder +> 'm te breken**. + +Alles hier is gebouwd op tokens in `src/styles.css`. Verzin nooit losse +px-waarden of kleuren in een component; gebruik een token. Zo blijft de hele +app in één keer te herstemmen. + +--- + +## 1. De drie pijlers + +1. **Stil oppervlak.** Warm off-white, haarlijnen, plat. Geen glow, geen + gradients, geen glasmorfisme, geen geneste schaduw. +2. **Levende activiteit.** Werk toon je als rust of een golfje, nooit als + ronddraaiende spinner. +3. **Begrijpelijk.** Eén accent, één type-ladder, één set radii, één easing. + Hiërarchie komt uit grootte/gewicht/kleur — niet uit decoratie. + +--- + +## 2. Kleur + +Eén accent: blauw (`--accent-blue`). Alles wat "klik mij / hier ben je / dit is +aan" zegt is blauw: links, focus-ring, actieve nav, geselecteerde tab, toggles, +selectie. De **primaire knop** blijft juist monochroom (tekst↔achtergrond +omgekeerd) — dat is de shadcn-conventie, geen tweede accent. + +| Rol | Token | +|---|---| +| Achtergrond / rail / kaart | `--bg` · `--rail` · `--surface` · `--raised` | +| Lijnen | `--border` (sterk) · `--border-soft` (hairline) | +| Tekst | `--text` · `--muted` · `--faint` | +| Primaire actie | `--accent` (+ `--accent-ink`) | +| Het accent | `--accent-blue` · `--accent-blue-ink` · `--accent-soft` (ring/tint) | +| Semantiek | `--green` (git/PR/toestemming) · `--amber` (wacht-op-jou) · `--red` (destructief) | + +Regels: groen/amber/rood zijn **gereserveerd**, nooit decoratie. Neutraal is +warm, nooit koudgrijs. Dark mode is basalt-warm, geen zuiver zwart. Elke token +is `light-dark(licht, donker)` — schrijf beide kanten, altijd. + +--- + +## 3. Typografie + +- **Archivo** (`--font-ui`) voor alles; **JetBrains Mono** (`--font-code`) + uitsluitend voor machinedata (timers, model-id's, paden, diffs, tellers). +- Eén type-ladder — gebruik de tokens, nooit losse px: + +| Token | px | Gebruik | +|---|---|---| +| `--text-micro` | 10.5 | meta, tellers, caps-labels | +| `--text-caption` | 11.5 | labels, captions | +| `--text-label` | 12.5 | secundair / beschrijvingen | +| `--text-control` | 13.5 | **UI-standaard** (body van de app) | +| `--text-body` | 14 | leestekst | +| `--text-subtitle` | 16 | kleine titels | +| `--text-section` | 18 | sectiekoppen | +| `--text-title` | 22 | paginatitels | +| `--text-display` | 28 | hero-getallen | + +- Koppen: gewicht 500, `letter-spacing: var(--tracking-tight)` (−0.02em), + `text-wrap: balance`. Leading via `--leading-*` (tight 1.2 / ui 1.45 / + body 1.55 / relaxed 1.65). +- **Getallen lijnen uit**: alles wat een getal is krijgt `.num` of + `font-variant-numeric: tabular-nums` (stat-waarden, quota, tellers, timers). +- Utilities: `.num` (tabulaire cijfers), `.caps` (uppercase microlabel), + `.prose` (68ch leesmaat). Componeer hiermee; verzin geen nieuwe. + +--- + +## 4. Motion + +Bewegen is transform + opacity, nooit `width/height/top/left`. Eén easing +(`--ease-out`), duren `--motion-fast/normal/slow` (140/280/420ms). Alles settle-t +vroeg, niets bounct, niets loopt oneindig. De vaste set: + +- **Intent-reveal:** één rustige rise per navigatie (`.main-inner > *`), niet + per kaart. +- **Press-physics:** `scale(0.97–0.98)` op knoppen, nav-rijen, tabs, chips, + segments. Nooit op inputs, tekst of panelen. +- **Modal:** scrim vervaagt in, kaart rijst en settle-t. +- **Ripple i.p.v. spinner:** `.spin` is een kalm blauw golfje. + +`prefers-reduced-motion` zet **alles** uit met nul informatieverlies (globale +guard in `styles.css`). Nieuwe animatie = tokenduur + één keyframe in het +Motion-blok. Meer niet. + +--- + +## 5. Skins (`data-style`) + +Dezelfde taal draagt meerdere complete skins: + +- `devin` (default) — warm, zacht, ronder (`:root`). +- `strak` — koeler grijs-blauw, scherpere radii. + +Zetten: `?style=strak` of een opgeslagen keuze (`localStorage` `ocx-style`), +toegepast vóór eerste paint in `main.tsx`. Een nieuwe skin = één blok +token-overrides in `styles.css` (light **én** dark). De taal (§1–§4) blijft +onder elke skin gelden. + +--- + +## 5b. Taste-regels (overgenomen uit `design-system/taste/`) + +Bindend voor nieuw werk. Twee observaties minimum per regel (zie de bron). + +- **Kleur:** neutraal warm tinten, **één accent max**; licht is eersteklas + standaard. Geen paarse gradients, AI-glow, acid-on-black. Het accent is voor + links/focus/toggles/status — **niet** voor nav-selectie (die is een kalme + `--raised`). +- **Type:** Archivo/General Sans-humanist voor interface; mono **strikt** voor + data. Nooit Inter/Geist/Space Grotesk of mono voor labels/prose. +- **Motion:** vroeg settelen, lage amplitude/frequentie; **één** signatuur- + systeem (de Stroom/ripple), geen verspreide micro-animaties. Geen bounce, + elastic of oneindige ambient motion. +- **Dichtheid:** compact, informatiedicht (dichtheid 5–7). `h28`/`r6` voor + secundaire controls, `r10` voor kaarten. Geen marketing-witruimte in product. +- **Stem:** warm, direct, menselijk Nederlands op Joep-vlakken. Geen em-dashes, + buzzwords, lifecycle-jargon of verzonnen metrics. +- **Structuur:** haarlijnen + ruimte voor scheiding. **Geen** kaart-in-kaart, + bento-velden of geneste elevation. Tweebaans sidebar met vaste glyph-baan. +- **Iconen:** echte SVG-lijniconen (Lucide/shadcn, ~1.75px stroke, 15–16px + grid). Nooit emoji als icoon — nergens. +- **Metafoor:** water/stroom voor systeemstatus; instrument-framing + ("gezandstraald instrument"). Geen keuken/bon/brigade of corporate-dashboard. + +## 6. Bans (hard) + +- Geen spinners/loaders (ripple vervangt ze). +- Geen emoji als icoon of in copy. Iconen zijn SVG-lijniconen (Lucide-stijl). +- Geen em-dashes, buzzwords of verzonnen metrics in copy. +- Geen gradients, glow, glasmorfisme, bento-kaartjes. +- Geen kaart-in-kaart, geen geneste elevation. +- Geen oneindige ambient motion. + +--- + +## 7. Zo breid je uit + +- **Nieuwe kleur/rol:** token toevoegen in `:root` mét `light-dark()`, en in + élke skin (`[data-style="strak"]`). Nooit een losse hex in een component. +- **Nieuwe component:** hergebruik `.btn`/`.badge`/`.input`/`.switch`/`.seg`, + radii- en type-tokens. Haarlijn-border, rustige hover, press-physics als het + klikbaar is. +- **Nieuwe tekst:** géén hardgecodeerde UI-strings in `src/pages`/ + `src/components` (zie `gui/AGENTS.md`). Zet de string in **alle** locale- + bestanden (`src/i18n/*.ts`) en render met `useT()`. Draai `bun run lint:i18n`. +- **Nieuwe motion:** tokenduur + keyframe in het Motion-blok; transform/opacity + only; check `prefers-reduced-motion`. +- **Controleren:** `bun run typecheck`, `bun --bun run lint:gui`, + `bun run lint:i18n`, `bun run privacy:scan`. Zie `AGENTS.md`. diff --git a/gui/bun.lock b/gui/bun.lock index 1ed75c2ac..56c90814e 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -5,6 +5,8 @@ "": { "name": "gui", "dependencies": { + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@tanstack/react-virtual": "^3.14.5", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -81,6 +83,10 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], + "@fontsource-variable/archivo": ["@fontsource-variable/archivo@5.3.0", "", {}, "sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g=="], + + "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], diff --git a/gui/package.json b/gui/package.json index ce7f602b0..61d4523e8 100644 --- a/gui/package.json +++ b/gui/package.json @@ -14,6 +14,8 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@tanstack/react-virtual": "^3.14.5", "react": "^19.2.7", "react-dom": "^19.2.7" diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 65a18fa4b..d34cfa0fa 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -288,8 +288,8 @@ export default function App() {
+
} {page === "grok" && } +
diff --git a/gui/src/main.tsx b/gui/src/main.tsx index 024e29626..b16c063fb 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -1,9 +1,25 @@ import React from "react"; import ReactDOM from "react-dom/client"; +// ChefGroep design language: Archivo (UI, the free General Sans equivalent) + +// JetBrains Mono (data only). Bundled locally so nothing depends on a CDN. +import "@fontsource-variable/archivo/wght.css"; +import "@fontsource-variable/jetbrains-mono"; import App from "./App"; import { LanguageProvider } from "./i18n/provider"; import "./styles.css"; +// ChefGroep design language ships two complete skins (design-system §14). +// devin (warm, default) is the :root skin; strak (cool, sharp) is opt-in via +// ?style=strak or a saved choice. Applied before first paint to avoid a flash. +try { + const params = new URLSearchParams(window.location.search); + const style = params.get("style") ?? localStorage.getItem("ocx-style"); + if (style === "strak" || style === "devin") { + document.documentElement.setAttribute("data-style", style); + localStorage.setItem("ocx-style", style); + } +} catch { /* ignore */ } + ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/gui/src/styles.css b/gui/src/styles.css index df6f124a1..df9b551b5 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1,10 +1,12 @@ /* ============================================================================ - opencodex dashboard — design system - Infra console in the OpenAI product grammar: white/near-black monochrome - base, black (light) / white (dark) primary actions, pill buttons, hairline - borders, flat surfaces, monospace for model ids / urls. Light + dark via - the native light-dark() function — every token is authored once; - `data-theme` (or the OS) picks the side. + opencodex dashboard — ChefGroep design language (v2 "Devin-richting") + A still, warm, matte instrument: warm off-white surfaces, one blue accent, + hairline borders, flat surfaces (no glow/glass/gradients), monospace for + machine data only. shadcn component conventions, Lucide icons, ripples + instead of spinners. Light + dark are both first-class via the native + light-dark() function — every token is authored once; `data-theme` (or the + OS) picks the side, `data-style` swaps the whole skin (devin | strak). + Ref: OnlineChefGroep/design-system (tokens.css, DESIGN.md, motion-spec.md). ============================================================================ */ @import "./styles/provider-catalog.css"; @@ -25,32 +27,39 @@ /* default: follow the OS; [data-theme] below pins color-scheme so light-dark() obeys it */ color-scheme: light dark; - --bg: light-dark(#ffffff, #212121); - --rail: light-dark(#f9f9f9, #171717); - --surface: light-dark(#ffffff, #262626); - --raised: light-dark(#f4f4f4, #303030); - --raised-hover: light-dark(#ececec, #3a3a3a); - --border: light-dark(#e6e6e6, #3d3d3d); - --border-soft: light-dark(#f0f0f0, #333333); - --hover: light-dark(rgba(13, 13, 13, 0.03), rgba(255, 255, 255, 0.03)); - - --text: light-dark(#0d0d0d, #ececec); - --muted: light-dark(#6e6e6e, #a6a6a6); - --faint: light-dark(#707070, #9a9a9a); - - /* monochrome primary: black actions on light, white actions on dark */ - --accent: light-dark(#0d0d0d, #ececec); - --accent-hover: light-dark(#3d3d3d, #ffffff); - --accent-ink: light-dark(#ffffff, #0d0d0d); - --accent-soft: light-dark(rgba(13, 13, 13, 0.06), rgba(255, 255, 255, 0.09)); - --accent-ring: light-dark(rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.38)); - - --green: light-dark(#0a7d5c, #4ecb9d); - --green-soft: light-dark(rgba(16, 163, 127, 0.10), rgba(78, 203, 157, 0.13)); - --red: light-dark(#b91c1c, #f87171); - --red-soft: light-dark(rgba(185, 28, 28, 0.09), rgba(248, 113, 113, 0.13)); - --amber: light-dark(#9a4a08, #fbbf24); - --amber-soft: light-dark(rgba(180, 83, 9, 0.10), rgba(251, 191, 36, 0.13)); + /* ChefGroep design-system (v2 "Devin-richting") — warm off-white, one blue + accent. Values from OnlineChefGroep/design-system tokens.css (2026-07). The + opencodex light-dark() authoring convention is preserved. */ + --bg: light-dark(#f7f6f5, #121111); + --rail: light-dark(#f2f1f0, #171615); + --surface: light-dark(#ffffff, #1b1a19); + --raised: light-dark(#efefef, #242322); + --raised-hover: light-dark(#e7e6e5, #2c2b2a); + --border: light-dark(rgba(0, 0, 0, 0.14), rgba(255, 255, 255, 0.16)); + --border-soft: light-dark(rgba(0, 0, 0, 0.08), rgba(255, 255, 255, 0.09)); + --hover: light-dark(rgba(0, 0, 0, 0.045), rgba(255, 255, 255, 0.05)); + + --text: light-dark(#191919, #f0eeeb); + --muted: light-dark(rgba(0, 0, 0, 0.55), rgba(240, 238, 235, 0.55)); + --faint: light-dark(rgba(0, 0, 0, 0.38), rgba(240, 238, 235, 0.35)); + + /* primary action stays a text↔bg inversion (shadcn .primary contract) */ + --accent: light-dark(#191919, #f0eeeb); + --accent-hover: light-dark(#333333, #ffffff); + --accent-ink: light-dark(#f7f6f5, #191919); + /* the single blue accent — links, focus, switch-on, selection */ + --accent-blue: light-dark(#317cff, #5c97ff); + --accent-blue-ink: light-dark(#1d5fd6, #8ab4ff); + --accent-soft: light-dark(rgba(49, 124, 255, 0.09), rgba(92, 151, 255, 0.14)); + --accent-ring: light-dark(rgba(49, 124, 255, 0.55), rgba(92, 151, 255, 0.55)); + + /* green reserved for git/PR/approval, amber for hold, red for destructive */ + --green: light-dark(#1f883d, #3fb950); + --green-soft: light-dark(rgba(31, 136, 61, 0.10), rgba(63, 185, 80, 0.13)); + --red: light-dark(#cf222e, #f85149); + --red-soft: light-dark(rgba(207, 34, 46, 0.09), rgba(248, 81, 73, 0.13)); + --amber: light-dark(#bf5b00, #d9a038); + --amber-soft: light-dark(rgba(191, 91, 0, 0.10), rgba(217, 160, 56, 0.13)); /* geometry: 4px base grid, three surface radii, and one pill radius */ --space-0-5: 2px; @@ -70,38 +79,44 @@ --prose-measure: 70ch; --radius-2xs: 4px; - --radius: 12px; + --radius: 10px; --radius-sm: 8px; --radius-xs: 6px; - --radius-lg: 16px; + --radius-lg: 14px; --radius-round: 50%; --radius-pill: 999px; /* typography: product UI first, Korean-safe fallbacks, mono only for machine data */ - --font-ui: "OpenAI Sans", "Pretendard Variable", Pretendard, "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; - --font-code: ui-monospace, "SFMono-Regular", "Cascadia Code", "JetBrains Mono", "Noto Sans Mono CJK KR", Menlo, Consolas, monospace; + --font-ui: "Archivo Variable", "Archivo", "Pretendard Variable", Pretendard, "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif; + --font-code: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, "SFMono-Regular", "Cascadia Code", "Noto Sans Mono CJK KR", Menlo, Consolas, monospace; --font: var(--font-ui); --mono: var(--font-code); - --text-micro: 10px; - --text-caption: 11px; - --text-label: 12px; - --text-control: 13px; - --text-body: 14px; - --text-subtitle: 16px; - --text-title: 20px; - --text-display: 24px; + /* type scale (design-system §11): one modular ladder, used everywhere via + tokens — never ad-hoc px. Data uses tabular-nums so columns line up. */ + --text-micro: 10.5px; /* meta, counts, badge */ + --text-caption: 11.5px; /* labels, kbd, captions */ + --text-label: 12.5px; /* secondary / descriptions */ + --text-control: 13.5px; /* UI standard */ + --text-body: 14px; /* body copy */ + --text-subtitle: 16px; /* small titles */ + --text-section: 18px; /* section headers */ + --text-title: 22px; /* page titles */ + --text-display: 28px; /* hero numbers */ --weight-regular: 400; --weight-medium: 500; --weight-semibold: 600; --weight-bold: 700; + /* leading: tight for headings, roomy for prose, calm for UI */ --leading-tight: 1.2; - --leading-ui: 1.35; - --leading-body: 1.5; - --leading-relaxed: 1.6; + --leading-ui: 1.45; + --leading-body: 1.55; + --leading-relaxed: 1.65; --tracking-normal: 0; + --tracking-tight: -0.02em; /* headings + big numbers */ + --tracking-caps: 0.06em; /* uppercase micro-labels */ --tracking-wide: 0.04em; --control-sm: 28px; @@ -113,8 +128,12 @@ --icon-md: 16px; --icon-lg: 20px; - --motion-fast: 120ms; - --motion-normal: 180ms; + --motion-fast: 140ms; + --motion-normal: 280ms; + --motion-slow: 420ms; + /* design-system signature easing — everything settles early, nothing bounces */ + --ease-out: cubic-bezier(0.22, 1, 0.36, 1); + --ease-in: cubic-bezier(0.4, 0, 1, 1); /* stacking: sticky chrome < overlay hosts < popovers/drawers < modals */ --z-sticky: 20; @@ -122,34 +141,71 @@ --z-popover: 40; --z-modal: 50; - --shadow: 0 1px 2px light-dark(rgba(16, 24, 40, 0.06), rgba(0, 0, 0, 0.5)), 0 10px 28px light-dark(rgba(16, 24, 40, 0.07), rgba(0, 0, 0, 0.30)); - --shadow-sm: 0 1px 2px light-dark(rgba(16, 24, 40, 0.06), rgba(0, 0, 0, 0.4)); + /* design language bans product-card shadow/glow; keep only a soft overlay lift */ + --shadow: 0 8px 28px light-dark(rgba(0, 0, 0, 0.10), rgba(0, 0, 0, 0.44)); + --shadow-sm: 0 1px 2px light-dark(rgba(0, 0, 0, 0.05), rgba(0, 0, 0, 0.34)); /* toggle switch */ --toggle-w: 36px; --toggle-h: 20px; --toggle-dot: 14px; - --toggle-off-bg: light-dark(#d4d4d4, #4a4a4a); - --toggle-on-bg: light-dark(#0d0d0d, #4ecb9d); - --toggle-dot-color: light-dark(#ffffff, #0d0d0d); + --toggle-off-bg: light-dark(#e0dedc, #3a3937); + --toggle-on-bg: var(--accent-blue); + --toggle-dot-color: #ffffff; } /* explicit user choice pins the scheme; "system" removes the attribute and falls back to the OS */ :root[data-theme="light"] { color-scheme: light; } :root[data-theme="dark"] { color-scheme: dark; } +/* second complete skin over the same language (design-system §14): cooler, + sharper, tighter radii, a colder blue. The default skin is the :root above + ("devin"). Set data-style="strak" on to opt in; works in light+dark. */ +:root[data-style="strak"] { + --bg: light-dark(#f4f6f9, #0f1013); + --rail: light-dark(#eef1f6, #141519); + --surface: light-dark(#ffffff, #16181c); + --raised: light-dark(#ebeef3, #1e2126); + --raised-hover: light-dark(#e2e6ec, #262a30); + --border: light-dark(#c6cdd8, #363a43); + --border-soft: light-dark(#e2e6ec, #25282e); + --hover: light-dark(rgba(19, 20, 23, 0.05), rgba(236, 237, 240, 0.06)); + --text: light-dark(#131417, #ecedf0); + --muted: light-dark(#525860, #9ba1ab); + --faint: light-dark(#8f96a1, #676d78); + --accent: light-dark(#131417, #ecedf0); + --accent-hover: light-dark(#2b2f36, #ffffff); + --accent-ink: light-dark(#f4f6f9, #131417); + --accent-blue: light-dark(#2563eb, #4f8dff); + --accent-blue-ink: light-dark(#1d4fd7, #7fa9ff); + --accent-soft: light-dark(rgba(37, 99, 235, 0.09), rgba(79, 141, 255, 0.12)); + --accent-ring: light-dark(rgba(37, 99, 235, 0.55), rgba(79, 141, 255, 0.55)); + --radius-2xs: 3px; + --radius: 6px; + --radius-sm: 5px; + --radius-xs: 4px; + --radius-lg: 8px; + --glass-rail: light-dark(#eef1f6, #141519); + --glass-panel: light-dark(#ffffff, #16181c); +} + /* glass material tokens: rail/nav layer only — content cards stay opaque */ :root { - --glass-rail: light-dark(rgba(249, 249, 249, 0.66), rgba(23, 23, 23, 0.62)); - --glass-panel: light-dark(rgba(255, 255, 255, 0.78), rgba(38, 38, 38, 0.82)); - --glass-blur: saturate(1.6) blur(22px); + /* matte, opaque warm surfaces — no glassmorphism (design language ban) */ + --glass-rail: light-dark(#f2f1f0, #171615); + --glass-panel: light-dark(#ffffff, #1b1a19); + --glass-blur: none; } * { box-sizing: border-box; } html, body, #root { height: 100%; } -html { overflow-x: hidden; background: var(--bg); } +html { + overflow-x: hidden; background: var(--bg); + -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} body { margin: 0; @@ -157,29 +213,16 @@ body { background: transparent; color: var(--text); font-family: var(--font-ui); - font-size: var(--text-body); - line-height: var(--leading-body); - -webkit-font-smoothing: antialiased; - text-rendering: optimizeLegibility; + font-size: var(--text-control); + line-height: var(--leading-ui); + font-feature-settings: "kern", "liga", "calt"; } -/* single ambient soft-focus wash behind the app (OpenAI organic-blur grammar). - One wash per viewport; the glass rail and modals refract it via backdrop-filter. */ -body::before { - content: ""; - position: fixed; - inset: -20%; - z-index: -1; - pointer-events: none; - background: - radial-gradient(42% 38% at 12% 6%, light-dark(rgba(164, 196, 255, 0.42), rgba(96, 130, 200, 0.16)), transparent 70%), - radial-gradient(46% 42% at 88% 18%, light-dark(rgba(168, 226, 197, 0.38), rgba(88, 160, 130, 0.13)), transparent 70%), - radial-gradient(40% 36% at 70% 92%, light-dark(rgba(255, 224, 194, 0.30), rgba(180, 140, 100, 0.08)), transparent 72%); - filter: blur(70px); -} +/* design language: still, warm, matte surface — no gradients, no glow behind + the app. The flat --bg is the whole backdrop. */ -a { color: var(--text); text-decoration: underline; text-decoration-color: var(--faint); text-underline-offset: 2px; } -a:hover { text-decoration-color: var(--text); } +a { color: var(--accent-blue-ink); text-decoration: underline; text-decoration-color: var(--accent-soft); text-underline-offset: 2px; } +a:hover { text-decoration-color: var(--accent-blue-ink); } code { font-family: var(--font-code); font-size: var(--text-label); } .mono { font-family: var(--font-code); font-variant-numeric: tabular-nums; } @@ -189,7 +232,16 @@ code { font-family: var(--font-code); font-size: var(--text-label); } gap: var(--space-2); } -h1, h2, h3, h4 { margin: 0; font-weight: var(--weight-semibold); letter-spacing: 0; line-height: var(--leading-tight); } +h1, h2, h3, h4 { margin: 0; font-weight: var(--weight-medium); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); text-wrap: balance; } +p { text-wrap: pretty; } + +/* ---- typography utilities (design-system §11) — compose, don't re-invent ---- + .num tabular figures so numeric columns and timers line up + .caps small uppercase section label + .prose readable measure for long-form copy */ +.num { font-variant-numeric: tabular-nums; font-feature-settings: "tnum"; } +.caps { font-size: var(--text-micro); font-weight: var(--weight-semibold); text-transform: uppercase; letter-spacing: var(--tracking-caps); color: var(--faint); } +.prose { font-size: var(--text-body); line-height: var(--leading-relaxed); max-width: 68ch; text-wrap: pretty; } /* [Decision Log] - 목적: 화면마다 임의로 지정된 폰트 크기와 굵기를 역할 기반 타이포그래피로 통일한다. @@ -221,8 +273,17 @@ p.muted.text-control { ::selection { background: var(--accent-soft); } -/* native controls (checkbox/radio) follow the accent instead of browser blue */ -input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } +/* native controls (checkbox/radio) follow the blue accent */ +input[type="checkbox"], input[type="radio"] { accent-color: var(--accent-blue); } + +/* range slider — design-system settings surface: 2px hairline track, ring thumb */ +input[type="range"] { -webkit-appearance: none; appearance: none; height: 2px; background: var(--border); border-radius: 1px; outline: none; } +input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--surface); border: 1.5px solid var(--accent-blue); cursor: pointer; transition: transform 120ms var(--ease-out); } +input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15); } +input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; border-radius: 50%; background: var(--surface); border: 1.5px solid var(--accent-blue); cursor: pointer; } + +/* keyboard hint — mono, hairline key cap (design-system) */ +kbd { font-family: var(--font-code); font-size: var(--text-caption); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; color: var(--muted); background: var(--surface); } /* scrollbars */ *::-webkit-scrollbar { width: 10px; height: 10px; } @@ -274,9 +335,10 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } color: var(--muted); font: inherit; font-size: var(--text-control); font-weight: var(--weight-medium); transition: background var(--motion-fast), color var(--motion-fast); } -.nav-item:hover { background: var(--accent-soft); color: var(--text); } -.nav-item.active { background: var(--accent-soft); color: var(--text); } -.nav-item.active { font-weight: var(--weight-semibold); } +.nav-item:hover { background: var(--hover); color: var(--text); } +/* design-system nav: active is a calm raised surface, not the accent — one + accent max, reserved for links/focus/toggles/status (taste-rules: color). */ +.nav-item.active { background: var(--raised); color: var(--text); font-weight: var(--weight-medium); } .nav-item svg { width: 17px; height: 17px; color: var(--faint); flex-shrink: 0; } .nav-item.active svg { color: var(--text); } @@ -363,14 +425,14 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } /* ---- page header ---- */ .page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 6px; } .page-head h2 { font-size: var(--text-title); } -.page-sub { color: var(--muted); font-size: var(--text-body); margin: 4px 0 22px; max-width: var(--prose-measure); } +.page-sub { color: var(--muted); font-size: var(--text-body); line-height: var(--leading-body); margin: 6px 0 24px; max-width: 68ch; text-wrap: pretty; } /* 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:hover { color: var(--text); } -.page-tab--active { color: var(--text); border-bottom-color: var(--accent); font-weight: var(--weight-semibold); } +.page-tab--active { color: var(--accent-blue-ink); border-bottom-color: var(--accent-blue); font-weight: var(--weight-semibold); } .page-tab:focus-visible { outline: 2px solid var(--accent-ring); outline-offset: -2px; } .page-sub b { color: var(--text); font-weight: var(--weight-semibold); } .api-page h2 svg { width: 1em; height: 1em; vertical-align: -0.16em; } @@ -499,20 +561,24 @@ input[type="checkbox"], input[type="radio"] { accent-color: var(--accent); } /* ---- buttons ---- */ .btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; - padding: 8px 16px; border-radius: var(--radius-pill); + padding: 8px 14px; border-radius: var(--radius-sm); 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; + border: 1px solid transparent; + transition: background var(--motion-fast), border-color var(--motion-fast), opacity var(--motion-fast), transform 100ms var(--ease-out); + white-space: nowrap; } a.btn, a.btn:hover { text-decoration: none; } .btn svg { width: 15px; height: 15px; } .btn:disabled { opacity: 0.55; cursor: default; } +/* design language press-physics: settle, never bounce */ +.btn:active:not(:disabled) { transform: scale(0.97); } .btn-primary { background: var(--accent); color: var(--accent-ink); } .btn-primary:hover:not(:disabled) { background: var(--accent-hover); } .btn-ghost { background: var(--bg); color: var(--text); border-color: var(--border); } .btn-ghost:hover:not(:disabled) { background: var(--raised); } .btn-danger { background: transparent; color: var(--red); border-color: rgba(248, 113, 113, 0.3); } .btn-danger:hover:not(:disabled) { background: var(--red-soft); } -.btn-sm { padding: 4px 12px; font-size: var(--text-label); border-radius: var(--radius-pill); } +.btn-sm { padding: 4px 12px; font-size: var(--text-label); border-radius: var(--radius-xs); } .btn-icon { appearance: none; border: none; @@ -616,10 +682,10 @@ a.btn, a.btn:hover { text-decoration: none; } @media (max-width: 640px) { .stat-row { grid-template-columns: repeat(2, 1fr); } } .stat { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; transition: border-color var(--motion-fast); } .stat:hover { border-color: var(--faint); } -.stat .label { font-size: var(--text-label); color: var(--muted); margin-bottom: 6px; display: flex; align-items: center; gap: 6px; font-weight: var(--weight-medium); } -.stat .label svg { width: 14px; height: 14px; } -.stat .value { font-size: var(--text-title); font-weight: var(--weight-semibold); letter-spacing: 0; line-height: var(--leading-tight); } -.stat .value.mono { font-family: var(--font-code); font-size: var(--text-subtitle); } +.stat .label { font-size: var(--text-micro); color: var(--faint); margin-bottom: 7px; display: flex; align-items: center; gap: 6px; font-weight: var(--weight-semibold); text-transform: uppercase; letter-spacing: var(--tracking-caps); } +.stat .label svg { width: 13px; height: 13px; } +.stat .value { font-size: var(--text-title); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); font-variant-numeric: tabular-nums; } +.stat .value.mono { font-family: var(--font-code); font-size: var(--text-subtitle); letter-spacing: 0; } .startup-health-bar { min-height: var(--control-lg); @@ -693,8 +759,8 @@ a.btn, a.btn:hover { text-decoration: none; } .model-card .id { font-family: var(--font-code); font-weight: var(--weight-semibold); font-size: var(--text-control); letter-spacing: 0; color: var(--text); } /* ---- badges / status dot ---- */ -.badge { display: inline-flex; align-items: center; gap: 5px; font-size: var(--text-caption); font-weight: var(--weight-semibold); line-height: var(--leading-ui); padding: 2px 8px; border-radius: var(--radius-pill); border: 1px solid transparent; font-family: var(--font-code); letter-spacing: 0; } -.badge-accent { background: var(--accent-soft); color: var(--text); } +.badge { display: inline-flex; align-items: center; gap: 5px; font-size: var(--text-caption); font-weight: var(--weight-medium); line-height: var(--leading-ui); padding: 2.5px 9px; border-radius: var(--radius-pill); border: 1px solid transparent; font-family: var(--font-ui); letter-spacing: 0; } +.badge-accent { background: var(--accent-soft); color: var(--accent-blue-ink); } .badge-green { background: var(--green-soft); color: var(--green); } .badge-amber { background: var(--amber-soft); color: var(--amber); } .badge-muted { background: var(--raised); color: var(--muted); border: 1px solid var(--border); } @@ -762,7 +828,7 @@ a.btn, a.btn:hover { text-decoration: none; } font: inherit; font-size: var(--text-control); line-height: var(--leading-ui); transition: border-color var(--motion-fast); } .input::placeholder { color: var(--faint); } -.input:focus { border-color: var(--faint); outline: none; box-shadow: 0 0 0 3px var(--accent-soft); } +.input:focus { border-color: var(--accent-blue); outline: none; box-shadow: 0 0 0 3px var(--accent-soft); } textarea.input { resize: vertical; font-family: var(--font-code); line-height: var(--leading-relaxed); } .field-label { font-size: var(--text-label); color: var(--muted); font-weight: var(--weight-medium); margin-bottom: 5px; display: block; } select.input { appearance: none; } @@ -830,7 +896,7 @@ select.input { appearance: none; } /* Pointer hover alone — softer than keyboard focus so the two cues stay distinct. */ .select-option:hover:not(.select-option-active) { background: var(--hover); } /* Selected value (committed). */ -.select-option.active { background: var(--accent-soft); color: var(--accent); font-weight: var(--weight-semibold); } +.select-option.active { background: var(--accent-soft); color: var(--accent-blue-ink); font-weight: var(--weight-semibold); } /* Keyboard / aria-activedescendant focus — visible without relying on :hover. */ .select-option-active { background: var(--hover); @@ -910,14 +976,48 @@ select.input { appearance: none; } } .notice-err svg { color: var(--red); } -.h-section { font-size: var(--text-control); font-weight: var(--weight-semibold); line-height: var(--leading-ui); color: var(--text); margin: 30px 0 12px; display: flex; align-items: center; gap: 8px; } -.h-section .count { color: var(--muted); font-weight: var(--weight-medium); font-family: var(--font-code); font-size: var(--text-label); } +.h-section { font-size: var(--text-caption); font-weight: var(--weight-semibold); text-transform: uppercase; letter-spacing: var(--tracking-caps); line-height: var(--leading-ui); color: var(--faint); margin: 30px 0 12px; display: flex; align-items: center; gap: 8px; } +.h-section .count { color: var(--muted); font-weight: var(--weight-medium); font-family: var(--font-code); font-size: var(--text-label); font-variant-numeric: tabular-nums; } -.spin { width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: var(--radius-round); display: inline-block; animation: spin 0.7s linear infinite; } -@keyframes spin { to { transform: rotate(360deg); } } +/* design language bans spinners — the signature "ripple": a 2px bar that flows + left→right and back (the Stroom, from design-system worked-row). Under + reduced-motion the static bar remains, so activity is never lost. */ +.spin { position: relative; width: 18px; height: 10px; display: inline-block; flex-shrink: 0; vertical-align: middle; color: var(--accent-blue); } +.spin::before { content: ""; position: absolute; left: 0; right: 0; top: 4px; height: 2px; border-radius: 1px; background: currentColor; animation: ocx-rippleflow 1.1s linear infinite; } +@keyframes ocx-rippleflow { + 0% { transform: scaleX(0.15); transform-origin: left; } + 45% { transform: scaleX(1); transform-origin: left; } + 46% { transform-origin: right; } + 100% { transform: scaleX(0.15); transform-origin: right; } +} @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } } +/* ============================================================================ + Motion system (design-system motion-spec.md). Bounded set, transform/opacity + only, one shared easing, everything settles early. The reduced-motion guard + above disables all of it with zero information loss. To extend: add a token + duration + a keyframe here; never animate width/height/top/left. + ============================================================================ */ + +/* Intent reveal — one calm rise per navigation (ErrorBoundary is keyed by page, + so its subtree remounts and replays this). Not per-card: a wall of staggered + items is theater. */ +/* keyed on the current page (App.tsx), so it re-creates and replays per nav */ +.page-reveal { animation: ocx-page-in var(--motion-slow) var(--ease-out) both; } +@keyframes ocx-page-in { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } } + +/* Modal enter — scrim fades, card rises and settles. */ +@keyframes ocx-overlay-in { from { opacity: 0; } to { opacity: 1; } } +@keyframes ocx-modal-in { from { opacity: 0; transform: translateY(10px) scale(0.985); } to { opacity: 1; transform: none; } } + +/* Press-physics (Motion 4) — the same tactile scale on every pressable row, + tab, chip and option, matching .btn. Never on inputs, text or panels. */ +.nav-item, .page-tab, .select-option, .chip, .seg button, .usage-segmented-btn, .claude-tabs button { + transition: background var(--motion-fast) var(--ease-out), color var(--motion-fast) var(--ease-out), border-color var(--motion-fast) var(--ease-out), transform 100ms var(--ease-out); +} +.nav-item:active, .page-tab:active, .select-option:active, .chip:active, .seg button:active, .usage-segmented-btn:active, .claude-tabs button:active { transform: scale(0.98); } + /* glass fallbacks: solid surfaces when blur is unsupported or unwanted */ @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { .sidebar { background: var(--rail); } @@ -934,7 +1034,7 @@ select.input { appearance: none; } /* ---- modal ---- */ /* Liquid-glass modal: heavy tint + blur so background content is unreadable */ -.modal-overlay { position: fixed; inset: 0; background: light-dark(rgba(17, 19, 28, 0.78), rgba(0, 0, 0, 0.82)); backdrop-filter: blur(40px) saturate(1.2); -webkit-backdrop-filter: blur(40px) saturate(1.2); display: flex; align-items: flex-start; justify-content: center; padding: 8vh 16px; z-index: var(--z-modal); } +.modal-overlay { position: fixed; inset: 0; background: light-dark(rgba(20, 18, 16, 0.52), rgba(0, 0, 0, 0.62)); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px); display: flex; align-items: flex-start; justify-content: center; padding: 8vh 16px; z-index: var(--z-modal); animation: ocx-overlay-in var(--motion-fast) var(--ease-out); } /* Native .showModal() UA styles (solid border + ::backdrop) stack on top of .modal-overlay and produce a white frame + double dim. Reset when the dialog IS the overlay. */ dialog.modal-overlay { @@ -949,22 +1049,7 @@ dialog.modal-overlay { dialog.modal-overlay::backdrop { background: transparent; } -.modal-card { - position: relative; - z-index: 1; - background: color-mix(in oklab, canvas 92%, transparent); - backdrop-filter: blur(20px) saturate(1.4); - -webkit-backdrop-filter: blur(20px) saturate(1.4); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 20px; - width: 100%; - max-width: 520px; - /* Tight elevation with border — avoid hairline + wide soft blur (AI tell). */ - box-shadow: var(--shadow-sm); - max-height: 84vh; - overflow-y: auto; -} +.modal-card { position: relative; z-index: 1; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 20px; width: 100%; max-width: 520px; box-shadow: var(--shadow); max-height: 84vh; overflow-y: auto; animation: ocx-modal-in var(--motion-normal) var(--ease-out); } .modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } .modal-head h3 { font-size: var(--text-subtitle); } @@ -1721,7 +1806,7 @@ button.prov-account-row.active { cursor: default; } } .usage-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin-top: 8px; } -.usage-cards .stat-value { font-size: var(--text-title); font-weight: var(--weight-semibold); margin-top: 4px; } +.usage-cards .stat-value { font-size: var(--text-title); font-weight: var(--weight-semibold); margin-top: 4px; font-variant-numeric: tabular-nums; letter-spacing: var(--tracking-tight); } .usage-head { flex-wrap: wrap; } .usage-filters { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; } .usage-segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius-pill); padding: 2px; gap: 2px; background: var(--surface); } diff --git a/gui/src/styles/provider-overview-dashboard.css b/gui/src/styles/provider-overview-dashboard.css index ba11de9d0..9479c1dd6 100644 --- a/gui/src/styles/provider-overview-dashboard.css +++ b/gui/src/styles/provider-overview-dashboard.css @@ -53,19 +53,23 @@ } .pws-dashboard-card-count { - font-size: 1.25rem; - font-weight: 700; + font-size: var(--text-title); + font-weight: var(--weight-semibold); line-height: 1; + font-variant-numeric: tabular-nums; + letter-spacing: var(--tracking-tight); } -.pws-dashboard-card--ok .pws-dashboard-card-count { color: var(--green, #22c55e); } -.pws-dashboard-card--warn .pws-dashboard-card-count { color: var(--yellow, #eab308); } -.pws-dashboard-card--muted .pws-dashboard-card-count { color: var(--fg-muted, #888); } +.pws-dashboard-card--ok .pws-dashboard-card-count { color: var(--green); } +.pws-dashboard-card--warn .pws-dashboard-card-count { color: var(--amber); } +.pws-dashboard-card--muted .pws-dashboard-card-count { color: var(--faint); } .pws-dashboard-card-label { - font-size: 0.7rem; - color: var(--fg-muted, #888); - letter-spacing: 0.02em; + font-size: var(--text-micro); + color: var(--faint); + font-weight: var(--weight-semibold); + text-transform: uppercase; + letter-spacing: var(--tracking-caps); } /* Rate Limits | Recently Used side-by-side (no vertical stack / scroll) */ From c23de7a6788bc8d006033d68a4b763e58eb7d3b5 Mon Sep 17 00:00:00 2001 From: OnlineChef <280567955+OnlineChef@users.noreply.github.com> Date: Sat, 1 Aug 2026 08:50:39 +0200 Subject: [PATCH 29/31] feat(providers): generalize account UI and per-provider fallback Collapse Codex-only auth nav into Providers, add configurable per-provider fallback hops, and keep runtime failover on the existing combo engine so sofie patches stay reproducible. Co-authored-by: Cursor Co-authored-by: Codesmith --- gui/src/App.tsx | 6 +- gui/src/app-routing.ts | 8 +- .../provider-workspace/ProviderDetails.tsx | 3 + .../provider-workspace/ProviderSettings.tsx | 151 +++++++++++++++- .../components/provider-workspace/types.ts | 7 + gui/src/i18n/de.ts | 12 +- gui/src/i18n/en.ts | 12 +- gui/src/i18n/ja.ts | 12 +- gui/src/i18n/ko.ts | 12 +- gui/src/i18n/ru.ts | 12 +- gui/src/i18n/zh.ts | 12 +- gui/src/pages/Providers.tsx | 6 + gui/src/pages/providers-shared.ts | 1 + gui/src/provider-workspace/catalog.ts | 5 + .../styles/provider-workspace-settings.css | 8 + gui/tests/dashboard-tabs.test.ts | 5 +- gui/tests/provider-settings-fallback.test.tsx | 52 ++++++ gui/tests/sidebar-codex-auth.test.ts | 33 ++-- src/config.ts | 8 + src/providers/fallback.ts | 167 ++++++++++++++++++ src/server/auth-cors.ts | 1 + src/server/management/provider-routes.ts | 19 ++ src/server/responses/core.ts | 40 +++-- src/types.ts | 8 + tests/provider-fallback.test.ts | 166 +++++++++++++++++ tests/server-combo-failover-e2e.test.ts | 113 ++++++++++++ 26 files changed, 823 insertions(+), 56 deletions(-) create mode 100644 gui/tests/provider-settings-fallback.test.tsx create mode 100644 src/providers/fallback.ts create mode 100644 tests/provider-fallback.test.ts diff --git a/gui/src/App.tsx b/gui/src/App.tsx index d34cfa0fa..2a868c898 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -8,13 +8,12 @@ import Subagents from "./pages/Subagents"; import Logs from "./pages/Logs"; import Usage from "./pages/Usage"; import Storage from "./pages/Storage"; -import CodexAuth from "./pages/CodexAuth"; import ApiKeys from "./pages/ApiKeys"; import Claude from "./pages/Claude"; import Grok from "./pages/Grok"; import Startup from "./pages/Startup"; import ErrorBoundary from "./components/ErrorBoundary"; -import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; +import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n/shared"; import { Select, Switch } from "./ui"; import { installApiAuthFetch } from "./api"; @@ -37,7 +36,6 @@ const PAGE_TKEY: Record = { logs: "nav.logs", usage: "nav.usage", storage: "nav.storage", - "codex-auth": "nav.codexAuth", api: "nav.api", claude: "nav.claude", grok: "nav.grok", @@ -48,7 +46,6 @@ const THEME_KEY = "ocx-theme"; const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [ { id: "dashboard", tkey: "nav.dashboard", Icon: IconGrid }, - { id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey }, { id: "providers", tkey: "nav.providers", Icon: IconServer }, { id: "models", tkey: "nav.models", Icon: IconBoxes }, { id: "subagents", tkey: "nav.subagents", Icon: IconBot }, @@ -305,7 +302,6 @@ export default function App() { {page === "logs" && } {page === "usage" && } {page === "storage" && } - {page === "codex-auth" && } {page === "api" && } {page === "claude" && } {page === "grok" && } diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index bb03f96a8..bc8248b69 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -12,7 +12,6 @@ export type Page = | "logs" | "usage" | "storage" - | "codex-auth" | "api" | "claude" | "grok"; @@ -27,7 +26,6 @@ export const VALID_PAGES = new Set([ "logs", "usage", "storage", - "codex-auth", "api", "claude", "grok", @@ -83,6 +81,12 @@ export function resolveAppHashChange(rawHash: string): AppHashChangeAction { return { page: "providers", replaceTo: "providers" }; } + // Account management used to be a Codex-only destination. Providers now owns + // OAuth accounts, API-key pools and the OpenAI/Codex pool in one place. + if (rawHash === "codex-auth" || rawHash.startsWith("codex-auth/")) { + return { page: "providers", replaceTo: "providers" }; + } + // An unrecognised sub-hash is normalised away rather than left in the URL. if (!hashBelongsToPage(rawHash, nextPage)) { return { page: nextPage, replaceTo: nextPage }; diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index e825dcee6..344610d99 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -30,6 +30,7 @@ export default function ProviderDetails({ modelUsage, quotaReport, availableModels, + peerProviders, hasLiveModels, selectedModels, modelsLoading, @@ -58,6 +59,7 @@ export default function ProviderDetails({ modelUsage?: ProviderModelUsageRow[]; quotaReport?: ProviderQuotaReportView; availableModels: string[]; + peerProviders?: import("./ProviderSettings").ProviderPeerOption[]; /** Server-reported live-catalog provenance; see filterModels(). */ hasLiveModels: boolean; selectedModels: string[]; @@ -291,6 +293,7 @@ export default function ProviderDetails({ item={item} apiBase={apiBase} availableModels={availableModels} + peerProviders={peerProviders} onUpdateProvider={onUpdateProvider} onDirtyChange={setSettingsDirty} onRegisterSave={registerSettingsSave} diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index c43776467..13971d843 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -11,22 +11,56 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { baseUrlForChoice, matchChoiceId, resolvedBaseUrlForChoice } from "../../base-url-choice"; import { readJsonIfOk } from "../../fetch-json"; import { useT } from "../../i18n/shared"; -import { IconLock } from "../../icons"; +import { IconLock, IconPlus, IconTrash } from "../../icons"; import { isCatalogProviderId } from "../../provider-icons"; import type { CatalogPreset } from "../provider-catalog/provider-presets"; import { authModeLabel } from "./ProviderRail"; -import type { WorkspaceItem, ProviderUpdatePatch } from "./types"; +import type { ProviderFallbackTarget, WorkspaceItem, ProviderUpdatePatch } from "./types"; const ADAPTERS = ["openai-responses", "openai-chat", "anthropic", "google", "azure-openai", "cursor"] as const; const EMPTY_MODELS: string[] = []; +const EMPTY_PEERS: ProviderPeerOption[] = []; + +export type ProviderPeerOption = { + name: string; + disabled?: boolean; + models?: string[]; + defaultModel?: string; +}; type ChoicesStatus = "idle" | "loading" | "ready" | "error"; +function normalizeFallback(raw: ProviderFallbackTarget[] | undefined): ProviderFallbackTarget[] { + if (!Array.isArray(raw)) return []; + return raw + .map(row => ({ + provider: typeof row.provider === "string" ? row.provider.trim() : "", + model: typeof row.model === "string" ? row.model.trim() : "", + })) + .filter(row => row.provider && row.model); +} + +function fallbackFingerprint(rows: ProviderFallbackTarget[] | undefined): string { + return JSON.stringify(normalizeFallback(rows)); +} + +function modelsForPeer(peer: ProviderPeerOption | undefined, currentModel: string): string[] { + const set = new Set(); + for (const id of peer?.models ?? []) { + if (id.trim()) set.add(id.trim()); + } + if (peer?.defaultModel?.trim()) set.add(peer.defaultModel.trim()); + if (currentModel.trim()) set.add(currentModel.trim()); + return [...set].sort((a, b) => a.localeCompare(b)); +} + export default function ProviderSettings({ - item, availableModels = EMPTY_MODELS, apiBase, onUpdateProvider, onDirtyChange, onRegisterSave, + item, availableModels = EMPTY_MODELS, peerProviders = EMPTY_PEERS, apiBase, onUpdateProvider, onDirtyChange, onRegisterSave, }: { item: WorkspaceItem; availableModels?: string[]; + /** Other configured providers (and their known models) for the fallback picker. */ + peerProviders?: ProviderPeerOption[]; /** When set, load endpoint choices for catalog providers that expose baseUrlChoices. */ apiBase?: string; onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>; @@ -44,6 +78,7 @@ export default function ProviderSettings({ const [note, setNote] = useState(item.note ?? ""); const [allowPrivateNetwork, setAllowPrivateNetwork] = useState(item.allowPrivateNetwork ?? false); const [liveModels, setLiveModels] = useState(item.liveModels !== false); + const [fallback, setFallback] = useState(() => normalizeFallback(item.fallback)); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); const [baseUrlChoices, setBaseUrlChoices] = useState(); @@ -60,9 +95,10 @@ export default function ProviderSettings({ setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false); + setFallback(normalizeFallback(item.fallback)); setMsg(null); queueMicrotask(() => setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl))); - }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, item.liveModels, baseUrlChoices]); + }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, item.liveModels, item.fallback, baseUrlChoices]); /* eslint-enable react-hooks/set-state-in-effect */ useEffect(() => { @@ -102,7 +138,8 @@ export default function ProviderSettings({ || (adapter.trim() === "anthropic" && authMode === "key" && apiKeyTransport !== (item.apiKeyTransport ?? "x-api-key")) || note.trim() !== (item.note ?? "") || allowPrivateNetwork !== (item.allowPrivateNetwork ?? false) - || liveModels !== (item.liveModels !== false); + || liveModels !== (item.liveModels !== false) + || fallbackFingerprint(fallback) !== fallbackFingerprint(item.fallback); useEffect(() => { onDirtyChange?.(dirty); return () => onDirtyChange?.(false); }, [dirty, onDirtyChange]); @@ -119,6 +156,11 @@ export default function ProviderSettings({ return list; }, [adapter]); + const fallbackPeers = useMemo( + () => peerProviders.filter(p => p.name !== item.name), + [peerProviders, item.name], + ); + const isPreset = isCatalogProviderId(item.name); const hasEndpointPicker = choicesStatus === "ready" && !!(baseUrlChoices && baseUrlChoices.length > 0); const supportsApiKeyTransport = adapter.trim() === "anthropic" && authMode === "key"; @@ -132,10 +174,24 @@ export default function ProviderSettings({ ? resolvedBaseUrlForChoice(baseUrlChoices, endpointChoice, baseUrl) : baseUrl.trim(); if (!adapter.trim() || !nextBaseUrl) { setMsg({ ok: false, text: t("pws.adapterBaseRequired") }); return false; } + const nextFallback = normalizeFallback(fallback); + if (fallback.some(row => (row.provider.trim() && !row.model.trim()) || (!row.provider.trim() && row.model.trim()))) { + setMsg({ ok: false, text: t("pws.fallbackIncomplete") }); + return false; + } setSaving(true); setMsg(null); try { - const patch: ProviderUpdatePatch = { adapter: adapter.trim(), baseUrl: nextBaseUrl, defaultModel: defaultModel.trim(), authMode, note: note.trim(), allowPrivateNetwork, liveModels }; + const patch: ProviderUpdatePatch = { + adapter: adapter.trim(), + baseUrl: nextBaseUrl, + defaultModel: defaultModel.trim(), + authMode, + note: note.trim(), + allowPrivateNetwork, + liveModels, + fallback: nextFallback, + }; if (supportsApiKeyTransport) patch.apiKeyTransport = apiKeyTransport; else if (item.apiKeyTransport !== undefined) patch.apiKeyTransport = ""; const res = await onUpdateProvider(item.name, patch); @@ -160,19 +216,25 @@ export default function ProviderSettings({ setAdapter(item.adapter); setBaseUrl(item.baseUrl); setDefaultModel(item.defaultModel ?? ""); setAuthMode(initialAuth); setApiKeyTransport(item.apiKeyTransport ?? "x-api-key"); - setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false); setMsg(null); + setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false); + setFallback(normalizeFallback(item.fallback)); + setMsg(null); setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl)); }; - const endpointLabel = (id: string, fallback: string) => { + const endpointLabel = (id: string, fallbackLabel: string) => { switch (id) { case "token-plan": return t("modal.endpoint.tokenPlan"); case "payg": return t("modal.endpoint.payAsYouGo"); case "custom": return t("modal.endpoint.custom"); - default: return fallback; + default: return fallbackLabel; } }; + const updateFallbackRow = (index: number, patch: Partial) => { + setFallback(rows => rows.map((row, i) => i === index ? { ...row, ...patch } : row)); + }; + return (
+ +
+ {t("pws.fallback")} + {t("pws.fallbackDesc")} +
+ {fallback.map((row, index) => { + const peer = fallbackPeers.find(p => p.name === row.provider); + const modelIds = modelsForPeer(peer, row.model); + return ( +
+ + {modelIds.length > 0 ? ( + + ) : ( + updateFallbackRow(index, { model: e.target.value })} + /> + )} + +
+ ); + })} + +
+
+ {dirty && (
{t("pws.settingsUnsavedBar")} diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index 3cea9d24b..83586fc2b 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -85,6 +85,11 @@ export interface ProviderAuthHandlers { onEditAlias: (provider: string, type: "oauth" | "api-key", id: string, current?: string) => void | Promise; } +export type ProviderFallbackTarget = { + provider: string; + model: string; +}; + export type ProviderUpdatePatch = { adapter?: string; baseUrl?: string; @@ -96,4 +101,6 @@ export type ProviderUpdatePatch = { disabled?: boolean; allowPrivateNetwork?: boolean; liveModels?: boolean; + /** Ordered failover chain; `[]` clears. */ + fallback?: ProviderFallbackTarget[]; }; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 6b97d24d3..639377c0d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -4,7 +4,7 @@ import type { TKey } from "./en"; export const de: Record = { "nav.dashboard": "Übersicht", "nav.startup": "Startsicherheit", - "nav.providers": "Anbieter", + "nav.providers": "Anbieter & Konten", "nav.models": "Modelle", "nav.combos": "Combos", "nav.subagents": "Sub-Agenten", @@ -1262,6 +1262,16 @@ export const de: Record = { "pws.allowPrivateNetwork": "Lokales/privates Netzwerk erlauben", "pws.liveModels": "Modelle beim Anbieter erkennen", "pws.liveModelsDesc": "Lädt den Live-Modellkatalog des Anbieters. Ausschalten, um nur konfigurierte statische Modelle zu verwenden.", + "pws.fallback": "Fallback-Anbieter", + "pws.fallbackDesc": "Bei wiederholbaren Fehlern (429, 5xx, Stream-Abbruch) werden diese Ziele der Reihe nach versucht. Leer lassen, um den Fehler an den Client zurückzugeben.", + "pws.fallback.add": "Fallback hinzufügen", + "pws.fallback.provider": "Fallback-Anbieter", + "pws.fallback.model": "Fallback-Modell", + "pws.fallback.pickProvider": "Anbieter wählen", + "pws.fallback.pickModel": "Modell wählen", + "pws.fallback.modelPlaceholder": "Modell-ID", + "pws.fallback.disabled": "{name} (deaktiviert)", + "pws.fallbackIncomplete": "Jede Fallback-Zeile braucht Anbieter und Modell.", "pws.optionalPlaceholder": "Optional", "pws.providerId": "Anbieter-ID", "pws.reauth": "Erneute Anmeldung erforderlich", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0192e6ac1..36d179929 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -4,7 +4,7 @@ export const en = { // sidebar / nav / common "nav.dashboard": "Dashboard", "nav.startup": "Startup", - "nav.providers": "Providers", + "nav.providers": "Providers & accounts", "nav.models": "Models", "nav.combos": "Combos", "nav.subagents": "Subagents", @@ -982,6 +982,16 @@ export const en = { "pws.allowPrivateNetwork": "Allow local/private network", "pws.liveModels": "Discover models from provider", "pws.liveModelsDesc": "Fetch the provider's live model catalog. Turn this off to use only configured/static models.", + "pws.fallback": "Fallback providers", + "pws.fallbackDesc": "On retryable failures (429, 5xx, stream drop), hop to these targets in order. Leave empty to return the error to the client.", + "pws.fallback.add": "Add fallback", + "pws.fallback.provider": "Fallback provider", + "pws.fallback.model": "Fallback model", + "pws.fallback.pickProvider": "Pick provider", + "pws.fallback.pickModel": "Pick model", + "pws.fallback.modelPlaceholder": "model id", + "pws.fallback.disabled": "{name} (disabled)", + "pws.fallbackIncomplete": "Each fallback row needs both a provider and a model.", "pws.optionalPlaceholder": "Optional", "pws.providerId": "Provider ID", "pws.reauth": "Needs re-auth", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 33d6b6e23..e301efa98 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,7 +4,7 @@ export const ja: Record = { // sidebar / nav / common "nav.dashboard": "ダッシュボード", "nav.startup": "起動安全性", - "nav.providers": "プロバイダー", + "nav.providers": "プロバイダーとアカウント", "nav.models": "モデル", "nav.combos": "コンボ", "nav.subagents": "サブエージェント", @@ -959,6 +959,16 @@ export const ja: Record = { "pws.allowPrivateNetwork": "ローカル/プライベートネットワークを許可", "pws.liveModels": "プロバイダーからモデルを検出", "pws.liveModelsDesc": "プロバイダーのライブモデルカタログを取得します。オフにすると設定済みの静的モデルのみを使用します。", + "pws.fallback": "フォールバックプロバイダー", + "pws.fallbackDesc": "再試行可能な失敗(429、5xx、ストリーム切断)時に、これらのターゲットを順に試します。空のままにするとエラーをクライアントに返します。", + "pws.fallback.add": "フォールバックを追加", + "pws.fallback.provider": "フォールバックプロバイダー", + "pws.fallback.model": "フォールバックモデル", + "pws.fallback.pickProvider": "プロバイダーを選択", + "pws.fallback.pickModel": "モデルを選択", + "pws.fallback.modelPlaceholder": "モデル ID", + "pws.fallback.disabled": "{name}(無効)", + "pws.fallbackIncomplete": "各フォールバック行にはプロバイダーとモデルの両方が必要です。", "pws.optionalPlaceholder": "任意", "pws.providerId": "プロバイダー ID", "pws.reauth": "再認証が必要", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index bc558b44a..16e308849 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,7 +4,7 @@ export const ko: Record = { // sidebar / nav / common "nav.dashboard": "대시보드", "nav.startup": "시작 안전성", - "nav.providers": "프로바이더", + "nav.providers": "프로바이더 및 계정", "nav.models": "모델", "nav.combos": "콤보", "nav.subagents": "서브에이전트", @@ -1282,6 +1282,16 @@ export const ko: Record = { "pws.allowPrivateNetwork": "로컬/사설 네트워크 허용", "pws.liveModels": "프로바이더에서 모델 검색", "pws.liveModelsDesc": "프로바이더의 실시간 모델 카탈로그를 가져옵니다. 끄면 설정된 정적 모델만 사용합니다.", + "pws.fallback": "폴백 프로바이더", + "pws.fallbackDesc": "재시도 가능한 실패(429, 5xx, 스트림 중단) 시 이 대상을 순서대로 시도합니다. 비워 두면 오류를 클라이언트에 반환합니다.", + "pws.fallback.add": "폴백 추가", + "pws.fallback.provider": "폴백 프로바이더", + "pws.fallback.model": "폴백 모델", + "pws.fallback.pickProvider": "프로바이더 선택", + "pws.fallback.pickModel": "모델 선택", + "pws.fallback.modelPlaceholder": "모델 ID", + "pws.fallback.disabled": "{name} (비활성)", + "pws.fallbackIncomplete": "각 폴백 행에는 프로바이더와 모델이 모두 필요합니다.", "pws.optionalPlaceholder": "선택사항", "pws.providerId": "프로바이더 ID", "pws.reauth": "재인증 필요", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 01a87a664..130f3d9bd 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,7 +4,7 @@ export const ru: Record = { // sidebar / nav / common "nav.dashboard": "Дашборд", "nav.startup": "Безопасность запуска", - "nav.providers": "Провайдеры", + "nav.providers": "Провайдеры и аккаунты", "nav.models": "Модели", "nav.combos": "Комбо", "nav.subagents": "Подагенты", @@ -1001,6 +1001,16 @@ export const ru: Record = { "pws.allowPrivateNetwork": "Разрешить локальную/частную сеть", "pws.liveModels": "Обнаруживать модели провайдера", "pws.liveModelsDesc": "Загружать актуальный каталог моделей провайдера. Выключите, чтобы использовать только настроенные статические модели.", + "pws.fallback": "Резервные провайдеры", + "pws.fallbackDesc": "При повторяемых сбоях (429, 5xx, обрыв потока) цели пробуются по порядку. Оставьте пустым, чтобы вернуть ошибку клиенту.", + "pws.fallback.add": "Добавить резерв", + "pws.fallback.provider": "Резервный провайдер", + "pws.fallback.model": "Резервная модель", + "pws.fallback.pickProvider": "Выберите провайдера", + "pws.fallback.pickModel": "Выберите модель", + "pws.fallback.modelPlaceholder": "id модели", + "pws.fallback.disabled": "{name} (отключён)", + "pws.fallbackIncomplete": "В каждой строке резерва нужны и провайдер, и модель.", "pws.optionalPlaceholder": "Необязательно", "pws.providerId": "ID провайдера", "pws.reauth": "Нужна переавторизация", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 9ec90b6bf..7acc5756e 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,7 +4,7 @@ export const zh: Record = { // sidebar / nav / common "nav.dashboard": "仪表盘", "nav.startup": "启动安全", - "nav.providers": "提供方", + "nav.providers": "提供方与账户", "nav.models": "模型", "nav.combos": "组合", "nav.subagents": "子代理", @@ -1282,6 +1282,16 @@ export const zh: Record = { "pws.allowPrivateNetwork": "允许本地/私有网络", "pws.liveModels": "从提供方发现模型", "pws.liveModelsDesc": "获取提供方的实时模型目录。关闭后仅使用已配置的静态模型。", + "pws.fallback": "备用提供方", + "pws.fallbackDesc": "遇到可重试失败(429、5xx、流中断)时按顺序尝试这些目标。留空则将错误返回给客户端。", + "pws.fallback.add": "添加备用", + "pws.fallback.provider": "备用提供方", + "pws.fallback.model": "备用模型", + "pws.fallback.pickProvider": "选择提供方", + "pws.fallback.pickModel": "选择模型", + "pws.fallback.modelPlaceholder": "模型 ID", + "pws.fallback.disabled": "{name}(已禁用)", + "pws.fallbackIncomplete": "每一行备用都需要同时填写提供方和模型。", "pws.optionalPlaceholder": "可选", "pws.providerId": "提供商 ID", "pws.reauth": "需要重新认证", diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 3d3677b41..9d660b5fb 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -281,6 +281,12 @@ export default function Providers({ apiBase }: { apiBase: string }) { modelUsage={data.modelUsage} quotaReport={data.quotaReport} availableModels={data.availableModels} + peerProviders={Object.entries(config.providers).map(([name, p]) => ({ + name, + disabled: p.disabled, + models: p.models, + defaultModel: p.defaultModel, + }))} hasLiveModels={data.hasLiveModels} selectedModels={data.selectedModels} modelsLoading={data.modelsLoading} diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index 34ebbc825..ecd1d0699 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -14,6 +14,7 @@ export interface ProvidersConfig { disabled?: boolean; note?: string; codexAccountMode?: "direct" | "pool"; + fallback?: Array<{ provider: string; model: string }>; }>; } diff --git a/gui/src/provider-workspace/catalog.ts b/gui/src/provider-workspace/catalog.ts index 08a8cde13..b7bc1a143 100644 --- a/gui/src/provider-workspace/catalog.ts +++ b/gui/src/provider-workspace/catalog.ts @@ -45,6 +45,11 @@ export interface WorkspaceProvider { disabled?: boolean; note?: string; allowPrivateNetwork?: boolean; + /** + * Ordered failover targets for plain (non-combo) requests to this provider. + * Empty/omitted = no hop; failures return to the caller. + */ + fallback?: Array<{ provider: string; model: string }>; } /** Three-way pricing/ownership tier for a ready provider row. */ diff --git a/gui/src/styles/provider-workspace-settings.css b/gui/src/styles/provider-workspace-settings.css index 5241c5905..a50b910dd 100644 --- a/gui/src/styles/provider-workspace-settings.css +++ b/gui/src/styles/provider-workspace-settings.css @@ -97,6 +97,14 @@ .pwi-settings-textarea:focus { border-color: var(--accent-ring); outline: none; } .pwi-settings-hint { font-size: var(--text-caption); color: var(--muted); line-height: 1.4; } +.pwi-fallback-list { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; } +.pwi-fallback-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) auto; + gap: 8px; + align-items: center; +} + .pwi-settings-sticky-bar { position: sticky; bottom: 0; z-index: 2; display: flex; align-items: center; gap: 8px; diff --git a/gui/tests/dashboard-tabs.test.ts b/gui/tests/dashboard-tabs.test.ts index b1b4b9808..5ab0fa30b 100644 --- a/gui/tests/dashboard-tabs.test.ts +++ b/gui/tests/dashboard-tabs.test.ts @@ -42,12 +42,13 @@ test("registering Dashboard tabs does not disturb the Logs or Providers contract expect(hashBelongsToPage("logs/debug", "dashboard")).toBe(false); }); -test("Codex Auth sits directly after Dashboard in the sidebar", async () => { +test("provider-independent account management sits directly after Dashboard", async () => { const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); const nav = app.slice(app.indexOf("const NAV"), app.indexOf("];", app.indexOf("const NAV"))); const order = [...nav.matchAll(/id: "([a-z-]+)"/g)].map((m) => m[1]); expect(order[0]).toBe("dashboard"); - expect(order[1]).toBe("codex-auth"); + expect(order[1]).toBe("providers"); + expect(order).not.toContain("codex-auth"); // Order only — no divider markup was introduced (Q3). expect(app).not.toContain("nav-divider"); }); diff --git a/gui/tests/provider-settings-fallback.test.tsx b/gui/tests/provider-settings-fallback.test.tsx new file mode 100644 index 000000000..1443bcd3c --- /dev/null +++ b/gui/tests/provider-settings-fallback.test.tsx @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import ProviderSettings from "../src/components/provider-workspace/ProviderSettings"; +import { LanguageProvider } from "../src/i18n/provider"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; + +let previousLanguageDescriptor: PropertyDescriptor | undefined; + +beforeEach(() => { + previousLanguageDescriptor = Object.getOwnPropertyDescriptor(globalThis.navigator, "language"); + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: "en-US", + }); +}); + +afterEach(() => { + if (previousLanguageDescriptor) { + Object.defineProperty(globalThis.navigator, "language", previousLanguageDescriptor); + } else { + Reflect.deleteProperty(globalThis.navigator, "language"); + } +}); + +const item: WorkspaceItem = { + name: "google-antigravity", + adapter: "google", + baseUrl: "https://example.test", + authMode: "oauth", + defaultModel: "gemini-3.6-flash", + fallback: [{ provider: "deepseek", model: "deepseek-v4-flash" }], +}; + +test("ProviderSettings renders configured fallback targets", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain("Fallback providers"); + expect(html).toContain("deepseek"); + expect(html).toContain("deepseek-v4-flash"); + expect(html).toContain("Add fallback"); +}); diff --git a/gui/tests/sidebar-codex-auth.test.ts b/gui/tests/sidebar-codex-auth.test.ts index 5f2079b8f..b246b2ebd 100644 --- a/gui/tests/sidebar-codex-auth.test.ts +++ b/gui/tests/sidebar-codex-auth.test.ts @@ -1,24 +1,27 @@ import { expect, test } from "bun:test"; +import { resolveAppHashChange } from "../src/app-routing"; /** - * Superseded by WP2a (devlog/_plan/260725_gui_view_consolidation/020_nav_and_dashboard_tabs.md). - * - * Codex Auth used to be filtered out of the sidebar in Workspace mode, on the - * reasoning that the Providers workspace embeds the same account pool. The - * maintainer instead promoted it to the second slot so it is always reachable. - * That old filter was also a latent WP5 hazard: once Classic is removed there is - * no non-workspace mode left, so a viewMode-keyed filter would have hidden the - * page permanently. + * Account management belongs to Providers: that workspace handles OAuth account + * sets and API-key pools for every provider and embeds the special OpenAI pool. + * Keep the old hash as a passive compatibility redirect for bookmarks. */ -test("Codex Auth is always present in the sidebar, never filtered by view mode", async () => { +test("the sidebar exposes one provider-independent account destination", async () => { const src = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); - // The old conditional filter must not come back. - expect(src).not.toContain('viewMode === "workspace" && id === "codex-auth"'); - expect(src).toContain("{NAV.map(({ id, tkey, Icon }) => ("); + expect(src).toContain('{ id: "providers", tkey: "nav.providers", Icon: IconServer }'); + expect(src).not.toContain('{ id: "codex-auth"'); + expect(src).not.toContain('page === "codex-auth"'); +}); - // It stays in the nav table and remains routable for deep links. - expect(src).toContain('{ id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey }'); - expect(src).toContain('{page === "codex-auth" && }'); +test("legacy Codex Auth links redirect to all-provider account management", () => { + expect(resolveAppHashChange("codex-auth")).toEqual({ + page: "providers", + replaceTo: "providers", + }); + expect(resolveAppHashChange("codex-auth/accounts")).toEqual({ + page: "providers", + replaceTo: "providers", + }); }); diff --git a/src/config.ts b/src/config.ts index baf4b8dd3..2681c3cc2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -15,6 +15,7 @@ import { hardenSecretDir, hardenSecretPath, hardenSecretPathAsync } from "./lib/ import { recordOwnedConfigPath } from "./lib/config-ownership"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; +import { providerFallbackIssues } from "./providers/fallback"; import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, @@ -771,6 +772,13 @@ const configSchema = z.object({ message: openRouterRoutingError, }); } + for (const issue of providerFallbackIssues(name, (provider as { fallback?: unknown }).fallback, config.providers)) { + ctx.addIssue({ + code: "custom", + path: ["providers", name, ...issue.path], + message: issue.message, + }); + } if (Object.hasOwn(provider, "virtualModels")) { ctx.addIssue({ code: "custom", diff --git a/src/providers/fallback.ts b/src/providers/fallback.ts new file mode 100644 index 000000000..158f59805 --- /dev/null +++ b/src/providers/fallback.ts @@ -0,0 +1,167 @@ +/** + * Per-provider fallback for plain (non-combo) requests. + * + * A combo already expresses "try these provider/model targets in order", including + * per-target cooldowns and a hop/stop classification of upstream failures. That engine + * only runs for models the client explicitly addresses as `combo/`, so a plain + * request that resolves to a single provider has no second chance: a 429 or a stream + * that dies without a terminal event (`upstream_server_error`) goes straight back to + * the caller. + * + * This module closes that gap without a second retry mechanism. A provider's `fallback` + * list is turned into a synthetic combo whose first target is the request's own route, + * so the normal combo hop loop drives the retry. + * + * The synthetic combo id embeds NUL, which `COMBO_ID_PATTERN` rejects. A configured + * combo can therefore never collide with it, and no client-supplied model string can + * resolve to it (`resolveComboId` matches only `combo/` or an explicit alias). + */ +import { COMBO_NAMESPACE, targetKey } from "../combos/types"; +import type { OcxComboConfig, OcxComboTarget, OcxConfig, OcxProviderConfig } from "../types"; + +export interface ProviderFallbackIssue { + path: Array; + message: string; +} + +export interface ProviderFallbackPlan { + comboId: string; + /** `config` with the synthetic combo injected; safe to pass to the combo hop loop. */ + config: OcxConfig; +} + +function syntheticComboId(provider: string, model: string): string { + return `provider-fallback\u0000${provider}\u0000${model}`; +} + +/** True when `id` came from `syntheticComboId` rather than the user's combos map. */ +export function isProviderFallbackComboId(id: string): boolean { + return id.startsWith("provider-fallback\u0000"); +} + +/** Human-readable form of a combo id for logs and error messages (NUL is not printable). */ +export function comboIdLabel(id: string): string { + if (!isProviderFallbackComboId(id)) return id; + const [, provider, model] = id.split("\u0000"); + return `fallback:${provider}/${model}`; +} + +/** + * Configured fallback targets for one provider, trimmed and with malformed entries dropped. + * Validation reports those entries separately; routing must not fail on them. + */ +export function providerFallbackTargets( + provider: OcxProviderConfig | undefined, +): OcxComboTarget[] { + if (!Array.isArray(provider?.fallback)) return []; + const targets: OcxComboTarget[] = []; + for (const raw of provider.fallback) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; + const name = typeof raw.provider === "string" ? raw.provider.trim() : ""; + const model = typeof raw.model === "string" ? raw.model.trim() : ""; + if (!name || !model) continue; + targets.push({ provider: name, model }); + } + return targets; +} + +export function providerFallbackIssues( + providerName: string, + raw: unknown, + providers: Record, +): ProviderFallbackIssue[] { + const issues: ProviderFallbackIssue[] = []; + if (raw === undefined || raw === null) return issues; + if (!Array.isArray(raw)) { + issues.push({ path: ["fallback"], message: "fallback must be an array of { provider, model } targets" }); + return issues; + } + + const seen = new Set(); + for (let i = 0; i < raw.length; i++) { + const entry = raw[i]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + issues.push({ path: ["fallback", i], message: `fallback[${i}] must be an object` }); + continue; + } + const target = entry as Record; + const name = typeof target.provider === "string" ? target.provider.trim() : ""; + const model = typeof target.model === "string" ? target.model.trim() : ""; + + if (!name) { + issues.push({ path: ["fallback", i, "provider"], message: `fallback[${i}].provider is required` }); + } else if (!Object.hasOwn(providers, name)) { + issues.push({ + path: ["fallback", i, "provider"], + message: `fallback[${i}].provider "${name}" is not configured`, + }); + } + if (!model) { + issues.push({ path: ["fallback", i, "model"], message: `fallback[${i}].model is required` }); + } + if (!name || !model) continue; + + // Self-reference would retry the target that just failed, burning a full attempt on a + // provider the hop loop has already put in cooldown. + if (name === providerName) { + issues.push({ + path: ["fallback", i], + message: `fallback[${i}] must not point back at "${providerName}"`, + }); + } + const key = targetKey({ provider: name, model }); + if (seen.has(key)) { + issues.push({ path: ["fallback", i], message: `duplicate fallback target "${key}"` }); + } else { + seen.add(key); + } + } + return issues; +} + +export function providerFallbackError( + providerName: string, + raw: unknown, + providers: Record, +): string | null { + return providerFallbackIssues(providerName, raw, providers)[0]?.message ?? null; +} + +/** + * Build the synthetic combo for a request that routed to `provider`/`model`, or null when the + * provider has no usable fallback and the request should take the normal single-target path. + */ +export function providerFallbackPlan( + config: OcxConfig, + route: { provider: string; modelId: string }, +): ProviderFallbackPlan | null { + // A physical provider literally named "combo" is only kept addressable while no combos + // exist (preservesPhysicalComboProvider). Injecting one here would silently shadow it. + if (Object.hasOwn(config.providers, COMBO_NAMESPACE)) return null; + + const configured = providerFallbackTargets(config.providers[route.provider]); + if (configured.length === 0) return null; + + const usable = (target: OcxComboTarget): boolean => { + const provider = config.providers[target.provider]; + return !!provider && provider.disabled !== true; + }; + if (!usable({ provider: route.provider, model: route.modelId })) return null; + + const targets: OcxComboTarget[] = [{ provider: route.provider, model: route.modelId }]; + const seen = new Set([targetKey(targets[0]!)]); + for (const target of configured) { + const key = targetKey(target); + if (seen.has(key) || !usable(target)) continue; + seen.add(key); + targets.push(target); + } + if (targets.length < 2) return null; + + const comboId = syntheticComboId(route.provider, route.modelId); + const combo: OcxComboConfig = { targets, strategy: "failover" }; + return { + comboId, + config: { ...config, combos: { ...config.combos, [comboId]: combo } }, + }; +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 5b4d48c21..4f806e87e 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -399,6 +399,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { "freeTier", "liveModels", "models", + "fallback", "contextWindow", "modelContextWindows", "defaultMaxOutputTokens", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index e0ebac2c2..0756333ac 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -26,6 +26,7 @@ import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { ProviderOutboundPolicyError, providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; +import { providerFallbackError, providerFallbackTargets } from "../../providers/fallback"; import { providerCodexAccountMode } from "../../providers/registry"; import { extractModelEnvelopeRows, @@ -94,6 +95,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise).map(t => ({ + provider: t.provider.trim(), + model: t.model.trim(), + })) + : []; + if (targets.length) next.fallback = targets; + else delete next.fallback; + touched = true; + } + if (!touched) return jsonResponse({ error: "no recognized fields to update" }, 400); // A disabled-only toggle preserves the v2 fast lane for non-openai providers: it changes diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 0df0e241f..ca53c2a7f 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -26,6 +26,7 @@ import { pickComboTarget, targetKey, } from "../../combos"; +import { comboIdLabel, isProviderFallbackComboId, providerFallbackPlan } from "../../providers/fallback"; import { isInjectionDebugEnabled } from "../../lib/debug-settings"; import { injectionDebugLog } from "../../lib/injection-debug-log"; import { resolveClientRetryAfter } from "../../lib/retry-after"; @@ -894,24 +895,22 @@ export async function handleComboResponses( const requestedModel = typeof (rawBody as { model?: unknown } | null)?.model === "string" ? (rawBody as { model: string }).model : `combo/${comboId}`; - Object.assign(logCtx, { - requestedModel, - model: requestedModel, - provider: "combo", - comboId, - }); + // A per-provider fallback chain runs on this same hop loop but is not a combo the user + // configured: the log row must keep the winning target's own provider/model rather than + // collapsing into a synthetic `combo` row nothing in the GUI can open. + const comboIdentity = isProviderFallbackComboId(comboId) + ? { requestedModel } + : { requestedModel, model: requestedModel, provider: "combo", comboId }; + Object.assign(logCtx, comboIdentity); const combo = getCombo(config, comboId); if (!combo) { - return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`); + return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboIdLabel(comboId)}`); } const adoptFailedChildLog = (childLog: RequestLogContext): void => { // Attempts remain the complete physical history; the logical row mirrors the most recent // failed target so an exhausted combo still has useful top-level reasoning diagnostics. Object.assign(logCtx, childLog, { - requestedModel, - model: requestedModel, - provider: "combo", - comboId, + ...comboIdentity, attempts: logCtx.attempts, activeAttempt: undefined, activeAttemptStartedAt: undefined, @@ -944,7 +943,7 @@ export async function handleComboResponses( && !isComboTargetInCooldown(comboId, target, initialNow), }); if (!pick) { - return comboUnavailableResponse(`No available targets for combo: ${comboId}`); + return comboUnavailableResponse(`No available targets for combo: ${comboIdLabel(comboId)}`); } let lastFailure: Response | null = null; @@ -1060,10 +1059,7 @@ export async function handleComboResponses( attemptRetained = true; noteComboSuccess(comboId, combo, pick.target); Object.assign(logCtx, childLog, { - requestedModel, - model: requestedModel, - provider: "combo", - comboId, + ...comboIdentity, attempts: logCtx.attempts, activeAttempt: attempt, activeAttemptStartedAt: started, @@ -1116,7 +1112,7 @@ export async function handleComboResponses( return lastFailure; } console.warn( - `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${response.status} after ${Date.now() - started}ms`, + `[combo] ${comboIdLabel(comboId)}: ${targetKey(pick.target)} failed with ${response.status} after ${Date.now() - started}ms`, ); const nextPick = advanceComboAfterFailure(config, pick, { retryAfter: failure.retryAfter, @@ -1241,6 +1237,16 @@ export async function handleResponses( await maybePrimeSubagentQuota(config); } + // Per-provider fallback replays the request across the provider's configured targets using the + // combo hop loop. Thread spawns are excluded: they carry their own Codex account/model fallback + // below, which the combo path deliberately skips. + if (!options.comboAttempt && !isThreadSpawnRequest(req.headers)) { + const plan = providerFallbackPlan(config, { provider: route.providerName, modelId: route.modelId }); + if (plan) { + return handleComboResponses(req, body, plan.comboId, plan.config, logCtx, options); + } + } + let authCtx: CodexAuthContext = { kind: "main", accountId: null }; let selectedForwardHeaders = req.headers; let subagentFallbackAccountId = config.activeCodexAccountId ?? null; diff --git a/src/types.ts b/src/types.ts index 84c50ef43..4ede9c80c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -984,6 +984,14 @@ export interface OcxProviderConfig { compat?: ProviderCompat; /** Keep provider settings on disk but exclude it from routing and model/catalog listings. */ disabled?: boolean; + /** + * Ordered failover targets for plain (non-combo) requests that resolve to this provider. + * When the provider answers with a retryable failure — 429, 5xx, or an `upstream_server_error` + * such as a stream that dies without a terminal event — the request is replayed against these + * targets in order, reusing the combo failover engine's per-target cooldowns. + * Empty or omitted keeps today's behaviour: the failure is returned to the caller. + */ + fallback?: OcxComboTarget[]; /** * Codex account-selection mode. Valid ONLY on the canonical built-in `openai` forward provider. * "pool" (default) rotates main + added Codex accounts through the affinity/quota/cooldown/ diff --git a/tests/provider-fallback.test.ts b/tests/provider-fallback.test.ts new file mode 100644 index 000000000..72750360e --- /dev/null +++ b/tests/provider-fallback.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "bun:test"; +import { + comboIdLabel, + isProviderFallbackComboId, + providerFallbackError, + providerFallbackIssues, + providerFallbackPlan, + providerFallbackTargets, +} from "../src/providers/fallback"; +import { isValidComboId } from "../src/combos"; +import type { OcxConfig } from "../src/types"; + +function baseConfig(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "a", + providers: { + a: { adapter: "openai-chat", baseUrl: "https://a.example/v1", apiKey: "ka", models: ["m1"] }, + b: { adapter: "openai-chat", baseUrl: "https://b.example/v1", apiKey: "kb", models: ["m2"] }, + c: { adapter: "openai-chat", baseUrl: "https://c.example/v1", apiKey: "kc", models: ["m3"] }, + }, + ...overrides, + }; +} + +function withFallback(fallback: unknown, overrides: Partial = {}): OcxConfig { + const config = baseConfig(overrides); + (config.providers.a as Record).fallback = fallback; + return config; +} + +describe("provider fallback validation", () => { + test("accepts an ordered list of configured targets", () => { + const providers = baseConfig().providers; + const issues = providerFallbackIssues("a", [ + { provider: "b", model: "m2" }, + { provider: "c", model: "m3" }, + ], providers); + expect(issues).toEqual([]); + }); + + test("omitted fallback is not an error", () => { + expect(providerFallbackIssues("a", undefined, baseConfig().providers)).toEqual([]); + }); + + test("rejects a non-array", () => { + expect(providerFallbackError("a", { provider: "b", model: "m2" }, baseConfig().providers)) + .toBe("fallback must be an array of { provider, model } targets"); + }); + + test("rejects an unconfigured provider", () => { + expect(providerFallbackError("a", [{ provider: "nope", model: "m2" }], baseConfig().providers)) + .toBe('fallback[0].provider "nope" is not configured'); + }); + + test("rejects a missing model", () => { + expect(providerFallbackError("a", [{ provider: "b" }], baseConfig().providers)) + .toBe("fallback[0].model is required"); + }); + + test("rejects a self-referencing target", () => { + expect(providerFallbackError("a", [{ provider: "a", model: "m1" }], baseConfig().providers)) + .toBe('fallback[0] must not point back at "a"'); + }); + + test("rejects duplicate targets", () => { + expect(providerFallbackError("a", [ + { provider: "b", model: "m2" }, + { provider: "b", model: "m2" }, + ], baseConfig().providers)).toBe('duplicate fallback target "b/m2"'); + }); +}); + +describe("provider fallback targets", () => { + test("trims entries and drops malformed ones", () => { + const config = withFallback([ + { provider: " b ", model: " m2 " }, + { provider: "", model: "m3" }, + null, + "c/m3", + ]); + expect(providerFallbackTargets(config.providers.a)).toEqual([{ provider: "b", model: "m2" }]); + }); + + test("no fallback yields an empty list", () => { + expect(providerFallbackTargets(baseConfig().providers.a)).toEqual([]); + }); +}); + +describe("provider fallback plan", () => { + test("puts the request's own route first, then the configured chain", () => { + const config = withFallback([{ provider: "b", model: "m2" }, { provider: "c", model: "m3" }]); + const plan = providerFallbackPlan(config, { provider: "a", modelId: "m1" }); + expect(plan).not.toBeNull(); + expect(plan!.config.combos![plan!.comboId]).toEqual({ + strategy: "failover", + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + { provider: "c", model: "m3" }, + ], + }); + }); + + test("leaves the caller's config untouched", () => { + const config = withFallback([{ provider: "b", model: "m2" }]); + providerFallbackPlan(config, { provider: "a", modelId: "m1" }); + expect(config.combos).toBeUndefined(); + }); + + test("preserves configured combos alongside the synthetic one", () => { + const config = withFallback([{ provider: "b", model: "m2" }], { + combos: { free: { targets: [{ provider: "a", model: "m1" }] } }, + }); + const plan = providerFallbackPlan(config, { provider: "a", modelId: "m1" })!; + expect(Object.keys(plan.config.combos!).sort()).toEqual([plan.comboId, "free"].sort()); + }); + + test("no plan when the provider has no fallback", () => { + expect(providerFallbackPlan(baseConfig(), { provider: "a", modelId: "m1" })).toBeNull(); + }); + + test("skips disabled fallback providers and yields no plan when none remain", () => { + const config = withFallback([{ provider: "b", model: "m2" }]); + config.providers.b!.disabled = true; + expect(providerFallbackPlan(config, { provider: "a", modelId: "m1" })).toBeNull(); + }); + + test("skips fallback targets whose provider was deleted", () => { + const config = withFallback([{ provider: "gone", model: "m9" }, { provider: "c", model: "m3" }]); + const plan = providerFallbackPlan(config, { provider: "a", modelId: "m1" })!; + expect(plan.config.combos![plan.comboId]!.targets).toEqual([ + { provider: "a", model: "m1" }, + { provider: "c", model: "m3" }, + ]); + }); + + test("declines to shadow a physical provider named \"combo\"", () => { + const config = withFallback([{ provider: "b", model: "m2" }]); + config.providers.combo = { adapter: "openai-chat", baseUrl: "https://combo.example/v1" }; + expect(providerFallbackPlan(config, { provider: "a", modelId: "m1" })).toBeNull(); + }); +}); + +describe("synthetic combo ids", () => { + test("cannot collide with a user-configurable combo id", () => { + const config = withFallback([{ provider: "b", model: "m2" }]); + const { comboId } = providerFallbackPlan(config, { provider: "a", modelId: "m1" })!; + expect(isProviderFallbackComboId(comboId)).toBe(true); + expect(isValidComboId(comboId)).toBe(false); + }); + + test("are distinct per provider/model so cooldowns do not bleed across routes", () => { + const config = withFallback([{ provider: "b", model: "m2" }]); + const first = providerFallbackPlan(config, { provider: "a", modelId: "m1" })!; + const second = providerFallbackPlan(config, { provider: "a", modelId: "m9" })!; + expect(first.comboId).not.toBe(second.comboId); + }); + + test("render readably in logs and error messages", () => { + const config = withFallback([{ provider: "b", model: "m2" }]); + const { comboId } = providerFallbackPlan(config, { provider: "a", modelId: "m1" })!; + expect(comboIdLabel(comboId)).toBe("fallback:a/m1"); + expect(comboIdLabel("free")).toBe("free"); + }); +}); diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index 4de483572..ea2db3f33 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -1772,6 +1772,119 @@ describe("server combo failover 030 activation matrix", () => { }, 10_000); }); +describe("per-provider fallback for plain models", () => { + function fallbackConfig( + providers: OcxConfig["providers"], + fallback: Array<{ provider: string; model: string }>, + ): OcxConfig { + const names = Object.keys(providers); + return { + port: 0, + defaultProvider: names[0]!, + providers: { ...providers, [names[0]!]: { ...providers[names[0]!]!, fallback } }, + }; + } + + test("a 502 on a plain model hops to the provider's configured fallback", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json({ error: { message: "upstream died" } }, { status: 502 }); + }); + const b = serve(() => { + hits.push("b"); + return chatSuccess("fallback backup", "m2"); + }); + const config = fallbackConfig({ + a: provider("openai-chat", baseUrl(a), "key-a"), + b: provider("openai-chat", baseUrl(b), "key-b"), + }, [{ provider: "b", model: "m2" }]); + + const response = await postModelLogged(config, "a/m1"); + expect(response.status).toBe(200); + expect(JSON.stringify(await response.json())).toContain("fallback backup"); + expect(hits).toEqual(["a", "b"]); + }); + + test("the log row keeps the winning target instead of collapsing into a combo row", async () => { + const a = serve(() => Response.json({ error: { message: "upstream died" } }, { status: 502 })); + const b = serve(() => chatSuccess("fallback backup", "m2")); + const config = fallbackConfig({ + a: provider("openai-chat", baseUrl(a), "key-a"), + b: provider("openai-chat", baseUrl(b), "key-b"), + }, [{ provider: "b", model: "m2" }]); + + expect((await postModelLogged(config, "a/m1")).status).toBe(200); + const { log } = await latestAttemptReceipts(config); + expect(log).toMatchObject({ requestedModel: "a/m1", provider: "b", model: "m2" }); + expect(log.attempts).toMatchObject([{ provider: "a", status: 502 }, { provider: "b", status: 200 }]); + }); + + test("a non-retryable 400 stops on the primary without touching the fallback", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json({ error: { message: "bad request", type: "invalid_request_error" } }, { status: 400 }); + }); + const b = serve(() => { + hits.push("b"); + return chatSuccess("must not be reached", "m2"); + }); + const config = fallbackConfig({ + a: provider("openai-chat", baseUrl(a), "key-a"), + b: provider("openai-chat", baseUrl(b), "key-b"), + }, [{ provider: "b", model: "m2" }]); + + expect((await postModelLogged(config, "a/m1")).status).toBe(400); + expect(hits).toEqual(["a"]); + }); + + test("without a configured fallback the failure still reaches the caller", async () => { + const a = serve(() => Response.json({ error: { message: "upstream died" } }, { status: 502 })); + const config: OcxConfig = { + port: 0, + defaultProvider: "a", + providers: { a: provider("openai-chat", baseUrl(a), "key-a") }, + }; + expect((await postModelLogged(config, "a/m1")).status).toBe(502); + }); + + test("an exhausted chain returns the last failure rather than a combo_unavailable", async () => { + const a = serve(() => Response.json({ error: { message: "a died" } }, { status: 502 })); + const b = serve(() => Response.json({ error: { message: "b overloaded" } }, { status: 503 })); + const config = fallbackConfig({ + a: provider("openai-chat", baseUrl(a), "key-a"), + b: provider("openai-chat", baseUrl(b), "key-b"), + }, [{ provider: "b", model: "m2" }]); + + const response = await postModelLogged(config, "a/m1"); + expect(response.status).toBe(503); + }); + + test("an explicit combo request is unaffected by provider fallback config", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json({ error: { message: "a died" } }, { status: 502 }); + }); + const b = serve(() => { + hits.push("b"); + return chatSuccess("combo backup", "m2"); + }); + const providers = { + a: provider("openai-chat", baseUrl(a), "key-a", { fallback: [{ provider: "b", model: "m2" }] }), + b: provider("openai-chat", baseUrl(b), "key-b"), + }; + const config = comboConfig(providers); + + const response = await postLogged(config); + expect(response.status).toBe(200); + expect(hits).toEqual(["a", "b"]); + const { log } = await latestAttemptReceipts(config); + expect(log).toMatchObject({ provider: "combo", model: "combo/free", resolvedModel: "m2" }); + }); +}); + describe("cursor conversation continuity across store:false chains", () => { function fakeCursorTransportFactory(seenConversationIds: string[]): CursorTransportFactory { return () => ({ From 0685b7668527feda87693fd0285e31cde2929419 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sat, 1 Aug 2026 07:20:44 +0000 Subject: [PATCH 30/31] fix(providers): keep anthropic terminal guard and single expansion on fallback hops - keep the Anthropic terminal-continuation guard active on the primary hop of a per-provider fallback chain (synthetic combos are not user combos) - hand the pre-expansion body to the fallback combo so previous_response_id is expanded exactly once - sweep expired combo-target cooldowns so synthetic combo ids cannot accumulate state forever - resolve the initial GUI route from the hash through resolveAppHashChange so a bookmarked #codex-auth lands on Providers - cover the fallback editor interaction (ordered patch, incomplete row blocks save) Co-authored-by: Codesmith --- gui/src/use-app-route-state.ts | 10 +- gui/tests/provider-settings-fallback.test.tsx | 133 ++++++++++++++++-- gui/tests/providers-hash-history.test.tsx | 8 ++ src/combos/failover.ts | 21 +++ src/combos/index.ts | 1 + src/server/responses/core.ts | 25 +++- tests/combos.test.ts | 17 +++ tests/server-combo-failover-e2e.test.ts | 34 +++++ tests/terminal-guard-server.test.ts | 40 ++++++ 9 files changed, 275 insertions(+), 14 deletions(-) diff --git a/gui/src/use-app-route-state.ts b/gui/src/use-app-route-state.ts index 6965d16d4..e63b68f0b 100644 --- a/gui/src/use-app-route-state.ts +++ b/gui/src/use-app-route-state.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { hashBelongsToPage, - readPageFromHash, resolveAppHashChange, type Page, } from "./app-routing"; @@ -42,7 +41,14 @@ function clearStaleViewKeys(): void { * router immediately rewrites. */ export function useAppRouteState() { - const [page, setPageState] = useState(readPageFromHash); + // The first hash must go through the same resolver as `hashchange`: `readPageFromHash` only + // knows the current page ids, so a bookmarked legacy hash (`#codex-auth/accounts`) would land + // on the dashboard fallback instead of the page it was retired into. + const [page, setPageState] = useState(() => + resolveAppHashChange( + normalizeHashPath(typeof window === "undefined" ? "" : window.location.hash), + ).page, + ); useEffect(() => { clearStaleViewKeys(); }, []); diff --git a/gui/tests/provider-settings-fallback.test.tsx b/gui/tests/provider-settings-fallback.test.tsx index 1443bcd3c..a708fafc2 100644 --- a/gui/tests/provider-settings-fallback.test.tsx +++ b/gui/tests/provider-settings-fallback.test.tsx @@ -1,7 +1,9 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; import { renderToStaticMarkup } from "react-dom/server"; import ProviderSettings from "../src/components/provider-workspace/ProviderSettings"; import { LanguageProvider } from "../src/i18n/provider"; +import type { ProviderUpdatePatch } from "../src/components/provider-workspace/types"; import type { WorkspaceItem } from "../src/provider-workspace/catalog"; let previousLanguageDescriptor: PropertyDescriptor | undefined; @@ -22,8 +24,14 @@ afterEach(() => { } }); +const peers = [ + { name: "google-antigravity", models: ["gemini-3.6-flash"] }, + { name: "deepseek", models: ["deepseek-v4-flash", "deepseek-v4-reasoner"], defaultModel: "deepseek-v4-flash" }, + { name: "cursor", models: [] }, +]; + const item: WorkspaceItem = { - name: "google-antigravity", + name: "antigravity-primary", adapter: "google", baseUrl: "https://example.test", authMode: "oauth", @@ -34,14 +42,7 @@ const item: WorkspaceItem = { test("ProviderSettings renders configured fallback targets", () => { const html = renderToStaticMarkup( - + , ); @@ -50,3 +51,117 @@ test("ProviderSettings renders configured fallback targets", () => { expect(html).toContain("deepseek-v4-flash"); expect(html).toContain("Add fallback"); }); + +/** + * The static render above only proves the chain is displayed. These cases drive the form the + * way a user does, because the patch shape (ordered targets, trimmed rows, refusal to save a + * half-filled row) is the part the management API contract depends on. + */ +const domGlobals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; + +async function mountSettings(onUpdateProvider: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>) { + const previous = Object.fromEntries(domGlobals.map(key => [key, Reflect.get(globalThis, key)])); + const testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + 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; + + const container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.append(container as never); + + // Lazy import: a static react-dom/client import binds to whichever document existed when the + // module graph loaded and corrupts sibling suites in the same process. + const [{ act }, { createRoot }] = await Promise.all([import("react"), import("react-dom/client")]); + let root!: import("react-dom/client").Root; + await act(async () => { + root = createRoot(container); + root.render( + + + , + ); + }); + + const setSelectValue = (select: HTMLSelectElement, value: string) => { + Object.getOwnPropertyDescriptor(testWindow.HTMLSelectElement.prototype, "value")! + .set!.call(select, value); + select.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }; + const buttonByText = (text: string) => + [...container.querySelectorAll("button")] + .find(button => button.textContent?.includes(text)); + // The save button only exists while the sticky bar is showing unsaved changes. + const saveButton = () => container.querySelector(".pwi-settings-sticky-bar .btn-primary"); + + const cleanup = async () => { + await act(async () => { root.unmount(); }); + testWindow.close(); + for (const key of domGlobals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previous[key] }); + } + }; + + return { act, container, setSelectValue, buttonByText, saveButton, cleanup }; +} + +test("saving emits the edited fallback chain in order", async () => { + const patches: ProviderUpdatePatch[] = []; + const { act, container, setSelectValue, buttonByText, saveButton, cleanup } = await mountSettings(async (_name, patch) => { + patches.push(patch); + return { ok: true }; + }); + + try { + const modelSelect = container.querySelector('select[aria-label="Fallback model"]')!; + await act(async () => { setSelectValue(modelSelect, "deepseek-v4-reasoner"); }); + + await act(async () => { buttonByText("Add fallback")!.click(); }); + const providerSelects = container.querySelectorAll('select[aria-label="Fallback provider"]'); + expect(providerSelects).toHaveLength(2); + // Picking a peer that advertises models auto-selects its first one, so the row is complete. + await act(async () => { setSelectValue(providerSelects[1]!, "google-antigravity"); }); + + await act(async () => { saveButton()!.click(); }); + + expect(patches).toHaveLength(1); + expect(patches[0]!.fallback).toEqual([ + { provider: "deepseek", model: "deepseek-v4-reasoner" }, + { provider: "google-antigravity", model: "gemini-3.6-flash" }, + ]); + } finally { + await cleanup(); + } +}); + +test("a half-filled fallback row blocks the save instead of dropping the row", async () => { + const patches: ProviderUpdatePatch[] = []; + const { act, container, setSelectValue, buttonByText, saveButton, cleanup } = await mountSettings(async (_name, patch) => { + patches.push(patch); + return { ok: true }; + }); + + try { + // An incomplete row alone never makes the form dirty, so edit a saved row first to get + // the save control on screen; the incomplete row must then block that save. + const modelSelect = container.querySelector('select[aria-label="Fallback model"]')!; + await act(async () => { setSelectValue(modelSelect, "deepseek-v4-reasoner"); }); + + await act(async () => { buttonByText("Add fallback")!.click(); }); + const providerSelects = container.querySelectorAll('select[aria-label="Fallback provider"]'); + // `cursor` advertises no models, so the row stays without one. + await act(async () => { setSelectValue(providerSelects[1]!, "cursor"); }); + + await act(async () => { saveButton()!.click(); }); + + expect(patches).toHaveLength(0); + expect(container.querySelector(".pwi-settings-msg--err")?.textContent) + .toContain("provider and a model"); + } finally { + await cleanup(); + } +}); diff --git a/gui/tests/providers-hash-history.test.tsx b/gui/tests/providers-hash-history.test.tsx index 932f0f5fd..2b1d4dce1 100644 --- a/gui/tests/providers-hash-history.test.tsx +++ b/gui/tests/providers-hash-history.test.tsx @@ -171,6 +171,14 @@ describe("useAppRouteState (real hook)", () => { expect(seen.current!.page).toBe("models"); }); + test("a bookmarked Codex Auth hash opens Providers on the initial load", async () => { + // `codex-auth` is no longer a page id, so the initial state must come from the same + // resolver `hashchange` uses — `readPageFromHash` alone would fall back to the dashboard. + const { seen } = await mountAt("#codex-auth/accounts"); + expect(seen.current!.page).toBe("providers"); + expect(normalizeHashPath(win.location.hash)).toBe("providers"); + }); + test("an unknown suffix is normalised through the hook", async () => { const { seen } = await mountAt("#models/nope"); expect(normalizeHashPath(win.location.hash)).toBe("models"); diff --git a/src/combos/failover.ts b/src/combos/failover.ts index 2ba677136..ff0e1a1ff 100644 --- a/src/combos/failover.ts +++ b/src/combos/failover.ts @@ -12,6 +12,21 @@ const MAX_COOLDOWN_MS = 10 * 60_000; /** Map<`${comboId}\0${provider/model}`, TargetCooldown> */ const targetCooldowns = new Map(); +/** + * Expired entries are normally dropped lazily by `isComboTargetInCooldown`, which only runs for + * combo ids that are asked about again. Per-provider fallback derives its combo id from the + * requested model, so a client that spreads traffic over many model names would otherwise retain + * one dead entry per (model, target) pair forever. Sweeping on insert once the map grows past + * this many entries bounds retention to cooldowns issued within the last `MAX_COOLDOWN_MS`. + */ +const COOLDOWN_SWEEP_THRESHOLD = 256; + +function sweepExpiredCooldowns(now: number): void { + for (const [key, entry] of targetCooldowns) { + if (entry.cooldownUntil <= now) targetCooldowns.delete(key); + } +} + function cooldownMapKey( comboId: string, target: Pick, @@ -64,6 +79,12 @@ export function coolComboTarget( targetCooldowns.set(cooldownMapKey(comboId, target), { cooldownUntil: now + Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS), }); + if (targetCooldowns.size > COOLDOWN_SWEEP_THRESHOLD) sweepExpiredCooldowns(now); +} + +/** Retained cooldown entries; observability seam for the sweep-on-insert bound. */ +export function comboTargetCooldownCountForTests(): number { + return targetCooldowns.size; } export function clearComboTargetCooldowns(comboId?: string): void { diff --git a/src/combos/index.ts b/src/combos/index.ts index f1a0e8ebd..686af90fe 100644 --- a/src/combos/index.ts +++ b/src/combos/index.ts @@ -28,6 +28,7 @@ export { } from "./resolve"; export { clearComboTargetCooldowns, + comboTargetCooldownCountForTests, coolComboTarget, isComboTargetInCooldown, parseRetryAfterMs, diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index ca53c2a7f..480cbda30 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -559,6 +559,13 @@ export interface HandleResponsesOptions { promptCacheKeyIsSharedCohort?: boolean; /** Internal recursion guard; callers outside this module must not set it. */ comboAttempt?: boolean; + /** + * Internal: this combo attempt belongs to a synthetic per-provider fallback chain, not to a + * combo the user configured. Such a hop must otherwise behave exactly like the plain request + * it replaced (see `providerFallbackPlan`), so single-provider behaviour that combos disable + * on purpose — currently the Anthropic terminal-guard continuation — stays enabled. + */ + providerFallbackAttempt?: boolean; /** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */ deferCodexResetDerivedCooldown?: boolean; /** 030-owned handoff when a child consumed the original failure under bounds. */ @@ -898,7 +905,8 @@ export async function handleComboResponses( // A per-provider fallback chain runs on this same hop loop but is not a combo the user // configured: the log row must keep the winning target's own provider/model rather than // collapsing into a synthetic `combo` row nothing in the GUI can open. - const comboIdentity = isProviderFallbackComboId(comboId) + const providerFallbackChain = isProviderFallbackComboId(comboId); + const comboIdentity = providerFallbackChain ? { requestedModel } : { requestedModel, model: requestedModel, provider: "combo", comboId }; Object.assign(logCtx, comboIdentity); @@ -1011,6 +1019,9 @@ export async function handleComboResponses( response = await handleResponses(childRequest, config, childLog, { ...options, comboAttempt: true, + // A synthetic fallback hop must keep the single-provider behaviour of the plain request + // it stands in for; a user-configured combo keeps the stricter combo semantics. + providerFallbackAttempt: providerFallbackChain, // Each combo target gets its own attempt budget. The shared budget bounds retries // against ONE upstream — transport retries plus account-pool rotation — but a combo // target is a different provider entirely, and the target list plus per-target @@ -1243,7 +1254,11 @@ export async function handleResponses( if (!options.comboAttempt && !isThreadSpawnRequest(req.headers)) { const plan = providerFallbackPlan(config, { provider: route.providerName, modelId: route.modelId }); if (plan) { - return handleComboResponses(req, body, plan.comboId, plan.config, logCtx, options); + // Hand the hop loop the PRE-expansion body, exactly as the `combo/` entry point does. + // `expandPreviousResponseInput` is not idempotent and leaves `previous_response_id` in + // place, so replaying an already-expanded body would prepend the restored history a second + // time in every child request. + return handleComboResponses(req, originalBody, plan.comboId, plan.config, logCtx, options); } } @@ -2696,7 +2711,11 @@ export async function handleResponses( // Keep the normal request/recovery path above intact, and use this bounded callback only for the // one internal continuation pass. A continuation failure becomes an in-stream adapter error so // the client never sees a second hidden HTTP response or an unbounded retry loop. - const terminalGuardEnabled = activeAdapter.name === "anthropic" && !options.comboAttempt && !routedCompaction; + // A per-provider fallback hop is a plain request wearing a combo's clothes, so it keeps the + // guard; a user-configured combo target does not (the parent hop loop owns that turn). + const terminalGuardEnabled = activeAdapter.name === "anthropic" + && (!options.comboAttempt || options.providerFallbackAttempt === true) + && !routedCompaction; const fetchTerminalGuardContinuation = async function* (nextParsed: OcxParsedRequest): AsyncGenerator { let imageTierBias = 0; let response: Response | undefined; diff --git a/tests/combos.test.ts b/tests/combos.test.ts index 417665ec1..0085e816a 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -14,6 +14,7 @@ import { comboIdFromRawBody, comboModelId, comboPublicModelId, + comboTargetCooldownCountForTests, concreteComboRequestBody, coolComboTarget, getCombo, @@ -278,6 +279,22 @@ describe("combo target cooldowns", () => { clearComboTargetCooldowns("other"); expect(isComboTargetInCooldown("other", target, 1_050)).toBe(false); }); + + test("expired entries for never-revisited combo ids are swept instead of retained forever", () => { + clearComboTargetCooldowns(); + // Per-provider fallback derives its combo id from the requested model, so a client that + // spreads traffic over many model names never asks about most ids again and the lazy + // expiry in isComboTargetInCooldown cannot reclaim them. + for (let i = 0; i < 400; i++) { + coolComboTarget(`provider-fallback\u0000a\u0000m${i}`, target, { now: 1_000, cooldownMs: 100 }); + } + expect(comboTargetCooldownCountForTests()).toBeGreaterThan(256); + + coolComboTarget("live", target, { now: 5_000, cooldownMs: 100 }); + expect(comboTargetCooldownCountForTests()).toBe(1); + expect(isComboTargetInCooldown("live", target, 5_050)).toBe(true); + clearComboTargetCooldowns(); + }); }); describe("combo failure policy and advancement", () => { diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index ea2db3f33..a720357f4 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -9,6 +9,7 @@ import { isComboTargetInCooldown, } from "../src/combos"; import { readConfigDiagnostics, saveConfig } from "../src/config"; +import { clearResponseStateForTests, rememberResponseState } from "../src/responses/state"; import type { ProviderAdapter } from "../src/adapters/base"; import { handleManagementAPI } from "../src/server/management-api"; import { saveCredential } from "../src/oauth/store"; @@ -1861,6 +1862,39 @@ describe("per-provider fallback for plain models", () => { expect(response.status).toBe(503); }); + test("a previous_response_id chain is expanded exactly once on the way to a target", async () => { + // The hop loop re-enters handleResponses for every target, and expandPreviousResponseInput + // is not idempotent: replaying an already-expanded body would prepend the restored history + // a second time in each child request. + const bodies: Array> = []; + const a = serve(async request => { + bodies.push(await request.json() as Record); + return chatSuccess("primary ok", "m1"); + }); + const b = serve(() => chatSuccess("must not be reached", "m2")); + const config = fallbackConfig({ + a: provider("openai-chat", baseUrl(a), "key-a"), + b: provider("openai-chat", baseUrl(b), "key-b"), + }, [{ provider: "b", model: "m2" }]); + + clearResponseStateForTests(); + rememberResponseState( + { store: true, input: [{ role: "user", content: "restored turn" }] }, + { id: "resp-chain", status: "completed", output: [] }, + ); + + const response = await postModelLogged(config, "a/m1", { + previous_response_id: "resp-chain", + store: true, + }); + expect(response.status).toBe(200); + + const messages = bodies[0]?.messages as Array> | undefined; + const restored = (messages ?? []).filter(message => JSON.stringify(message).includes("restored turn")); + expect(restored).toHaveLength(1); + clearResponseStateForTests(); + }); + test("an explicit combo request is unaffected by provider fallback config", async () => { const hits: string[] = []; const a = serve(() => { diff --git a/tests/terminal-guard-server.test.ts b/tests/terminal-guard-server.test.ts index f85a3cddf..89118415e 100644 --- a/tests/terminal-guard-server.test.ts +++ b/tests/terminal-guard-server.test.ts @@ -78,4 +78,44 @@ describe("server terminal guard integration", () => { expect(messages.at(-1)?.content?.[0]?.text).toContain("你刚才只描述了计划"); }); + test("a configured per-provider fallback does not disable the guard on the primary hop", async () => { + // Per-provider fallback replays plain requests through the combo hop loop, so the primary + // attempt runs with comboAttempt set. That must not cost the request its bounded + // continuation: without the guard Claude's plan-only turn reaches the client tool-call free. + const fallbackConfig = { + port: 0, + defaultProvider: "claude-se", + providers: { + "claude-se": { + adapter: "anthropic", + baseUrl: "https://example.test", + apiKey: "sk-test", + fallback: [{ provider: "claude-backup", model: "se-claude-opus-4.8" }], + }, + "claude-backup": { + adapter: "anthropic", + baseUrl: "https://backup.example.test", + apiKey: "sk-backup", + }, + }, + } as unknown as OcxConfig; + + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "claude-se/se-claude-opus-4.8", + input: "请检查这个问题并修复代码", + stream: true, + tools: [{ type: "function", name: "exec_command", description: "run a command", parameters: { type: "object" } }], + }), + }), fallbackConfig, { model: "", provider: "" }); + + const text = await response.text(); + expect(response.status).toBe(200); + expect(calls).toBe(2); + expect(text).toContain("exec_command"); + // The continuation stayed on the primary provider; the fallback target was never needed. + expect(requestBodies.every(body => body.model === "se-claude-opus-4.8")).toBe(true); + }); }); From e0ff202eb3fed24b67934afe9ab57bd23e9b02b4 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Sat, 1 Aug 2026 07:25:52 +0000 Subject: [PATCH 31/31] fix(gui): key fallback rows by stable id React Doctor's no-array-index-as-key fired on the fallback editor: rows are user-reorderable and removable, so the index is not a stable identity. Carry a per-row id in form state and drive updates/removal off it. Co-authored-by: Codesmith --- .../provider-workspace/ProviderSettings.tsx | 32 ++++++++++++------- gui/tests/provider-settings-fallback.test.tsx | 32 +++++++++++++++++++ 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index 13971d843..edd53dfec 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -30,6 +30,14 @@ export type ProviderPeerOption = { type ChoicesStatus = "idle" | "loading" | "ready" | "error"; +/** A fallback row plus a stable identity, so React keys survive reorder/removal. */ +type FallbackRow = ProviderFallbackTarget & { id: string }; + +let fallbackRowSeq = 0; +function withRowIds(rows: ProviderFallbackTarget[]): FallbackRow[] { + return rows.map(row => ({ ...row, id: `fb-${++fallbackRowSeq}` })); +} + function normalizeFallback(raw: ProviderFallbackTarget[] | undefined): ProviderFallbackTarget[] { if (!Array.isArray(raw)) return []; return raw @@ -78,7 +86,7 @@ export default function ProviderSettings({ const [note, setNote] = useState(item.note ?? ""); const [allowPrivateNetwork, setAllowPrivateNetwork] = useState(item.allowPrivateNetwork ?? false); const [liveModels, setLiveModels] = useState(item.liveModels !== false); - const [fallback, setFallback] = useState(() => normalizeFallback(item.fallback)); + const [fallback, setFallback] = useState(() => withRowIds(normalizeFallback(item.fallback))); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); const [baseUrlChoices, setBaseUrlChoices] = useState(); @@ -95,7 +103,7 @@ export default function ProviderSettings({ setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false); - setFallback(normalizeFallback(item.fallback)); + setFallback(withRowIds(normalizeFallback(item.fallback))); setMsg(null); queueMicrotask(() => setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl))); }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, item.liveModels, item.fallback, baseUrlChoices]); @@ -217,7 +225,7 @@ export default function ProviderSettings({ setDefaultModel(item.defaultModel ?? ""); setAuthMode(initialAuth); setApiKeyTransport(item.apiKeyTransport ?? "x-api-key"); setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false); - setFallback(normalizeFallback(item.fallback)); + setFallback(withRowIds(normalizeFallback(item.fallback))); setMsg(null); setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl)); }; @@ -231,8 +239,8 @@ export default function ProviderSettings({ } }; - const updateFallbackRow = (index: number, patch: Partial) => { - setFallback(rows => rows.map((row, i) => i === index ? { ...row, ...patch } : row)); + const updateFallbackRow = (id: string, patch: Partial) => { + setFallback(rows => rows.map(row => row.id === id ? { ...row, ...patch } : row)); }; return ( @@ -331,11 +339,11 @@ export default function ProviderSettings({ {t("pws.fallback")} {t("pws.fallbackDesc")}
- {fallback.map((row, index) => { + {fallback.map((row) => { const peer = fallbackPeers.find(p => p.name === row.provider); const modelIds = modelsForPeer(peer, row.model); return ( -
+