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"
+ }
+ }
+}
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 07fd1d1cf..052fc0709 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/blob/dev/CONTRIBUTING.md
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/.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/.github/workflows/release.yml b/.github/workflows/release.yml
index d9767d931..b54f233f1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -111,32 +111,38 @@ 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
+ # 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}'"
+ 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 +312,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
@@ -329,7 +335,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/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.
-
+
@@ -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/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 000000000..010e9dd7f
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,42 @@
+# Roadmap
+
+Roadmap for [OnlineChefGroep/opencodex](https://github.com/OnlineChefGroep/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**: 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`
+ (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)
+- [ ] **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
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..698f397ce 100644
--- a/docs-site/astro.config.mjs
+++ b/docs-site/astro.config.mjs
@@ -52,10 +52,12 @@ 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/",
+ // `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.
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/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/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/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 7c2de76b3..b2caee0f8 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). |
@@ -167,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.
@@ -194,6 +196,58 @@ 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.
+:::
+
+### 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`
@@ -301,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-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/docs/providers/omniroute.md b/docs/providers/omniroute.md
new file mode 100644
index 000000000..538f825dd
--- /dev/null
+++ b/docs/providers/omniroute.md
@@ -0,0 +1,111 @@
+# 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.
+
+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 .
+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"
+```
+
+`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
+mirroring OmniRoute's own `@omniroute/opencode-provider` defaults, plus the `auto` virtual combo
+router:
+
+| Model id | Notes |
+| --- | --- |
+| `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 (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 loopback (fleet)
+
+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 \
+ -e REQUIRE_API_KEY=false \
+ -p 127.0.0.1:20128:20128 \
+ diegosouzapw/omniroute
+```
+
+Smoke:
+
+```bash
+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"
+ }
+ }
+}
+```
+
+OmniRoute is registered with `allowBaseUrlOverride`, so the dashboard Add/Edit form exposes a
+custom base URL field. Destination policy rejects loopback unless `allowPrivateNetwork: true`.
+
+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/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 e5eec327b..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 },
@@ -280,7 +277,7 @@ export default function App() {
aria-label={t("dash.stop")} title={t("dash.stop")}>
{stopping ? t("dash.stopping") : t("dash.stop")}
-
+
{t("common.github")}
@@ -288,8 +285,8 @@ export default function App() {
+
}
{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..edd53dfec 100644
--- a/gui/src/components/provider-workspace/ProviderSettings.tsx
+++ b/gui/src/components/provider-workspace/ProviderSettings.tsx
@@ -11,22 +11,64 @@ 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";
+/** 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
+ .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 +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(() => withRowIds(normalizeFallback(item.fallback)));
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [baseUrlChoices, setBaseUrlChoices] = useState();
@@ -60,9 +103,10 @@ export default function ProviderSettings({
setNote(item.note ?? "");
setAllowPrivateNetwork(item.allowPrivateNetwork ?? false);
setLiveModels(item.liveModels !== false);
+ 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, 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 +146,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 +164,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 +182,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 +224,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(withRowIds(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 = (id: string, patch: Partial) => {
+ setFallback(rows => rows.map(row => row.id === id ? { ...row, ...patch } : row));
+ };
+
return (
@@ -264,6 +334,77 @@ export default function ProviderSettings({
{t("pws.liveModelsDesc")}
+
+
+
{t("pws.fallback")}
+
{t("pws.fallbackDesc")}
+
+ {fallback.map((row) => {
+ const peer = fallbackPeers.find(p => p.name === row.provider);
+ const modelIds = modelsForPeer(peer, row.model);
+ return (
+
+ {
+ const provider = e.target.value;
+ const first = modelsForPeer(fallbackPeers.find(p => p.name === provider), "")[0] ?? "";
+ updateFallbackRow(row.id, { provider, model: first });
+ }}
+ >
+ {t("pws.fallback.pickProvider")}
+ {fallbackPeers.map(p => (
+
+ {p.disabled ? t("pws.fallback.disabled", { name: p.name }) : p.name}
+
+ ))}
+
+ {modelIds.length > 0 ? (
+ updateFallbackRow(row.id, { model: e.target.value })}
+ >
+ {t("pws.fallback.pickModel")}
+ {modelIds.map(id => {id} )}
+
+ ) : (
+ updateFallbackRow(row.id, { model: e.target.value })}
+ />
+ )}
+ setFallback(rows => rows.filter(r => r.id !== row.id))}
+ >
+
+
+
+ );
+ })}
+
setFallback(rows => [...rows, ...withRowIds([{ provider: "", model: "" }])])}
+ >
+ {t("pws.fallback.add")}
+
+
+
+
{dirty && (
{t("pws.settingsUnsavedBar")}
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 && (
+
+ {quotaRefreshing ? : }
+ {quotaRefreshing ? t("prov.quotaRefreshing") : quotaFailed ? t("pws.retry") : t("prov.quotaRefresh")}
+
+ )}
+
+ {quotaFailed && (
+
{t("prov.quotaRefreshFailed")}
+ )}
{quota ? (
<>
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/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 a21cdbedc..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",
@@ -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…",
@@ -462,8 +464,6 @@ export const de: Record = {
"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",
@@ -599,11 +599,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",
@@ -853,7 +860,6 @@ export const de: Record = {
"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",
@@ -951,7 +957,7 @@ 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.",
@@ -961,8 +967,6 @@ export const de: Record = {
"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",
@@ -970,7 +974,6 @@ export const de: Record = {
"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",
@@ -989,7 +992,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",
@@ -997,7 +1000,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",
@@ -1056,10 +1059,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.threshold": "Auslösen, wenn Archivgröße überschreitet (GiB)",
"storage.policy.trigger": "Auslöser",
"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",
@@ -1202,7 +1205,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",
@@ -1216,7 +1221,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",
@@ -1257,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",
@@ -1540,4 +1555,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 1cb0a0e5e..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",
@@ -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…",
@@ -478,8 +480,6 @@ export const en = {
"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
@@ -603,7 +603,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.",
@@ -621,11 +621,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",
@@ -646,7 +653,7 @@ 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.",
@@ -656,8 +663,6 @@ export const en = {
"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",
@@ -665,7 +670,6 @@ export const en = {
"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",
@@ -684,7 +688,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",
@@ -692,7 +696,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",
@@ -751,10 +755,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.threshold": "Trigger when archived size exceeds (GiB)",
"storage.policy.trigger": "Trigger",
"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",
@@ -921,7 +925,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",
@@ -935,7 +941,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",
@@ -976,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",
@@ -1268,7 +1284,6 @@ export const en = {
"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",
@@ -1565,6 +1580,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 0feefa7d0..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": "サブエージェント",
@@ -247,6 +247,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 +317,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,6 +434,8 @@ export const ja: Record = {
"models.discoveryFailedNetwork": "ネットワークエラーによりモデル検出に失敗しました。",
"models.discoveryFailedProvider": "プロバイダーがモデル検出エラーを報告しました。",
"models.discoveryFailedGeneric": "モデル検出に失敗しました。",
+ "models.clientHiddenBadge": "クライアントから非表示",
+ "models.clientDegradedBadge": "新規セッションでは利用不可",
"models.openProviderSettings": "プロバイダー設定を開く",
"models.loading": "読み込み中…",
"models.search": "モデルを検索…",
@@ -445,8 +467,6 @@ export const ja: Record = {
"sub.workspace.notFeatured": "おすすめ未設定",
"sub.workspace.priority": "優先度",
"sub.workspace.removeFromFeatured": "{m} をおすすめから削除",
- "sub.workspace.selectModel": "モデルを選択",
- "sub.workspace.selectModelDesc": "一覧からモデルを選んで詳細を確認し、spawn_agent のおすすめに設定します。",
"sub.workspace.selector": "公開セレクター",
// logs
@@ -588,11 +608,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": "推定",
@@ -613,7 +640,7 @@ 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 が有効なディレクトリを指しているか確認してください。",
@@ -623,8 +650,6 @@ export const ja: Record = {
"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": "ポリシー",
@@ -632,7 +657,6 @@ export const ja: Record = {
"storage.cleanup.noArchives": "クリーンアップ対象のアーカイブセッションはありません。",
"storage.section.buckets": "バケット",
"storage.section.largest": "最大ファイル",
- "storage.workspace.overview": "概要",
"storage.workspace.selectBucket": "一覧からバケットを選ぶと内訳が表示されます。",
"storage.col.bucket": "バケット",
"storage.col.size": "サイズ",
@@ -651,7 +675,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": "アーカイブクリーンアップの確認",
@@ -659,7 +683,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": "完全に削除",
@@ -718,10 +742,10 @@ export const ja: Record = {
"storage.policy.invalid": "方針の値が無効です。",
"storage.policy.enabled": "自動クリーンアップを有効化",
"storage.policy.enabledHint": "既定はオフです。有効にすると選択したスケジュール(または今すぐ実行)でのみ動きます。",
- "storage.policy.threshold": "アーカイブサイズが超えたら(GiB)",
+ "storage.policy.threshold": "アーカイブサイズが超えたら実行(GiB)",
"storage.policy.trigger": "トリガー",
"storage.policy.target": "クリーンアップ目標",
- "storage.policy.targetPercent": "古いアーカイブを削除(%)",
+ "storage.policy.targetPercent": "古いアーカイブの割合を削除",
"storage.policy.targetReduce": "アーカイブを次のサイズまで縮小(GiB)",
"storage.policy.thresholdInc": "しきい値を上げる",
"storage.policy.thresholdDec": "しきい値を下げる",
@@ -888,11 +912,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": "選択中",
@@ -933,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": "再認証が必要",
@@ -1248,7 +1284,6 @@ export const ja: Record = {
"api.workspace.deleteKey": "キーを削除",
"api.workspace.deleteConfirm": "このキーを削除しますか?この操作は元に戻せません。",
"api.workspace.noKeysHint": "API キーがまだありません。作成して始めましょう。",
- "api.workspace.selectKeyHint": "一覧からキーを選択すると、詳細が表示されます。",
"api.workspace.usageExamples": "使用例",
"api.copyUrlHint": "クリックして URL をコピー",
"api.urlCopied": "URL をコピーしました",
@@ -1561,4 +1596,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 8e0931da8..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": "서브에이전트",
@@ -441,6 +441,8 @@ export const ko: Record = {
"models.discoveryFailedNetwork": "네트워크 오류로 모델 검색에 실패했습니다.",
"models.discoveryFailedProvider": "프로바이더가 모델 검색 오류를 보고했습니다.",
"models.discoveryFailedGeneric": "모델 검색에 실패했습니다.",
+ "models.clientHiddenBadge": "클라이언트에서 숨김",
+ "models.clientDegradedBadge": "새 세션에서 사용 불가",
"models.openProviderSettings": "프로바이더 설정 열기",
"models.loading": "불러오는 중…",
"models.search": "모델 검색…",
@@ -472,8 +474,6 @@ export const ko: Record = {
"sub.workspace.notFeatured": "추천되지 않음",
"sub.workspace.priority": "우선순위",
"sub.workspace.removeFromFeatured": "{m}을(를) 추천에서 제거",
- "sub.workspace.selectModel": "모델 선택",
- "sub.workspace.selectModelDesc": "목록에서 모델을 선택하여 세부 정보를 확인하고 spawn_agent에 추천하세요.",
"sub.workspace.selector": "공개 셀렉터",
// logs
@@ -614,11 +614,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": "추정",
@@ -873,7 +880,6 @@ export const ko: Record = {
"api.workspace.deleteKey": "키 삭제",
"api.workspace.deleteConfirm": "이 키를 삭제하시겠습니까? 되돌릴 수 없습니다.",
"api.workspace.noKeysHint": "아직 API 키가 없습니다. 키를 생성하여 시작하세요.",
- "api.workspace.selectKeyHint": "목록에서 키를 선택하여 세부 정보를 확인하세요.",
"api.workspace.usageExamples": "사용 예제",
"api.copyUrlHint": "클릭하여 URL 복사",
"api.urlCopied": "URL 복사됨",
@@ -971,7 +977,7 @@ 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이 올바른 디렉터리를 가리키는지 확인하세요.",
@@ -981,8 +987,6 @@ export const ko: Record = {
"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": "정책",
@@ -990,7 +994,6 @@ export const ko: Record = {
"storage.cleanup.noArchives": "정리할 보관 세션이 없습니다.",
"storage.section.buckets": "버킷",
"storage.section.largest": "가장 큰 파일",
- "storage.workspace.overview": "개요",
"storage.workspace.selectBucket": "목록에서 버킷을 선택하면 세부 내역을 볼 수 있습니다.",
"storage.col.bucket": "버킷",
"storage.col.size": "크기",
@@ -1009,7 +1012,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": "보관 정리 확인",
@@ -1017,7 +1020,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": "영구 삭제",
@@ -1076,10 +1079,10 @@ export const ko: Record = {
"storage.policy.invalid": "정책 값이 올바르지 않습니다.",
"storage.policy.enabled": "자동 정리 사용",
"storage.policy.enabledHint": "기본은 꺼짐입니다. 켜면 선택한 일정(또는 지금 실행)에만 동작합니다.",
- "storage.policy.threshold": "보관 용량이 초과하면 (GiB)",
+ "storage.policy.threshold": "보관 용량이 초과하면 실행 (GiB)",
"storage.policy.trigger": "트리거",
"storage.policy.target": "정리 목표",
- "storage.policy.targetPercent": "가장 오래된 보관 제거 (%)",
+ "storage.policy.targetPercent": "가장 오래된 보관 비율 제거",
"storage.policy.targetReduce": "보관 용량을 다음까지 줄이기 (GiB)",
"storage.policy.thresholdInc": "임계값 증가",
"storage.policy.thresholdDec": "임계값 감소",
@@ -1222,7 +1225,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": "모델별 사용량",
@@ -1236,7 +1241,7 @@ export const ko: Record = {
"pws.metricRequests": "요청",
"pws.metricTokens": "토큰",
"pws.usageUnavailable": "아직 기록된 사용량이 없습니다.",
- "pws.rateLimits": "요청 한도",
+ "pws.rateLimits": "프로바이더 한도",
"pws.quotaUnavailable": "이 프로바이더의 쿼터 데이터가 없습니다.",
"pws.accountQuotaUnavailable": "요금 한도 데이터를 일시적으로 가져올 수 없습니다. 이전 값이 있으면 그대로 표시합니다.",
"pws.selected": "선택됨",
@@ -1277,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": "재인증 필요",
@@ -1561,4 +1576,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 c6940fde3..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": "Подагенты",
@@ -247,6 +247,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 +324,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,6 +466,8 @@ export const ru: Record = {
"models.discoveryFailedNetwork": "Обнаружение моделей не удалось из-за сетевой ошибки.",
"models.discoveryFailedProvider": "Провайдер сообщил об ошибке обнаружения моделей.",
"models.discoveryFailedGeneric": "Не удалось обнаружить модели.",
+ "models.clientHiddenBadge": "Скрыто от клиентов",
+ "models.clientDegradedBadge": "Недоступно для новых сессий",
"models.openProviderSettings": "Открыть настройки провайдера",
"models.loading": "Загрузка…",
"models.search": "Поиск моделей…",
@@ -477,8 +499,6 @@ export const ru: Record = {
"sub.workspace.notFeatured": "Не в избранных",
"sub.workspace.priority": "Приоритет",
"sub.workspace.removeFromFeatured": "Убрать {m} из избранных",
- "sub.workspace.selectModel": "Выберите модель",
- "sub.workspace.selectModelDesc": "Выберите модель из списка, чтобы увидеть детали и добавить её в избранные для spawn_agent.",
"sub.workspace.selector": "Публичный селектор",
// logs
@@ -620,11 +640,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": "Оценено",
@@ -645,7 +672,7 @@ 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 указывает на корректный каталог.",
@@ -655,8 +682,6 @@ export const ru: Record = {
"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": "Политика",
@@ -664,7 +689,6 @@ export const ru: Record = {
"storage.cleanup.noArchives": "Нет архивных сессий для очистки.",
"storage.section.buckets": "Категории",
"storage.section.largest": "Крупнейшие файлы",
- "storage.workspace.overview": "Обзор",
"storage.workspace.selectBucket": "Выберите сегмент в списке, чтобы увидеть разбивку.",
"storage.col.bucket": "Категория",
"storage.col.size": "Размер",
@@ -683,7 +707,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": "Подтвердить очистку архива",
@@ -691,7 +715,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": "Удалить навсегда",
@@ -750,10 +774,10 @@ export const ru: Record = {
"storage.policy.invalid": "Недопустимые значения политики.",
"storage.policy.enabled": "Включить автоочистку",
"storage.policy.enabledHint": "По умолчанию выкл. При включении работает только по выбранному расписанию (или «Запустить сейчас»).",
- "storage.policy.threshold": "Когда размер архива больше (ГиБ)",
+ "storage.policy.threshold": "Запуск, если размер архива больше (ГиБ)",
"storage.policy.trigger": "Триггер",
"storage.policy.target": "Цель очистки",
- "storage.policy.targetPercent": "Удалить самые старые архивы (%)",
+ "storage.policy.targetPercent": "Удалить процент самых старых архивов",
"storage.policy.targetReduce": "Уменьшить архив до (ГиБ)",
"storage.policy.thresholdInc": "Увеличить порог",
"storage.policy.thresholdDec": "Уменьшить порог",
@@ -920,7 +944,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": "Разбивка по моделям",
@@ -934,7 +960,7 @@ export const ru: Record = {
"pws.metricRequests": "запросов",
"pws.metricTokens": "токенов",
"pws.usageUnavailable": "Использование пока не зафиксировано.",
- "pws.rateLimits": "Лимиты запросов",
+ "pws.rateLimits": "Лимиты провайдера",
"pws.quotaUnavailable": "Нет данных о квоте для этого провайдера.",
"pws.accountQuotaUnavailable": "Данные о лимитах временно недоступны; при наличии показываются последние известные значения.",
"pws.selected": "Выбрана",
@@ -975,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": "Нужна переавторизация",
@@ -1290,7 +1326,6 @@ export const ru: Record = {
"api.workspace.deleteKey": "Удалить ключ",
"api.workspace.deleteConfirm": "Удалить этот ключ? Это действие нельзя отменить.",
"api.workspace.noKeysHint": "API-ключей пока нет. Создайте один, чтобы начать.",
- "api.workspace.selectKeyHint": "Выберите ключ из списка, чтобы просмотреть сведения.",
"api.workspace.usageExamples": "Примеры использования",
"api.copyUrlHint": "Нажмите, чтобы скопировать URL",
"api.urlCopied": "URL скопирован",
@@ -1562,5 +1597,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 3e026741e..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": "子代理",
@@ -441,6 +441,8 @@ export const zh: Record = {
"models.discoveryFailedNetwork": "由于网络错误,模型发现失败。",
"models.discoveryFailedProvider": "提供方报告了模型发现错误。",
"models.discoveryFailedGeneric": "模型发现失败。",
+ "models.clientHiddenBadge": "已对客户端隐藏",
+ "models.clientDegradedBadge": "新会话不可用",
"models.openProviderSettings": "打开提供方设置",
"models.loading": "加载中…",
"models.search": "搜索模型…",
@@ -472,8 +474,6 @@ export const zh: Record = {
"sub.workspace.notFeatured": "未设为精选",
"sub.workspace.priority": "优先级",
"sub.workspace.removeFromFeatured": "将 {m} 从精选中移除",
- "sub.workspace.selectModel": "选择模型",
- "sub.workspace.selectModelDesc": "从列表中选择一个模型以查看详情,并将其设为 spawn_agent 的精选模型。",
"sub.workspace.selector": "公开选择器",
// logs
@@ -614,11 +614,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": "估算",
@@ -873,7 +880,6 @@ export const zh: Record = {
"api.workspace.deleteKey": "删除密钥",
"api.workspace.deleteConfirm": "确定要删除此密钥吗?此操作无法撤销。",
"api.workspace.noKeysHint": "还没有 API 密钥。生成一个以开始使用。",
- "api.workspace.selectKeyHint": "从列表中选择一个密钥以查看其详情。",
"api.workspace.usageExamples": "用法示例",
"api.copyUrlHint": "点击复制 URL",
"api.urlCopied": "已复制 URL",
@@ -971,7 +977,7 @@ 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 是否指向有效目录。",
@@ -981,8 +987,6 @@ export const zh: Record = {
"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": "策略",
@@ -990,7 +994,6 @@ export const zh: Record = {
"storage.cleanup.noArchives": "没有可清理的归档会话。",
"storage.section.buckets": "分类",
"storage.section.largest": "最大文件",
- "storage.workspace.overview": "概览",
"storage.workspace.selectBucket": "从列表中选择一个存储桶以查看明细。",
"storage.col.bucket": "分类",
"storage.col.size": "大小",
@@ -1009,7 +1012,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": "确认归档清理",
@@ -1017,7 +1020,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": "永久删除",
@@ -1076,10 +1079,10 @@ export const zh: Record = {
"storage.policy.invalid": "策略值无效。",
"storage.policy.enabled": "启用自动清理",
"storage.policy.enabledHint": "默认关闭。启用后仅按所选计划(或立即运行)执行。",
- "storage.policy.threshold": "当归档大小超过(GiB)",
+ "storage.policy.threshold": "归档大小超过时触发(GiB)",
"storage.policy.trigger": "触发条件",
"storage.policy.target": "清理目标",
- "storage.policy.targetPercent": "删除最旧归档(%)",
+ "storage.policy.targetPercent": "删除最旧归档百分比",
"storage.policy.targetReduce": "将归档缩小至(GiB)",
"storage.policy.thresholdInc": "提高阈值",
"storage.policy.thresholdDec": "降低阈值",
@@ -1222,7 +1225,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": "模型用量明细",
@@ -1236,7 +1241,7 @@ export const zh: Record = {
"pws.metricRequests": "请求",
"pws.metricTokens": "令牌",
"pws.usageUnavailable": "尚无用量记录。",
- "pws.rateLimits": "速率限制",
+ "pws.rateLimits": "提供方限额",
"pws.quotaUnavailable": "此提供商暂无配额数据。",
"pws.accountQuotaUnavailable": "速率限制数据暂时不可用;若有上次已知值则继续显示。",
"pws.selected": "已选择",
@@ -1277,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": "需要重新认证",
@@ -1561,4 +1576,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/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/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/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/Storage.tsx b/gui/src/pages/Storage.tsx
index c7e97c3cc..44f05f295 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/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 (
-
-
-
-
- {t("usage.title")}
-
-
- {sections.map(s => (
- onSelectSection(s.id)}
- aria-current={selectedSection === s.id ? "true" : undefined}
- >
- {s.label}
- {s.meta}
-
- ))}
-
-
-
-
-
- );
-}
-
-/** 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/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-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..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";
@@ -18,7 +20,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";
@@ -26,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;
@@ -71,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;
@@ -114,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;
@@ -123,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;
@@ -158,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; }
@@ -190,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]
- 목적: 화면마다 임의로 지정된 폰트 크기와 굵기를 역할 기반 타이포그래피로 통일한다.
@@ -222,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; }
@@ -275,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); }
@@ -364,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; }
@@ -500,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;
@@ -617,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);
@@ -694,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); }
@@ -763,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; }
@@ -831,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);
@@ -911,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); }
@@ -935,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 {
@@ -950,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); }
@@ -1722,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) */
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/src/ui.tsx b/gui/src/ui.tsx
index d42c0fb93..d10eeeedb 100644
--- a/gui/src/ui.tsx
+++ b/gui/src/ui.tsx
@@ -30,14 +30,14 @@ 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";
placement?: "below" | "right";
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);
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/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/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..5a7a5f459
--- /dev/null
+++ b/gui/tests/provider-settings-fallback.test.tsx
@@ -0,0 +1,199 @@
+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;
+
+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 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: "antigravity-primary",
+ 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");
+});
+
+/**
+ * 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();
+ }
+});
+
+test("removing a row keeps the surviving rows' own values", async () => {
+ const patches: ProviderUpdatePatch[] = [];
+ const { act, container, setSelectValue, buttonByText, saveButton, cleanup } = await mountSettings(async (_name, patch) => {
+ patches.push(patch);
+ return { ok: true };
+ });
+
+ try {
+ await act(async () => { buttonByText("Add fallback")!.click(); });
+ const providerSelects = container.querySelectorAll('select[aria-label="Fallback provider"]');
+ await act(async () => { setSelectValue(providerSelects[1]!, "google-antigravity"); });
+
+ // Rows carry a stable id, so dropping the first one must leave the second row's own
+ // provider/model in place rather than shifting values up a slot.
+ const removeButtons = container.querySelectorAll('.pwi-fallback-row button[aria-label]');
+ await act(async () => { removeButtons[0]!.click(); });
+
+ const rows = container.querySelectorAll(".pwi-fallback-row");
+ expect(rows).toHaveLength(1);
+ expect(container.querySelector('select[aria-label="Fallback provider"]')!.value)
+ .toBe("google-antigravity");
+ expect(container.querySelector('select[aria-label="Fallback model"]')!.value)
+ .toBe("gemini-3.6-flash");
+
+ await act(async () => { saveButton()!.click(); });
+ expect(patches).toHaveLength(1);
+ expect(patches[0]!.fallback).toEqual([{ provider: "google-antigravity", model: "gemini-3.6-flash" }]);
+ } 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/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/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/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"
}
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 @@
-
+
@@ -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 @@
-
+
@@ -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 @@
-
+
@@ -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 @@
-
+
@@ -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/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/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.",
diff --git a/src/adapters/base.ts b/src/adapters/base.ts
index 20ca5e017..262cf7e5a 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
@@ -47,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;
@@ -63,6 +66,10 @@ 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;
+ /** Shared physical-send budget for this client request. */
+ attemptBudget?: UpstreamAttemptBudget;
}
diff --git a/src/adapters/cursor.ts b/src/adapters/cursor.ts
index 2de2ff149..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";
@@ -139,6 +139,7 @@ export function createCursorAdapter(provider: OcxProviderConfig, deps: CursorAda
emit(event);
}
},
+ incoming.attemptBudget,
);
};
@@ -178,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 01b2b5519..8ff6bb3b3 100644
--- a/src/adapters/cursor/transport-retry.ts
+++ b/src/adapters/cursor/transport-retry.ts
@@ -2,7 +2,9 @@ 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";
// 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;
@@ -96,9 +112,13 @@ 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 &&
+ (attemptBudget?.remaining ?? 1) > 0 &&
!signal?.aborted &&
requestUncommitted(transport) &&
isRetryableCursorError(err);
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/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..b059e7511 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,
@@ -56,9 +61,27 @@ 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);
}
+ // 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/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/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/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/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/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/codex/pool-rotation.ts b/src/codex/pool-rotation.ts
index c75f6d34a..d25a7e336 100644
--- a/src/codex/pool-rotation.ts
+++ b/src/codex/pool-rotation.ts
@@ -2,6 +2,8 @@ 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/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/config.ts b/src/config.ts
index 9127897c1..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,
@@ -696,6 +697,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))) {
@@ -767,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/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/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/google-antigravity-routing.ts b/src/oauth/google-antigravity-routing.ts
new file mode 100644
index 000000000..8ad87d6ab
--- /dev/null
+++ b/src/oauth/google-antigravity-routing.ts
@@ -0,0 +1,503 @@
+/**
+ * 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);
+ }
+}
+
+/**
+ * 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 {
+ 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..52fa63dcf 100644
--- a/src/oauth/health.ts
+++ b/src/oauth/health.ts
@@ -1,5 +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";
@@ -175,14 +177,18 @@ 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)
+ : provider === "cursor"
+ ? getCursorAccountHealthSnapshot(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/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/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/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/providers/registry.ts b/src/providers/registry.ts
index 663e8c8ec..275b96894 100644
--- a/src/providers/registry.ts
+++ b/src/providers/registry.ts
@@ -13,9 +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 { ProviderCompat, ThinkingFormat, MaxTokensField, SessionAffinityFormat } from "./compat";
export type ProviderModelDiscoveryScalar = string | number | boolean;
@@ -95,6 +97,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`:
@@ -168,7 +175,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 +242,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
@@ -1194,6 +1233,37 @@ 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 {
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/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/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 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 [row.accountId, row]));
const projected = projectAccounts();
return jsonResponse({
@@ -232,17 +236,29 @@ 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);
+ } else if (provider === "cursor") {
+ const { resetCursorRoutingForManualSelection } = await import("../../oauth/cursor-routing");
+ resetCursorRoutingForManualSelection(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" && provider !== "cursor") {
+ return jsonResponse({ error: "pool config is only supported for anthropic, google-antigravity, and cursor" }, 400);
+ }
+ const pool = provider === "anthropic"
+ ? config.anthropicAccountPool ?? {}
+ : provider === "google-antigravity"
+ ? config.googleAntigravityAccountPool ?? {}
+ : config.cursorAccountPool ?? {};
return jsonResponse({
provider,
enabled: pool.enabled === true,
@@ -265,13 +281,20 @@ 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" && provider !== "cursor") {
+ return jsonResponse({ error: "pool config is only supported for anthropic, google-antigravity, and cursor" }, 400);
+ }
+ const currentPool = provider === "anthropic"
+ ? config.anthropicAccountPool
+ : 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);
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 +306,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 +314,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 +322,15 @@ 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 if (provider === "google-antigravity") config.googleAntigravityAccountPool = nextPool;
+ else config.cursorAccountPool = nextPool;
saveConfigPreservingClaudeCode(config);
return jsonResponse({
ok: true,
@@ -320,10 +346,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") return jsonResponse({ error: "clear-cooldown is only supported for anthropic" }, 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 { clearAnthropicAccountCooldown } = await import("../../oauth/anthropic-routing");
- const cleared = clearAnthropicAccountCooldown(accountId);
+ const cleared = provider === "anthropic"
+ ? (await import("../../oauth/anthropic-routing")).clearAnthropicAccountCooldown(accountId)
+ : provider === "google-antigravity"
+ ? (await import("../../oauth/google-antigravity-routing")).clearGoogleAntigravityAccountCooldown(accountId)
+ : (await import("../../oauth/cursor-routing")).clearCursorAccountCooldown(accountId);
return jsonResponse({ ok: true, cleared });
}
@@ -352,6 +383,20 @@ 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);
+ } 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/management/provider-routes.ts b/src/server/management/provider-routes.ts
index b792505cd..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,
@@ -40,6 +41,11 @@ import { codexAccountNamespaceProviderCollisionError } from "../../codex/account
import { clearThreadAccountMap } from "../../codex/routing";
import { primeCodexPoolQuotas } from "../../codex/auth-api";
import { getProviderDiscoveryStatus } from "../../codex/model-cache";
+import {
+ clientHideReasonLabel,
+ hideUnavailableModelsEnabled,
+ providerClientHideReason,
+} from "../../codex/catalog-visibility";
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
import { resolveCodexHomeDir } from "../../codex/home";
import { readUsageEntries } from "../../usage/log";
@@ -77,18 +83,28 @@ export async function handleProviderRoutes(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,
+ fallback: providerFallbackTargets(p),
+ 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
@@ -258,6 +274,23 @@ 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 e8d12ad54..480cbda30 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";
@@ -52,6 +53,32 @@ import {
resolveAnthropicAccountForSession,
rotateAnthropicAccountOn429,
} from "../../oauth/anthropic-routing";
+import {
+ GOOGLE_ANTIGRAVITY_POOL_MAX_FAILOVERS_PER_REQUEST,
+ bindGoogleAntigravitySessionAffinity,
+ formatGoogleAntigravityProviderForLog,
+ getGoogleAntigravityPoolCredential,
+ getGoogleAntigravityPoolRetryAfterSeconds,
+ googleAntigravitySessionKey,
+ isGoogleAntigravityAccountPoolEnabled,
+ promoteGoogleAntigravityActiveAccount,
+ releaseGoogleAntigravitySessionAffinity,
+ 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";
@@ -81,6 +108,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";
@@ -159,6 +195,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,
@@ -241,8 +288,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 {
@@ -255,6 +307,7 @@ interface CodexPoolAccountRetryArgs {
abortSignal?: AbortSignal;
onCodexAuthContextResolved?: (ctx: CodexAuthContext) => void;
deferCodexResetDerivedCooldown?: boolean;
+ attemptBudget?: UpstreamAttemptBudget;
};
firstAuthCtx: Extract;
firstResponse: Response;
@@ -316,6 +369,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(
@@ -375,6 +429,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,
@@ -502,10 +559,19 @@ 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. */
onConsumedComboFailure?: (failure: ConsumedComboFailure) => void;
+ /** Internal request-scoped physical upstream-send budget. */
+ attemptBudget?: UpstreamAttemptBudget;
}
@@ -836,24 +902,23 @@ 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 providerFallbackChain = isProviderFallbackComboId(comboId);
+ const comboIdentity = providerFallbackChain
+ ? { 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,
@@ -886,12 +951,19 @@ 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;
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,
@@ -947,6 +1019,17 @@ 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
+ // 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).
@@ -987,10 +1070,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,
@@ -1043,7 +1123,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,
@@ -1064,6 +1144,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);
@@ -1165,6 +1248,20 @@ 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) {
+ // 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);
+ }
+ }
+
let authCtx: CodexAuthContext = { kind: "main", accountId: null };
let selectedForwardHeaders = req.headers;
let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
@@ -1253,6 +1350,10 @@ export async function handleResponses(
let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
let anthropicPoolAccountId: string | null = null;
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),
@@ -1262,6 +1363,21 @@ 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);
+ 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)) {
@@ -1284,6 +1400,71 @@ 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 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;
@@ -1481,7 +1662,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);
@@ -1495,7 +1680,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;
}
@@ -1967,53 +2155,120 @@ 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,
+ retryAfter?: string | null,
+ ): Promise => {
+ if (
+ !cursorPoolAccountId
+ || cursorPoolFailovers >= CURSOR_POOL_MAX_FAILOVERS_PER_REQUEST
+ || options.abortSignal?.aborted
+ || !isCursorPoolRotationError(message)
+ ) {
+ return false;
+ }
+ const nextAccountId = rotateCursorAccountOnQuota(
+ config,
+ cursorPoolAccountId,
+ // 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;
try {
- noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
- await adapter.runTurn?.(
- parsed,
- { headers: selectedForwardHeaders, abortSignal: runTurnAbort.signal },
- 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, preflight.error.retryAfter)
+ ) {
+ 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: "" } : {}),
@@ -2036,7 +2291,7 @@ export async function handleResponses(
parsed._rawBody,
response,
continuationStateForResponse(providerState),
- adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined,
+ adapterNeedsForcedContinuation(activeRunTurnAdapter.name) ? { force: true } : undefined,
),
}),
},
@@ -2048,8 +2303,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, firstMeaningful.retryAfter)
+ ) {
+ continue;
+ }
+ break;
+ }
if (options.comboAttempt) {
const firstMeaningful = events.find(event => event.type !== "heartbeat");
if (!firstMeaningful || firstMeaningful.type === "error") {
@@ -2080,7 +2347,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" } });
@@ -2100,11 +2367,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(
@@ -2116,7 +2398,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) {
@@ -2152,11 +2438,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, 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();
@@ -2167,12 +2484,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 */ }
@@ -2207,7 +2534,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(),
@@ -2235,6 +2566,7 @@ export async function handleResponses(
&& anthropicPoolAccountId
&& isAnthropicAccountPoolEnabled(config)
&& anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
+ && await canRotateOrCoolFailure(upstreamResponse)
) {
const nextAccountId = rotateAnthropicAccountOn429(
config,
@@ -2263,6 +2595,66 @@ 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
+ && await canRotateOrCoolFailure(upstreamResponse)
+ ) {
+ 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 {
+ // 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;
+ }
+ }
// 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({
@@ -2319,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;
@@ -2339,6 +2735,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/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/src/telemetry/posthog-server.ts b/src/telemetry/posthog-server.ts
new file mode 100644
index 000000000..944fec6a9
--- /dev/null
+++ b/src/telemetry/posthog-server.ts
@@ -0,0 +1,250 @@
+/**
+ * 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;
+/** 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 = {
+ 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;
+ /** Failed send attempts so far; bounds retries of a transiently failed batch. */
+ attempts: number;
+}
+
+/**
+ * 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 (!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)) {
+ 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;
+ /** Epoch ms before which flushing is suppressed after a transient failure. */
+ private retryAfterMs = 0;
+
+ 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(),
+ attempts: 0,
+ });
+ this.enforceQueueBound();
+ if (this.queue.length >= MAX_BATCH_SIZE) {
+ void this.flush();
+ }
+ } catch {
+ /* swallow — telemetry must never break callers */
+ }
+ }
+
+ /** 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;
+ // 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({
+ 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) {
+ 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 and timeouts are transient — retry the batch */
+ retryable = true;
+ } finally {
+ 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. */
+ 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 983b483dd..4ede9c80c 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;
@@ -294,6 +295,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;
};
/**
@@ -673,6 +680,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. */
@@ -717,6 +736,57 @@ 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;
+ };
+ /**
+ * 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. */
@@ -730,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;
@@ -897,8 +976,22 @@ 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;
+ /**
+ * 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/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;
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/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/src/usage/percentiles.ts b/src/usage/percentiles.ts
new file mode 100644
index 000000000..47122a456
--- /dev/null
+++ b/src/usage/percentiles.ts
@@ -0,0 +1,110 @@
+/**
+ * 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;
+}
+
+/**
+ * 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 {
+ 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 };
+ }
+ 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: 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),
+ };
+}
+
+/**
+ * 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);
+ }
+ 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) {
+ 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..575239f8c
--- /dev/null
+++ b/src/usage/pricing.ts
@@ -0,0 +1,103 @@
+/**
+ * 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"),
+ // 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 };
+}
+
+/** Estimate EUR cost for a usage record. */
+export function estimateCostEur(
+ provider: string,
+ model: string | undefined,
+ 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 (input / 1000) * p.inputPer1k + (output / 1000) * p.outputPer1k;
+}
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/account-pool-management-api.test.ts b/tests/account-pool-management-api.test.ts
index 4dbe39926..d0ac2fd82 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,165 @@ 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("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(),
+ 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/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 });
});
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/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/ci-workflows.test.ts b/tests/ci-workflows.test.ts
index f5b6374d6..a142d1e62 100644
--- a/tests/ci-workflows.test.ts
+++ b/tests/ci-workflows.test.ts
@@ -265,10 +265,21 @@ 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)");
+ // 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
@@ -922,7 +933,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 +960,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 +1002,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 +1030,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 +1040,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 +1060,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 +1068,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 +1080,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 +1105,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 +1121,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 +1158,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 +1174,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 +1194,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 +1233,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 +1416,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://lidge-jun.github.io/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');
@@ -1467,7 +1480,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 +1530,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 +1595,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/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/cursor-account-pool.test.ts b/tests/cursor-account-pool.test.ts
new file mode 100644
index 000000000..c80013316
--- /dev/null
+++ b/tests/cursor-account-pool.test.ts
@@ -0,0 +1,151 @@
+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("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",
+ "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/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/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/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-account-pool.test.ts b/tests/google-antigravity-account-pool.test.ts
new file mode 100644
index 000000000..16799eae5
--- /dev/null
+++ b/tests/google-antigravity-account-pool.test.ts
@@ -0,0 +1,286 @@
+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,
+ releaseGoogleAntigravitySessionAffinity,
+ 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("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!);
+
+ 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/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,
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/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(
[
diff --git a/tests/provider-account-quota.test.ts b/tests/provider-account-quota.test.ts
index 16dd5dd3a..000e73d91 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.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({
+ 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.test",
+ });
+ 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.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();
+ 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.test");
+ 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/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);
+ });
+});
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/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();
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/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" });
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/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts
index 4de483572..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";
@@ -1772,6 +1773,152 @@ 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("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