diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..d1c3e9fa9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,52 @@ +# Dependabot configuration +# See: https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + +version: 2 +updates: + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Europe/Amsterdam" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "automated" + versioning-strategy: increase + groups: + minor-patch: + applies-to: version-updates + update-types: + - "minor" + - "patch" + + - package-ecosystem: "npm" + directory: "/gui" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + timezone: "Europe/Amsterdam" + open-pull-requests-limit: 10 + labels: + - "dependencies" + - "automated" + - "gui" + versioning-strategy: increase + groups: + minor-patch: + applies-to: version-updates + update-types: + - "minor" + - "patch" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + labels: + - "dependencies" + - "automated" + - "ci" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 804a04346..59979e44c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ on: - ".github/workflows/release.yml" - ".github/workflows/enforce-pr-target.yml" - ".github/workflows/stale-needs-info.yml" + - ".github/workflows/issue-triage.yml" push: branches: [main, preview, dev] paths: @@ -35,6 +36,7 @@ on: - ".github/workflows/release.yml" - ".github/workflows/enforce-pr-target.yml" - ".github/workflows/stale-needs-info.yml" + - ".github/workflows/issue-triage.yml" workflow_dispatch: permissions: @@ -171,3 +173,88 @@ jobs: - name: ocx help via bundled bun run: ocx help + + security: + name: Security audit + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.14 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install GUI dependencies + working-directory: gui + run: bun install --frozen-lockfile + + - name: Generate Bun audit report + id: bun-audit + continue-on-error: true + shell: bash + run: | + set +e + # Full JSON evidence: `bun audit --json` ignores --audit-level, so this + # captures findings at every severity for the uploaded artifact. + bun audit --json > bun-audit.json + # Gate decision: bun audit only honors --audit-level in non-JSON mode, + # so derive the pass/fail from the human-readable run (high/critical only). + bun audit --audit-level=high + status=$? + echo "exit_code=$status" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Generate GUI Bun audit report + id: gui-bun-audit + continue-on-error: true + working-directory: gui + shell: bash + run: | + set +e + bun audit --json > bun-audit.json + bun audit --audit-level=high + status=$? + echo "exit_code=$status" >> "$GITHUB_OUTPUT" + exit 0 + + - name: Upload Bun audit report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: bun-audit + path: bun-audit.json + retention-days: 7 + + - name: Upload GUI Bun audit report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: gui-bun-audit + path: gui/bun-audit.json + retention-days: 7 + + - name: Enforce Bun audit + if: steps.bun-audit.outputs.exit_code != '0' || steps.gui-bun-audit.outputs.exit_code != '0' + run: | + echo "== root bun-audit.json ==" + cat bun-audit.json || true + echo "== gui/bun-audit.json ==" + cat gui/bun-audit.json || true + exit 1 + + lint-github-actions: + name: Lint GitHub Actions + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Lint workflows + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..56f67d41c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,78 @@ +# Changelog + +All notable changes to the OnlineChefGroep fork of **opencodex** are documented here. + +**Repository:** [OnlineChefGroep/opencodex](https://github.com/OnlineChefGroep/opencodex) +**Package:** `@bitkyc08/opencodex` + +> This fork uses **independent semantic versioning** starting at `v1.0.0-alpha.1`. +> See [VERSIONING.md](./VERSIONING.md) for details. + +--- + +## [1.0.0-alpha.1] — Planned inaugural fork pre-release + +Prepared as the first independent fork release, fully detached from upstream release numbering. + +### Added + +- **Claude Desktop integration** — full Claude Desktop profile management via GUI and CLI +- **Combo rename & public aliases** — rename combos and expose public aliases +- **Output defaults & web-search replay markers** — custom output defaults +- **Response item ID repair** — opt-in repair for unstable passthrough IDs +- **Cursor context usage** — accurate context usage reporting across tool turns +- **Cursor cache telemetry** — clarify missing cache telemetry with GUI indicators +- **Native effort clamp** — gate native effort clamp by route identity +- **Dutch GUI localization** — full Dutch translation for the dashboard +- **Provider workspace shell** — dedicated dashboard panel for per-provider workspace +- **Quota bars** — visual quota display in the dashboard + +### Changed + +- **Independent versioning** — detached from upstream version numbers with the fork's own semver scheme +- **CI pipeline** — enhanced with CodeQL, Dependabot, security audit, workflow linting, and cross-platform package smoke tests +- **Canonical fork references** — active project, release, documentation, and support URLs point to OnlineChefGroep; historical upstream links remain where attribution or tracking requires them +- **Release workflow** — adapted for the fork's main-branch release model and prerelease tags + +### Fixed + +- Theme state: properly read `ocx-theme` from localStorage +- Stale theme button: React state now updates on theme selection +- Legacy hash links: restore `#combos`, `#subagents`, `#debug`, `#usage` routing +- Merge conflict markers removed from Claude Desktop i18n files + +### Integrated since fork + +- **Claude Desktop integration** — full Claude Desktop profile management via GUI and CLI, family + editor, health monitoring, auto-apply, and effort transparency. +- **Combo rename & public aliases** — rename combos and expose public aliases for model routing. +- **Output defaults & web-search replay markers** — custom output defaults and hide web-search + replay markers. +- **Response item ID repair** — opt-in repair for unstable passthrough IDs. +- **Cursor context usage** — accurate context usage reporting across tool turns. +- **Cursor cache telemetry** — clarify missing cache telemetry with GUI indicators. +- **Native effort clamp** — gate native effort clamp by route identity. +- **Native OpenAI slugs in combo resolution** — include native OpenAI slugs in combo member + resolution. +- **Adaptive thinking headroom** — preserve adaptive thinking output headroom for Anthropic. +- **Sidecar auth fixes** — verify direct helper auth origin, explicit ChatGPT auth intent. +- **Dutch GUI localization** — full Dutch translation for the dashboard. +- **Provider workspace shell** — dedicated dashboard panel for per-provider workspace. +- **Quota bars** — visual quota display in the dashboard. +- **Theme state fix** — properly read `ocx-theme` from localStorage. +- **Stale theme button fix** — update React state on theme selection. +- **Legacy hash link targets** — restore `#combos`, `#subagents`, `#debug`, `#usage` routing. + +### Infrastructure + +- **Independent versioning** — fully detached from upstream release numbering. +- **Enhanced CI** — added security audit, CodeQL analysis, GitHub Actions linting, and package-install smoke coverage. +- **Dependabot** — automated weekly dependency updates. +- **Release documentation** — `VERSIONING.md` and `RELEASE_PROCESS.md` for repeatable releases. + +--- + +## Earlier versions (upstream tracking) + +Versions `v2.7.26` through `v2.7.39` (and earlier) track the upstream +[lidge-jun/opencodex](https://github.com/lidge-jun/opencodex) releases. diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md new file mode 100644 index 000000000..6348bf212 --- /dev/null +++ b/RELEASE_PROCESS.md @@ -0,0 +1,50 @@ +# Release Process + +## Quick start + +```bash +# 1. Bump version in package.json +npm version patch # or minor / major + +# 2. Update CHANGELOG.md with the new version + notes + +# 3. Create a release commit + tag +git commit -m "chore(release): v$(node -p 'require("./package.json").version')" +git tag -a "v$(node -p 'require("./package.json").version')" -m "v$(node -p 'require("./package.json").version')" + +# 4. Push +git push origin main --follow-tags + +# 5. Dispatch the Release workflow from GitHub Actions +# → https://github.com/OnlineChefGroep/opencodex/actions/workflows/release.yml +# Enter version, dist-tag (latest/preview), dry-run (set false to publish) +``` + +## Automated release + +The [Release workflow](.github/workflows/release.yml) uses Trusted Publishing (OIDC) to authenticate +with npmjs.org — no tokens or secrets needed. It: + +1. Verifies `package.json` version matches the workflow input. +2. Builds the GUI bundle. +3. Runs `npm publish` with provenance attestation. +4. Creates a GitHub Release with release notes. +5. Pushes the version tag back to the repository. + +## Manual release (local) + +```bash +npm run build:gui +npm pack # verify the tarball contents +npm publish --dry-run # one last check +npm publish # actual publish +git tag -a v$(node -p 'require("./package.json").version') -m "v$(node -p 'require("./package.json").version')" +git push origin --tags +``` + +## Important notes + +- Always run `bun test --isolate tests/` before releasing. +- Update `CHANGELOG.md` before the release commit. +- The `latest` dist-tag is for stable releases; use `preview` for beta/experimental builds. +- Never release from a working branch — always from `main`. diff --git a/VERSIONING.md b/VERSIONING.md new file mode 100644 index 000000000..4ffb4c6d8 --- /dev/null +++ b/VERSIONING.md @@ -0,0 +1,43 @@ +# Versioning Policy + +**Package:** `@bitkyc08/opencodex` +**Repository:** `OnlineChefGroep/opencodex` +**License:** MIT + +This fork of [lidge-jun/opencodex](https://github.com/lidge-jun/opencodex) follows **independent +semantic versioning** — we are **fully detached** from upstream releases. + +Starting with the first fork release, our versioning is: + +``` +v.. +``` + +## Rules + +| Change | Rule | +|---|---| +| **Breaking change** (incompatible API, config, or CLI) | Bump **major** | +| **New feature** (backward-compatible) | Bump **minor** | +| **Bug fix** (backward-compatible) | Bump **patch** | + +## Current version + +Current: **`2.7.33`** (inherited from upstream; transition starts here) + +Next release: **`1.0.0`** — our first independent release, signifying the fork's new identity. + +## Version transition + +Because this fork initially tracked upstream releases, the existing tags (`v2.7.26` … `v2.7.39`) +are kept for history. All **new** releases use our own scheme starting at `v1.0.0`. + +## Release process + +1. Update `CHANGELOG.md` with the new version and notes. +2. Bump version in `package.json`. +3. Commit and tag: `git tag -a v -m "v"` +4. Push tag: `git push origin v` +5. The [Release workflow](.github/workflows/release.yml) handles npm publish + GitHub Release. + +See [RELEASE_PROCESS.md](./RELEASE_PROCESS.md) for the full step-by-step guide. diff --git a/docs-site/public/robots.txt b/docs-site/public/robots.txt index 23cccbd63..d09eccabf 100644 --- a/docs-site/public/robots.txt +++ b/docs-site/public/robots.txt @@ -1,4 +1,4 @@ User-agent: * Allow: / -Sitemap: https://opencodex.me/sitemap-index.xml +# Sitemap: docs not currently deployed diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index bc888ab58..cb1cd2e34 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -48,7 +48,7 @@ cd docs-site && bun install && bun dev ## Docs publishing -The public docs publish to GitHub Pages at . The +The public docs publish to GitHub Pages at . The `.github/workflows/deploy-docs.yml` workflow runs on `main` pushes that touch `docs-site/**` or the workflow itself, builds `docs-site`, and deploys the generated site. Before pushing docs changes, run: @@ -71,10 +71,6 @@ GitHub Actions intentionally stay small: - **Release** (`.github/workflows/release.yml`) is manual. It does not act as a second full CI pipeline; before dry-run or publish it requires the exact release commit (`GITHUB_SHA`) to already have a successful Cross-platform CI run. -- **Stale needs-info** (`.github/workflows/stale-needs-info.yml`) runs daily on the default branch. - Open issues labeled `needs-info` with no activity for 14 days get a warning; after 7 more idle - days they close as not planned. Any update clears the stale warning. To keep long-lived work open, - remove `needs-info` (for example when promoting an issue to `roadmap`). Use the helper for releases: @@ -179,7 +175,7 @@ preset it cannot stand behind. Promote the row to the registry once the evidence ## Adding an adapter -Implement `ProviderAdapter` (see [Adapters](/reference/adapters/)) in `src/adapters/`, +Implement `ProviderAdapter` (see [Adapters](/opencodex/reference/adapters/)) in `src/adapters/`, register its name in `src/server/adapter-resolve.ts`, and bridge its output to internal `AdapterEvent`s. Reuse `image.ts` for image handling and follow `openai-chat.ts` for ordinary streaming/tool calls; use `fetchResponse` only when the adapter owns transport retries, or `runTurn` diff --git a/gui/.env.example b/gui/.env.example new file mode 100644 index 000000000..d12f4564d --- /dev/null +++ b/gui/.env.example @@ -0,0 +1,8 @@ +# PostHog EU analytics (optional). Leave unset to disable. +# Project API key from https://eu.posthog.com — never commit real keys. +VITE_POSTHOG_KEY= +# Defaults to https://eu.i.posthog.com when unset. +# VITE_POSTHOG_HOST=https://eu.i.posthog.com + +# Optional API base for the proxy health check (default: same origin). +# VITE_API_BASE= diff --git a/gui/AGENTS.md b/gui/AGENTS.md index 61459e58b..9f413539c 100644 --- a/gui/AGENTS.md +++ b/gui/AGENTS.md @@ -14,7 +14,7 @@ This file applies to `gui/` and inherits the repository-wide rules in `/AGENTS.m - **No hardcoded visible UI text** in `src/pages`, `src/components`, `src/App.tsx`, or `src/ui.tsx`. - Every new user-facing string goes into **all** locale files: - `src/i18n/en.ts` — source of truth / `TKey` - - plus every other `src/i18n/{locale}.ts` module (discovered automatically by `bun run lint:i18n`; when adding a language, add `{locale}.ts` and wire it in `src/i18n/shared.ts`) + - plus every other `src/i18n/{locale}.ts` module (discovered automatically by `bun run lint:i18n`; when adding a language, add `{locale}.ts`, register it in the `DICTS` map in `src/i18n/dicts.ts`, and list it in `LOCALES` in `src/i18n/shared.ts`) - Render copy with `useT()` / `t("key")` or `` for `{cmd}` chips. - **Allowed literals without i18n keys** (see `.eslint/i18n-allowlist.ts`): - **Company / product names** (e.g. OpenAI, Anthropic, GitHub, Codex). diff --git a/gui/bun.lock b/gui/bun.lock index 56c90814e..0fdf06d7a 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -7,7 +7,9 @@ "dependencies": { "@fontsource-variable/archivo": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "@tanstack/react-virtual": "^3.14.5", + "posthog-js": "^1.409.5", "react": "^19.2.7", "react-dom": "^19.2.7", }, @@ -28,6 +30,10 @@ }, }, }, + "overrides": { + "brace-expansion": "^5.0.9", + "postcss": "^8.5.25", + }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -87,6 +93,8 @@ "@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.3.0", "", {}, "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw=="], + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.3.0", "", {}, "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q=="], + "@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=="], @@ -111,6 +119,12 @@ "@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], + "@posthog/browser-common": ["@posthog/browser-common@0.3.1", "", { "dependencies": { "@posthog/core": "^1.46.0", "@posthog/types": "^1.399.0" } }, "sha512-1nhMVY1wnHADTg8tR9yvm+lPAz5ROxznfQlBtLzB2FFo4lp/LU8lk9KyFPsARDkBxCfYachsJfvRjocL1G/AJQ=="], + + "@posthog/core": ["@posthog/core@1.46.1", "", { "dependencies": { "@posthog/types": "^1.399.0" } }, "sha512-EoCFduRkvrg9E5ylMi4QnZCjlAdRJCq6tJouWfngBVR79XSI4iPvIWYA+CdzokAjk+TfSVBFVJ++4Im3r+T0Dg=="], + + "@posthog/types": ["@posthog/types@1.399.0", "", {}, "sha512-/WDwBzqIPko8VJ1B+0rlso2XQEz9+2sqtsY9Tqy3p1GhgTqsFakcz/PmMpAnA321LTEZVRcO6x5hAwABV4yrDw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="], @@ -161,6 +175,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], @@ -197,7 +213,7 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.38", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw=="], - "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], @@ -207,6 +223,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -217,6 +235,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="], + "electron-to-chromium": ["electron-to-chromium@1.5.375", "", {}, "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q=="], "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], @@ -253,6 +273,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.4.9", "", {}, "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -333,7 +355,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -353,12 +375,18 @@ "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postcss": ["postcss@8.5.25", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw=="], + + "posthog-js": ["posthog-js@1.409.5", "", { "dependencies": { "@posthog/browser-common": "^0.3.1", "@posthog/core": "^1.46.1", "@posthog/types": "^1.399.0", "core-js": "^3.49.0", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.3.0" } }, "sha512-s1iJz+vq0YAluUD4OwLjUFIJt/9pqiiB6MITvHWIS16a7pqTJqbSLXsd5/8kJcIgfrOSZHe+mdYAXLYcJCS4LQ=="], + + "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], @@ -395,6 +423,8 @@ "vite": ["vite@8.1.0", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q=="], + "web-vitals": ["web-vitals@5.3.0", "", {}, "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], diff --git a/gui/package.json b/gui/package.json index 61d4523e8..cb2d247e4 100644 --- a/gui/package.json +++ b/gui/package.json @@ -16,7 +16,9 @@ "dependencies": { "@fontsource-variable/archivo": "^5.3.0", "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "@tanstack/react-virtual": "^3.14.5", + "posthog-js": "^1.409.5", "react": "^19.2.7", "react-dom": "^19.2.7" }, @@ -34,5 +36,9 @@ "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.1.0" + }, + "overrides": { + "brace-expansion": "^5.0.9", + "postcss": "^8.5.25" } } diff --git a/gui/public/provider-icons/omniroute-color.svg b/gui/public/provider-icons/omniroute-color.svg new file mode 100644 index 000000000..d5f721ad6 --- /dev/null +++ b/gui/public/provider-icons/omniroute-color.svg @@ -0,0 +1 @@ +OmniRoute diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index 344610d99..9d7ade2ef 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -29,6 +29,9 @@ export default function ProviderDetails({ usageTotals, modelUsage, quotaReport, + quotaRefreshing, + quotaFailed, + onRefreshQuota, availableModels, peerProviders, hasLiveModels, @@ -53,11 +56,15 @@ export default function ProviderDetails({ isDefault, onRemoveProvider, onSetDisabled, + capCooldown, }: { item: WorkspaceItem; usageTotals?: ProviderUsageTotals; modelUsage?: ProviderModelUsageRow[]; quotaReport?: ProviderQuotaReportView; + quotaRefreshing?: boolean; + quotaFailed?: boolean; + onRefreshQuota?: () => void; availableModels: string[]; peerProviders?: import("./ProviderSettings").ProviderPeerOption[]; /** Server-reported live-catalog provenance; see filterModels(). */ @@ -84,6 +91,8 @@ export default function ProviderDetails({ isDefault?: boolean; onRemoveProvider?: (name: string) => void; onSetDisabled?: (name: string, disabled: boolean) => void; + /** Active weekly/inference-cap cooldown from /api/config. */ + capCooldown?: import("../../pages/providers-shared").ProviderCapCooldown; }) { const t = useT(); const [tab, setTab] = useState("overview"); @@ -228,6 +237,7 @@ export default function ProviderDetails({ usageTotals={usageTotals} quotaReport={quotaReport} oauthEmail={oauthEmail} + capCooldown={capCooldown} onEditSettings={() => switchTab("settings")} onViewUsage={() => switchTab("usage")} onUpdateProvider={onUpdateProvider} @@ -258,6 +268,7 @@ export default function ProviderDetails({ availableModels={availableModels} hasLiveModels={hasLiveModels} selectedModels={selectedModels} + modelUsage={modelUsage} modelsLoading={modelsLoading} modelsLoadFailed={modelsLoadFailed} needsReauth={ @@ -269,7 +280,15 @@ export default function ProviderDetails({ /> )} {tab === "usage" && ( - + )} {tab === "accounts" && ( void; }) { const t = useT(); + const { locale } = useI18n(); const [query, setQuery] = useState(""); const [customModelId, setCustomModelId] = useState(""); const [customSaving, setCustomSaving] = useState(false); @@ -44,9 +63,34 @@ export default function ProviderModels({ const [customModelsLoadFailed, setCustomModelsLoadFailed] = useState(false); const [customModelsLoadEpoch, setCustomModelsLoadEpoch] = useState(0); const [copiedId, setCopiedId] = useState(null); + const [disabledNamespaced, setDisabledNamespaced] = useState>(new Set()); + const [nativeIds, setNativeIds] = useState>(new Set()); + const [catalogEpoch, setCatalogEpoch] = useState(0); + const [busyId, setBusyId] = useState(null); + const [bulkBusy, setBulkBusy] = useState(false); + const [fetching, setFetching] = useState(false); + const [actionStatus, setActionStatus] = useState(""); + const [actionOk, setActionOk] = useState(true); const copyResetRef = useRef(null); - const selectedSet = useMemo(() => new Set(selectedModels), [selectedModels]); + + const selectedMap = useMemo( + () => ({ [item.name]: selectedModels }), + [item.name, selectedModels], + ); const configuredModels = useMemo(() => item.models ?? [], [item.models]); + const usageById = useMemo(() => { + const map = new Map(); + for (const row of modelUsage ?? []) { + const key = row.resolvedModel || row.model; + const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key; + // Prefer higher-token row if both namespaced and bare collide. + const prev = map.get(bare); + if (!prev || row.totalTokens > prev.totalTokens) map.set(bare, row); + if (key !== bare) map.set(key, row); + } + return map; + }, [modelUsage]); + const trimmedCustomModelId = customModelId.trim(); const customModelInvalid = !customModelsReady || !trimmedCustomModelId @@ -80,9 +124,6 @@ export default function ProviderModels({ } catch { if (!active) return; setCustomModelIds([]); - // Without this the component stays permanently unable to add a model: `customModelsReady` - // never flips back and the effect has no trigger left, so a single transient GET failure - // disabled Add until the whole panel remounted. setCustomModelsReady(false); setCustomModelsLoadFailed(true); setCustomError(t("models.networkError")); @@ -92,6 +133,38 @@ export default function ProviderModels({ return () => { active = false; }; }, [apiBase, item.name, t, customModelsLoadEpoch]); + useEffect(() => { + let active = true; + const load = async () => { + try { + const response = await fetch(`${apiBase}/api/models`); + if (!response.ok) throw new Error(); + const rows: unknown = await response.json(); + if (!Array.isArray(rows)) throw new Error("invalid models"); + if (!active) return; + const blocked = new Set(); + const natives = new Set(); + for (const row of rows) { + if (!row || typeof row !== "object") continue; + const m = row as CatalogRow; + if (m.provider !== item.name || typeof m.id !== "string") continue; + if (m.native === true) natives.add(m.id); + if (m.disabled === true) { + if (typeof m.namespaced === "string") blocked.add(m.namespaced); + blocked.add(m.id); + } + } + setDisabledNamespaced(blocked); + setNativeIds(natives); + } catch { + if (!active) return; + /* keep last known disabled set */ + } + }; + void load(); + return () => { active = false; }; + }, [apiBase, item.name, catalogEpoch]); + const retryCustomModels = () => { setCustomModelsReady(false); setCustomModelsLoadFailed(false); @@ -117,6 +190,113 @@ export default function ProviderModels({ } }; + const isModelOn = (modelId: string) => modelVisible( + selectedMap, + item.name, + modelId, + nativeIds.has(modelId), + disabledNamespaced.has(modelId) || disabledNamespaced.has(`${item.name}/${modelId}`), + ); + + /** Returns whether the server accepted the change, so callers can roll back. */ + const applyVisibility = async (targets: string[], enabled: boolean): Promise => { + setActionStatus(""); + try { + const response = await putModelVisibility( + apiBase, + "models", + item.name, + targets.map(id => ({ id, native: nativeIds.has(id) })), + enabled, + ); + if (!response.ok) { + setActionOk(false); + setActionStatus(t("models.saveFailed")); + return false; + } + setActionOk(true); + setActionStatus(t("models.applied")); + setCatalogEpoch(epoch => epoch + 1); + onRetryModels?.(); + return true; + } catch { + setActionOk(false); + setActionStatus(t("models.networkError")); + return false; + } + }; + + const toggleModel = async (modelId: string) => { + if (busyId || bulkBusy || fetching) return; + const next = !isModelOn(modelId); + setBusyId(modelId); + const previousDisabled = disabledNamespaced; + // Optimistic disabled-set update for snappy chips. + setDisabledNamespaced(prev => { + const nextSet = new Set(prev); + const namespaced = nativeIds.has(modelId) ? modelId : `${item.name}/${modelId}`; + if (next) { + nextSet.delete(modelId); + nextSet.delete(namespaced); + } else { + nextSet.add(namespaced); + } + return nextSet; + }); + const saved = await applyVisibility([modelId], next); + // A rejected update must not leave the chip showing the optimistic state. + if (!saved) setDisabledNamespaced(previousDisabled); + setBusyId(null); + }; + + const bulkToggle = async (enable: boolean) => { + if (bulkBusy || busyId || fetching || models.length === 0) return; + setBulkBusy(true); + // Every model matching the current search, not just the render-capped + // chips: the capped tail would otherwise keep its old visibility. + const previousDisabled = disabledNamespaced; + setDisabledNamespaced(prev => { + const nextSet = new Set(prev); + for (const id of models) { + const namespaced = nativeIds.has(id) ? id : `${item.name}/${id}`; + if (enable) { + nextSet.delete(id); + nextSet.delete(namespaced); + } else { + nextSet.add(namespaced); + } + } + return nextSet; + }); + const saved = await applyVisibility(models, enable); + if (!saved) setDisabledNamespaced(previousDisabled); + setBulkBusy(false); + }; + + const fetchModels = async () => { + if (fetching || busyId || bulkBusy) return; + setFetching(true); + setActionStatus(""); + try { + // Clears provider model caches server-side and refreshes the Codex catalog. + const response = await fetch(`${apiBase}/api/sync`, { method: "POST" }); + if (!response.ok) { + setActionOk(false); + setActionStatus(t("dash.syncFailed", { error: `HTTP ${response.status}` })); + return; + } + setActionOk(true); + setActionStatus(t("dash.syncModels")); + setCatalogEpoch(epoch => epoch + 1); + onRetryModels?.(); + } catch { + setActionOk(false); + setActionStatus(t("models.networkError")); + } finally { + setFetching(false); + } + }; + const addCustomModel = async () => { if (customModelInvalid || customSaving) return; setCustomSaving(true); @@ -148,21 +328,37 @@ export default function ProviderModels({ && customModelIds.length === 0 && !item.defaultModel; const showingConfiguredFallback = availableModels.length === 0 && configuredModels.length > 0; - // Aggregators (OpenRouter etc.) can return thousands of ids; capping the mounted - // chips keeps the tab responsive. Filtering narrows the list, so the cap only - // bites on the unfiltered full catalog. const CHIP_RENDER_CAP = 300; const capped = models.length > CHIP_RENDER_CAP; const visibleModels = capped ? models.slice(0, CHIP_RENDER_CAP) : models; + // Computed over every match, matching what the bulk buttons submit. + const allOn = models.length > 0 && models.every(isModelOn); + const allOff = models.length > 0 && models.every(id => !isModelOn(id)); + const controlsBusy = Boolean(busyId) || bulkBusy || fetching; return (

{t("pws.tab.models")}

- {models.length > 0 && ( - {t("pws.modelsAvailable", { count: models.length })} - )} +
+ {models.length > 0 && ( + {t("pws.modelsAvailable", { count: models.length })} + )} + +
+ {actionStatus && ( +

+ {actionStatus} +

+ )} {needsReauth && (
{t("pws.modelsNeedsReauth")} @@ -211,14 +407,34 @@ export default function ProviderModels({

)} {!emptyBase && ( - setQuery(e.target.value)} - aria-label={t("pws.modelSearchPlaceholder")} - /> +
+ setQuery(e.target.value)} + aria-label={t("pws.modelSearchPlaceholder")} + /> +
+ + +
+
)} {modelsLoading && emptyBase ? (

{t("pws.modelsLoading")}

@@ -239,21 +455,34 @@ export default function ProviderModels({
    {visibleModels.map(modelId => { const isDefault = modelId === item.defaultModel; - const isSelected = selectedSet.has(modelId); + const on = isModelOn(modelId); const copied = copiedId === modelId; + const usage = usageById.get(modelId) ?? usageById.get(`${item.name}/${modelId}`); + const usageLabel = usage + ? `${formatTokenCount(usage.totalTokens, locale)} · ${formatRequestCount(usage.requests, locale)}` + : null; return ( -
  • +
  • + { void toggleModel(modelId); }} + disabled={controlsBusy} + label={modelId} + /> {isDefault ? {t("prov.defaultBadge")} : null} - {isSelected ? {t("pws.selected")} : null} + {busyId === modelId ? : null}
  • ); })} diff --git a/gui/src/components/provider-workspace/ProviderOverview.tsx b/gui/src/components/provider-workspace/ProviderOverview.tsx index ad35ac25d..42f4a46c6 100644 --- a/gui/src/components/provider-workspace/ProviderOverview.tsx +++ b/gui/src/components/provider-workspace/ProviderOverview.tsx @@ -11,12 +11,15 @@ import { accountQuotaFromReport, formatQuotaSourceLabel, type ProviderQuotaRepor import type { ProviderUsageTotals } from "./types"; import { authModeLabel } from "./ProviderRail"; import type { ProviderUpdatePatch } from "./types"; +import { formatResetFuture } from "../QuotaBars"; +import { formatProviderDisplayName } from "../../provider-icons"; export default function ProviderOverview({ item, usageTotals, quotaReport, oauthEmail, onEditSettings, onViewUsage, onUpdateProvider, onReauthenticate, onCancelLogin, reauthBusy = false, accountPanel, + capCooldown, }: { item: WorkspaceItem; usageTotals?: ProviderUsageTotals; @@ -34,6 +37,8 @@ export default function ProviderOverview({ * duplication is intentional (D2) and cannot desync because both read one controller. */ accountPanel?: ReactNode; + /** Active weekly/inference-cap cooldown from /api/config. */ + capCooldown?: import("../../pages/providers-shared").ProviderCapCooldown; }) { const t = useT(); const { locale } = useI18n(); @@ -48,9 +53,31 @@ export default function ProviderOverview({ const requests = usageTotals?.requests; const tokens = usageTotals?.totalTokens; const quota = accountQuotaFromReport(quotaReport); + const cooldownReset = capCooldown + ? formatResetFuture(capCooldown.until, t, locale) + : ""; return (
    + {capCooldown && ( +
    +
    + )}

    {t("pws.connection")}

    diff --git a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx index ee86fab4f..98cf24b5c 100644 --- a/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx +++ b/gui/src/components/provider-workspace/ProviderOverviewDashboard.tsx @@ -17,15 +17,17 @@ import { relativeTimeLabelsFromT, type ProviderUsageTotals, } from "../../provider-workspace/usage"; -import { maxQuotaUtilisation } from "../QuotaBars"; +import { maxQuotaUtilisation, formatResetFuture } from "../QuotaBars"; import { ProviderIcon } from "./ProviderRail"; import { formatProviderDisplayName } from "../../provider-icons"; import QuotaBars from "../QuotaBars"; +import type { ProviderCapCooldown } from "../../pages/providers-shared"; export default function ProviderOverviewDashboard({ sections, quotaReports, usageTotals, + providerCooldowns, usageLoading = false, quotasLoading = false, onSelectProvider, @@ -34,6 +36,7 @@ export default function ProviderOverviewDashboard({ sections: WorkspaceSections; quotaReports: Record; usageTotals: Record; + providerCooldowns?: Record; usageLoading?: boolean; quotasLoading?: boolean; onSelectProvider: (name: string) => void; @@ -49,7 +52,32 @@ export default function ProviderOverviewDashboard({ ); const knownNames = useMemo(() => new Set(allItems.map(p => p.name)), [allItems]); - const attention = useMemo(() => buildAttentionItems(sections, {}), [sections]); + const cooldownOverrides = useMemo(() => { + const out: Record = {}; + for (const [name, entry] of Object.entries(providerCooldowns ?? {})) { + if (!entry || typeof entry.until !== "number") continue; + const reset = formatResetFuture(entry.until, t, locale); + out[name] = t("pws.attention.capCooldown", { reset }); + } + return out; + }, [providerCooldowns, t, locale]); + + const cappedProviders = useMemo(() => { + const result: Array<{ name: string; entry: ProviderCapCooldown }> = []; + for (const [name, entry] of Object.entries(providerCooldowns ?? {})) { + if (!entry || typeof entry.until !== "number") continue; + result.push({ name, entry }); + } + return result.sort((a, b) => a.entry.until - b.entry.until || a.name.localeCompare(b.name)); + }, [providerCooldowns]); + const cappedNames = useMemo(() => new Set(cappedProviders.map(row => row.name)), [cappedProviders]); + + // Capped providers get the richer "Usage caps" section below, so keep them out of the + // generic attention list instead of listing the same provider twice. + const attention = useMemo( + () => buildAttentionItems(sections, cooldownOverrides).filter(item => !cappedNames.has(item.name)), + [sections, cooldownOverrides, cappedNames], + ); const attentionCount = attention.length; const readyReauthCount = useMemo( () => sections.ready.filter(p => p.activeNeedsReauth).length, @@ -111,6 +139,42 @@ export default function ProviderOverviewDashboard({
    + {cappedProviders.length > 0 && ( +
    +

    +

    +
    + {cappedProviders.map(({ name, entry }) => { + const reset = formatResetFuture(entry.until, t, locale); + return ( + + ); + })} +
    +
    + )} + {attentionCount > 0 && (

    diff --git a/gui/src/components/provider-workspace/ProviderRail.tsx b/gui/src/components/provider-workspace/ProviderRail.tsx index 8b896e49d..189f17c14 100644 --- a/gui/src/components/provider-workspace/ProviderRail.tsx +++ b/gui/src/components/provider-workspace/ProviderRail.tsx @@ -60,12 +60,14 @@ export function ProviderIcon({ name, adapter, baseUrl, cls }: { ); } -export function RailRow({ item, selected, tabbable, modelCount, isDefault, showConfigId, onClick, onFocus }: { +export function RailRow({ item, selected, tabbable, modelCount, isDefault, capped, showConfigId, onClick, onFocus }: { item: WorkspaceItem; selected: boolean; tabbable: boolean; modelCount?: number; isDefault?: boolean; + /** Weekly/inference hard-cap cooldown active for this provider. */ + capped?: boolean; /** When display names collide (e.g. openai + chatgpt → ChatGPT), show the config id. */ showConfigId?: boolean; onClick: () => void; @@ -74,7 +76,9 @@ export function RailRow({ item, selected, tabbable, modelCount, isDefault, showC const t = useT(); const free = isFreeProvider(item); const local = isLocalProvider(item); - const status = statusLabel(item, t); + // Keep the underlying status for assistive tech; the capped pill is visual-only for sighted users. + const baseStatus = statusLabel(item, t); + const status = capped ? `${baseStatus} · ${t("pws.capCooldown.badge")}` : baseStatus; const displayName = formatProviderDisplayName(item.name); const nameTitle = showConfigId ? `${displayName} (${item.name})` : displayName; const suffix = `${isDefault ? t("pws.rail.suffixDefault") : ""}${local ? t("pws.rail.suffixLocal") : free ? t("pws.rail.suffixFree") : ""}`; @@ -85,7 +89,7 @@ export function RailRow({ item, selected, tabbable, modelCount, isDefault, showC return ( ); diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 23acb434b..95d00a15f 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -43,6 +43,8 @@ export interface DetailSlotData { modelsLoading: boolean; modelsLoadFailed: boolean; onRetryModels?: () => void; + /** Active weekly/inference-cap cooldown for this provider, if any. */ + capCooldown?: import("../../pages/providers-shared").ProviderCapCooldown; } const SORT_DEFS: { id: ProviderSortMode; labelKey: "pws.sort.az" | "pws.sort.za" | "pws.sort.freePaid" | "pws.sort.paidFree" | "pws.sort.accountsFirst" }[] = [ @@ -66,6 +68,7 @@ export default function ProviderWorkspaceShell({ jsonSaving = false, modelsRefreshToken = 0, activeAccountNeedsReauth, + providerCooldowns, /** Stable key of active OAuth account ids — refetch overview quotas after account switch. */ quotaRefreshKey = "", detail, @@ -84,6 +87,8 @@ export default function ProviderWorkspaceShell({ /** Bump after login/config changes so /api/selected-models is refetched. */ modelsRefreshToken?: number; activeAccountNeedsReauth?: Record; + /** Active weekly/inference-cap cooldowns from /api/config. */ + providerCooldowns?: Record; /** * Explicit active-account identity key (e.g. `anthropic:|…`). Prefer this over * `activeAccountNeedsReauth` object identity so healthy account switches still refresh. @@ -479,6 +484,7 @@ export default function ProviderWorkspaceShell({ tabbable={railTabbableName === item.name} modelCount={modelCounts[item.name]} isDefault={defaultProvider === item.name} + capped={Boolean(providerCooldowns?.[item.name])} showConfigId={duplicateDisplayNames.has(formatProviderDisplayName(item.name))} onClick={() => onSelect(item.name)} onFocus={() => setRailFocusName(item.name)} @@ -530,6 +536,7 @@ export default function ProviderWorkspaceShell({ modelsLoading, modelsLoadFailed, onRetryModels: retryModels, + capCooldown: providerCooldowns?.[selectedItem.name], }) ?? (

    {formatProviderDisplayName(selectedItem.name)}

    @@ -544,6 +551,7 @@ export default function ProviderWorkspaceShell({ sections={sections} quotaReports={quotaReports} usageTotals={usageTotals} + providerCooldowns={providerCooldowns} usageLoading={usageLoading} quotasLoading={quotasLoading} onSelectProvider={(name) => onSelect(name)} diff --git a/gui/src/i18n/dicts.ts b/gui/src/i18n/dicts.ts new file mode 100644 index 000000000..532733db1 --- /dev/null +++ b/gui/src/i18n/dicts.ts @@ -0,0 +1,14 @@ +/** + * Shipped locale registry, deliberately free of React imports. + * + * `shared.ts` re-exports these, so GUI code keeps importing from `./shared`. Keeping the + * registry itself React-free lets the non-GUI copy gates in `tests/helpers/shipped-locales.ts` + * read the same source of truth instead of maintaining their own locale list. + */ +import { en, type TKey } from "./en"; +import { nl } from "./nl"; + +export type Locale = "en" | "nl"; +export type { TKey }; + +export const DICTS: Record> = { en, nl }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index bc31b3954..dce80279c 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -947,6 +947,8 @@ export const en = { "pws.selected": "Selected", "pws.copyModelId": "Copy ID", "pws.modelCopied": "Copied!", + "pws.copyModelIdFor": "Copy ID for {model}", + "pws.modelCopiedFor": "Copied {model}", "pws.modelsAvailable": "{count} available", "pws.modelSearchPlaceholder": "Filter models…", "pws.modelsLoading": "Loading models…", @@ -1035,6 +1037,13 @@ export const en = { "pws.dashboard.noQuota": "No quota data", "pws.dashboard.noUsage": "No usage data yet", "pws.dashboard.noRateLimits": "No rate-limit data yet", + "pws.capCooldown.title": "Weekly usage limit", + "pws.capCooldown.section": "Usage caps", + "pws.capCooldown.banner": "{provider} hit a weekly/inference cap. {reset}", + "pws.capCooldown.disabled": "Temporarily disabled until the limit resets.", + "pws.capCooldown.paused": "Routing for this provider is paused until then.", + "pws.capCooldown.badge": "Capped", + "pws.attention.capCooldown": "Weekly/inference limit — {reset}", "pws.allProviders": "Provider Overview", "pws.enabledLabel": "Enabled", "pws.testConnection": "Test connection", diff --git a/gui/src/i18n/nl.ts b/gui/src/i18n/nl.ts index 25f115569..ae99ecb2f 100644 --- a/gui/src/i18n/nl.ts +++ b/gui/src/i18n/nl.ts @@ -195,6 +195,17 @@ const overrides: Partial> = { "vk.detailStatus": "status {status}", "vk.detailUpstream": "upstream: {error}", "vk.detailId": "id {id}", + + // provider weekly/inference caps (Clinepass etc.) + "pws.capCooldown.title": "Wekelijks limiet", + "pws.capCooldown.section": "Verbruikslimieten", + "pws.capCooldown.banner": "{provider} zit aan een wekelijks/inference-plafond. {reset}", + "pws.capCooldown.disabled": "Tijdelijk uitgeschakeld tot de limiet reset.", + "pws.capCooldown.paused": "Routing voor deze leverancier staat tot die tijd stil.", + "pws.capCooldown.badge": "Vol", + "pws.attention.capCooldown": "Wekelijks/inference-limiet — {reset}", + "pws.copyModelIdFor": "Kopieer ID van {model}", + "pws.modelCopiedFor": "{model} gekopieerd", }; export const nl: Record = { ...en, ...overrides }; diff --git a/gui/src/i18n/shared.ts b/gui/src/i18n/shared.ts index d7d59d9be..814bcf47b 100644 --- a/gui/src/i18n/shared.ts +++ b/gui/src/i18n/shared.ts @@ -1,23 +1,38 @@ import { createContext, useContext } from "react"; -import { en, type TKey } from "./en"; -import { nl } from "./nl"; +import { DICTS, type Locale, type TKey } from "./dicts"; -export type Locale = "en" | "nl"; -export type { TKey }; - -export const DICTS: Record> = { en, nl }; +export type { Locale, TKey }; +export { DICTS }; export const LOCALES: { code: Locale; name: string; htmlLang: string }[] = [ - { code: "en", name: "English", htmlLang: "en" }, { code: "nl", name: "Nederlands", htmlLang: "nl" }, + { code: "en", name: "English", htmlLang: "en" }, ]; const LANG_KEY = "ocx-lang"; +/** + * Locales this build no longer ships. A saved preference for one of them was an explicit + * "not Dutch" choice, so it migrates to English instead of falling through to the Dutch + * default below. The migrated value is written back so the mapping runs once per browser. + */ +const RETIRED_LOCALES: Record = { + de: "en", + ja: "en", + ko: "en", + ru: "en", + zh: "en", +}; + export function detectInitial(): Locale { try { const stored = localStorage.getItem(LANG_KEY); if (stored === "en" || stored === "nl") return stored; + const migrated = stored ? RETIRED_LOCALES[stored] : undefined; + if (migrated) { + try { localStorage.setItem(LANG_KEY, migrated); } catch { /* ignore */ } + return migrated; + } } catch { /* ignore */ } const nav = typeof navigator !== "undefined" ? navigator.language.toLowerCase() : "nl"; if (nav.startsWith("en")) return "en"; diff --git a/gui/src/icons.tsx b/gui/src/icons.tsx index 0e807d197..52dc280ab 100644 --- a/gui/src/icons.tsx +++ b/gui/src/icons.tsx @@ -43,6 +43,7 @@ export const IconMoon = (p: P) => (); export const IconGlobe = (p: P) => (); export const IconSparkle = (p: P) => (); +export const IconSettings = (p: P) => (); /** Crossed arrows — Combos workspace nav / rail marker (load-balance / hop). */ export const IconShuffle = (p: P) => ( diff --git a/gui/src/oauth-tos-risk.ts b/gui/src/oauth-tos-risk.ts index d894d1f12..106a336a3 100644 --- a/gui/src/oauth-tos-risk.ts +++ b/gui/src/oauth-tos-risk.ts @@ -8,7 +8,10 @@ export type OAuthTosRiskLevel = "high" | "elevated"; const HIGH_RISK = new Set(["anthropic", "google-antigravity"]); -const ELEVATED_RISK = new Set(["github-copilot", "cursor"]); +// ChefGroep host patch: "cursor" removed so the OAuth ToS warning modal never blocks the +// multi-account Cursor login flow (previously applied as a minified-dist patch, see +// gui/dist/assets/*.pre-oauth-tos-cursor.bak on the joep host). +const ELEVATED_RISK = new Set(["github-copilot"]); export function oauthTosRisk(providerId: string): OAuthTosRiskLevel | null { const id = providerId.trim().toLowerCase(); diff --git a/gui/src/pages/Instellingen.tsx b/gui/src/pages/Instellingen.tsx new file mode 100644 index 000000000..aa88ac4fd --- /dev/null +++ b/gui/src/pages/Instellingen.tsx @@ -0,0 +1,76 @@ +import { useState } from "react"; +import { useI18n, LOCALES } from "../i18n/shared"; +import { IconX } from "../icons"; +import { applyTheme, readTheme, type Theme } from "../theme"; + +const THEMES: { value: Theme; labelNl: string; labelEn: string }[] = [ + { value: "light", labelNl: "Licht", labelEn: "Light" }, + { value: "dark", labelNl: "Donker", labelEn: "Dark" }, + { value: "system", labelNl: "Systeem", labelEn: "System" }, +]; + +export default function Instellingen({ onClose }: { apiBase: string; onClose: () => void }) { + const { locale, setLocale, t } = useI18n(); + const [theme, setTheme] = useState(() => readTheme()); + + return ( +
    { if (e.target === e.currentTarget) onClose(); }} + > +
    +
    +

    {t("dash.settingsSection")}

    + +
    + + {/* Taal — Language */} +
    +
    +
    {t("lang.label")}
    +
    {LOCALES.find(l => l.code === locale)?.name ?? locale}
    +
    +
    + +
    +
    + + {/* Weergave — Theme */} +
    +
    +
    {t("theme.label")}
    +
    + {THEMES.find(th => th.value === theme)?.labelNl ?? theme} +
    +
    +
    + {THEMES.map(th => ( + + ))} +
    +
    +
    +
    + ); +} diff --git a/gui/src/pages/Modellen.tsx b/gui/src/pages/Modellen.tsx new file mode 100644 index 000000000..ddd815b89 --- /dev/null +++ b/gui/src/pages/Modellen.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import Models from "./Models"; +import Combos from "./Combos"; +import Subagents from "./Subagents"; +import { useT, type TKey } from "../i18n/shared"; + +type Tab = "modellen" | "combos" | "subagents"; + +const TABS: { id: Tab; labelKey: TKey }[] = [ + { id: "modellen", labelKey: "nav.models" }, + { id: "combos", labelKey: "nav.combos" }, + { id: "subagents", labelKey: "nav.subagents" }, +]; + +const TAB_IDS = new Set(["modellen", "combos", "subagents"]); + +/** Modellen-view: routing/catalogus met combos en sub-agent delegatie als tabs binnen de view. */ +export default function Modellen({ apiBase, target }: { apiBase: string; target?: string }) { + const t = useT(); + const [tab, setTab] = useState(() => TAB_IDS.has(target as Tab) ? target as Tab : "modellen"); + // Deep links like #combos / #subagents open the matching tab straight away. Adjust during render + // when the routed target changes (React's documented alternative to syncing state in an effect). + const [seenTarget, setSeenTarget] = useState(target); + if (target !== seenTarget) { + setSeenTarget(target); + // Reset to the default tab when the routed target is absent or invalid, so navigating + // #modellen/combos -> #modellen doesn't leave the URL and the shown tab disagreeing. + setTab(TAB_IDS.has(target as Tab) ? target as Tab : "modellen"); + } + return ( + <> +
    +

    {t("nav.models")}

    +
    +

    {t("mod.subtitle")}

    +
    + {TABS.map(({ id, labelKey }) => ( + + ))} +
    + {tab === "modellen" && } + {tab === "combos" && } + {tab === "subagents" && } + + ); +} diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 9d660b5fb..e0998bdfd 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -151,8 +151,12 @@ export default function Providers({ apiBase }: { apiBase: string }) { void fetchOauth(); // Quotas: workspace shell owns /api/provider-quotas — do not double-fetch on mount. }, 0); + const interval = window.setInterval(() => { + void fetchConfig(); + }, 60_000); return () => { window.clearTimeout(timeout); + window.clearInterval(interval); }; }, [fetchConfig, fetchOauth]); @@ -258,6 +262,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { onSelect={setWorkspaceSelected} onAddProvider={intent => { setAddIntent(intent ?? null); setAdding(true); }} onEditConfig={openJsonEditor} + providerCooldowns={config.providerCooldowns} jsonEditor={{ open: jsonEditorOpen, draft, @@ -292,6 +297,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { modelsLoading={data.modelsLoading} modelsLoadFailed={data.modelsLoadFailed} onRetryModels={data.onRetryModels} + capCooldown={data.capCooldown} oauthEmail={loginStatus?.email} onDeselect={() => setWorkspaceSelected(null)} apiBase={apiBase} diff --git a/gui/src/pages/Systeem.tsx b/gui/src/pages/Systeem.tsx new file mode 100644 index 000000000..21512c712 --- /dev/null +++ b/gui/src/pages/Systeem.tsx @@ -0,0 +1,290 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; +import Storage from "./Storage"; +import ApiKeys from "./ApiKeys"; +import CodexAuth from "./CodexAuth"; +import ClaudeCode from "./ClaudeCode"; +import { formatUptime } from "../formatUptime"; +import { formatBytes } from "../format-bytes"; +import { readJsonOrThrow } from "../fetch-json"; +import { useI18n, useT } from "../i18n/shared"; +import { IconChevron, IconRefresh } from "../icons"; + +interface HealthData { status: string; version: string; uptime: number } + +type UpdateChannel = "latest" | "preview"; + +interface UpdateCheckData { + currentVersion: string; + latestVersion: string | null; + updateAvailable: boolean; + canUpdate: boolean; + command: string; + reason?: string; +} + +interface StorageSummary { total?: { bytes: number; fileCount: number } } + +function Sectie({ id, label, waarde, acties, children, activeTarget }: { + id?: string; + label: string; + waarde?: ReactNode; + acties?: ReactNode; + children?: ReactNode; + activeTarget?: string; +}) { + const t = useT(); + const [open, setOpen] = useState(() => !!id && id === activeTarget); + // A legacy deep link (#storage / #api / #codex-auth / #claude) opens its section on landing. + // Adjust during render when the routed target changes (React's documented alternative to an effect). + const [seenTarget, setSeenTarget] = useState(activeTarget); + if (activeTarget !== seenTarget) { + setSeenTarget(activeTarget); + if (id && id === activeTarget) setOpen(true); + } + return ( + <> +
    + {label} + {waarde !== undefined && {waarde}} + + {acties} + {children !== undefined && ( + + )} + +
    + {open && children !== undefined && ( +
    {children}
    + )} + + ); +} + +/** Systeem: proxy-status, versie/update, opslag, API-info, Codex Auth en de gevarenzone. */ +export default function Systeem({ apiBase, health, healthFailed, target }: { + apiBase: string; + health: HealthData | null; + healthFailed: boolean; + target?: string; +}) { + const { locale, t } = useI18n(); + const online = !healthFailed && health?.status === "ok"; + + const [updateCheck, setUpdateCheck] = useState(null); + const [updateBusy, setUpdateBusy] = useState(false); + const [updateMsg, setUpdateMsg] = useState(null); + const [syncBusy, setSyncBusy] = useState(false); + const [syncMsg, setSyncMsg] = useState(null); + const [storage, setStorage] = useState(null); + const [stopOpen, setStopOpen] = useState(false); + const [stopping, setStopping] = useState(false); + const [copied, setCopied] = useState(false); + const stopDialogRef = useRef(null); + + useEffect(() => { + let cancelled = false; + fetch(`${apiBase}/api/storage`) + .then(r => r.ok ? r.json() : null) + .then(data => { if (!cancelled && data) setStorage(data as StorageSummary); }) + .catch(() => { /* summary optional */ }); + return () => { cancelled = true; }; + }, [apiBase]); + + useEffect(() => { + const dialog = stopDialogRef.current; + if (!dialog) return; + if (stopOpen && !dialog.open) dialog.showModal(); + if (!stopOpen && dialog.open) dialog.close(); + }, [stopOpen]); + + const checkUpdate = async () => { + setUpdateBusy(true); + setUpdateMsg(null); + const channel: UpdateChannel = health?.version.includes("-preview.") ? "preview" : "latest"; + try { + const res = await fetch(`${apiBase}/api/update/check?tag=${channel}`); + const data = await readJsonOrThrow(res, t("sys.updateCheckFailed")); + if (!data) throw new Error(t("sys.updateCheckFailed")); + setUpdateCheck(data); + if (!data.updateAvailable) setUpdateMsg(t("sys.upToDate")); + } catch (err) { + setUpdateMsg(err instanceof Error ? err.message : String(err)); + } finally { + setUpdateBusy(false); + } + }; + + const runUpdate = async () => { + if (!updateCheck?.canUpdate) return; + setUpdateBusy(true); + setUpdateMsg(null); + const channel: UpdateChannel = health?.version.includes("-preview.") ? "preview" : "latest"; + try { + const res = await fetch(`${apiBase}/api/update/run`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tag: channel, restart: true }), + }); + const data = await readJsonOrThrow<{ job?: unknown }>(res, t("sys.updateStartFailed")); + if (!data?.job) throw new Error(t("sys.updateStartFailed")); + setUpdateMsg(t("sys.updateRunning")); + } catch (err) { + setUpdateMsg(err instanceof Error ? err.message : String(err)); + } finally { + setUpdateBusy(false); + } + }; + + const runSync = async () => { + setSyncBusy(true); + setSyncMsg(null); + try { + const res = await fetch(`${apiBase}/api/sync`, { method: "POST" }); + const data = await readJsonOrThrow<{ added?: number; message?: string }>(res, t("sys.syncFailed")); + setSyncMsg(t("dash.syncOk", { count: data?.added ?? 0 })); + } catch (err) { + setSyncMsg(err instanceof Error ? err.message : String(err)); + } finally { + setSyncBusy(false); + } + }; + + const stopProxy = async () => { + setStopping(true); + try { await fetch(`${apiBase}/api/stop`, { method: "POST" }); } catch { /* verbinding valt weg */ } + setStopOpen(false); + }; + + const endpoint = window.location.origin; + + const copyEndpoint = () => { + navigator.clipboard.writeText(endpoint).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }).catch(() => { /* clipboard geblokkeerd */ }); + }; + + return ( + <> +
    +

    {t("shell.navSystem")}

    +
    +

    {t("sys.subtitle")}

    + +
    +
    +
    {t("sys.proxy")}
    +
    + + {online ? t("dash.online") : t("dash.offline")} + +
    + {health && ( +
    + {t("sys.uptime", { uptime: formatUptime(health.uptime, locale) })} +
    + )} +
    +
    +
    {t("dash.version")}
    +
    v{health?.version ?? "—"}
    +
    + {updateCheck?.canUpdate && updateCheck.updateAvailable ? ( + + ) : ( + + )} +
    +
    + {updateMsg && ( +
    + {updateMsg} +
    + )} +
    +
    {t("sys.catalog")}
    +
    {syncMsg ?? t("sys.catalogDesc")}
    +
    + +
    +
    +
    + + + + + + + {copied ? t("sys.copied") : t("sys.copy")} + + } + > + + + + + + + + + + + +
    +
    {t("sys.dangerZone")}
    +
    + {t("sys.stopDesc")} + +
    +
    + + { event.preventDefault(); setStopOpen(false); }} + > +
    +
    +

    {t("sys.stopConfirmTitle")}

    +
    +
    {t("sys.stopConfirmDesc")}
    +
    + + +
    +
    +
    + + ); +} diff --git a/gui/src/pages/Verkeer.tsx b/gui/src/pages/Verkeer.tsx new file mode 100644 index 000000000..ab06971c2 --- /dev/null +++ b/gui/src/pages/Verkeer.tsx @@ -0,0 +1,238 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import Usage from "./Usage"; +import { useI18n, type TKey } from "../i18n/shared"; +import { formatTokens } from "../format-tokens"; +import { statusCodeInfo } from "../status-codes"; +import { modelLabel } from "../model-display"; + +interface UsageSummary { + summary: { requests: number; totalTokens: number }; + days: Array<{ date: string; requests: number; totalTokens?: number }>; +} + +interface BonEntry { + requestId?: string; + timestamp: number; + model: string; + provider: string; + status: number; + durationMs: number; + errorCode?: string; + upstreamError?: string; + totalTokens?: number; + usage?: { inputTokens: number; outputTokens: number; totalTokens?: number }; +} + +const TAIL_INTERVAL_MS = 5000; + +function bonTokens(entry: BonEntry): number | undefined { + if (entry.usage) return entry.usage.totalTokens ?? entry.usage.inputTokens + entry.usage.outputTokens; + return entry.totalTokens; +} + +function bonStempel(entry: BonEntry): { labelKey: TKey; cls: string } { + if (entry.status >= 200 && entry.status < 300) return { labelKey: "vk.stampDone", cls: "stempel--klaar" }; + if (entry.status === 0) return { labelKey: "vk.stampError", cls: "stempel--fout" }; + if (entry.status >= 400) return { labelKey: "vk.stampError", cls: "stempel--fout" }; + return { labelKey: "vk.stampBusy", cls: "" }; +} + +function tijd(ts: number, locale: string): string { + return new Date(ts).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", second: "2-digit" }); +} + +function vandaagKey(): string { + return new Date().toISOString().slice(0, 10); +} + +/** Verkeer: stat-strip + de bonnenrail met recente requests, met de volledige analyse eronder. */ +export default function Verkeer({ apiBase, target }: { apiBase: string; target?: string }) { + const { locale, t } = useI18n(); + const [summary30d, setSummary30d] = useState(null); + const [logs, setLogs] = useState([]); + const [logsFailed, setLogsFailed] = useState(false); + const [providerFilter, setProviderFilter] = useState(null); + const [paused, setPaused] = useState(false); + const [openBon, setOpenBon] = useState(null); + const [analyseOpen, setAnalyseOpen] = useState(target === "usage"); + const pausedRef = useRef(paused); + useEffect(() => { pausedRef.current = paused; }, [paused]); + + // The legacy #usage deep link opens the full analysis section on landing. Adjust during render + // when the routed target changes (React's documented alternative to syncing state in an effect). + const [seenTarget, setSeenTarget] = useState(target); + if (target !== seenTarget) { + setSeenTarget(target); + if (target === "usage") setAnalyseOpen(true); + } + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const res = await fetch(`${apiBase}/api/usage?range=30d`); + if (!res.ok) return; + const data = await res.json() as UsageSummary; + if (!cancelled) setSummary30d(data); + } catch { /* keep last-good */ } + }; + void load(); + const iv = setInterval(() => void load(), 60_000); + return () => { cancelled = true; clearInterval(iv); }; + }, [apiBase]); + + useEffect(() => { + let cancelled = false; + const tail = async () => { + if (pausedRef.current) return; + try { + const res = await fetch(`${apiBase}/api/logs`); + if (!res.ok) throw new Error(String(res.status)); + const data = await res.json() as BonEntry[]; + if (!cancelled) { + setLogs(Array.isArray(data) ? data.toSorted((a, b) => b.timestamp - a.timestamp) : []); + setLogsFailed(false); + } + } catch { + if (!cancelled) setLogsFailed(true); + } + }; + void tail(); + const iv = setInterval(() => void tail(), TAIL_INTERVAL_MS); + return () => { cancelled = true; clearInterval(iv); }; + }, [apiBase]); + + const providers = useMemo(() => [...new Set(logs.map(l => l.provider))].sort(), [logs]); + const zichtbaar = useMemo( + () => (providerFilter ? logs.filter(l => l.provider === providerFilter) : logs).slice(0, 60), + [logs, providerFilter], + ); + + const requestsVandaag = useMemo(() => { + const key = vandaagKey(); + return summary30d?.days.find(d => d.date === key)?.requests ?? 0; + }, [summary30d]); + + const requests30d = summary30d?.summary.requests ?? 0; + const tokens30d = summary30d?.summary.totalTokens ?? 0; + + return ( + <> +
    +

    {t("shell.navTraffic")}

    +
    +

    {t("vk.subtitle")}

    + +
    +
    + {formatTokens(tokens30d, locale)} + {t("vk.tokens30d")} +
    +
    + {requestsVandaag.toLocaleString(locale)} + {t("vk.requestsToday")} +
    +
    + {requests30d.toLocaleString(locale)} + {t("vk.requests30d")} +
    +
    + +
    +
    + + {providers.map(p => ( + + ))} +
    + +
    + + {logsFailed && ( +

    + {t("vk.loadFailed")} +

    + )} + +
    setPaused(true)}> + {zichtbaar.length === 0 ? ( +

    + {t("vk.empty")} +

    + ) : zichtbaar.map(entry => { + const id = entry.requestId ?? `${entry.timestamp}-${entry.provider}-${entry.model}`; + const stempel = bonStempel(entry); + const tokens = bonTokens(entry); + const isOpen = openBon === id; + const statusInfo = statusCodeInfo(entry.status, locale); + return ( +
    + + {isOpen && ( +
    +
    {t("vk.detailStatus", { status: entry.status })}{statusInfo ? ` · ${statusInfo.label}` : ""}
    + {entry.errorCode &&
    {t("vk.detailError", { code: entry.errorCode })}
    } + {entry.upstreamError &&
    {t("vk.detailUpstream", { error: entry.upstreamError })}
    } + {entry.usage && ( +
    {t("vk.detailInOut", { in: entry.usage.inputTokens, out: entry.usage.outputTokens })}
    + )} + {entry.requestId &&
    {t("vk.detailId", { id: entry.requestId })}
    } +
    + )} +
    + ); + })} +
    + +
    + + {analyseOpen && ( +
    + +
    + )} +
    + + ); +} diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index ecd1d0699..2180933b0 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -16,6 +16,18 @@ export interface ProvidersConfig { codexAccountMode?: "direct" | "pool"; fallback?: Array<{ provider: string; model: string }>; }>; + /** Active weekly/inference-cap cooldowns from /api/config (until > now). */ + providerCooldowns?: Record; +} + +/** Hard-cap cooldown entry exposed by safeConfigDTO. */ +export interface ProviderCapCooldown { + until: number; + reason: string; + message: string; + source?: string; + disabledProvider?: boolean; + recordedAt?: number; } export interface OAuthStatus { diff --git a/gui/src/posthog-sanitize.ts b/gui/src/posthog-sanitize.ts new file mode 100644 index 000000000..c5c130d03 --- /dev/null +++ b/gui/src/posthog-sanitize.ts @@ -0,0 +1,43 @@ +import { LEGACY_ROUTES, VALID_PAGES } from "./route"; + +/** + * Known hash routes for $current_url. Anything else is treated as sensitive/unknown + * and dropped to the bare origin+pathname. + * + * Canonical De Pas pages plus their known sub-targets, and the legacy English deep + * links that `parseHash` still accepts before they rewrite. + */ +const KNOWN_HASH_ROUTES = new Set([ + ...VALID_PAGES, + "modellen/modellen", + "modellen/combos", + "modellen/subagents", + "verkeer/usage", + "systeem/storage", + "systeem/codex-auth", + "systeem/api", + "systeem/claude", + ...Object.keys(LEGACY_ROUTES), + "logs/debug", + "providers/workspace", +]); + +/** Page heads that are safe to emit even when the sub-target is unknown. */ +const KNOWN_PAGE_HEADS = new Set([ + ...VALID_PAGES, + ...Object.keys(LEGACY_ROUTES), +]); + +/** Minimized $current_url: origin + pathname + only a known hash route. + * Drops the query string and any unknown hash contents so auth codes, + * invitation tokens, or emails never reach PostHog. */ +export function sanitizedCurrentUrl(loc: Pick): string { + const base = `${loc.origin}${loc.pathname}`; + const hashRoute = loc.hash.replace(/^#\/?(.*)$/, "$1").replace(/^\/+/, ""); + if (!hashRoute) return base; + if (KNOWN_HASH_ROUTES.has(hashRoute)) return `${base}#${hashRoute}`; + // Unknown sub-target on a known page: emit the page head only. + const head = hashRoute.split("/")[0] ?? ""; + if (KNOWN_PAGE_HEADS.has(head)) return `${base}#${head}`; + return base; +} diff --git a/gui/src/posthog.ts b/gui/src/posthog.ts new file mode 100644 index 000000000..7661b9056 --- /dev/null +++ b/gui/src/posthog.ts @@ -0,0 +1,47 @@ +import posthog from "posthog-js"; +import { sanitizedCurrentUrl } from "./posthog-sanitize"; + +export { sanitizedCurrentUrl } from "./posthog-sanitize"; + +const DEFAULT_HOST = "https://eu.i.posthog.com"; + +function posthogKey(): string | undefined { + const key = import.meta.env.VITE_POSTHOG_KEY; + return typeof key === "string" && key.trim() ? key.trim() : undefined; +} + +/** Init PostHog only when VITE_POSTHOG_KEY is set. No identify / no PII. */ +export function initPostHog(): void { + const key = posthogKey(); + if (!key || typeof window === "undefined") { + return; + } + + const host = + typeof import.meta.env.VITE_POSTHOG_HOST === "string" && + import.meta.env.VITE_POSTHOG_HOST.trim() + ? import.meta.env.VITE_POSTHOG_HOST.trim() + : DEFAULT_HOST; + + posthog.init(key, { + api_host: host, + capture_pageview: false, + capture_pageleave: true, + persistence: "localStorage", + person_profiles: "identified_only", + }); + + captureHashPageview(); + window.addEventListener("hashchange", captureHashPageview); +} + +/** Manual $pageview for hash routes (e.g. #leveranciers). */ +export function captureHashPageview(): void { + if (!posthogKey() || !posthog.__loaded) { + return; + } + + posthog.capture("$pageview", { + $current_url: sanitizedCurrentUrl(window.location), + }); +} diff --git a/gui/src/route.ts b/gui/src/route.ts new file mode 100644 index 000000000..1a28b2090 --- /dev/null +++ b/gui/src/route.ts @@ -0,0 +1,38 @@ +export type Page = "leveranciers" | "modellen" | "verkeer" | "systeem"; + +export const VALID_PAGES = new Set(["leveranciers", "modellen", "verkeer", "systeem"]); + +export interface Route { page: Page; target?: string } + +/** + * Legacy deep links from the old 11-page shell land on the view that absorbed them, carrying a + * sub-target so the destination opens the right tab/section instead of its default. Old bookmarks + * to #codex-auth / #api / #claude / #combos / #subagents used to collapse to just the parent page; + * threading the target keeps them landing where the user expects. Canonical form: #/[/]. + */ +export const LEGACY_ROUTES: Record = { + dashboard: { page: "systeem" }, + providers: { page: "leveranciers" }, + models: { page: "modellen", target: "modellen" }, + combos: { page: "modellen", target: "combos" }, + subagents: { page: "modellen", target: "subagents" }, + logs: { page: "verkeer" }, + debug: { page: "verkeer" }, + usage: { page: "verkeer", target: "usage" }, + storage: { page: "systeem", target: "storage" }, + "codex-auth": { page: "systeem", target: "codex-auth" }, + api: { page: "systeem", target: "api" }, + claude: { page: "systeem", target: "claude" }, +}; + +/** Parse a raw `location.hash` (with or without a leading `#` / `#/`) into a route. */ +export function parseHash(hash: string): Route { + const raw = hash.replace(/^#\/?/, ""); + const [head, sub] = raw.split("/"); + if (VALID_PAGES.has(head as Page)) return { page: head as Page, target: sub || undefined }; + return LEGACY_ROUTES[head] ?? { page: "leveranciers" }; +} + +export function canonicalHash(route: Route): string { + return route.target ? `${route.page}/${route.target}` : route.page; +} diff --git a/gui/src/styles/depas.css b/gui/src/styles/depas.css new file mode 100644 index 000000000..c6c372773 --- /dev/null +++ b/gui/src/styles/depas.css @@ -0,0 +1,536 @@ +/* ============================================================================ + De Pas — ChefGroep design language for the OpenCodex dashboard. + Binds to chefgroep-vault/.ulpi/design/DESIGN.md + opencodex-dashboard.md. + Light tiled kitchen: tegelwit base, gietijzer text, koraal action, + IBM Plex Mono for tickets/data, Archivo for signage and interface. + Loaded last: its token overrides re-skin every legacy component. + ============================================================================ */ + +@import "@fontsource-variable/archivo/wdth.css"; +@import "@fontsource/ibm-plex-mono/400.css"; +@import "@fontsource/ibm-plex-mono/600.css"; + +:root { + color-scheme: light; + + /* palette — 60% tegelwit / 30% gietijzer / 10% koraal */ + --tegelwit: #F5F7F4; + --gietijzer: #201E1B; + --gietijzer-60: #5C5852; + --rvs: #9AA29C; + --rvs-licht: #E4E8E3; + --koraal: #E23A17; + --gaar: #20714A; + --wijn: #8F1D2C; + + /* legacy token remap: every existing component re-skins through these */ + --bg: var(--tegelwit); + --rail: #EDF0EC; + --surface: #FFFFFF; + --raised: var(--rvs-licht); + --raised-hover: #DCE1DA; + --border: var(--rvs-licht); + --border-soft: #ECEFEA; + --hover: rgba(32, 30, 27, 0.04); + + --text: var(--gietijzer); + --muted: var(--gietijzer-60); + --faint: var(--rvs); + + --accent: var(--koraal); + --accent-hover: #C6320F; + --accent-ink: #FFFFFF; + --accent-soft: rgba(226, 58, 23, 0.08); + --accent-ring: rgba(226, 58, 23, 0.4); + + --green: var(--gaar); + --green-soft: rgba(32, 113, 74, 0.10); + --red: var(--wijn); + --red-soft: rgba(143, 29, 44, 0.08); + /* attention ("even jou nodig") is koraal, never a third hue */ + --amber: var(--koraal); + --amber-soft: rgba(226, 58, 23, 0.10); + + /* geometry: 4px buttons/inputs, 8px panels, no pills (search excepted) */ + --radius-2xs: 2px; + --radius-xs: 4px; + --radius-sm: 4px; + --radius: 8px; + --radius-lg: 8px; + --radius-pill: 4px; + + --font-ui: "Archivo Variable", "Archivo", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-code: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; + --font: var(--font-ui); + --mono: var(--font-code); + + /* one elevation level */ + --shadow: 0 1px 2px rgb(32 30 27 / 0.08); + --shadow-sm: 0 1px 2px rgb(32 30 27 / 0.08); + + --toggle-on-bg: var(--gaar); + --toggle-dot-color: #FFFFFF; + + --glass-rail: rgba(245, 247, 244, 0.94); + --glass-panel: rgba(255, 255, 255, 0.96); + --glass-blur: none; +} + +/* the OS/user theme attribute no longer switches palettes: one identity */ +:root[data-theme="light"], +:root[data-theme="dark"] { color-scheme: light; } + +/* no ambient wash: tiles, not atmosphere */ +body::before { display: none; } + +body { background: var(--tegelwit); } + +/* switches stay round for affordance (they are not pill buttons) */ +.switch, .toggle { border-radius: 999px; } +.switch .knob, .toggle::after { border-radius: 50%; } + +/* focus: 2px koraal, 2px offset, everywhere */ +:focus-visible { outline: 2px solid var(--koraal); outline-offset: 2px; } + +/* --------------------------------------------------------------------------- + Shell: topbar + tekstnav met koraal onderstreping. Geen sidebar. + --------------------------------------------------------------------------- */ +.depas-app { + min-height: 100%; + display: flex; + flex-direction: column; +} + +.depas-topbar { + display: flex; + align-items: center; + gap: 24px; + padding: 12px 32px; + background: var(--tegelwit); + border-bottom: 1px solid var(--rvs-licht); + position: sticky; + top: 0; + z-index: 30; +} + +.depas-brand { + display: flex; + align-items: baseline; + gap: 8px; + white-space: nowrap; +} + +.depas-brand-name { + font-family: var(--font-ui); + font-variation-settings: "wdth" 125; + font-weight: 800; + font-size: 1.125rem; + letter-spacing: -0.02em; + color: var(--gietijzer); +} + +.depas-brand-ver { + font-family: var(--font-code); + font-size: 0.75rem; + color: var(--rvs); +} + +.depas-nav { + display: flex; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; + overflow-x: auto; +} + +.depas-nav-item { + appearance: none; + border: none; + background: transparent; + font: inherit; + font-weight: 500; + font-size: 0.875rem; + color: var(--gietijzer-60); + padding: 8px 12px; + cursor: pointer; + border-radius: var(--radius-xs); + border-bottom: 2px solid transparent; + white-space: nowrap; +} + +.depas-nav-item:hover { color: var(--gietijzer); background: var(--hover); } + +.depas-nav-item.active { + color: var(--gietijzer); + font-weight: 600; + border-bottom-color: var(--koraal); + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.depas-topbar-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.depas-main { + flex: 1; + width: 100%; + max-width: 1080px; + margin: 0 auto; + padding: 32px; +} + +@media (max-width: 760px) { + .depas-topbar { padding: 8px 16px; gap: 12px; flex-wrap: wrap; } + .depas-main { padding: 16px; } +} + +/* --------------------------------------------------------------------------- + Stempels: IBM Plex Mono 600, uppercase, 0.08em, 2px radius kader. + BEZIG gietijzer-kader · KLAAR gaar · HULP koraal-gevuld · FOUT wijn-gevuld · STIL rvs + --------------------------------------------------------------------------- */ +.stempel { + display: inline-flex; + align-items: center; + gap: 6px; + font-family: var(--font-code); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + line-height: 1.2; + padding: 2px 8px; + border-radius: 2px; + border: 1.5px solid var(--gietijzer); + color: var(--gietijzer); + background: transparent; + white-space: nowrap; +} + +.stempel--klaar, .stempel--online { border-color: var(--gaar); color: var(--gaar); } +.stempel--hulp { border-color: var(--koraal); background: var(--koraal); color: #fff; } +.stempel--fout, .stempel--offline { border-color: var(--wijn); background: var(--wijn); color: #fff; } +.stempel--stil { border-color: var(--rvs); color: var(--rvs); } +.stempel--vers { border-color: transparent; padding-left: 0; padding-right: 0; color: var(--gietijzer-60); text-transform: none; letter-spacing: 0; font-weight: 400; } +.stempel--verouderd { border-color: transparent; padding-left: 0; padding-right: 0; color: var(--rvs); text-transform: none; letter-spacing: 0; font-weight: 400; } + +/* vuur-puls: het enige ambient element */ +.vuur { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--koraal); + animation: vuur-puls 2.4s ease-in-out infinite; + flex-shrink: 0; +} + +.dot-gaar { width: 6px; height: 6px; border-radius: 50%; background: var(--gaar); flex-shrink: 0; } +.dot-wijn { width: 6px; height: 6px; border-radius: 50%; background: var(--wijn); flex-shrink: 0; } + +@keyframes vuur-puls { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +@media (prefers-reduced-motion: reduce) { + .vuur { animation: none; } +} + +/* stempelslag bij statuswissel */ +@keyframes stempelslag { + from { transform: scale(1.15); } + to { transform: scale(1); } +} + +.stempel--slag { animation: stempelslag 140ms ease-out; } + +/* --------------------------------------------------------------------------- + Leverancierskaart: breed paneel, eigen datastroom, voegen als scheiding. + --------------------------------------------------------------------------- */ +.lev-kaart { + background: var(--surface); + border: 1px solid var(--rvs-licht); + border-radius: var(--radius); + box-shadow: var(--shadow); + margin-bottom: 16px; +} + +.lev-kaart--fout { border-left: 3px solid var(--wijn); } + +.lev-kaart-kop { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-soft); + min-height: 40px; +} + +.lev-kaart-naam { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 1rem; + min-width: 0; +} + +.lev-kaart-kop-rechts { + margin-left: auto; + display: flex; + align-items: center; + gap: 12px; +} + +.lev-kaart-vers { + font-family: var(--font-code); + font-size: 0.75rem; + color: var(--gietijzer-60); + white-space: nowrap; +} + +.lev-kaart-vers--verouderd { color: var(--rvs); } + +.lev-kaart-body { padding: 12px 16px; } + +.lev-statusregel { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + font-size: 0.875rem; +} + +.lev-statusregel .mono { font-size: 0.8125rem; } + +.lev-foutregel { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + color: var(--wijn); + font-size: 0.875rem; +} + +.lev-rijen { margin-top: 8px; } + +.lev-rij { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; + border-top: 1px solid var(--border-soft); + min-height: 40px; + font-size: 0.875rem; +} + +.lev-rij-label { font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; } +.lev-rij-meta { font-family: var(--font-code); font-size: 0.75rem; color: var(--gietijzer-60); } +.lev-rij-acties { margin-left: auto; display: flex; align-items: center; gap: 8px; } + +/* --------------------------------------------------------------------------- + Bonnen + rail: live events als keukentickets. + --------------------------------------------------------------------------- */ +.rail { + position: relative; + border-top: 1px solid var(--gietijzer); + padding-top: 16px; + background-image: repeating-linear-gradient( + to right, + transparent 0 114px, + var(--gietijzer) 114px 120px + ); + background-size: 100% 6px; + background-repeat: no-repeat; + background-position: 0 -3px; +} + +.bon { + font-family: var(--font-code); + font-size: 0.8125rem; + background: var(--surface); + border: 1px solid var(--rvs-licht); + border-top: 2px dotted var(--rvs); + border-radius: 0 0 var(--radius-xs) var(--radius-xs); + padding: 10px 12px; + box-shadow: var(--shadow); +} + +.bon + .bon { margin-top: 8px; } + +.bon-kop { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.bon-tijd { color: var(--gietijzer-60); white-space: nowrap; } +.bon-titel { font-weight: 600; } +.bon-meta { color: var(--gietijzer-60); } +.bon-kop .stempel { margin-left: auto; } + +.bon-detail { + margin-top: 8px; + padding-top: 8px; + border-top: 1px dashed var(--rvs-licht); + color: var(--gietijzer-60); + overflow-x: auto; +} + +.bon-enter { animation: bon-enter 240ms ease-out; } + +@keyframes bon-enter { + from { transform: translateY(-8px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +@media (prefers-reduced-motion: reduce) { + .bon-enter { animation: none; } +} + +/* --------------------------------------------------------------------------- + Stat-strip (geen cards) + lijsten met voegen + gevarenzone + --------------------------------------------------------------------------- */ +.stat-strip { + display: flex; + gap: 48px; + flex-wrap: wrap; + padding: 16px 0 24px; +} + +.stat-strip-item { display: flex; flex-direction: column; gap: 2px; } + +.stat-strip-waarde { + font-family: var(--font-code); + font-weight: 600; + font-size: 1.5rem; + line-height: 1.2; + font-variant-numeric: tabular-nums; +} + +.stat-strip-label { font-size: 0.75rem; color: var(--gietijzer-60); } + +.voegen-lijst { border-top: 1px solid var(--rvs-licht); } + +.voegen-rij { + display: flex; + align-items: center; + gap: 16px; + padding: 14px 0; + border-bottom: 1px solid var(--rvs-licht); + min-height: 44px; +} + +.voegen-rij dt, .voegen-label { font-weight: 500; font-size: 0.875rem; min-width: 180px; } +.voegen-rij dd { margin: 0; } +.voegen-waarde { font-family: var(--font-code); font-size: 0.8125rem; color: var(--gietijzer-60); word-break: break-all; } +.voegen-rij-acties { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; } + +.gevarenzone { + margin-top: 48px; + border: 1px solid var(--wijn); + border-radius: var(--radius); + padding: 16px; +} + +.gevarenzone-kop { + font-family: var(--font-code); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--wijn); + margin-bottom: 8px; +} + +.btn-wijn { + background: transparent; + border-color: var(--wijn); + color: var(--wijn); +} + +.btn-wijn:hover { background: var(--red-soft); } + +/* --------------------------------------------------------------------------- + Viewkop: Archivo display + subregel + --------------------------------------------------------------------------- */ +.depas-viewkop { + display: flex; + align-items: baseline; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.depas-viewkop h2 { + font-family: var(--font-ui); + font-variation-settings: "wdth" 125; + font-weight: 800; + font-size: 1.5rem; + letter-spacing: -0.02em; +} + +.depas-viewsub { color: var(--gietijzer-60); font-size: 0.875rem; margin: 0 0 24px; } + +/* proxy-offline banner over de volle breedte */ +.depas-offline-banner { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + background: var(--wijn); + color: #fff; + padding: 10px 32px; + font-size: 0.9375rem; + font-weight: 600; +} + +/* instellingen-sheet */ +.depas-sheet-scrim { + position: fixed; + inset: 0; + background: rgba(32, 30, 27, 0.4); + z-index: 60; +} + +.depas-sheet { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(480px, 100vw); + background: var(--tegelwit); + border-left: 1px solid var(--rvs-licht); + box-shadow: var(--shadow); + z-index: 61; + overflow-y: auto; + padding: 24px; +} + +.depas-sheet-kop { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 16px; +} + +.depas-sheet-kop h3 { + font-family: var(--font-ui); + font-variation-settings: "wdth" 125; + font-weight: 800; + font-size: 1.125rem; + letter-spacing: -0.02em; +} + +/* legacy pagina's binnen de nieuwe shell: neutraliseer donker-specifieke chrome */ +.depas-main .page-head h2 { + font-family: var(--font-ui); + font-variation-settings: "wdth" 125; + font-weight: 800; + letter-spacing: -0.02em; +} diff --git a/gui/src/styles/provider-overview-dashboard.css b/gui/src/styles/provider-overview-dashboard.css index 9479c1dd6..b4e03cec8 100644 --- a/gui/src/styles/provider-overview-dashboard.css +++ b/gui/src/styles/provider-overview-dashboard.css @@ -98,7 +98,8 @@ padding-bottom: 2px; } -.pws-dashboard-attention .pws-dashboard-section-title { +.pws-dashboard-attention .pws-dashboard-section-title, +.pws-dashboard-caps .pws-dashboard-section-title { display: inline-flex; align-items: center; gap: 6px; diff --git a/gui/src/styles/provider-quota.css b/gui/src/styles/provider-quota.css index b11ed3d45..ff695bcf3 100644 --- a/gui/src/styles/provider-quota.css +++ b/gui/src/styles/provider-quota.css @@ -75,3 +75,12 @@ color: var(--amber); font-size: 12px; } + +/* Per-provider refresh row under the quota bars (last-updated · error · refresh). */ +.provider-quota-meta { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + margin-top: 8px; +} diff --git a/gui/src/styles/provider-workspace-shell.css b/gui/src/styles/provider-workspace-shell.css index 783c3edfe..08ce67732 100644 --- a/gui/src/styles/provider-workspace-shell.css +++ b/gui/src/styles/provider-workspace-shell.css @@ -369,6 +369,28 @@ border-color: var(--amber); } +.pwi-rail-badge--capped { + color: var(--yellow, #eab308); + border-color: var(--yellow, #eab308); +} + +.providers-workspace-rail-row--capped .providers-workspace-rail-name-label { + opacity: 0.9; +} + +.pws-cap-cooldown { + display: flex; + gap: 10px; + align-items: flex-start; + margin-bottom: 12px; +} + +.pws-cap-cooldown-msg { + margin-top: 6px; + font-size: 0.8rem; + overflow-wrap: anywhere; +} + .providers-workspace-rail-trail { display: inline-flex; align-items: center; @@ -916,6 +938,32 @@ text-align: left; } +.pws-models-head-actions { + display: inline-flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.pws-models-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.pws-models-toolbar .pws-model-search { + flex: 1; + min-width: 160px; + margin: 0; +} + +.pws-models-bulk { + display: inline-flex; + gap: 4px; +} + .pws-model-chip { display: inline-flex; align-items: center; @@ -927,6 +975,15 @@ background: color-mix(in oklab, var(--surface, var(--panel)) 92%, var(--text) 4%); } +.pws-model-chip-off { + opacity: 0.55; +} + +.pws-model-usage { + display: block; + margin-top: 2px; +} + .pws-model-chip-main { display: inline-flex; align-items: center; @@ -978,6 +1035,24 @@ border-top: 1px solid color-mix(in oklab, var(--border) 45%, transparent); } +.pws-usage-block-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.pws-usage-block-head .pws-section-title { + margin-bottom: 0; +} + +/* "Quota updated" stat with its inline per-provider refresh button. */ +.pws-quota-updated { + display: inline-flex; + align-items: center; + gap: 6px; +} + .pws-usage-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/gui/src/theme.ts b/gui/src/theme.ts new file mode 100644 index 000000000..773192d87 --- /dev/null +++ b/gui/src/theme.ts @@ -0,0 +1,21 @@ +export type Theme = "light" | "dark" | "system"; + +export function readTheme(): Theme { + try { + const t = localStorage.getItem("ocx-theme"); + if (t === "light" || t === "dark") return t; + } catch { /* ignore */ } + return "system"; +} + +export function applyTheme(next: Theme) { + try { + if (next === "system") { + localStorage.removeItem("ocx-theme"); + document.documentElement.removeAttribute("data-theme"); + } else { + localStorage.setItem("ocx-theme", next); + document.documentElement.setAttribute("data-theme", next); + } + } catch { /* ignore */ } +} diff --git a/gui/src/use-provider-quotas.ts b/gui/src/use-provider-quotas.ts new file mode 100644 index 000000000..af64dbf7f --- /dev/null +++ b/gui/src/use-provider-quotas.ts @@ -0,0 +1,114 @@ +/** + * useProviderQuotas — single owner of /api/provider-quotas state for the GUI. + * + * Per-provider refresh (`refreshProvider`) hits `?provider=` so one + * provider's probe never touches other upstreams. Rows merge by provider and + * a stale response can never overwrite a newer row (generation-guarded). + * Concurrent refreshes of the same request (URL) join the in-flight request; + * a forced `refreshAll(true)` (`?refresh=1`) never joins a non-forced one. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ProviderQuotaReportView } from "./provider-workspace/report"; + +interface WireReport { + provider: string; + label?: string; + source?: string; + updatedAt?: number; + quota?: unknown; +} + +export interface ProviderQuotasApi { + reports: Record; + /** Providers with a refresh in flight (per-provider spinners; never a global one). */ + refreshing: Record; + /** Providers whose last refresh failed (last-good data stays visible). */ + failed: Record; + /** Force-probe one provider's upstream. Resolves true on success. */ + refreshProvider: (name: string) => Promise; + /** Aggregate load; force=true re-probes every provider (config-level changes only). */ + refreshAll: (force?: boolean) => Promise; +} + +const ALL = "*"; + +export function useProviderQuotas(apiBase: string): ProviderQuotasApi { + const [reports, setReports] = useState>({}); + const [refreshing, setRefreshing] = useState>({}); + const [failed, setFailed] = useState>({}); + const aliveRef = useRef(true); + const generationRef = useRef>({}); + const inflightRef = useRef>>(new Map()); + + useEffect(() => { + aliveRef.current = true; + return () => { aliveRef.current = false; }; + }, []); + + const mergeReports = useCallback((rows: WireReport[]) => { + if (!aliveRef.current || rows.length === 0) return; + setReports(prev => { + const next = { ...prev }; + for (const row of rows) { + if (!row?.provider) continue; + const updatedAt = typeof row.updatedAt === "number" ? row.updatedAt : Date.now(); + const existing = next[row.provider]; + // Race guard: a slower response must not roll back a newer row. + if (existing?.updatedAt !== undefined && existing.updatedAt > updatedAt) continue; + next[row.provider] = { label: row.label, source: row.source, updatedAt, quota: row.quota }; + } + return next; + }); + }, []); + + const runScoped = useCallback((scope: string, url: string): Promise => { + // Dedupe by URL, not scope: a forced refresh (`?refresh=1`) and a plain one both + // target scope ALL but carry different URLs, so the forced call must not join + // (and lose its re-probe intent to) an in-flight non-forced call. + const joinable = inflightRef.current.get(url); + if (joinable) return joinable; + + const generation = (generationRef.current[scope] ?? 0) + 1; + generationRef.current[scope] = generation; + const isCurrent = () => aliveRef.current && generationRef.current[scope] === generation; + + if (scope !== ALL) setRefreshing(prev => ({ ...prev, [scope]: true })); + + const promise = (async () => { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(String(res.status)); + const data = await res.json() as { reports?: WireReport[] }; + if (!isCurrent()) return true; + mergeReports(data.reports ?? []); + if (scope === ALL) { + setFailed({}); + } else { + setFailed(prev => ({ ...prev, [scope]: false })); + } + return true; + } catch { + if (isCurrent() && scope !== ALL) setFailed(prev => ({ ...prev, [scope]: true })); + return false; + } finally { + inflightRef.current.delete(url); + if (isCurrent() && scope !== ALL) setRefreshing(prev => ({ ...prev, [scope]: false })); + } + })(); + + inflightRef.current.set(url, promise); + return promise; + }, [mergeReports]); + + const refreshProvider = useCallback( + (name: string) => runScoped(name, `${apiBase}/api/provider-quotas?provider=${encodeURIComponent(name)}`), + [apiBase, runScoped], + ); + + const refreshAll = useCallback( + (force = false) => runScoped(ALL, `${apiBase}/api/provider-quotas${force ? "?refresh=1" : ""}`), + [apiBase, runScoped], + ); + + return { reports, refreshing, failed, refreshProvider, refreshAll }; +} diff --git a/gui/src/vite-env.d.ts b/gui/src/vite-env.d.ts index 845174293..152c6ecf1 100644 --- a/gui/src/vite-env.d.ts +++ b/gui/src/vite-env.d.ts @@ -2,3 +2,13 @@ // Injected at build time by vite.config.ts `define` as the UI version fallback. declare const __APP_VERSION__: string; + +interface ImportMetaEnv { + readonly VITE_API_BASE?: string; + readonly VITE_POSTHOG_KEY?: string; + readonly VITE_POSTHOG_HOST?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/gui/tests/apikeys-layout.test.ts b/gui/tests/apikeys-layout.test.ts index 2e73d84c7..a13baffac 100644 --- a/gui/tests/apikeys-layout.test.ts +++ b/gui/tests/apikeys-layout.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { localeDicts } from "./helpers/locales"; async function apiKeysSources(): Promise { const page = await Bun.file(new URL("../src/pages/ApiKeys.tsx", import.meta.url)).text(); @@ -87,17 +88,21 @@ test("ApiKeys workspace keeps endpoint, generate, models, and usage panels", asy expect(page).toContain('from "../api-access-models"'); }); -test("apikeys workspace i18n keys exist in every locale", async () => { - const locales = ["en"] as const; // full dictionaries; nl.ts spreads en and only overrides a subset - for (const locale of locales) { - const dict = await Bun.file(new URL(`../src/i18n/${locale}.ts`, import.meta.url)).text(); - expect(dict).toContain('"api.workspace.overview":'); - expect(dict).toContain('"api.workspace.details":'); - expect(dict).toContain('"api.workspace.deleteKey":'); - expect(dict).toContain('"api.workspace.usageExamples":'); - expect(dict).toContain('"api.copyUrlHint":'); - expect(dict).toContain('"api.urlCopied":'); - expect(dict).toContain('"api.copyExampleHint":'); - expect(dict).toContain('"api.exampleCopied":'); +test("apikeys workspace i18n keys resolve in every shipped locale", () => { + // The gate follows the shipped registry (en/nl) and asserts *resolved* dictionaries: + // nl spreads en, so a raw-source check would false-negative on inherited keys. + for (const [locale, dict] of localeDicts()) { + for (const key of [ + "api.workspace.overview", + "api.workspace.details", + "api.workspace.deleteKey", + "api.workspace.usageExamples", + "api.copyUrlHint", + "api.urlCopied", + "api.copyExampleHint", + "api.exampleCopied", + ] as const) { + expect(`${locale}:${key}:${dict[key] ?? ""}`).not.toBe(`${locale}:${key}:`); + } } }); diff --git a/gui/tests/claude-auth-mode-badge.test.ts b/gui/tests/claude-auth-mode-badge.test.ts index 6af9f3617..cfe419b1c 100644 --- a/gui/tests/claude-auth-mode-badge.test.ts +++ b/gui/tests/claude-auth-mode-badge.test.ts @@ -1,7 +1,6 @@ import { expect, test } from "bun:test"; import { buildManualEnv } from "../src/pages/claude-manual-env"; - -const LOCALES = ["en"] as const; // full dictionaries; nl.ts spreads en and only overrides a subset +import { localeDicts } from "./helpers/locales"; async function read(path: string): Promise { return Bun.file(new URL(path, import.meta.url)).text(); @@ -22,12 +21,11 @@ const NEW_KEYS = [ "claude.authSource.unknown", ]; -test("every locale carries the auth-mode keys", async () => { +test("every locale carries the auth-mode keys", () => { const missing: string[] = []; - for (const locale of LOCALES) { - const dict = await read(`../src/i18n/${locale}.ts`); + for (const [locale, dict] of localeDicts()) { for (const key of NEW_KEYS) { - if (!dict.includes(`"${key}"`)) missing.push(`${locale}:${key}`); + if (!(dict[key as keyof typeof dict] ?? "").trim()) missing.push(`${locale}:${key}`); } } expect(missing).toEqual([]); diff --git a/gui/tests/claude-desktop-locale.test.ts b/gui/tests/claude-desktop-locale.test.ts index 5d30d6e1f..48e220e38 100644 --- a/gui/tests/claude-desktop-locale.test.ts +++ b/gui/tests/claude-desktop-locale.test.ts @@ -1,40 +1,34 @@ import { expect, test } from "bun:test"; +import { localeDicts, SHIPPED_LOCALES } from "./helpers/locales"; +import { en } from "../src/i18n/en"; +import { DICTS } from "../src/i18n/shared"; -const LOCALES = ["en"] as const; // full dictionaries; nl.ts spreads en and only overrides a subset - -async function readDict(locale: string): Promise> { - const src = await Bun.file(new URL(`../src/i18n/${locale}.ts`, import.meta.url)).text(); - const out = new Map(); - for (const m of src.matchAll(/^\s*"([^"]+)":\s*"((?:[^"\\]|\\.)*)"/gm)) { - out.set(m[1]!, m[2]!); - } - return out; -} - -// `en.ts` is the source of truth for `TKey`, so a MISSING key already fails `tsc`. These +// `nl.ts` is typed `Record`, so a MISSING key already fails `tsc`. These // cases cover what the type cannot see: a key that exists but renders nothing, which // would ship a blank tab label or an empty lane heading. The Claude Desktop keys arrived // through a hand-resolved merge conflict, so they get an explicit guard. -test("every locale defines a non-empty value for each Claude Desktop key", async () => { - const en = await readDict("en"); - const desktopKeys = [...en.keys()].filter(k => k.startsWith("claudeDesktop.") || k.startsWith("claude.tab")); +// +// The gates read the resolved dictionaries rather than locale module text: overlay locales +// spread `en` and only override a subset, so source-text scanning would report every +// inherited key as missing. +test("every locale defines a non-empty value for each Claude Desktop key", () => { + const desktopKeys = Object.keys(en).filter(k => k.startsWith("claudeDesktop.") || k.startsWith("claude.tab")); expect(desktopKeys.length).toBeGreaterThan(50); const blank: string[] = []; - for (const locale of LOCALES) { - const dict = await readDict(locale); + for (const [locale, dict] of localeDicts()) { for (const key of desktopKeys) { - if (!(dict.get(key) ?? "").trim()) blank.push(`${locale}:${key}`); + if (!(dict[key as keyof typeof dict] ?? "").trim()) blank.push(`${locale}:${key}`); } } expect(blank).toEqual([]); }); -// Dutch is an override layer spread over `en`, so it cannot be missing keys. What it CAN -// carry is a stale or misspelled key that silently never renders, which `Partial>` -// only catches for keys tsc can see as literals — this guards the source text directly. -test("Dutch overrides only reference keys that exist in the English source", async () => { - const en = await readDict("en"); - const unknown = [...(await readDict("nl")).keys()].filter(k => !en.has(k)); - expect(unknown).toEqual([]); +test("locale key sets stay identical to the English source", () => { + const enKeys = Object.keys(en).sort(); + for (const locale of SHIPPED_LOCALES.filter(l => l !== "en")) { + const other = Object.keys(DICTS[locale]).sort(); + expect(`${locale}:${other.length}`).toBe(`${locale}:${enKeys.length}`); + expect(other).toEqual(enKeys); + } }); diff --git a/gui/tests/grok-page.test.ts b/gui/tests/grok-page.test.ts index 96891c742..b10e45b7c 100644 --- a/gui/tests/grok-page.test.ts +++ b/gui/tests/grok-page.test.ts @@ -1,6 +1,5 @@ import { expect, test } from "bun:test"; - -const LOCALES = ["en"] as const; // full dictionaries; nl.ts spreads en and only overrides a subset +import { localeDicts } from "./helpers/locales"; async function read(path: string): Promise { return Bun.file(new URL(path, import.meta.url)).text(); @@ -36,7 +35,7 @@ test("the Grok page is routable and present in the nav", async () => { expect(app).toContain('{ id: "grok", tkey: "nav.grok"'); }); -test("every locale carries the Grok keys", async () => { +test("every locale carries the Grok keys", () => { const keys = ["nav.grok", "grok.title", "grok.subtitle", "grok.loading", "grok.loadFail", "grok.notConfiguredTitle", "grok.notConfiguredHint", "grok.endpoint", "grok.colModel", "grok.colAlias", "grok.colContext", @@ -45,11 +44,9 @@ test("every locale carries the Grok keys", async () => { "grok.applySkipped", "grok.saveApply", "grok.saving", "grok.applying", "grok.unsaved", "grok.upToDate", "grok.toggleModel"]; const missing: string[] = []; - for (const locale of LOCALES) { - const dict = await read(`../src/i18n/${locale}.ts`); + for (const [locale, dict] of localeDicts()) { for (const key of keys) { - const match = new RegExp(`"${key.replace(".", "\\.")}":\\s*"([^"]+)"`).exec(dict); - if (!match) missing.push(`${locale}:${key}`); + if (!(dict[key as keyof typeof dict] ?? "").trim()) missing.push(`${locale}:${key}`); } } expect(missing).toEqual([]); diff --git a/gui/tests/helpers/locales.ts b/gui/tests/helpers/locales.ts new file mode 100644 index 000000000..fc1621986 --- /dev/null +++ b/gui/tests/helpers/locales.ts @@ -0,0 +1,18 @@ +import { DICTS, type Locale, type TKey } from "../../src/i18n/shared"; + +/** + * Locale gates must follow the registry, never a hand-maintained list. A hardcoded + * array silently turns into a no-op (or an ENOENT) the moment the shipped locale set + * changes, which is exactly when these gates need to fire. + */ +export const SHIPPED_LOCALES = Object.keys(DICTS) as Locale[]; + +/** Resolved dictionary per locale. Overlay locales (nl spreads en) resolve to full maps. */ +export function localeDicts(): [Locale, Record][] { + return SHIPPED_LOCALES.map(locale => [locale, DICTS[locale]]); +} + +/** Raw locale module source, for gates that assert a key is absent from the source. */ +export async function localeSource(locale: Locale): Promise { + return await Bun.file(new URL(`../../src/i18n/${locale}.ts`, import.meta.url)).text(); +} diff --git a/gui/tests/locale-migration.test.ts b/gui/tests/locale-migration.test.ts new file mode 100644 index 000000000..b0c879016 --- /dev/null +++ b/gui/tests/locale-migration.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { detectInitial } from "../src/i18n/shared"; + +/** + * This build ships `en` and `nl` only. Browsers that stored `de`, `ja`, `ko`, `ru`, or `zh` + * before those locales were retired must not silently land on the Dutch default: the saved + * value was an explicit "not Dutch" choice, so it migrates to English and is written back. + */ + +const globals = ["localStorage", "navigator"] as const; +let previous: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +let store: Map; + +function install(language: string): void { + const localStorageStub = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { store.set(key, value); }, + removeItem: (key: string) => { store.delete(key); }, + }; + Object.defineProperties(globalThis, { + localStorage: { configurable: true, value: localStorageStub }, + navigator: { configurable: true, value: { language } }, + }); +} + +beforeEach(() => { + previous = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previous; + store = new Map(); +}); + +afterEach(() => { + for (const key of globals) { + const descriptor = previous[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +test("saved preferences for retired locales migrate to English and persist", () => { + for (const retired of ["de", "ja", "ko", "ru", "zh"]) { + store = new Map([["ocx-lang", retired]]); + // A Dutch browser is the hostile case: without the migration the Dutch default wins. + install("nl-NL"); + expect(detectInitial()).toBe("en"); + expect(store.get("ocx-lang")).toBe("en"); + } +}); + +test("shipped preferences are returned untouched", () => { + for (const shipped of ["en", "nl"] as const) { + store = new Map([["ocx-lang", shipped]]); + install(shipped === "en" ? "nl-NL" : "en-US"); + expect(detectInitial()).toBe(shipped); + expect(store.get("ocx-lang")).toBe(shipped); + } +}); + +test("an unknown saved value falls through to browser detection without persisting", () => { + store = new Map([["ocx-lang", "klingon"]]); + install("en-US"); + expect(detectInitial()).toBe("en"); + expect(store.get("ocx-lang")).toBe("klingon"); + + store = new Map([["ocx-lang", "klingon"]]); + install("fr-FR"); + expect(detectInitial()).toBe("nl"); +}); + +test("with no saved preference the Dutch host default still applies", () => { + install("de-DE"); + expect(detectInitial()).toBe("nl"); + expect(store.has("ocx-lang")).toBe(false); + + install("en-GB"); + expect(detectInitial()).toBe("en"); +}); diff --git a/gui/tests/posthog-sanitize.test.ts b/gui/tests/posthog-sanitize.test.ts new file mode 100644 index 000000000..2cd8eb456 --- /dev/null +++ b/gui/tests/posthog-sanitize.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { sanitizedCurrentUrl } from "../src/posthog-sanitize"; + +function loc(hash: string): Pick { + return { origin: "http://127.0.0.1:10100", pathname: "/", hash }; +} + +describe("sanitizedCurrentUrl", () => { + test("keeps canonical De Pas page hashes", () => { + expect(sanitizedCurrentUrl(loc("#leveranciers"))).toBe("http://127.0.0.1:10100/#leveranciers"); + expect(sanitizedCurrentUrl(loc("#modellen/combos"))).toBe("http://127.0.0.1:10100/#modellen/combos"); + expect(sanitizedCurrentUrl(loc("#systeem/codex-auth"))).toBe("http://127.0.0.1:10100/#systeem/codex-auth"); + }); + + test("keeps legacy English deep links", () => { + expect(sanitizedCurrentUrl(loc("#providers"))).toBe("http://127.0.0.1:10100/#providers"); + expect(sanitizedCurrentUrl(loc("#logs/debug"))).toBe("http://127.0.0.1:10100/#logs/debug"); + }); + + test("strips unknown page heads and unknown sub-targets down to the page", () => { + expect(sanitizedCurrentUrl(loc("#oauth-callback?code=sekrit"))).toBe("http://127.0.0.1:10100/"); + expect(sanitizedCurrentUrl(loc("#systeem/unknown-tab"))).toBe("http://127.0.0.1:10100/#systeem"); + }); +}); diff --git a/gui/tests/provider-model-custom-add.test.tsx b/gui/tests/provider-model-custom-add.test.tsx index d8c184831..1878d6d52 100644 --- a/gui/tests/provider-model-custom-add.test.tsx +++ b/gui/tests/provider-model-custom-add.test.tsx @@ -196,6 +196,9 @@ test("a failed custom-model lookup recovers through retry without a remount", as const posts: string[] = []; globalThis.fetch = (async (input, init) => { if (!init?.method || init.method === "GET") { + // Count only the custom-model lookup: the Models tab also GETs /api/models on mount, + // and this case is about the custom-models effect having no remaining retry trigger. + if (!String(input).includes("/api/custom-models")) return Response.json([]); getCalls += 1; if (getCalls === 1) throw new Error("offline"); return Response.json([]); diff --git a/gui/tests/subagents-classic.test.ts b/gui/tests/subagents-classic.test.ts index c42cc1ded..bb58d0f87 100644 --- a/gui/tests/subagents-classic.test.ts +++ b/gui/tests/subagents-classic.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { localeDicts } from "./helpers/locales"; /** * Subagents ships a single denser workspace layout (rail + featured main pane). @@ -55,9 +56,10 @@ test("Subagents workspace assets and i18n keys are present", async () => { const workspaceCss = Bun.file(new URL("../src/styles-subagents-workspace.css", import.meta.url)); expect(await workspaceCss.exists()).toBe(true); - // en is the only full dictionary; nl.ts spreads en and only overrides a subset. - for (const locale of ["en"]) { - const src = await Bun.file(new URL(`../src/i18n/${locale}.ts`, import.meta.url)).text(); - expect(src).toContain("sub.workspace."); + // Every shipped locale resolves the Workspace-only strings. The gate follows the + // registry (en/nl) and reads resolved dictionaries, since nl spreads en. + for (const [locale, dict] of localeDicts()) { + expect(`${locale}:${dict["sub.workspace.allModels"] ?? ""}`).not.toBe(`${locale}:`); + expect(`${locale}:${dict["sub.workspace.mainAria"] ?? ""}`).not.toBe(`${locale}:`); } }); diff --git a/gui/tests/usage-grok-filter.test.ts b/gui/tests/usage-grok-filter.test.ts index cbb249f1d..0150cc550 100644 --- a/gui/tests/usage-grok-filter.test.ts +++ b/gui/tests/usage-grok-filter.test.ts @@ -1,6 +1,5 @@ import { expect, test } from "bun:test"; - -const LOCALES = ["en"] as const; // full dictionaries; nl.ts spreads en and only overrides a subset +import { localeDicts } from "./helpers/locales"; async function read(path: string): Promise { return Bun.file(new URL(path, import.meta.url)).text(); @@ -14,11 +13,10 @@ test("the Usage filter includes grok with its icon", async () => { expect(page).toContain("/provider-icons/grok.svg"); }); -test("every locale carries the grok surface label", async () => { +test("every locale carries the grok surface label", () => { const missing: string[] = []; - for (const locale of LOCALES) { - const dict = await read(`../src/i18n/${locale}.ts`); - if (!dict.includes('"logs.filter.surface.grok"')) missing.push(locale); + for (const [locale, dict] of localeDicts()) { + if (!(dict["logs.filter.surface.grok"] ?? "").trim()) missing.push(locale); } expect(missing).toEqual([]); }); diff --git a/gui/tests/usage-layout.test.ts b/gui/tests/usage-layout.test.ts index d5258c33b..8cc6b9363 100644 --- a/gui/tests/usage-layout.test.ts +++ b/gui/tests/usage-layout.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { localeSource, SHIPPED_LOCALES } from "./helpers/locales"; 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(); @@ -52,9 +53,8 @@ test("Usage loading and empty states guard the stacked body", async () => { }); test("retired usage workspace i18n keys stay removed from every locale", async () => { - const locales = ["en"] as const; // full dictionaries; nl.ts spreads en and only overrides a subset - for (const locale of locales) { - const dict = await Bun.file(new URL(`../src/i18n/${locale}.ts`, import.meta.url)).text(); + for (const locale of SHIPPED_LOCALES) { + const dict = await localeSource(locale); expect(dict).not.toContain('"usage.workspace.sections":'); expect(dict).not.toContain('"usage.workspace.report":'); expect(dict).not.toContain('"usage.workspace.mainAria":'); diff --git a/readme/README.ja.md b/readme/README.ja.md index 63d078ee7..9d95499c6 100644 --- a/readme/README.ja.md +++ b/readme/README.ja.md @@ -1,41 +1,35 @@

    make codex open!

    -

    OpenAI Codex & Claude Code 向けの汎用プロバイダープロキシ
    -コマンド2つで、Codex と Claude Code の両方が好きな LLM で動きます。

    +

    OpenAI Codex & Claude Code 向けの汎用プロバイダープロキシ — Codex CLI・App・SDK と Claude Code で任意の LLM を使えます。

    +

    npm install -g @bitkyc08/opencodex · ocx start · localhost:10100

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

    -```bash -npm install -g @bitkyc08/opencodex -ocx start # プロキシ + ダッシュボード: localhost:10100 -``` -

    - opencodex 経由でルーティングされたモデルで動作する Claude Code — ステータスバーに gpt-5.6-luna-medium が有効なモデルとして表示
    - Claude Code でどんなモデルでも。ピッカーは純正 Claude Code のまま、動いているモデルは自由に。 + opencodex — 任意の LLM を Codex で使用

    - opencodex デモ — Codex アプリで非 OpenAI ルーティングモデルでタスクを実行
    - Codex でどんなモデルでも。プロバイダーを選ぶだけ — 同じ Codex ワークフローで、違う頭脳。 + English · 한국어 · 简体中文 · Русский · 日本語

    - English · 한국어 · 简体中文 · Русский · 日本語 · 📖 完全なドキュメント → -

    - -

    - opencodex アーキテクチャ — Codex CLI が opencodex プロキシ経由で任意の LLM プロバイダーにルーティング + opencodex アーキテクチャ — Codex CLI が opencodex プロキシ経由で任意の LLM プロバイダーにルーティング

    Claude、Gemini、Grok、GLM、DeepSeek、Kimi、Qwen、Ollama など、任意の LLM を Codex で — そして **Claude Code** でも — 使えます。誰かがサポートを追加してくれるのを待つ必要はありません。 opencodex は Codex の Responses API をプロバイダーが話すプロトコルに変換する、軽量なローカルプロキシです。ストリーミング、ツール呼び出し、推論トークン、画像 — すべて双方向で動作します。 +

    + opencodex デモ — Codex アプリで非 OpenAI ルーティングモデルでタスクを実行 +

    +

    Codex で任意のモデルを。 プロバイダーを選ぶだけ — 同じ Codex ワークフローで、違う頭脳。

    + Codex 認証のための **ChatGPT アカウントプール**も管理できます。複数の ChatGPT / Codex アカウントを追加し、 ダッシュボードで 5 時間 / 週間 / 30 日クォータを更新し、新しいセッションを最も使用量の少ない健全なアカウントに自動 ルーティングできます。既存の Codex スレッドはそれを開始したアカウントに固定されたままなので、長い SSH・tmux・モバイル接続 @@ -140,7 +134,7 @@ ocx gui ```bash # Anthropic 経由で Claude Opus を使用 -codex -m "anthropic/claude-opus-5" "このスタックトレースを説明して" +codex -m "anthropic/claude-opus-4-8" "このスタックトレースを説明して" # Google 経由で Gemini を使用 codex -m "google/gemini-3-pro" "auth.ts のユニットテストを書いて" @@ -171,7 +165,7 @@ GPT-5.6 Sol/Terra/Luna は OpenAI API キーおよび OpenRouter プリセット ルーティング/カタログメタデータを準備しておきます。

    - 推論負荷ピッカーと共に opencodex ルーティングモデルを表示する Codex App + 推論負荷ピッカーと共に opencodex ルーティングモデルを表示する Codex App

    ## OpenAI プロバイダーのアカウントモード @@ -240,7 +234,7 @@ opencodex は 2 つの動作を分離して保持します: | Ollama / vLLM / LM Studio(ローカル) | `openai-chat` | key(通常は空欄) | | 任意の OpenAI 互換エンドポイント | `openai-chat` | key | -このほか DeepSeek、Groq、OpenRouter、Together、Fireworks、Cerebras、Mistral、Hugging Face、NVIDIA NIM、MiniMax、Qwen Cloud、Tencent Cloud Coding Plan、SiliconFlow などがあります。完全な一覧は `ocx init` または[プロバイダードキュメント](https://opencodex.me/ja/reference/configuration/)で確認してください。 +このほか DeepSeek、Groq、OpenRouter、Together、Fireworks、Cerebras、Mistral、Hugging Face、NVIDIA NIM、MiniMax、Qwen Cloud、Tencent Cloud Coding Plan、SiliconFlow などがあります。完全な一覧は `ocx init` または[プロバイダードキュメント](reference/configuration/)で確認してください。 Cursor サポートは段階的な実験的ブリッジです: `ocx init` とダッシュボードの Add Provider ピッカーに Cursor の静的公開モデルカタログを持つローカル config として表示されます。Cursor アクセストークンを設定するとライブ HTTP/2 トランスポートが有効になります。Cursor サーバー駆動のネイティブ @@ -280,17 +274,10 @@ opencodex にはプロキシを自動起動する方法が 2 つあります: | **方式** | OS サービスマネージャー(launchd / systemd / schtasks) | `codex` スクリプトランチャーをラップし実際の `codex.exe` は触らない | | **タイミング** | ログイン後に常時実行 | オンデマンド — `codex` 起動時に `ocx ensure` を実行 | | **再起動** | クラッシュ時に自動再起動 | `codex` 呼び出しごとに 1 回起動 | -| **Codex 更新** | 影響なし | 安定して置換されたランチャーは次の通常の `ocx` コマンドで修復 | +| **Codex 更新** | 影響なし | `ocx codex-shim install` または `ocx update` 時に修復 | | **削除** | `ocx service uninstall` | `ocx codex-shim uninstall` | 常にプロキシを起動しておくには **service**(開発マシン推奨)、軽くオンデマンドで使うには **shim** を使ってください。 - -外部の Codex 更新でインストール済み shim が上書きされた場合、次の通常の `ocx` コマンドが -安定した新しいランチャーをバックアップして shim を復元します。まだ変更中のランチャーには触れず、 -後続のコマンドで再試行します。修復失敗は要求されたコマンドを失敗させず警告だけを表示し、手動の -代替手段は `ocx codex-shim install` です。自動修復を無効にするには -`codexShimAutoRestore` を `false` にするか、プロセスで -`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` を設定します。 shim 自動起動はデフォルトでオンで、GUI ダッシュボードからオフにできます。設定されたプロキシポートが既に使用 中の場合、`ocx start` が自動的に別の空きローカルポートを選び、Codex の設定もそのポートに更新します。 @@ -410,14 +397,14 @@ OpenAI に復元し、残った opencodex ユーザースレッドも OpenAI に ocx recover-history --legacy-openai ``` -全フィールドの詳細は **[設定リファレンス](https://opencodex.me/ja/reference/configuration/)** を参照してください。 +全フィールドの詳細は **[設定リファレンス](reference/configuration/)** を参照してください。 ## ドキュメント -公開ドキュメント(インストール、プロバイダー、ルーティング、サイドカー、Codex 統合、Codex App モデルピッカー、CLI/設定リファレンス)は [`docs-site/`](../docs-site) の Astro サイトとしてビルドされ -**[opencodex.me](https://opencodex.me/ja/)** に公開されます。 +公開ドキュメント(インストール、プロバイダー、ルーティング、サイドカー、Codex 統合、Codex App モデルピッカー、CLI/設定リファレンス)は [`docs-site/`](./docs-site) の Astro サイトとしてビルドされ +**[github.com/OnlineChefGroep/opencodex]()** に公開されます。 -メンテナ用の source of truth は [`structure/`](../structure) に、過去の調査/診断ノートは [`docs/`](../docs) にあります。 +メンテナ用の source of truth は [`structure/`](./structure) に、過去の調査/診断ノートは [`docs/`](./docs) にあります。 ## 開発 @@ -439,7 +426,7 @@ API は `/healthz`、`/v1/responses`、`POST /v1/images/generations`、`POST /v1 bun run dev:gui ``` -**[コントリビュート](https://opencodex.me/ja/contributing/)** を参照してください。 +**[コントリビュート](contributing/)** を参照してください。 ## 免責事項 diff --git a/readme/README.ko.md b/readme/README.ko.md index a2dd16128..319c9270f 100644 --- a/readme/README.ko.md +++ b/readme/README.ko.md @@ -1,41 +1,35 @@

    make codex open!

    -

    OpenAI Codex & Claude Code를 위한 범용 프로바이더 프록시
    -명령어 두 줄이면 Codex와 Claude Code가 원하는 LLM으로 돌아갑니다.

    +

    OpenAI Codex & Claude Code를 위한 범용 프로바이더 프록시 — Codex CLI·App·SDK와 Claude Code에서 어떤 LLM이든 사용하세요.

    +

    npm install -g @bitkyc08/opencodex · ocx start · localhost:10100

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

    -```bash -npm install -g @bitkyc08/opencodex -ocx start # 프록시 + 대시보드: localhost:10100 -``` -

    - opencodex로 라우팅된 모델에서 돌아가는 Claude Code — 상태 표시줄에 gpt-5.6-luna-medium이 활성 모델로 표시됨
    - Claude Code에서 어떤 모델이든. 선택기는 Claude Code 그대로, 돌아가는 모델은 원하는 대로. + opencodex — 어떤 LLM이든 Codex에서 사용

    - opencodex 데모 — Codex 앱에서 비-OpenAI 라우팅 모델로 작업 실행
    - Codex에서 어떤 모델이든. 프로바이더만 고르면 끝 — 같은 Codex 워크플로, 다른 두뇌. + English · 한국어 · 简体中文 · Русский · 日本語

    - English · 한국어 · 简体中文 · Русский · 日本語 · 📖 전체 문서 → -

    - -

    - opencodex 아키텍처 — Codex CLI가 opencodex 프록시를 통해 모든 LLM 프로바이더로 라우팅 + opencodex 아키텍처 — Codex CLI가 opencodex 프록시를 통해 모든 LLM 프로바이더로 라우팅

    Claude, Gemini, Grok, GLM, DeepSeek, Kimi, Qwen, Ollama 등 어떤 LLM이든 Codex에서 — 그리고 **Claude Code**에서도 — 사용하세요. 누군가 지원을 추가해 주길 기다릴 필요 없이. opencodex는 Codex의 Responses API를 프로바이더가 쓰는 프로토콜로 변환해 주는 가벼운 로컬 프록시입니다. streaming, tool 호출, reasoning 토큰, 이미지까지 양방향으로 모두 동작합니다. +

    + opencodex 데모 — Codex 앱에서 비-OpenAI 라우팅 모델로 작업 실행 +

    +

    Codex에서 어떤 모델이든. 프로바이더만 고르면 끝 — 같은 Codex 워크플로, 다른 두뇌.

    + 또한 Codex 인증을 위한 **ChatGPT 계정 풀**을 관리할 수 있습니다. 여러 ChatGPT / Codex 계정을 추가하고, 대시보드에서 5시간 / 주간 / 30일 쿼터를 갱신하며, 새 세션을 사용량이 가장 적은 정상 계정으로 자동 라우팅할 수 있습니다. 기존 Codex 스레드는 시작한 계정에 그대로 고정되므로, 긴 SSH·tmux·모바일 연결 @@ -140,7 +134,7 @@ ocx gui ```bash # Anthropic을 통해 Claude Opus 사용 -codex -m "anthropic/claude-opus-5" "이 스택 트레이스를 설명해 줘" +codex -m "anthropic/claude-opus-4-8" "이 스택 트레이스를 설명해 줘" # Google을 통해 Gemini 사용 codex -m "google/gemini-3-pro" "auth.ts의 유닛 테스트를 작성해 줘" @@ -171,7 +165,7 @@ seed됩니다(`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`; OpenRouter는 `ope routing/catalog metadata를 준비해 둡니다.

    - opencodex 라우팅 모델을 reasoning effort 선택기와 함께 보여주는 Codex App + opencodex 라우팅 모델을 reasoning effort 선택기와 함께 보여주는 Codex App

    ## OpenAI 프로바이더 계정 모드 @@ -239,7 +233,7 @@ opencodex는 두 가지 동작을 분리해서 유지합니다: | Ollama / vLLM / LM Studio (로컬) | `openai-chat` | key (보통 비워둠) | | 모든 OpenAI 호환 엔드포인트 | `openai-chat` | key | -그 외에 DeepSeek, Groq, OpenRouter, Together, Fireworks, Cerebras, Mistral, Hugging Face, NVIDIA NIM, MiniMax, Qwen Cloud, Tencent Cloud Coding Plan, SiliconFlow 등이 있습니다. 전체 목록은 `ocx init` 또는 [프로바이더 문서](https://opencodex.me/ko/reference/configuration/)에서 확인하세요. +그 외에 DeepSeek, Groq, OpenRouter, Together, Fireworks, Cerebras, Mistral, Hugging Face, NVIDIA NIM, MiniMax, Qwen Cloud, Tencent Cloud Coding Plan, SiliconFlow 등이 있습니다. 전체 목록은 `ocx init` 또는 [프로바이더 문서](reference/configuration/)에서 확인하세요. ## CLI @@ -295,16 +289,10 @@ opencodex에는 프록시를 자동 시작하는 두 가지 방법이 있습니 | **방식** | OS 서비스 관리자 (launchd / systemd / schtasks) | `codex` 스크립트 런처를 래핑하며 실제 `codex.exe`는 건드리지 않음 | | **시점** | 로그인 후 항상 실행 | 온디맨드 — `codex` 실행 시 `ocx ensure` 실행 | | **재시작** | 크래시 시 자동 재시작 | `codex` 호출마다 한 번 시작 | -| **Codex 업데이트** | 영향 없음 | 안정적으로 교체가 끝난 런처는 다음 일반 `ocx` 명령에서 복구 | +| **Codex 업데이트** | 영향 없음 | `ocx codex-shim install` 또는 `ocx update` 시 복구 | | **제거** | `ocx service uninstall` | `ocx codex-shim uninstall` | 항상 프록시를 켜두려면 **service** (개발 머신 권장), 가볍게 온디맨드로 쓰려면 **shim**을 사용하세요. - -외부 Codex 업데이트가 설치된 shim을 덮어쓰면 다음 일반 `ocx` 명령이 안정화된 새 런처를 백업하고 -shim을 복구합니다. 아직 변경 중인 런처는 건드리지 않고 이후 명령에서 다시 시도합니다. 복구 실패는 -요청한 명령을 실패시키지 않고 경고만 출력하며, 수동 대체 명령은 `ocx codex-shim install`입니다. -자동 복구를 끄려면 `codexShimAutoRestore`를 `false`로 설정하거나 프로세스에 -`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`을 설정하세요. shim 자동 시작은 기본으로 켜져 있으며 GUI 대시보드에서 끌 수 있습니다. 설정된 프록시 포트가 이미 사용 중이면 `ocx start`가 자동으로 다른 빈 로컬 포트를 고르고 Codex 설정도 그 포트로 갱신합니다. @@ -424,14 +412,14 @@ OpenAI로 복원하고, 남은 opencodex 유저 스레드도 OpenAI로 eject 하 ocx recover-history --legacy-openai ``` -모든 필드에 대한 자세한 내용은 **[설정 레퍼런스](https://opencodex.me/ko/reference/configuration/)** 를 참고하세요. +모든 필드에 대한 자세한 내용은 **[설정 레퍼런스](reference/configuration/)** 를 참고하세요. ## 문서 -공개 문서(설치, 프로바이더, 라우팅, sidecar, Codex 통합, Codex App 모델 선택기, CLI/설정 레퍼런스)는 [`docs-site/`](../docs-site)의 Astro 사이트로 빌드되어 -**[opencodex.me](https://opencodex.me/ko/)** 에 게시됩니다. +공개 문서(설치, 프로바이더, 라우팅, sidecar, Codex 통합, Codex App 모델 선택기, CLI/설정 레퍼런스)는 [`docs-site/`](./docs-site)의 Astro 사이트로 빌드되어 +**[github.com/OnlineChefGroep/opencodex]()** 에 게시됩니다. -유지보수용 source of truth는 [`structure/`](../structure)에, 과거 조사/진단 노트는 [`docs/`](../docs)에 있습니다. +유지보수용 source of truth는 [`structure/`](./structure)에, 과거 조사/진단 노트는 [`docs/`](./docs)에 있습니다. ## 개발 @@ -453,7 +441,7 @@ API는 `/healthz`, `/v1/responses`, `POST /v1/images/generations`, `POST /v1/ima bun run dev:gui ``` -**[기여하기](https://opencodex.me/ko/contributing/)** 를 참고하세요. +**[기여하기](contributing/)** 를 참고하세요. ## 면책 조항 diff --git a/readme/README.ru.md b/readme/README.ru.md index 7ade75a73..e4c31fa9f 100644 --- a/readme/README.ru.md +++ b/readme/README.ru.md @@ -1,41 +1,35 @@

    make codex open!

    -

    Универсальный прокси провайдеров для OpenAI Codex & Claude Code
    -Две команды — и Codex, и Claude Code работают на любой LLM, которую вы укажете.

    +

    Универсальный прокси провайдеров для OpenAI Codex & Claude Code — используйте любую LLM с Codex CLI, App, SDK и Claude Code.

    +

    npm install -g @bitkyc08/opencodex · ocx start · localhost:10100

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

    - -```bash -npm install -g @bitkyc08/opencodex -ocx start # прокси + дашборд: localhost:10100 -``` - -

    - Claude Code работает на маршрутизированной модели через opencodex — в строке состояния активна gpt-5.6-luna-medium
    - Claude Code на любой модели. Селектор — обычный Claude Code, а вот модель за ним — какую захотите. + CI status

    - Демонстрация opencodex — выполнение задачи в приложении Codex на маршрутизируемой модели не от OpenAI
    - Codex на любой модели. Выберите провайдера — и вперёд: тот же рабочий процесс Codex, другой «мозг». + opencodex — универсальный прокси провайдеров для Codex, используйте любую LLM

    - English · 한국어 · 简体中文 · Русский · 日本語 · 📖 Полная документация → + English · 한국어 · 简体中文 · Русский · 日本語

    - Архитектура opencodex — Codex CLI направляет запросы через прокси opencodex к любому LLM-провайдеру + Архитектура opencodex — Codex CLI направляет запросы через прокси opencodex к любому LLM-провайдеру

    Используйте Claude, Gemini, Grok, GLM, DeepSeek, Kimi, Qwen, Ollama или любую другую LLM с Codex — и с **Claude Code** — не дожидаясь, пока кто-нибудь добавит поддержку. opencodex — это лёгкий локальный прокси, который транслирует Responses API Codex в протокол, понятный вашему провайдеру. Потоковая передача, вызовы инструментов, токены рассуждений, изображения — всё работает в обе стороны. +

    + Демонстрация opencodex — выполнение задачи в приложении Codex на маршрутизируемой модели не от OpenAI +

    +

    Codex на любой модели. Выберите провайдера — и вперёд: тот же рабочий процесс Codex, другой «мозг».

    + Кроме того, opencodex умеет управлять **пулом аккаунтов ChatGPT** для аутентификации Codex. Добавьте несколько аккаунтов ChatGPT / Codex, обновляйте их квоты (5 ч / неделя / 30 дней) в панели управления — и новые сессии будут автоматически направляться на работоспособный аккаунт с наименьшим использованием. @@ -153,7 +147,7 @@ ocx gui ```bash # Claude Opus через Anthropic -codex -m "anthropic/claude-opus-5" "Explain this stack trace" +codex -m "anthropic/claude-opus-4-8" "Explain this stack trace" # Gemini через Google codex -m "google/gemini-3-pro" "Write unit tests for auth.ts" @@ -184,7 +178,7 @@ OpenAI API-ключа и OpenRouter (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-lu провайдеров, которые могут их обслуживать.

    - Codex App с маршрутизируемыми моделями opencodex и селектором уровня рассуждений + Codex App с маршрутизируемыми моделями opencodex и селектором уровня рассуждений

    ## Режимы аккаунтов провайдера OpenAI @@ -269,7 +263,7 @@ OpenAI API-ключа и OpenRouter (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-lu | Ollama / vLLM / LM Studio (локально) | `openai-chat` | key (обычно пустой) | | Любой OpenAI-совместимый эндпоинт | `openai-chat` | key | -А также DeepSeek, Groq, OpenRouter, Together, Fireworks, Cerebras, Mistral, Hugging Face, NVIDIA NIM, MiniMax, Qwen Cloud, Tencent Cloud Coding Plan, SiliconFlow и другие. Полный список — в `ocx init` или в [документации по провайдерам](https://opencodex.me/reference/configuration/). +А также DeepSeek, Groq, OpenRouter, Together, Fireworks, Cerebras, Mistral, Hugging Face, NVIDIA NIM, MiniMax, Qwen Cloud, Tencent Cloud Coding Plan, SiliconFlow и другие. Полный список — в `ocx init` или в [документации по провайдерам](https://github.com/OnlineChefGroep/opencodex). Поддержка Cursor — поэтапный экспериментальный мост: он появляется в `ocx init` и в селекторе Add Provider панели управления как локальная конфигурация со статическим публичным каталогом @@ -312,16 +306,10 @@ ocx update [--tag preview] # обновить opencodex; preview-устан | **Как** | Менеджер служб ОС (launchd / systemd / schtasks) | Оборачивает скриптовые лончеры `codex`; настоящий `codex.exe` не затрагивается | | **Когда** | Всегда работает после входа в систему | По требованию — выполняет `ocx ensure` при запуске `codex` | | **Перезапуск** | Автоматический перезапуск при сбое | Запускается один раз на каждый вызов `codex` | -| **Обновления Codex** | Не влияют | Стабильно заменённый лончер восстанавливается следующей обычной командой `ocx` | +| **Обновления Codex** | Не влияют | Восстанавливается при следующем `ocx codex-shim install` или `ocx update` | | **Удаление** | `ocx service uninstall` | `ocx codex-shim uninstall` | Используйте **службу**, если прокси должен работать постоянно (рекомендуется для машин разработчиков). -Если внешнее обновление Codex перезапишет установленный shim, следующая обычная команда `ocx` -сохранит стабильный новый лончер в резервную копию и восстановит shim. Лончер, который ещё меняется, -остаётся нетронутым до следующей команды. Ошибка восстановления выдаёт предупреждение, но не приводит -к сбою запрошенной команды; ручной вариант — `ocx codex-shim install`. Для отключения установите -`codexShimAutoRestore` в `false` или задайте процессу -`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. Используйте **shim** для лёгкого запуска прокси по требованию без фонового демона. Автозапуск через shim включён по умолчанию и отключается в GUI-панели управления. Если настроенный порт прокси уже занят, `ocx start` автоматически выберет другой свободный локальный порт и обновит настройки Codex. @@ -450,15 +438,15 @@ opencodex автоматически перепривязывает истори ocx recover-history --legacy-openai ``` -Описание всех полей — в **[справочнике по конфигурации](https://opencodex.me/reference/configuration/)**. +Описание всех полей — в **[справочнике по конфигурации](https://github.com/OnlineChefGroep/opencodex)**. ## Документация -Публичная документация — установка, провайдеры, маршрутизация, сайдкары, интеграция с Codex, селектор моделей Codex App и справочник по CLI/конфигурации — собирается из [`docs-site/`](../docs-site) и публикуется на **[opencodex.me](https://opencodex.me/)**. +Публичная документация — установка, провайдеры, маршрутизация, сайдкары, интеграция с Codex, селектор моделей Codex App и справочник по CLI/конфигурации — собирается из [`docs-site/`](./docs-site) и публикуется на **[github.com/OnlineChefGroep/opencodex](https://github.com/OnlineChefGroep/opencodex/)**. -Заметки мейнтейнеров, служащие источником истины, находятся в [`structure/`](../structure). Материалы прошлых исследований хранятся в [`docs/`](../docs). -Инструкции для контрибьюторов — в [`CONTRIBUTING.md`](../CONTRIBUTING.md), а порядок сообщений -о проблемах безопасности — в [`SECURITY.md`](../SECURITY.md). +Заметки мейнтейнеров, служащие источником истины, находятся в [`structure/`](./structure). Материалы прошлых исследований хранятся в [`docs/`](./docs). +Инструкции для контрибьюторов — в [`CONTRIBUTING.md`](./CONTRIBUTING.md), а порядок сообщений +о проблемах безопасности — в [`SECURITY.md`](./SECURITY.md). ## Разработка @@ -480,7 +468,7 @@ bun x tsc --noEmit # проверка типов bun run dev:gui ``` -См. **[руководство для контрибьюторов](../CONTRIBUTING.md)**. +См. **[руководство для контрибьюторов](./CONTRIBUTING.md)**. ## Отказ от ответственности diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index 749464c66..31551f5ca 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -1,41 +1,35 @@

    make codex open!

    -

    面向 OpenAI Codex 与 Claude Code 的通用 provider 代理
    -两条命令,Codex 和 Claude Code 就能用任何 LLM 跑起来。

    +

    面向 OpenAI Codex 与 Claude Code 的通用 provider 代理 —— 在 Codex CLI、App、SDK 和 Claude Code 中使用任意 LLM。

    +

    npm install -g @bitkyc08/opencodex · ocx start · localhost:10100

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

    -```bash -npm install -g @bitkyc08/opencodex -ocx start # 代理 + 仪表盘: localhost:10100 -``` -

    - 通过 opencodex 运行路由模型的 Claude Code —— 状态栏显示 gpt-5.6-luna-medium 为当前模型
    - Claude Code 可以用任何模型。选择器是原生 Claude Code,跑起来的模型随你挑。 + opencodex — 让 Codex 接入任意 LLM

    - opencodex 演示 —— 在 Codex 应用中用路由的非 OpenAI 模型执行任务
    - Codex 可以用任何模型。选好 provider 直接开跑 —— 同样的 Codex 工作流,换个大脑。 + English · 한국어 · 简体中文 · Русский · 日本語

    - English · 한국어 · 简体中文 · Русский · 日本語 · 📖 完整文档 → -

    - -

    - opencodex 架构 — Codex CLI 通过 opencodex 代理路由到任意 LLM 提供商 + opencodex 架构 — Codex CLI 通过 opencodex 代理路由到任意 LLM 提供商

    在 Codex 中 —— 以及在 **Claude Code** 中 —— 使用 Claude、Gemini、Grok、GLM、DeepSeek、Kimi、Qwen、Ollama 或任意其他 LLM,无需等待官方添加支持。 opencodex 是一个轻量级本地代理,把 Codex 的 Responses API 翻译成你的 provider 所讲的协议。streaming、tool 调用、reasoning token、图片 —— 全部双向工作。 +

    + opencodex 演示 —— 在 Codex 应用中用路由的非 OpenAI 模型执行任务 +

    +

    在 Codex 里运行任意模型。选好 provider 即可 —— 同样的 Codex 工作流,不同的大脑。

    + 它还能为 Codex 认证管理一个 **ChatGPT 账户池**。添加多个 ChatGPT / Codex 账户,在仪表盘中刷新它们的 5 小时 / 每周 / 30 天配额,并让新会话自动路由到使用量最低的健康账户。现有 Codex 线程会固定在启动它的 账户上,因此长时间的 SSH、tmux 或移动端连接的会话不会在对话中途切换账户。 @@ -151,7 +145,7 @@ ocx gui ```bash # 通过 Anthropic 使用 Claude Opus -codex -m "anthropic/claude-opus-5" "解释这个 stack trace" +codex -m "anthropic/claude-opus-4-8" "解释这个 stack trace" # 通过 Google 使用 Gemini codex -m "google/gemini-3-pro" "为 auth.ts 写单元测试" @@ -180,7 +174,7 @@ reasoning 为 `low`。可用性仍受上游 preview gate 限制;opencodex 只是准备好你的账户/provider 可访问时所需的路由和目录元数据。

    - Codex App 展示 opencodex 路由模型及 reasoning effort 选择器 + Codex App 展示 opencodex 路由模型及 reasoning effort 选择器

    ## OpenAI provider 账户模式 @@ -232,7 +226,7 @@ opencodex 保持两种独立行为: | Ollama / vLLM / LM Studio(本地) | `openai-chat` | key(通常留空) | | 任意 OpenAI 兼容端点 | `openai-chat` | key | -此外还有 DeepSeek、Groq、OpenRouter、Together、Fireworks、Cerebras、Mistral、Hugging Face、NVIDIA NIM、MiniMax、Qwen Cloud、腾讯云 Coding Plan、SiliconFlow 等等。完整列表可通过 `ocx init` 查看,或参阅 [provider 文档](https://opencodex.me/zh-cn/reference/configuration/)。 +此外还有 DeepSeek、Groq、OpenRouter、Together、Fireworks、Cerebras、Mistral、Hugging Face、NVIDIA NIM、MiniMax、Qwen Cloud、腾讯云 Coding Plan、SiliconFlow 等等。完整列表可通过 `ocx init` 查看,或参阅 [provider 文档](reference/configuration/)。 ## CLI @@ -287,16 +281,10 @@ opencodex 提供两种自动启动代理的方式: | **方式** | OS 服务管理器(launchd / systemd / schtasks) | 包装 `codex` 脚本启动器;不会改动真实 `codex.exe` | | **时机** | 登录后始终运行 | 按需 — 仅在运行 `codex` 时启动 | | **重启** | 崩溃后自动重启 | 每次调用 `codex` 时启动一次 | -| **Codex 更新** | 不受影响 | 稳定完成的启动器替换会在下一条普通 `ocx` 命令中修复 | +| **Codex 更新** | 不受影响 | 下次运行 `ocx codex-shim install` 或 `ocx update` 时修复 | | **移除** | `ocx service uninstall` | `ocx codex-shim uninstall` | 如需常驻代理,使用 **service**(推荐开发环境)。轻量按需启动使用 **shim**。 - -如果外部 Codex 更新覆盖了已安装的 shim,下一条普通 `ocx` 命令会备份已稳定的新启动器并恢复 -shim。仍在变化的启动器不会被改动,而会在后续命令中重试。修复失败只会警告,不会让请求的命令 -失败;手动备用命令为 `ocx codex-shim install`。若要关闭自动恢复,请将 -`codexShimAutoRestore` 设为 `false`,或为进程设置 -`OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 如果配置的代理端口已被占用,`ocx start` 会自动选择另一个空闲本地端口并更新 Codex 使用它。 ### 卸载 @@ -401,13 +389,13 @@ Codex 不会尝试 resume 一个其 provider 已不在 `config.toml` 中的线 ocx recover-history --legacy-openai ``` -每个字段的详细说明参阅 **[配置参考](https://opencodex.me/zh-cn/reference/configuration/)**。 +每个字段的详细说明参阅 **[配置参考](reference/configuration/)**。 ## 文档 -完整文档——安装、provider 配置、路由、sidecar、Codex 集成、Codex App 模型选择器、CLI/配置参考——由 [`docs-site/`](../docs-site) 目录下的 Astro 站点构建,发布在 **[opencodex.me](https://opencodex.me/zh-cn/)**。 +完整文档——安装、provider 配置、路由、sidecar、Codex 集成、Codex App 模型选择器、CLI/配置参考——由 [`docs-site/`](./docs-site) 目录下的 Astro 站点构建,发布在 **[github.com/OnlineChefGroep/opencodex]()**。 -维护者 source of truth 位于 [`structure/`](../structure),历史调查和诊断笔记保留在 [`docs/`](../docs)。 +维护者 source of truth 位于 [`structure/`](./structure),历史调查和诊断笔记保留在 [`docs/`](./docs)。 ## 开发 @@ -428,7 +416,7 @@ bun x tsc --noEmit # 类型检查 bun run dev:gui ``` -参阅 **[贡献指南](https://opencodex.me/zh-cn/contributing/)**。 +参阅 **[贡献指南](contributing/)**。 ## 免责声明 diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index 6e95b2821..b6471eb87 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -167,7 +167,8 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM // the request builder appends the per-model suffix (see effort-map.ts) and reasoning models // advertise effort so Codex exposes the tier picker. `supportsReasoningEffort` tracks whether the // model has *selectable effort tiers* (CURSOR_MODEL_EFFORT_TIERS), NOT merely whether it reasons: - // gemini/grok/kimi-k2.7/gpt-5-mini are reasoning models in the SOT but are sent bare (no tier picker). + // gemini/gpt-5-mini/kimi-k2.7-code are reasoning models in the SOT but are sent bare (no tier + // picker), while grok-4.5* and kimi-k3 do have verified tiers in the effort map. ...CURSOR_ROUTER_MODEL_IDS.map(id => ({ id, contextWindow: CONTEXT_200K, supportsReasoningEffort: false })), { id: "claude-sonnet-5", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, @@ -227,6 +228,15 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM // kimi-k3: cursor.com/docs/models/kimi-k3; account-verified via GetUsableModels (2026-07-28) — // ships only as effort-suffixed kimi-k3-{low,high,max}, so the tier picker is exposed. { id: "kimi-k3", contextWindow: CONTEXT_262K, supportsReasoningEffort: true }, + // `-thinking` ids are complete Cursor wire ids (the historical `claude-*-thinking` naming), and no + // `-thinking-` variant is verified in GetUsableModels -> no tier picker, sent bare like + // claude-opus-4-7-fast. Give them effort-map tiers only once live ids confirm the suffixes. + { id: "claude-opus-5-thinking", contextWindow: CONTEXT_200K }, + { id: "claude-opus-4-8-thinking", contextWindow: CONTEXT_200K }, + { id: "claude-opus-4-7-thinking", contextWindow: CONTEXT_200K }, + { id: "claude-fable-5-thinking", contextWindow: CONTEXT_200K }, + { id: "claude-sonnet-5-thinking", contextWindow: CONTEXT_200K }, + { id: "gemini-3.6-flash", contextWindow: CONTEXT_GEMINI }, { id: "grok-4.5", contextWindow: 500_000, supportsReasoningEffort: true }, { id: "grok-4.5-fast", contextWindow: 500_000, supportsReasoningEffort: true }, diff --git a/src/codex/pacer.ts b/src/codex/pacer.ts new file mode 100644 index 000000000..39cf47505 --- /dev/null +++ b/src/codex/pacer.ts @@ -0,0 +1,85 @@ +import type { OcxConfig } from "../types"; + +/** + * Jittered inter-request pacer for outbound Codex pool calls. + * + * Off by default (`config.codexRequestPacing?.enabled` falsy). When enabled, each + * pool account keeps its own `lastSendAt` timestamp and a new send waits a + * randomized gap in `[minMs, maxMs]` minus the time elapsed since that account's + * previous send (floored at 0). Per-account state means a multi-account pool + * desyncs instead of aligning into a single fixed, ban-prone cadence. + * + * Defaults preserve current behavior: disabled resolves instantly and never + * awaits, so single-account and pre-pacing setups are untouched. + */ + +const PACE_DEFAULT_MIN_MS = 150; +const PACE_DEFAULT_MAX_MS = 900; + +/** Per-account last-send timestamps (ms). Module-level, in-process, non-persistent. */ +const lastSendAtByAccount = new Map(); + +/** Resolve the inclusive [min, max] bounds, clamping/normalizing bad input. */ +function paceBounds(pacing: NonNullable): { min: number; max: number } { + const rawMin = typeof pacing.minMs === "number" && Number.isFinite(pacing.minMs) ? pacing.minMs : PACE_DEFAULT_MIN_MS; + const rawMax = typeof pacing.maxMs === "number" && Number.isFinite(pacing.maxMs) ? pacing.maxMs : PACE_DEFAULT_MAX_MS; + const min = Math.max(0, Math.min(rawMin, rawMax)); + const max = Math.max(min, Math.max(rawMin, rawMax)); + return { min, max }; +} + +/** Randomized gap in ms within the configured [minMs, maxMs] window. */ +function paceGapMs(pacing: NonNullable): number { + const { min, max } = paceBounds(pacing); + return min + Math.random() * (max - min); +} + +/** Reset all pacer state. Intended for deterministic tests. */ +export function resetCodexPacerState(): void { + lastSendAtByAccount.clear(); +} + +/** + * Resolve the EFFECTIVE pacing config, accounting for auto-enable: + * when codexRotationMode === "round-robin" and >1 pool account is configured, + * pacing defaults to ON (with default bounds) even if codexRequestPacing is unset. + * Explicit config always wins. + */ +export function resolveEffectivePacing( + config: Pick, + poolSize: number, +): NonNullable | null { + const explicit = config.codexRequestPacing; + if (explicit) { + // Explicit config wins — respect enabled: true OR false. + if (!explicit.enabled) return null; + return { minMs: PACE_DEFAULT_MIN_MS, maxMs: PACE_DEFAULT_MAX_MS, ...explicit }; + } + // No explicit config: auto-enable for multi-account round-robin pools + // (reduces ban-prone cadence alignment across accounts). + if (config.codexRotationMode === "round-robin" && poolSize > 1) { + return { enabled: true, minMs: PACE_DEFAULT_MIN_MS, maxMs: PACE_DEFAULT_MAX_MS }; + } + return null; +} + +/** + * Await the per-account jittered gap before an outbound Codex pool send. + * No-op (no await) when pacing is disabled or no account id is supplied. + */ +export async function codexPaceBeforeSend(config: OcxConfig, accountId: string | null, poolSize?: number): Promise { + const effective = poolSize !== undefined + ? resolveEffectivePacing(config, poolSize) + : (config.codexRequestPacing?.enabled ? { enabled: true, minMs: PACE_DEFAULT_MIN_MS, maxMs: PACE_DEFAULT_MAX_MS, ...config.codexRequestPacing } : null); + if (!effective) return; + if (!accountId) return; + const now = Date.now(); + const last = lastSendAtByAccount.get(accountId); + if (last !== undefined) { + const wait = Math.max(0, paceGapMs(effective) - (now - last)); + if (wait > 0) await new Promise(resolve => setTimeout(resolve, wait)); + } + // Record the actual send time (after any wait) so the next gap measures from + // the real send cadence rather than the moment we entered the pacer. + lastSendAtByAccount.set(accountId, Date.now()); +} diff --git a/src/codex/routing.ts b/src/codex/routing.ts index bbc13f844..de27ed57c 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -11,6 +11,7 @@ import { normalizeAccountPoolStrategy, notePoolRotationFailure, notePoolRotationSuccess, + clearPoolRotationState, peekRoundRobinAccount, pickRoundRobinAccount, seedPoolRotationAccount, @@ -18,7 +19,7 @@ import { import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account"; import { isSelectableCodexPoolAccount } from "./account-id"; -import type { OcxConfig } from "../types"; +import type { OcxAccountPoolRotationStrategy, OcxConfig } from "../types"; type ThreadAffinityEntry = { accountId: string; @@ -35,6 +36,26 @@ export type CodexThreadResolution = | { status: "none" } | { status: "expired"; accountId: string }; +/** + * Effective Codex pool strategy. + * + * `accountPoolStrategy` is authoritative. `codexRotationMode` is the fork's older alias + * (see `OcxConfig.codexRotationMode`): when the canonical field is unset, `"round-robin"` + * there selects round-robin, while `"failover"` / unset normalize to the default quota + * sticky path. Without this the alias would silently become inert. + */ +function resolveCodexPoolStrategy(config: OcxConfig): OcxAccountPoolRotationStrategy { + return normalizeAccountPoolStrategy(config.accountPoolStrategy ?? config.codexRotationMode); +} + +/** + * Reset the Codex round-robin ring. Intended for deterministic tests; the cursor now lives + * in `pool-rotation`'s per-pool selection state rather than a persisted file. + */ +export function resetCodexRoundRobinCursor(): void { + clearPoolRotationState(POOL_KEY_CODEX); +} + /** * Process-local cursor for automatic RR/fill-first (and quota-429 when not * sync-writing) picks. Keeps unrelated `saveConfig` from persisting transient @@ -824,7 +845,7 @@ function pickUnboundStrategyAccount( commit: boolean, quotaScope?: CodexQuotaScope, ): string | null { - const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); + const strategy = resolveCodexPoolStrategy(config); if (strategy === "quota") return null; const poolKey = codexPoolKeyForScope(quotaScope); @@ -910,7 +931,7 @@ export function pickAlternateCodexAccount( now = Date.now(), quotaScope?: CodexQuotaScope, ): string | null { - const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); + const strategy = resolveCodexPoolStrategy(config); if (strategy === "round-robin") { const eligible = listEligibleCodexAccountIds(config, now, quotaScope).filter(id => id !== excludeId); return pickRoundRobinAccount(codexPoolKeyForScope(quotaScope), eligible, stickyLimitForConfig(config)); @@ -945,7 +966,7 @@ function setActiveCodexAccount(config: OcxConfig, accountId: string): void { /** Quota strategy persists; RR/fill-first keep a process-local cursor only. */ function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void { - if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") { + if (resolveCodexPoolStrategy(config) === "quota") { setActiveCodexAccount(config, accountId); return; } @@ -1054,7 +1075,7 @@ export function previewCodexAccountForRequest( ) { // Quota strategy only: non-quota strategies keep affinity for ongoing threads // (new-session-only rotation — docs / affinity policy A). - const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); + const strategy = resolveCodexPoolStrategy(config); if (strategy === "quota") { const threshold = config.autoSwitchThreshold ?? 80; if (threshold > 0) { @@ -1136,7 +1157,7 @@ export function resolveCodexAccountForThreadDetailed( // serving for up to 60s after a secondary with quota is available (#584). // Non-quota strategies (RR / fill-first) keep affinity for ongoing threads — // rotation is new-session-only (affinity policy A). - const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy); + const strategy = resolveCodexPoolStrategy(config); if (strategy === "quota") { const threshold = config.autoSwitchThreshold ?? 80; const usage = threshold > 0 diff --git a/src/oauth/cursor.ts b/src/oauth/cursor.ts index 09b1d9f4c..fa4307460 100644 --- a/src/oauth/cursor.ts +++ b/src/oauth/cursor.ts @@ -63,11 +63,21 @@ export function credentialsFromCursorTokens(accessToken: string, refreshToken: s }; } +export interface CursorLoginOpts { + /** When true (Add account / reauth), force the browser account picker so a second identity can be chosen. */ + forceAccountSelect?: boolean; + /** Injectable poll cadence for tests; production uses the default. */ + pollBaseDelayMs?: number; +} + /** Generate PKCE params + the cursor.com deep-link login URL (challenge only — never the verifier). */ -export async function generateCursorAuthParams(): Promise { +export async function generateCursorAuthParams(opts?: Pick): Promise { const { verifier, challenge } = await generatePKCE(); const uuid = crypto.randomUUID(); const params = new URLSearchParams({ challenge, uuid, mode: "login", redirectTarget: "cli" }); + // select_account mirrors google-antigravity: without it, an already-signed-in browser session + // re-approves the same JWT `sub` and multiauth updates the existing row instead of appending. + if (opts?.forceAccountSelect) params.set("prompt", "select_account"); return { verifier, challenge, uuid, loginUrl: `${CURSOR_LOGIN_URL}?${params.toString()}` }; } @@ -138,11 +148,23 @@ export async function pollCursorAuth( /** Run the standalone Cursor login: surface the URL via `onAuth`, then poll until approved. */ export async function loginCursor( ctrl: OAuthController, - pollBaseDelayMs: number = POLL_BASE_DELAY_MS, + optsOrPollDelay: CursorLoginOpts | number = {}, ): Promise { - const { verifier, uuid, loginUrl } = await generateCursorAuthParams(); - ctrl.onAuth?.({ url: loginUrl, instructions: "Approve the Cursor login in your browser, then return here." }); - ctrl.onProgress?.("Waiting for Cursor login approval…"); + const opts: CursorLoginOpts = typeof optsOrPollDelay === "number" + ? { pollBaseDelayMs: optsOrPollDelay } + : optsOrPollDelay; + const forceAccountSelect = opts.forceAccountSelect === true; + const pollBaseDelayMs = opts.pollBaseDelayMs ?? POLL_BASE_DELAY_MS; + const { verifier, uuid, loginUrl } = await generateCursorAuthParams({ forceAccountSelect }); + ctrl.onAuth?.({ + url: loginUrl, + instructions: forceAccountSelect + ? "Choose the Cursor account to add in your browser, approve the login, then return here." + : "Approve the Cursor login in your browser, then return here.", + }); + ctrl.onProgress?.(forceAccountSelect + ? "Waiting for Cursor account selection…" + : "Waiting for Cursor login approval…"); const { accessToken, refreshToken } = await pollCursorAuth(uuid, verifier, ctrl.signal, pollBaseDelayMs); return credentialsFromCursorTokens(accessToken, refreshToken); } diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 72e8c6d1f..e7699e2be 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -133,7 +133,9 @@ export const OAUTH_PROVIDERS: Record = { defaultModel: oauthDefaultModel("google-antigravity"), }, cursor: { - login: (ctrl) => loginCursor(ctrl), + // forceLogin (GUI "Add account" / reauth) opens the browser account picker so a second + // Cursor identity can be appended under multiauth instead of refreshing the active row. + login: (ctrl, opts) => loginCursor(ctrl, { forceAccountSelect: opts?.forceLogin === true }), refresh: refreshCursorToken, providerConfig: oauthConfig("cursor"), defaultModel: oauthDefaultModel("cursor"), diff --git a/src/providers/cap-cooldown.ts b/src/providers/cap-cooldown.ts new file mode 100644 index 000000000..be26b7f23 --- /dev/null +++ b/src/providers/cap-cooldown.ts @@ -0,0 +1,257 @@ +/** + * Provider-level weekly/inference-cap cooldowns. + * + * When upstream returns a hard weekly/inference cap (e.g. Clinepass INFERENCE_CAP_ERROR + * with "resets in Nd Nh"), we persist a cooldown on the LIVE server config, optionally + * disable the provider, and surface a short summary via /api/config.providerCooldowns. + * + * Callers must pass the live `OcxConfig` instance owned by `startServer` — never a fresh + * `loadConfig()` snapshot — so routing and management see the change immediately. + */ +import { saveConfigPreservingClaudeCode } from "../config"; +import type { OcxConfig, ProviderCapCooldown } from "../types"; + +export type { ProviderCapCooldown }; + +const DAY_MS = 24 * 60 * 60 * 1000; +const HOUR_MS = 60 * 60 * 1000; +const MIN_MS = 60 * 1000; + +/** A recomputed window must exceed the active one by more than this to replace it. */ +const COOLDOWN_EXTEND_TOLERANCE_MS = HOUR_MS; +/** How often the live server sweeps expired cooldowns back off `providers[].disabled`. */ +const DEFAULT_SWEEP_MS = 60 * 1000; + +/** Live server config bound at startServer; used when the request-log path has no config arg. */ +let liveConfig: OcxConfig | null = null; + +/** Bind the long-lived server config so cooldown recording mutates routing state. */ +export function bindProviderCapCooldownConfig(config: OcxConfig): void { + liveConfig = config; +} + +/** Parse "resets in 1d 22h" / "resets in 2d" / "resets in 3h" style phrases. */ +export function parseResetsInMs(message: string, now = Date.now()): number | undefined { + const text = message.replace(/\s+/g, " "); + const m = text.match(/resets?\s+in\s+(?:(\d+)\s*d(?:ays?)?)?(?:\s*)?(?:(\d+)\s*h(?:ours?)?)?(?:\s*)?(?:(\d+)\s*m(?:in(?:utes?)?)?)?/i); + if (!m) return undefined; + const days = Number(m[1] || 0); + const hours = Number(m[2] || 0); + const mins = Number(m[3] || 0); + if (!Number.isFinite(days) || !Number.isFinite(hours) || !Number.isFinite(mins)) return undefined; + if (days === 0 && hours === 0 && mins === 0) return undefined; + return now + days * DAY_MS + hours * HOUR_MS + mins * MIN_MS; +} + +/** + * Strong hard-cap signal only: INFERENCE_CAP / weekly+limit with a parseable reset, + * or explicit package-expired / out-of-usage phrases. Bare "usage limit" 429s are ignored + * so temporary rate limits do not auto-disable a provider for 24h. + */ +export function isHardCapMessage(status: number, upstreamError?: string): boolean { + if (status !== 429 && status !== 402) return false; + const text = (upstreamError || "").toLowerCase(); + if (text.includes("inference_cap")) return true; + if (text.includes("package has expired") || text.includes("out of usage")) return true; + const weekly = text.includes("weekly") && (text.includes("limit") || text.includes("cap")); + if (weekly && parseResetsInMs(upstreamError || "") !== undefined) return true; + return false; +} + +/** Map log labels like `openai-work` back to a config.providers key. */ +export function resolveProviderConfigKey(config: OcxConfig, logProvider: string): string | null { + if (!logProvider || logProvider === "combo") return null; + // Own-property only: `providers` is a plain record, so a log label like `constructor` or + // `toString` would otherwise resolve to an inherited function and be treated as a provider. + if (Object.hasOwn(config.providers, logProvider)) return logProvider; + const names = Object.keys(config.providers).sort((a, b) => b.length - a.length); + for (const name of names) { + if (logProvider.startsWith(`${name}-`)) return name; + } + return null; +} + +export function activeProviderCooldowns( + config: OcxConfig, + now = Date.now(), +): Record { + const raw = config.providerCooldowns; + if (!raw || typeof raw !== "object") return {}; + const out: Record = {}; + for (const [name, entry] of Object.entries(raw)) { + if (!entry || typeof entry.until !== "number" || !Number.isFinite(entry.until)) continue; + if (entry.until <= now) continue; + out[name] = entry; + } + return out; +} + +/** + * Expire stale cooldowns. Re-enables providers this path auto-disabled when the window ends. + * Returns true when config was mutated. + */ +export function expireProviderCooldowns(config: OcxConfig, now = Date.now()): boolean { + const bag = config.providerCooldowns; + if (!bag) return false; + let changed = false; + for (const [name, entry] of Object.entries(bag)) { + if (!entry || typeof entry.until !== "number" || entry.until > now) continue; + delete bag[name]; + changed = true; + if (entry.disabledProvider && config.providers[name]?.disabled === true) { + delete config.providers[name].disabled; + } + } + if (Object.keys(bag).length === 0) { + delete config.providerCooldowns; + } + return changed; +} + +/** + * Hand ownership of `providers[name].disabled` back to the operator. + * + * `expireProviderCooldowns` only re-enables providers this module disabled, which it tracks + * via `disabledProvider`. Once a human toggles `disabled` through the management API that + * flag is a lie: leaving it set lets the expiry sweep silently re-enable a provider the + * operator deliberately turned off. Returns true when the flag was cleared. + */ +export function releaseProviderCooldownDisableOwnership(config: OcxConfig, providerName: string): boolean { + const entry = config.providerCooldowns?.[providerName]; + if (!entry || entry.disabledProvider !== true) return false; + entry.disabledProvider = false; + return true; +} + +export interface ProviderCooldownSweep { + stop: () => void; +} + +/** The single in-flight sweep, so repeated `startServer` calls cannot stack timers. */ +let activeSweep: ProviderCooldownSweep | null = null; + +/** + * Periodically expire cooldowns on the live config. + * + * Routing reads `providers[name].disabled` directly (see `server/responses/core.ts`, + * `combos/resolve.ts`, `codex/subagent-model-fallback.ts`), and expiry otherwise only runs + * at startup and on `GET /api/config`. A headless proxy — the primary mode, driving Codex + * CLI or Claude Code with no dashboard open — would therefore keep an auto-paused provider + * disabled indefinitely past its reset. This sweep is that auto-recovery. + */ +export function startProviderCooldownSweep( + config: OcxConfig, + opts?: { intervalMs?: number; save?: (config: OcxConfig) => void }, +): ProviderCooldownSweep { + // One live config means one sweep: replace any prior timer instead of stacking them. + activeSweep?.stop(); + const save = opts?.save ?? saveConfigPreservingClaudeCode; + const timer = setInterval(() => { + try { + if (expireProviderCooldowns(config)) save(config); + } catch { + /* best-effort: a failed sweep must never take the proxy down */ + } + }, opts?.intervalMs ?? DEFAULT_SWEEP_MS); + // Never hold the process open on this alone. + (timer as { unref?: () => void }).unref?.(); + const sweep: ProviderCooldownSweep = { + stop: () => { + clearInterval(timer); + if (activeSweep === sweep) activeSweep = null; + }, + }; + activeSweep = sweep; + return sweep; +} + +export interface RecordProviderCapCooldownOpts { + disable?: boolean; + now?: number; + /** + * Persistence for a newly recorded window: `false` skips it, a function replaces + * `saveConfigPreservingClaudeCode` (tests count real writes), default persists to disk. + */ + save?: boolean | ((config: OcxConfig) => void); +} + +/** Record a hard-cap 429/402 onto the live provider config and optionally disable until reset. */ +export function recordProviderCapCooldown( + config: OcxConfig, + providerName: string, + status: number, + upstreamError: string | undefined, + opts?: RecordProviderCapCooldownOpts, +): ProviderCapCooldown | null { + const key = resolveProviderConfigKey(config, providerName); + if (!key || !isHardCapMessage(status, upstreamError)) return null; + const now = opts?.now ?? Date.now(); + const rawMessage = (upstreamError || "Usage limit reached").slice(0, 400); + const until = parseResetsInMs(rawMessage, now); + if (until === undefined && !/inference_cap/i.test(rawMessage) + && !/package has expired/i.test(rawMessage) + && !/out of usage/i.test(rawMessage)) { + // Weekly phrases without a parseable reset are not strong enough to auto-pause. + return null; + } + const untilMs = until ?? (now + DAY_MS); + const reason = /inference_cap/i.test(rawMessage) + ? "INFERENCE_CAP_ERROR" + : /weekly/i.test(rawMessage) + ? "weekly_usage_limit" + : "usage_cap"; + // Short GUI-safe summary — avoid echoing long upstream bodies (emails, org ids). + const message = reason === "INFERENCE_CAP_ERROR" + ? "Weekly inference cap reached." + : reason === "weekly_usage_limit" + ? "Weekly usage limit reached." + : "Usage cap reached."; + + expireProviderCooldowns(config, now); + const bag = (config.providerCooldowns ??= {}); + const prev = bag[key]; + // An already-active cooldown is authoritative. Clients retry hard, so this runs once per + // rejected request; rewriting `until` each time would fsync config.json on the request + // finalization path for the whole cap window. Upstream countdowns ("resets in 1d 22h") are + // hour-quantized, so a recomputed window drifts later by up to an hour without meaning + // anything — only a materially longer window (a 24h cap escalating to weekly) replaces it. + if (prev && prev.until + COOLDOWN_EXTEND_TOLERANCE_MS >= untilMs) return prev; + + const wantDisable = opts?.disable !== false; + const didDisable = wantDisable + && !!config.providers[key] + && config.defaultProvider !== key + && config.providers[key].disabled !== true; + + if (didDisable) { + config.providers[key].disabled = true; + } + + const entry: ProviderCapCooldown = { + until: untilMs, + reason, + message: `${message} Resets ~${new Date(untilMs).toISOString()}.`, + source: status === 402 ? "upstream-402" : "upstream-429", + disabledProvider: didDisable || (prev?.disabledProvider === true && config.providers[key]?.disabled === true), + recordedAt: now, + }; + bag[key] = entry; + if (opts?.save !== false) { + const save = typeof opts?.save === "function" ? opts.save : saveConfigPreservingClaudeCode; + save(config); + } + console.warn( + `[opencodex] Provider cap cooldown set provider=${key} until=${new Date(untilMs).toISOString()} reason=${reason} disabled=${!!entry.disabledProvider}`, + ); + return entry; +} + +/** Best-effort record using the bound live config (request-log hot path). */ +export function recordProviderCapCooldownLive( + providerName: string, + status: number, + upstreamError: string | undefined, +): ProviderCapCooldown | null { + if (!liveConfig) return null; + return recordProviderCapCooldown(liveConfig, providerName, status, upstreamError); +} diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 4d723bdb3..5d0e9f80c 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -922,6 +922,29 @@ async function maybeFetchProviderQuota( } } +/** + * Force-probe exactly one provider's quota. Never touches other providers' + * upstreams; merges the fresh row into the shared cache only when the cache + * already exists for the current provider set (a single-provider probe must + * not establish a global cache entry that would then serve one-row responses). + */ +export async function fetchSingleProviderQuotaReport( + config: OcxConfig, + name: string, +): Promise<{ generatedAt: number; report: ProviderQuotaReport | null }> { + const provider = Object.hasOwn(config.providers, name) ? config.providers[name] : undefined; + if (!provider) return { generatedAt: Date.now(), report: null }; + const key = cacheKey(config); + const epoch = invalidationEpoch; + const report = await maybeFetchProviderQuota(name, provider, config, true); + if (report && epoch === invalidationEpoch && cache && cache.key === key) { + const reports = cache.response.reports.filter(item => item.provider !== name); + reports.push(report); + cache = { key, ts: cache.ts, response: { generatedAt: Date.now(), reports } }; + } + return { generatedAt: Date.now(), report }; +} + export async function fetchProviderQuotaReports(config: OcxConfig, forceRefresh = false): Promise { const key = cacheKey(config); const now = Date.now(); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 4f806e87e..1a62959c9 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -14,6 +14,7 @@ import { import { providerDestinationConfigError } from "../lib/destination-policy"; import { getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; +import { activeProviderCooldowns } from "../providers/cap-cooldown"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; @@ -427,6 +428,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { if (codexAccountMode) dto.codexAccountMode = codexAccountMode; providers[name] = dto; } + const activeCooldowns = activeProviderCooldowns(config); return { port: config.port, hostname: config.hostname ?? "127.0.0.1", @@ -434,5 +436,6 @@ export function safeConfigDTO(config: OcxConfig): unknown { codexAutoStart: codexAutoStartEnabled(config), websockets: config.websockets, providers, + ...(Object.keys(activeCooldowns).length > 0 ? { providerCooldowns: activeCooldowns } : {}), }; } diff --git a/src/server/index.ts b/src/server/index.ts index 828caca8a..dac117e68 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -28,6 +28,11 @@ import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-st import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { providerCodexAccountMode } from "../providers/registry"; import type { StorageCleanupPolicy } from "../types"; +import { + bindProviderCapCooldownConfig, + expireProviderCooldowns, + startProviderCooldownSweep, +} from "../providers/cap-cooldown"; import { CodexAccountCooldownError, cooldownErrorMessage, @@ -252,6 +257,11 @@ function attachLiveSidebandUpstream(ws: ServerWebSocket): void { export function startServer(port?: number) { const config = runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig())); + // Cap-cooldown / request-log side effects must mutate THIS live object (not a disk reload). + bindProviderCapCooldownConfig(config); + if (expireProviderCooldowns(config)) saveConfig(config); + // Auto-pausing a capped provider is only safe if it auto-recovers without the dashboard. + startProviderCooldownSweep(config); applyProxyEnv(config); assertServerAuthConfig(config); const managementAuth = initializeManagementAuthState(config); diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 3801ca849..763c78635 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -27,6 +27,7 @@ import { isStreamMode } from "../../lib/bun-stream-caps"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; +import { expireProviderCooldowns } from "../../providers/cap-cooldown"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; @@ -67,6 +68,7 @@ import type { ManagementContext } from "./context"; export async function handleConfigRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; if (url.pathname === "/api/config" && req.method === "GET") { + if (expireProviderCooldowns(config)) saveConfigPreservingClaudeCode(config); return jsonResponse(safeConfigDTO(config)); } diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 0756333ac..e6590b683 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -62,6 +62,7 @@ import { setDebugSettings, type DebugFlag, } from "../../lib/debug-settings"; +import { releaseProviderCooldownDisableOwnership } from "../../providers/cap-cooldown"; import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types"; import { drainAndShutdown } from "../lifecycle"; import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log"; @@ -333,6 +334,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise; defaultProvider: string; + /** + * Active hard-cap cooldowns (weekly/inference). Mutated on the live server config so + * routing and /api/config see disables without restart. + */ + providerCooldowns?: Record; /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ openaiProviderTierVersion?: 1 | 2; /** Claude Code inbound + launcher settings. */ @@ -640,6 +657,14 @@ export interface OcxConfig { providerContextCaps?: Record; /** Global Codex-visible context cap value (tokens). Falls back to DEFAULT_PROVIDER_CONTEXT_CAP. */ contextCapValue?: number; + /** 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; + }; /** Bind hostname. Default "127.0.0.1" (loopback only). Set "0.0.0.0" to expose on all interfaces. */ hostname?: string; /** @@ -779,14 +804,6 @@ export interface OcxConfig { * by default for backward compatibility; single-account setups are unaffected while disabled. */ codexRequestPacing?: OcxCodexRequestPacing; - /** Token/cost budget thresholds for usage alerts (see src/usage/budgets.ts). */ - budgets?: { - tokenDaily?: number; - tokenWeekly?: number; - costDailyEur?: number; - alertActions?: Array<"log" | "posthog" | "webhook">; - webhookUrl?: string; - }; /** Virtual `combo/` models spanning concrete provider/model targets (issue #133). */ combos?: Record; /** Background proactive token refresh ("Token Guardian"). Off by default; see OcxTokenGuardianConfig. */ diff --git a/tests/cap-cooldown.test.ts b/tests/cap-cooldown.test.ts new file mode 100644 index 000000000..aa1177a45 --- /dev/null +++ b/tests/cap-cooldown.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, test } from "bun:test"; +import { + activeProviderCooldowns, + expireProviderCooldowns, + isHardCapMessage, + parseResetsInMs, + recordProviderCapCooldown, + releaseProviderCooldownDisableOwnership, + resolveProviderConfigKey, + startProviderCooldownSweep, +} from "../src/providers/cap-cooldown"; +import type { OcxConfig } from "../src/types"; + +const HOUR_MS = 60 * 60 * 1000; + +function bareConfig(overrides?: Partial): OcxConfig { + return { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { baseUrl: "https://api.openai.com/v1", adapter: "openai-responses" }, + "cline-pass": { baseUrl: "https://api.cline.bot/v1", adapter: "openai-chat" }, + }, + ...overrides, + } as OcxConfig; +} + +describe("parseResetsInMs", () => { + test("parses Clinepass-style 'resets in 1d 22h'", () => { + const now = Date.UTC(2026, 6, 31, 0, 0, 0); + const until = parseResetsInMs("Error 429: You have reached your weekly Clinepass limit. The limit resets in 1d 22h, please try again later.", now); + expect(until).toBe(now + (1 * 24 + 22) * 60 * 60 * 1000); + }); + + test("parses hours-only", () => { + const now = 1_000_000; + expect(parseResetsInMs("resets in 3h", now)).toBe(now + 3 * 60 * 60 * 1000); + }); + + test("returns undefined when no match", () => { + expect(parseResetsInMs("rate limited, try later")).toBeUndefined(); + }); +}); + +describe("isHardCapMessage", () => { + test("detects INFERENCE_CAP_ERROR", () => { + expect(isHardCapMessage(429, '{"code":"INFERENCE_CAP_ERROR","message":"weekly Clinepass limit"}')).toBe(true); + }); + + test("detects weekly limit with parseable reset", () => { + expect(isHardCapMessage(429, "weekly Clinepass limit. The limit resets in 1d 22h")).toBe(true); + }); + + test("ignores ordinary rate limits", () => { + expect(isHardCapMessage(429, "Too many requests")).toBe(false); + }); + + test("ignores bare usage-limit without reset window", () => { + expect(isHardCapMessage(429, "You hit a usage limit, slow down")).toBe(false); + }); + + test("ignores non-429", () => { + expect(isHardCapMessage(500, "INFERENCE_CAP_ERROR")).toBe(false); + }); +}); + +describe("recordProviderCapCooldown (live config)", () => { + test("mutates the given config and disables non-default provider", () => { + const config = bareConfig(); + const now = Date.UTC(2026, 6, 31, 0, 0, 0); + const entry = recordProviderCapCooldown( + config, + "cline-pass", + 429, + 'Error 429: {"code":"INFERENCE_CAP_ERROR","message":"weekly Clinepass limit. The limit resets in 1d 22h"}', + { now, save: false }, + ); + expect(entry).not.toBeNull(); + expect(config.providers["cline-pass"]?.disabled).toBe(true); + expect(config.providerCooldowns?.["cline-pass"]?.disabledProvider).toBe(true); + expect(config.providerCooldowns?.["cline-pass"]?.until).toBe(now + (1 * 24 + 22) * HOUR_MS); + expect(activeProviderCooldowns(config, now)["cline-pass"]).toBeTruthy(); + }); + + test("does not disable the default provider but still records cooldown", () => { + const config = bareConfig(); + const now = 1_000_000; + const entry = recordProviderCapCooldown( + config, + "openai", + 429, + '{"code":"INFERENCE_CAP_ERROR","message":"weekly limit"}', + { now, save: false }, + ); + expect(entry).not.toBeNull(); + expect(config.providers.openai?.disabled).toBeUndefined(); + expect(entry?.disabledProvider).toBe(false); + }); + + test("resolves openai-