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/scripts/issue-quality.test.cjs b/.github/scripts/issue-quality.test.cjs index c6a58996a..d988ae3cd 100644 --- a/.github/scripts/issue-quality.test.cjs +++ b/.github/scripts/issue-quality.test.cjs @@ -742,7 +742,7 @@ describe("validateIssue - documentation", () => { "### Documentation problem type", "Incorrect documentation", "### Documentation location", - "https://lidge-jun.github.io/opencodex/providers/", + "https://github.com/OnlineChefGroep/opencodex", "### What is wrong or missing?", "The page says kimi uses /v1/chat/completions but it actually uses /v1/responses.", "### What should the documentation explain instead?", @@ -893,7 +893,7 @@ describe("shouldReopen", () => { state: "closed", closed_at: "2026-07-20T10:00:00Z", state_reason: "not_planned", - closed_by: "lidge-jun", + closed_by: "OnlineChefGroep", }; assert.equal(shouldReopen(baseBotState, issue, false), false); }); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0208df13f..c897119ac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,3 +136,60 @@ 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: 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: 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: Enforce Bun audit + if: steps.bun-audit.outputs.exit_code != '0' + run: | + cat bun-audit.json + 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/.github/workflows/enforce-issue-quality.yml b/.github/workflows/enforce-issue-quality.yml index 2ef8ddc59..164397207 100644 --- a/.github/workflows/enforce-issue-quality.yml +++ b/.github/workflows/enforce-issue-quality.yml @@ -1040,7 +1040,7 @@ jobs: "", guidanceList, "", - "See the [Contributing guide](https://lidge-jun.github.io/opencodex/contributing/) for details.", + "See the [README](https://github.com/OnlineChefGroep/opencodex) for details.", "", "Once the report passes the automated checks, it will be reopened automatically unless a maintainer has changed its state.", ].join("\n")); diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index edeaaba43..288d1d693 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,32 +110,30 @@ jobs: run: | set -euo pipefail + # All releases run from main. Stable versions (no pre-release suffix) get dist-tag "latest". + # Pre-release versions (e.g. 1.0.0-alpha.1) get dist-tag "preview". case "$GITHUB_REF" in refs/heads/main) - expected_tag="latest" if [[ "$RELEASE_VERSION" == *-* ]]; then - echo "::error::main releases must use a stable semver version; got ${RELEASE_VERSION}" - exit 1 - fi - ;; - refs/heads/preview) - expected_tag="preview" - if [[ "$RELEASE_VERSION" != *-preview.* ]]; then - echo "::error::preview releases must use a preview prerelease version; got ${RELEASE_VERSION}" - exit 1 + # Pre-release version — must use "preview" dist-tag + if [ "$NPM_DIST_TAG" != "preview" ]; then + echo "::error::Pre-release versions (${RELEASE_VERSION}) must use dist-tag 'preview', got '${NPM_DIST_TAG}'" + exit 1 + fi + else + # Stable version — must use "latest" dist-tag + if [ "$NPM_DIST_TAG" != "latest" ]; then + echo "::error::Stable releases (${RELEASE_VERSION}) must use dist-tag 'latest', got '${NPM_DIST_TAG}'" + exit 1 + fi fi ;; *) - echo "::error::Release must run from main or preview; got ${GITHUB_REF}" + echo "::error::Release must run from main; got ${GITHUB_REF}" exit 1 ;; esac - if [ "$NPM_DIST_TAG" != "$expected_tag" ]; then - echo "::error::${GITHUB_REF#refs/heads/} releases must publish with npm dist-tag '${expected_tag}', got '${NPM_DIST_TAG}'" - exit 1 - fi - ci_url="$( gh run list \ --workflow ci.yml \ @@ -320,7 +318,7 @@ jobs: # Preview builds must be marked prerelease so GitHub "latest" keeps pointing at the # stable channel (matching npm dist-tags); see issue #64. prerelease_flag="" - if [[ "$RELEASE_VERSION" == *-preview.* ]]; then + if [[ "$RELEASE_VERSION" == *-* ]]; then prerelease_flag="--prerelease" fi diff --git a/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/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..a356e9ee7 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,41 @@ +# Roadmap + +**Independent OnlineChefGroep fork** of [opencodex](https://github.com/lidge-jun/opencodex) + +## Vision + +A fully self-sufficient fork with our own release cadence, feature set, and quality bar — +not dependent on upstream decisions or timelines, while retaining a deliberate intake path for +relevant security and compatibility fixes. + +## Short term (done) + +- [x] **Independent versioning** — fully detached from upstream version numbers, with our own semver scheme +- [x] **Integrated inherited branch work** — Dutch GUI, Claude Desktop, combo/alias, Cursor fixes, and related stacked work reconciled into `main` +- [x] **Enhanced CI** — CodeQL, Dependabot, security audit, workflow linting, and cross-platform package smoke tests +- [x] **Release process** — `VERSIONING.md`, `RELEASE_PROCESS.md`, and `CHANGELOG.md` +- [x] **Fork identity documentation** — canonical repository links and translated README fork notices updated + +## Near term (next) + +- [ ] **Publish first fork release (`v1.0.0-alpha.1`)** — tag, run the guarded release workflow, verify npm install, and record release evidence +- [ ] **Canonical npm ownership** — migrate the package to an OnlineChefGroep-controlled npm scope and retain a compatibility path for `@bitkyc08/opencodex` +- [ ] **Clean up old tags** — remove or archive stale upstream tags that do not identify fork releases +- [ ] **Dependency audit hardening** — review root and GUI dependencies, then make high-severity audit failures enforceable instead of informational +- [ ] **TypeScript strict mode** — enable `strict` in `tsconfig` and fix all violations +- [ ] **Upstream intake policy** — document how security, protocol, and client-compatibility fixes are evaluated and imported without restoring release dependence + +## Medium term + +- [ ] **Custom provider: OnlineChef AI gateway** — first-party provider integration +- [ ] **Performance benchmarks** — proxy latency regression tests in CI +- [ ] **Improved documentation** — deploy docs to GitHub Pages for the fork +- [ ] **Automated dependency upgrades** — Dependabot auto-merge for verified non-breaking updates +- [ ] **Smoke test suite** — end-to-end tests that start the proxy and exercise real provider-compatible requests + +## Long term + +- [ ] **Own GUI theme** — custom branding for the dashboard +- [ ] **Plugin system** — third-party provider adapters +- [ ] **Service mode improvements** — better systemd/launchd integration +- [ ] **Multi-host fleet management** — centralized config across machines diff --git a/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/astro.config.mjs b/docs-site/astro.config.mjs index 01b6c508f..91cdf5ca3 100644 --- a/docs-site/astro.config.mjs +++ b/docs-site/astro.config.mjs @@ -2,22 +2,51 @@ import { defineConfig } from "astro/config"; import starlight from "@astrojs/starlight"; -// Canonical GitHub Pages custom domain. The site is served at the domain root, -// so Starlight must not emit the former /opencodex project-site prefix. -const SITE_URL = "https://opencodex.me"; +// Fork docs — not currently deployed, but config stays ready for GitHub Pages. +const SITE_URL = "https://github.com/OnlineChefGroep/opencodex"; -// NOTE: the WebSite / SoftwareApplication JSON-LD deliberately does NOT live here. -// Google only reads site-name markup from the home page of a site, and a global -// `head` entry would replay one `#website` entity (with the root `url`) on every -// docs page and every locale. Duplicated, conflicting WebSite objects are exactly -// what makes Google fall back to the domain ("opencodex.me") for the site name. -// The markup is emitted once per locale home page from `src/components/SiteJsonLd.astro`. +const jsonLd = JSON.stringify({ + "@context": "https://schema.org", + "@graph": [ + { + "@type": "WebSite", + "@id": `${SITE_URL}/#website`, + url: `${SITE_URL}/`, + name: "opencodex (OnlineChefGroep fork)", + description: + "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI, App, SDK, and Claude Code.", + inLanguage: ["en", "ko", "zh-CN", "ru", "ja"], + }, + { + "@type": "SoftwareApplication", + "@id": `${SITE_URL}/#software`, + name: "opencodex", + alternateName: "ocx", + description: + "Local LLM proxy that lets OpenAI Codex (CLI, App, SDK) and Claude Code run on any model — Claude, Gemini, Grok, DeepSeek, Kimi, Qwen, Ollama, OpenRouter, and more — with streaming, tool calls, reasoning tokens, and images working in both directions.", + keywords: + "codex, claude code, openai codex proxy, claude code proxy, llm proxy, ai gateway, anthropic, gemini, grok, deepseek, ollama, openrouter, responses api, codex cli", + featureList: [ + "Run Codex CLI/App/SDK on any LLM provider", + "Run Claude Code on any LLM via the Anthropic Messages API", + "ChatGPT account pool with quota-aware routing", + "Streaming, tool calls, reasoning tokens, and vision in both directions", + "Web dashboard on localhost:10100", + ], + applicationCategory: "DeveloperApplication", + operatingSystem: "macOS, Linux, Windows", + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + softwareHelp: { "@type": "CreativeWork", url: `${SITE_URL}/` }, + downloadUrl: "https://www.npmjs.com/package/@bitkyc08/opencodex", + url: "https://github.com/OnlineChefGroep/opencodex", + }, + ], +}); export default defineConfig({ site: SITE_URL, + base: "/", trailingSlash: "ignore", - // lightningcss merges animation-timeline into the `animation` shorthand, - // which Chrome cannot parse — the scroll-driven animations die silently. vite: { build: { cssMinify: "esbuild" } }, integrations: [ starlight({ @@ -30,7 +59,7 @@ export default defineConfig({ dark: "./src/assets/logo-dark.png", replacesTitle: false, }, - favicon: "/favicon.ico", + favicon: "/favicon.png", customCss: [ "@fontsource-variable/geist", "pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css", @@ -41,8 +70,6 @@ export default defineConfig({ PageTitle: "./src/components/PageTitle.astro", }, head: [ - // Google favicon guidelines: PNG at a multiple of 48px, exposed via rel="icon". - { tag: "link", attrs: { rel: "icon", type: "image/png", sizes: "192x192", href: "/favicon.png" } }, { tag: "meta", attrs: { property: "og:image", content: `${SITE_URL}/og.png` } }, { tag: "meta", attrs: { property: "og:image:width", content: "1200" } }, { tag: "meta", attrs: { property: "og:image:height", content: "630" } }, @@ -50,15 +77,15 @@ export default defineConfig({ { tag: "meta", attrs: { name: "twitter:image", content: `${SITE_URL}/og.png` } }, { tag: "meta", attrs: { name: "theme-color", media: "(prefers-color-scheme: light)", content: "#ffffff" } }, { tag: "meta", attrs: { name: "theme-color", media: "(prefers-color-scheme: dark)", content: "#212121" } }, + { tag: "script", attrs: { type: "application/ld+json" }, content: jsonLd }, ], social: [ - { icon: "github", label: "GitHub", href: "https://github.com/lidge-jun/opencodex" }, + { icon: "github", label: "GitHub", href: "https://github.com/OnlineChefGroep/opencodex" }, ], editLink: { - baseUrl: "https://github.com/lidge-jun/opencodex/edit/main/docs-site/", + baseUrl: "https://github.com/OnlineChefGroep/opencodex/edit/main/docs-site/", }, lastUpdated: true, - // English at the site root; Korean under /ko, Simplified Chinese under /zh-cn, Russian under /ru, Japanese under /ja. defaultLocale: "root", locales: { root: { label: "English", lang: "en" }, @@ -69,62 +96,45 @@ export default defineConfig({ }, sidebar: [ { - label: "Getting Started", - translations: { ko: "시작하기", "zh-CN": "开始使用", ru: "Начало работы", ja: "はじめに" }, - items: [ - { label: "Installation", translations: { ko: "설치", "zh-CN": "安装", ru: "Установка", ja: "インストール" }, slug: "getting-started/installation" }, - { label: "Quickstart", translations: { ko: "빠른 시작", "zh-CN": "快速开始", ru: "Быстрый старт", ja: "クイックスタート" }, slug: "getting-started/quickstart" }, - { label: "How It Works", translations: { ko: "동작 원리", "zh-CN": "工作原理", ru: "Как это работает", ja: "仕組み" }, slug: "getting-started/how-it-works" }, - ], + label: "Getting started", + translations: { + ko: "시작하기", + "zh-cn": "开始使用", + ru: "Начало работы", + ja: "はじめに", + }, + autogenerate: { directory: "getting-started" }, }, { - label: "Guides", - translations: { ko: "가이드", "zh-CN": "指南", ru: "Руководства", ja: "ガイド" }, - items: [ - { label: "Providers", translations: { ko: "프로바이더", "zh-CN": "提供商", ru: "Провайдеры", ja: "プロバイダー" }, slug: "guides/providers" }, - { label: "Model Routing", translations: { ko: "모델 라우팅", "zh-CN": "模型路由", ru: "Маршрутизация моделей", ja: "モデルルーティング" }, slug: "guides/model-routing" }, - { label: "Codex Integration", translations: { ko: "Codex 통합", "zh-CN": "Codex 集成", ru: "Интеграция с Codex", ja: "Codex 連携" }, slug: "guides/codex-integration" }, - { label: "Codex App Model Picker", translations: { ko: "Codex App 모델 선택기", "zh-CN": "Codex App 模型选择器", ru: "Выбор модели в Codex App", ja: "Codex App モデルピッカー" }, slug: "guides/codex-app-models" }, - { label: "Model Ordering", translations: { ko: "모델 정렬에 관하여", "zh-CN": "模型排序", ru: "Сортировка моделей", ja: "モデルの並び順" }, slug: "guides/model-ordering" }, - { label: "Claude Code", translations: { ko: "Claude Code", "zh-CN": "Claude Code", ru: "Claude Code", ja: "Claude Code" }, slug: "guides/claude-code" }, - { label: "Grok Build", translations: { ko: "Grok Build", "zh-CN": "Grok Build", ru: "Grok Build", ja: "Grok Build" }, slug: "guides/grok-build" }, - { label: "Sidecars: Web Search & Vision", translations: { ko: "사이드카: 웹 검색 & 비전", "zh-CN": "边车:网络搜索与视觉", ru: "Сайдкары: веб-поиск и зрение", ja: "サイドカー: ウェブ検索 & ビジョン" }, slug: "guides/sidecars" }, - { label: "Web Dashboard", translations: { ko: "웹 대시보드", "zh-CN": "网页控制台", ru: "Веб-дашборд", ja: "ウェブダッシュボード" }, slug: "guides/web-dashboard" }, - { label: "Sub-agent Surface", translations: { ko: "서브에이전트 서피스", "zh-CN": "子代理界面", ru: "Интерфейс подагентов", ja: "サブエージェントサーフェス" }, slug: "guides/sub-agent-surface" }, - ], + label: "Providers", + translations: { + ko: "프로바이더", + "zh-cn": "Provider", + ru: "Провайдеры", + ja: "プロバイダー", + }, + autogenerate: { directory: "providers" }, }, { - label: "Benchmarks", - translations: { ko: "벤치마크", "zh-CN": "基准测试", ru: "Бенчмарки", ja: "ベンチマーク" }, - collapsed: true, - items: [ - { label: "Overview", translations: { ko: "개요", "zh-CN": "概览", ru: "Обзор", ja: "概要" }, slug: "benchmarks" }, - { label: "Coding", translations: { ko: "코딩", "zh-CN": "编程", ru: "Кодинг", ja: "コーディング" }, slug: "benchmarks/coding" }, - { label: "Frontend", translations: { ko: "프론트엔드", "zh-CN": "前端", ru: "Фронтенд", ja: "フロントエンド" }, slug: "benchmarks/frontend" }, - { label: "Terminal", translations: { ko: "터미널", "zh-CN": "终端", ru: "Терминал", ja: "ターミナル" }, slug: "benchmarks/terminal" }, - { label: "Security", translations: { ko: "보안", "zh-CN": "安全", ru: "Безопасность", ja: "セキュリティ" }, slug: "benchmarks/security" }, - { label: "Intelligence", translations: { ko: "인텔리전스", "zh-CN": "智能", ru: "Интеллект", ja: "インテリジェンス" }, slug: "benchmarks/intelligence" }, - ], + label: "Guides", + translations: { + ko: "가이드", + "zh-cn": "指南", + ru: "Руководства", + ja: "ガイド", + }, + autogenerate: { directory: "guides" }, }, { label: "Reference", - translations: { ko: "레퍼런스", "zh-CN": "参考", ru: "Справочник", ja: "リファレンス" }, - items: [ - { label: "CLI", translations: { ko: "CLI", "zh-CN": "命令行", ru: "CLI", ja: "CLI" }, slug: "reference/cli" }, - { label: "Configuration", translations: { ko: "설정", "zh-CN": "配置", ru: "Конфигурация", ja: "設定" }, slug: "reference/configuration" }, - { label: "Adapters", translations: { ko: "어댑터", "zh-CN": "适配器", ru: "Адаптеры", ja: "アダプター" }, slug: "reference/adapters" }, - { label: "Architecture", translations: { ko: "아키텍처", "zh-CN": "架构", ru: "Архитектура", ja: "アーキテクチャ" }, slug: "reference/architecture" }, - ], - }, - { - label: "Troubleshooting", - translations: { ko: "문제 해결", "zh-CN": "故障排除", ru: "Устранение неполадок", ja: "トラブルシューティング" }, - collapsed: true, - items: [ - { label: "Windows Memory Growth", translations: { ko: "Windows 메모리 증가", "zh-CN": "Windows 内存增长", ru: "Рост памяти в Windows", ja: "Windows メモリ増加" }, slug: "troubleshooting/windows-memory" }, - ], + translations: { + ko: "레퍼런스", + "zh-cn": "参考", + ru: "Справочник", + ja: "リファレンス", + }, + autogenerate: { directory: "reference" }, }, - { label: "Contributing", translations: { ko: "기여하기", "zh-CN": "贡献", ru: "Как внести вклад", ja: "コントリビュート" }, slug: "contributing" }, ], }), ], 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/components/Landing.astro b/docs-site/src/components/Landing.astro index d9e2965f2..105e4ed4a 100644 --- a/docs-site/src/components/Landing.astro +++ b/docs-site/src/components/Landing.astro @@ -112,7 +112,7 @@ const docsMap = [ icon: 'folder', links: [ { label: t('Contributing', '기여하기', '贡献', 'Участие в проекте', 'コントリビュート'), href: `${prefix}contributing/` }, - { label: 'GitHub', href: 'https://github.com/lidge-jun/opencodex' }, + { label: 'GitHub', href: 'https://github.com/OnlineChefGroep/opencodex' }, { label: 'npm', href: 'https://www.npmjs.com/package/@bitkyc08/opencodex' }, ], }, @@ -158,7 +158,7 @@ const docsMap = [ {t('Get Started', '시작하기', '快速开始', 'Начать', 'はじめに')} - GitHub + GitHub
    {providers.map((p) => ( diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index ebc1de681..c2c9eb218 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -5,11 +5,8 @@ description: Develop opencodex — setup, layout, conventions, and how to add a ## Setup -Source development requires the `bun` CLI on your `PATH`. The published npm package bundles its own -Bun runtime for users, but this checkout's scripts run through your local Bun installation. - ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # proxy API in dev mode @@ -48,7 +45,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 +68,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: @@ -84,29 +77,10 @@ bun run release --publish # publish after the CI-gated dry run is unde bun run release:watch # watch the newest Release workflow run ``` -## Branches - -- `dev` — the default integration target. Open your pull request here unless it - belongs to a scoped line below. -- `dev2-go` — parallel integration line for the Go native port (`go/`, the - native runtime entrypoint, and the Go release-asset tooling). Open for pull - requests alongside `dev`. Send work here only when it belongs to the Go port; - everything else goes to `dev`. The automated target-branch check accepts both - and cannot tell them apart, so scope is settled in review — a maintainer may - ask you to retarget. -- `main` — releases only. It moves by maintainer-controlled promotion from - `dev`; do not open feature pull requests against it. -- `preview` — the prerelease train. - -Porting and rebase pull requests are welcome. Carrying a fix from one -integration line to another, or rebasing a stale branch onto the current head, -is normal contribution rather than noise — note the source commits in the -description. - ## Project maintainers The current maintainers, their responsibilities, and the review and merge policy are documented in -[`MAINTAINERS.md`](https://github.com/lidge-jun/opencodex/blob/main/MAINTAINERS.md). GitHub review +[`MAINTAINERS.md`](https://github.com/OnlineChefGroep/opencodex/blob/main/MAINTAINERS.md). GitHub review ownership for the repository and security-sensitive paths is declared in `.github/CODEOWNERS`. ## Conventions @@ -145,7 +119,7 @@ live in `src/oauth/`; registry metadata alone is not an OAuth flow. ## 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/docs-site/src/content/docs/getting-started/installation.md b/docs-site/src/content/docs/getting-started/installation.md index 7c68e1b84..f0a38dd31 100644 --- a/docs-site/src/content/docs/getting-started/installation.md +++ b/docs-site/src/content/docs/getting-started/installation.md @@ -60,7 +60,7 @@ ocx update --tag preview To hack on opencodex itself: ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # starts the proxy API in dev mode (src/cli/index.ts start) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index fb814832e..d4c985aef 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -28,32 +28,6 @@ ocx claude | `CLAUDE_CODE_MAX_CONTEXT_TOKENS` / `DISABLE_COMPACT` | Legacy context override when `maxContextTokens` is set (conditional) | Variables you export yourself always win. Extra arguments pass through: `ocx claude -p "hello"`. -## Auth mode - -Claude Code needs a token in `ANTHROPIC_AUTH_TOKEN` to talk to a gateway, but setting that -variable also disables your claude.ai login and its connectors. Which of the two you want -depends on something opencodex can look up, so by default it does. - -Leave **Auth mode** on **Auto** (the default) in **Claude → Claude Code** and opencodex -decides at each launch: - -| What it finds | What it does | -| --- | --- | -| A Claude login (`~/.claude.json` OAuth account, `.credentials.json`, the macOS keychain, or an exported `ANTHROPIC_API_KEY`) | Leaves the token unset, so your subscription and connectors keep working | -| No Claude auth at all | Injects a placeholder token, so Claude Code stops asking you to log in and routes through the proxy | -| It cannot tell (unreadable keychain, corrupt file) | Assumes subscription and prints a warning — it never moves a paying subscriber onto the proxy on a failed read | - -This is recomputed every launch, not remembered, so logging in or out is picked up on the -next `ocx claude` with nothing to reconfigure. - -Pick **Subscription** or **Proxy** explicitly when you want it fixed. An explicit choice is -stored in `claudeCode.authMode` and detection never overrides it — including after you log -in or out later. Switch back to Auto to hand the decision back. - -On macOS, auto-connect (`claudeCode.systemEnv`) follows the same resolution, so a plain -`claude` launched outside `ocx` behaves the same way. That file is a snapshot refreshed when -the proxy starts or you save settings, while `ocx claude` always resolves live. - ## System environment integration (macOS) ## Claude Desktop profile @@ -85,13 +59,6 @@ Import validates the complete file before saving, so an invalid file leaves the unchanged. Add `--apply` to write a valid imported profile to Desktop immediately. Use `none` only for an empty family; every non-empty family must keep one default. -Apply writes to Claude Desktop's real Electron user-data `configLibrary`: `~/Library/Application -Support/Claude/configLibrary` on macOS, `%APPDATA%\Claude\configLibrary` on Windows, and -`${XDG_CONFIG_HOME:-~/.config}/Claude/configLibrary` on Linux. Set -`OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR` for an explicit library override or -`CLAUDE_USER_DATA_DIR` for an alternate Desktop user-data root. The legacy `Claude-3p` directory is -not read or deleted automatically. - Non-Anthropic routes receive stable aliases such as `claude-opus-4-8-2026MMDD`. The date-looking part is a synthetic route slot, not the model's release date. Real Anthropic Claude routes keep their real ids. New routes default to the Opus family, but moving a route does not change the @@ -151,11 +118,6 @@ capabilities (reasoning-effort ladder, thinking types) in the official ModelInfo Desktop's third-party gateway mode can offer its effort selector. Real Anthropic models keep their canonical ids. The synthetic 2026 date is an internal slot, not a release date. Legacy hash aliases and `claude-ocx---` ids from older configs still resolve. - -If Claude Desktop's footer picker does not change the model for an already-running 3P -conversation, use `/model ` in that conversation. OpenCodex cannot observe picker state; it -routes the model id carried by each request. Confirm the result under **Logs → requestedModel**. - Models with an authoritative 1M context window get an extra `…[1m]` picker row: selecting it makes Claude Code account a full 1M context for that model (auto-compaction stays on) — the proxy strips the marker before routing. @@ -212,8 +174,6 @@ fall back to 350k. `ANTHROPIC_SMALL_FAST_MODEL`. The effective Haiku is `tierModels.haiku ?? smallFastModel`, fed to both Haiku variables. -When both `tierModels.haiku` and `smallFastModel` are absent, OpenCodex leaves both helper variables unset; Claude Code then chooses its native helper model (currently Sonnet), which may incur native-provider charges. - ## Roster agents (injectAgents) `ocx claude` (and the system-env daemon) syncs your featured subagent roster (Subagents tab, @@ -222,7 +182,7 @@ up to 5 models) plus `ocx-self` into `~/.claude/agents/ocx-*.md`. - **`ocx-self`** pins your `/model` picker default (falling back to `claudeCode.model`); omitted when neither exists. It does NOT use model inheritance. - Each agent body contains an `` directive — the proxy uses this to - pin the real route. The Agent tool's `model` argument is therefore inert; pass `"haiku"` as a + pin the real route. The Agent tool's `model` argument is therefore inert; pass `"sonnet"` as a placeholder. - Frontmatter carries the alias; routing is directive-driven. - Only marker-verified `ocx-*.md` files containing `generated-by: opencodex` are ever @@ -316,7 +276,7 @@ images are cached by backend, model, detail, image bytes, and request context, s image-and-context pair is not described again on every replay. Remote `https:` images are never cached because their contents can change. -See the [configuration reference](/reference/configuration/#sidecars) for every key. +See the [configuration reference](/opencodex/reference/configuration/#sidecars) for every key. Anthropic-OAuth web search and image description reuse the repository's existing Claude Code OAuth fingerprint precedent, but should still be soak-tested with your account and workload before you depend on them for long unattended runs. @@ -459,4 +419,4 @@ it by default (`blockedSkills: ["claude-api"]`). **Subagent dispatches to wrong model** — Roster agents (`ocx-*`) use `` directives, not the Agent tool's `model` argument. Make sure the directive matches the intended -route. Pass `"haiku"` as the model placeholder. +route. Pass `"sonnet"` as the model placeholder. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 8314f0ddc..edfe6d843 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -19,11 +19,6 @@ The API route publishes 1,050,000 context / 922,000 max input metadata. Its `sol-pro`, `terra-pro`, and `luna-pro` virtual ids keep their selected public identity while the wire uses the base model plus `reasoning.mode: "pro"`. -If the built-in `openai` provider is missing or disabled, the dashboard Accounts picker and Codex -Auth page can restore it: absent rows are created from the canonical preset, disabled canonical -rows are re-enabled without replacing saved mode or model settings, and noncanonical `openai` -rows are not offered that recovery path. - Shipped v1 configs migrate automatically to marker 2 and one option-aware row. The original config is retained once at `~/.opencodex/config.json.pre-openai-tiers-v2.bak`; restore it with `cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`. @@ -55,8 +50,8 @@ The `openai` provider needs **no API key**. Direct forwards credentials from you ``` Only a curated set of headers is forwarded (`FORWARD_HEADERS`: authorization, ChatGPT account id, -OpenAI beta/originator/session — see [Adapters](/reference/adapters/)). This path is also -what powers the [web-search and vision sidecars](/guides/sidecars/). +OpenAI beta/originator/session — see [Adapters](/opencodex/reference/adapters/)). This path is also +what powers the [web-search and vision sidecars](/opencodex/guides/sidecars/). The ChatGPT passthrough catalog also layers in the bare GPT-5.6 Sol/Terra/Luna slugs (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`) for accounts that can use them. @@ -85,12 +80,12 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | Live-first Grok catalog; `grok-4.5` is the fallback default. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude models; live model list fetched from `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Import-first login reuses the installed `kiro-cli` session — requires the Kiro CLI installed (`curl -fsSL https://cli.kiro.dev/install | bash`) and signed in via `kiro-cli login`. | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Import-first login reuses the installed `kiro-cli` session. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | -You can also start OAuth from the [web dashboard](/guides/web-dashboard/). +You can also start OAuth from the [web dashboard](/opencodex/guides/web-dashboard/). ### Multiple OAuth accounts @@ -100,63 +95,6 @@ active account without logging the others out. Identity-less Kimi and Kiro crede active slot, while `chatgpt` is always single-slot because Codex pool accounts have a separate ledger. Tokens stay in `~/.opencodex/auth.json`; `/api/oauth/accounts` returns masked metadata only. -### OAuth reliability - -opencodex coordinates token refresh and Codex pool routing so concurrent requests do not race the -credential store. This is reliability and diagnostics work — it does **not** guarantee protection -from provider enforcement, rate limits, or account actions. - -**Refresh coordination.** Before a routed call, an expired access token is refreshed once per -`(provider, account)`: - -1. In-process single-flight — concurrent callers share one refresh promise. -2. Per-account file lock — cross-process writers serialize on the same account. -3. Generation CAS — persist only when the stored credential generation still matches; a newer writer - wins, and an older refresh result cannot overwrite it. - -Terminal refresh failures mark the account as needing reauthentication instead of retrying forever. - -**Cooldowns (Codex pool).** Upstream `429` / quota responses set a hard cooldown from -`Retry-After`, quota `reset` headers (capped), or a short default backoff. Accounts on an explicit -`Retry-After` cooldown are not probed early; reset-derived cooldowns may receive a paced probe lease -so recovery can be detected without flooding the provider. - -**Session affinity.** Codex thread→account affinity is process-local (in-memory only; not persisted -across proxy restarts). On credential failures (`401` / `403`) the account is quarantined for -reauth and affinities for that account are cleared. On `429`, the account enters cooldown, affinities -are cleared, and pool selection may rotate — threads are not pinned through a rate-limit response. - -**Codex client metadata.** The ChatGPT forward path passes through the curated `FORWARD_HEADERS` -allowlist (authorization, `chatgpt-account-id`, originator, session/thread ids, and related Codex -headers — see [Adapters](/reference/adapters/)). Pool mode overwrites only auth and -`chatgpt-account-id` to match the selected credential. opencodex does **not** fabricate official -client identity (for example `originator`, session, or thread headers) when the caller did not send -them. - -**Diagnostics and reauth.** Human `ocx status` prints an OAuth health block (redacted account ids, -no tokens). `ocx doctor` adds an OAuth reliability section with writable-store / single-flight checks -and WARN rows that include a recovery Action. When an OAuth provider account needs reauthentication, run -`ocx login ` (or use Reauthenticate in the dashboard). Codex pool accounts are not an -`ocx login` provider — reauthenticate via the dashboard Codex account pool. See -[`ocx status` / `ocx doctor`](/reference/cli/) in the CLI reference. - -### Kiro credential import - -Kiro login expects the Kiro CLI: install it (`curl -fsSL https://cli.kiro.dev/install | bash`) -and sign in with `kiro-cli login` first. Without a kiro-cli session, `ocx login kiro` falls -back to a pasted access token or the `KIRO_ACCESS_TOKEN` environment variable. - -`ocx login kiro` searches the platform Kiro CLI stores and opens SQLite databases read-only. Two -environment variables make selection explicit without copying credentials into opencodex: - -- `KIROCLI_DB_PATH` selects a nonstandard Kiro CLI SQLite database. The path must already exist; - opencodex does not create it or modify the database, WAL, or SHM files. -- `KIROCLI_TOKEN_KEY` selects the exact `auth_kv` token key when a database contains multiple - otherwise ambiguous token rows. A missing selection fails login instead of guessing. - -Keep these variables and the selected database private. Do not attach database files or raw login -diagnostics to bug reports. - ## 3. API-key catalog opencodex ships 53 built-in presets: 42 key-based, seven OAuth, three local, and the default @@ -182,7 +120,6 @@ validates the key, and stores it. Notable entries: | Hugging Face | `https://router.huggingface.co/v1` | | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | -| Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` | | Qwen Cloud | Token plan (default): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · Pay as you go: `https://dashscope.aliyuncs.com/compatible-mode/v1` · or Custom | | Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` | | SiliconFlow | `https://api.siliconflow.cn/v1` | @@ -199,10 +136,6 @@ endpoint (e.g. **Xiaomi MiMo**) use the `anthropic` adapter (`x-api-key`). > interactive coding tools only. General API automation, custom application backends, and > non-interactive batch use are prohibited and may cause the plan key to be suspended. -> **Two GLM routes:** `zai` is the Z.AI international coding-plan subscription; `zhipu-bigmodel` -> is Zhipu's domestic BigModel pay-as-you-go endpoint. Different hosts, different keys, different -> billing — a key issued for one will not authenticate against the other. - ### Multiple API keys Key-based providers can also keep multiple keys. Adding a key through the Providers page stores it @@ -214,7 +147,7 @@ management API is `/api/providers/keys` and returns masked keys only. Use `ocx account list`, `ocx account current`, and `ocx account use` to inspect or switch the same Codex, OAuth, and API-key pools without opening the dashboard. See the -[CLI reference](/reference/cli/#ocx-account-subcommand) for commands, JSON output, and +[CLI reference](/opencodex/reference/cli/#ocx-account-subcommand) for commands, JSON output, and new-session behavior. ### GPT-5.6 preview paths @@ -254,7 +187,7 @@ account. Cursor server-driven native read/write/delete/ls/grep/shell/fetch execu is disabled by default because it bypasses Codex's approval and sandbox path; set `unsafeAllowNativeLocalExec: true` on the `providers.cursor` object in `~/.opencodex/config.json` only for trusted local experiments (or via **Providers → Cursor → Edit JSON** in the dashboard). -See the [Configuration reference](/reference/configuration/#cursor-provider-adapter-cursor) +See the [Configuration reference](/opencodex/reference/configuration/#cursor-provider-adapter-cursor) for a full example. MCP, screen recording, and computer-use are available as executor hooks; without a configured local executor, opencodex returns typed no-executor results instead of policy-blocking the request. Cursor OAuth and live model discovery are enabled for this experimental adapter; @@ -265,7 +198,7 @@ Cursor is still not shown in key-login lists. Ollama Cloud is a hosted (not local) Ollama, OpenAI-compatible at `https://ollama.com/v1` with a key from [ollama.com/settings/keys](https://ollama.com/settings/keys). opencodex classifies its cloud -lineup by vision capability so the [vision sidecar](/guides/sidecars/) only kicks in for +lineup by vision capability so the [vision sidecar](/opencodex/guides/sidecars/) only kicks in for text-only models. Text-only models (e.g. `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, `nemotron-3-*`) are listed in `noVisionModels`; vision-native models (e.g. `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5`, `gemini-3-flash-preview`) are not. Matching is @@ -285,5 +218,5 @@ Point opencodex at a local OpenAI-compatible server — usually with a blank key If a provider speaks Chat Completions, the `openai-chat` adapter handles it — choose **Custom** in the dashboard or `custom` in `ocx init` and enter the base URL. See the -[Configuration reference](/reference/configuration/) for every provider field +[Configuration reference](/opencodex/reference/configuration/) for every provider field (`headers`, `noReasoningModels`, `noVisionModels`, `models`, …). diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 764e8feee..0d753d9a5 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -6,7 +6,7 @@ description: Control how Codex spawns and manages sub-agents across all models. opencodex lets you choose the multi-agent collaboration surface for every model in the catalog. The **Sub-agent** toggle in the dashboard and Models page controls this globally. :::note -On the v2 surface (`multi_agent_v2`), a spawned sub-agent inherits the parent model **by default**: `fork_turns` defaults to `all`, and full-history forks reject overrides. Since v2.7.2 opencodex injects guidance that teaches the model how to break inheritance — a `spawn_agent` call that sets `fork_turns` to `"none"` (or a partial fork such as `"3"`) can pass `model` / `reasoning_effort` arguments, which the Codex runtime parses and applies even though the published tool schema hides them. Known transport limitation: when a **native** parent spawns a child routed to a **non-native** provider, the Codex client may send the `NEW_TASK` payload only as backend-encrypted `encrypted_content` ([#92](https://github.com/lidge-jun/opencodex/issues/92)). opencodex does not forward that unreadable task to an external provider: a direct route fails with HTTP 400 and code `unreadable_encrypted_agent_task`, while a combo skips non-decrypting targets and selects a canonical native ChatGPT target when one is available. Use v1 for heterogeneous-provider delegation, select a native ChatGPT child, or resend the task as plaintext v2 `agent_message` content. +On the v2 surface (`multi_agent_v2`), a spawned sub-agent inherits the parent model **by default**: `fork_turns` defaults to `all`, and full-history forks reject overrides. Since v2.7.2 opencodex injects guidance that teaches the model how to break inheritance — a `spawn_agent` call that sets `fork_turns` to `"none"` (or a partial fork such as `"3"`) can pass `model` / `reasoning_effort` arguments, which the Codex runtime parses and applies even though the published tool schema hides them. Known limitation: when a **native** parent spawns a child routed to a **non-native** provider, the Codex client may send the `NEW_TASK` payload only as backend-encrypted `encrypted_content`, so the routed child receives an empty task body ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). The model override still applies, but the task text can be lost — the v1 surface remains the reliable choice for heterogeneous-provider delegation. ::: ## Modes @@ -15,17 +15,7 @@ On the v2 surface (`multi_agent_v2`), a spawned sub-agent inherits the parent mo | --- | --- | --- | | **v1** | `multi_agent_v1` | Classic namespaced agent tools with `send_input` / `close_agent` / `resume_agent`. A `spawn_agent` model override can start a sub-agent on a different model. | | **base** (default) | Upstream pins | Restores upstream model pins: gpt-5.6-sol and gpt-5.6-terra use v2, gpt-5.6-luna uses v1, and unpinned models follow the Codex `multi_agent_v2` feature flag. Spawn behavior follows the surface that resolves for that model. | -| **v2** | `multi_agent_v2` | Flat `spawn_agent` tools with concurrent sessions and `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`. Children inherit the parent model on full-history forks; `fork_turns: "none"` (or a partial fork) accepts `model` / `reasoning_effort` overrides. If a native→routed child receives only backend-encrypted task content, external routes return `unreadable_encrypted_agent_task`; mixed combos prefer a decrypt-capable native target ([#92](https://github.com/lidge-jun/opencodex/issues/92)). | - -### Encrypted v2 task delivery - -Only the native ChatGPT backend can read its encrypted task payload. For an unreadable v2 `agent_message`, opencodex applies these rules before provider dispatch: - -- A direct non-native route returns HTTP 400 with `error.code = "unreadable_encrypted_agent_task"`. The response never echoes the encrypted payload. -- A combo considers only canonical native ChatGPT targets for that task, including retries. If the combo has no decrypt-capable target, it returns the same 400 response instead of sending an empty task to an external provider. -- Readable plaintext tasks keep the normal combo order and failover behavior. - -To recover, switch the child to a native ChatGPT model, add a native target to the combo, use the v1 surface for heterogeneous-provider delegation, or resend the task as plaintext v2 `agent_message` content when you control the caller. +| **v2** | `multi_agent_v2` | Flat `spawn_agent` tools with concurrent sessions and `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`. Children inherit the parent model on full-history forks; `fork_turns: "none"` (or a partial fork) accepts `model` / `reasoning_effort` overrides. Task body may arrive encrypted for native→routed children ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). | ## How it works @@ -43,7 +33,7 @@ The dashboard's **Sub-agent delegation** picker stores an `injectionModel` and, `multiAgentGuidanceText` identifies the surface from the request's tools — including the Codex Desktop WebSocket path (`responses_lite`), where tools arrive inside an `additional_tools` input item instead of the request's `tools` array. -On a **v2** turn (Sol/Terra in base mode, every model in v2 mode), the proxy injects a compact guidance block — budgeted to 700 characters — whenever an eligible injection model is set or the effective sub-agent roster is non-empty. The block conditionally describes `model` / `reasoning_effort` overrides without assuming whether they appear in the active schema, mandates `fork_turns: "none"` (or a partial fork), names only an eligible canonical preferred model, and lists only configured models in Codex's picker-visible, v2-compatible, priority-sorted first five with their available effort ladders. +On a **v2** turn (Sol/Terra in base mode, every model in v2 mode), the proxy injects a compact guidance block — budgeted to 700 characters — whenever an injection model is set or the configured sub-agent roster resolves in the catalog. The block teaches `spawn_agent`'s hidden `model` / `reasoning_effort` arguments, mandates `fork_turns: "none"` (or a partial fork) for overrides, names the preferred model and effort, and lists the `subagentModels` roster with the effort ladder each advertises in the injected catalog — the same list Codex validates spawn efforts against. On a **v1** turn the proxy only mirrors upstream's Proactive delegation text at the top effort tier (max / ultra). No model designation, roster, or custom prompt is added there — v1 stays lean by design. @@ -56,7 +46,7 @@ To replace the built-in v2 guidance, set `injectionPrompt` (config key, or `PUT - **Dashboard** → first stat cell: click **v1**, **base**, or **v2**. - **Models** page → top-row segmented control. - Both pages have a **?** button that opens a help modal with a link back here. -- **Dashboard** → **Sub-agent delegation**: choose a preferred model and optional reasoning effort. On v2 the injected guidance instructs the agent to spawn with `fork_turns: "none"` so the model override applies. If a native→routed child receives only encrypted task content, use a native target or v1; external-only delivery now fails explicitly with `unreadable_encrypted_agent_task` ([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **Dashboard** → **Sub-agent delegation**: choose a preferred model and optional reasoning effort. On v2 the injected guidance instructs the agent to spawn with `fork_turns: "none"` so the model override applies — though for native→routed children the task body can currently arrive encrypted ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). ### CLI diff --git a/docs-site/src/content/docs/ja/contributing.md b/docs-site/src/content/docs/ja/contributing.md index 8194e3f54..3229c05a5 100644 --- a/docs-site/src/content/docs/ja/contributing.md +++ b/docs-site/src/content/docs/ja/contributing.md @@ -6,7 +6,7 @@ description: opencodex の開発環境、構成、規約、プロバイダーと ## セットアップ ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # 開発モードのプロキシ API @@ -46,7 +46,7 @@ cd docs-site && bun install && bun dev ## ドキュメントのデプロイ -公開ドキュメントは GitHub Pages の に公開されます。 +公開ドキュメントは GitHub Pages の に公開されます。 `.github/workflows/deploy-docs.yml` は `main` push で `docs-site/**` またはワークフロー自体が変わると 実行されます。`docs-site` をビルドした後、生成されたサイトをデプロイします。ドキュメント変更を push する前に以下を 実行してください。 @@ -78,22 +78,6 @@ bun run release --publish # CI-gated dry-run を確認した後、実 bun run release:watch # 直近の Release ワークフロー run を監視 ``` -## ブランチ - -- `dev` — 既定の統合先。下のスコープ付きブランチに該当しない限り、ここに PR を出します。 -- `dev2-go` — Go ネイティブポート(`go/`、ネイティブランタイムのエントリポイント、Go - リリースアセットのツール)向けの並行統合ラインです。`dev` と並んで PR を受け付けます。 - Go ポートに属する作業だけをここに出し、それ以外は `dev` に出してください。ターゲット - ブランチ検査は両方を許可しますが、両者を区別できません。スコープはレビューで決まり、 - メンテナーが `dev` への変更を依頼することがあります。 -- `main` — リリース専用。`dev` からメンテナーが昇格させるときだけ動きます。機能 PR を直接 - 出さないでください。 -- `preview` — プレリリーストレイン。 - -ポーティング PR とリベース PR を歓迎します。ある統合ラインの修正を別のラインへ運ぶこと、 -古いブランチを現在の head にリベースすることは、ノイズではなく通常の貢献です。説明欄に -元のコミットを記載してください。 - ## 規約 - **ES Modules のみ**(`import`/`export`)、TypeScript、`strict` モード。`bun x tsc --noEmit` をクリーンに @@ -131,7 +115,7 @@ OAuth 設定 seed に供給します。`enrichProviderFromCatalog()` はモデ ## アダプターを追加 -`src/adapters/` に `ProviderAdapter`([アダプター](/ja/reference/adapters/)参照)を実装し、 +`src/adapters/` に `ProviderAdapter`([アダプター](/opencodex/ja/reference/adapters/)参照)を実装し、 `src/server/adapter-resolve.ts` に名前を登録した後、出力を内部 `AdapterEvent` にブリッジしてください。画像 処理には `image.ts` を再利用し、一般的なストリーミング/ツール呼び出しは `openai-chat.ts` を参考にしてください。 アダプターが送信再試行を自ら担う場合のみ `fetchResponse` を使い、Cursor のような実際の双方向転送には diff --git a/docs-site/src/content/docs/ja/getting-started/installation.md b/docs-site/src/content/docs/ja/getting-started/installation.md index 52ff9d178..9ade20744 100644 --- a/docs-site/src/content/docs/ja/getting-started/installation.md +++ b/docs-site/src/content/docs/ja/getting-started/installation.md @@ -59,7 +59,7 @@ ocx update --tag preview opencodex 自体を直接修正しながら作業するには: ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # 開発モードでプロキシ API を起動 (src/cli/index.ts start) diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index f1133444c..44e15dd69 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -18,8 +18,6 @@ bare `gpt-5.6-sol` は Providers ページの Pool/Direct オプションに従 max input 922,000 で `*-pro` virtual ID は公開状態を維持し、wire でベースモデルと `reasoning.mode: "pro"` に切り替わります。 -組み込み `openai` が欠落または無効な場合、ダッシュボードの Accounts ピッカーと Codex Auth から復元できます。欠落行は正規プリセットから作成され、正規の無効行は保存済みのモードやモデル設定を置き換えずに再有効化され、非正規の `openai` 行にはその復元経路は出ません。 - 出荷版 v1 config は marker 2 の単一オプション行に自動移行されます。オリジナルは `~/.opencodex/config.json.pre-openai-tiers-v2.bak` に一度保存され、次のコマンドで復元します: `cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`。 @@ -51,8 +49,8 @@ max input 922,000 で `*-pro` virtual ID は公開状態を維持し、wire で ``` 厳選されたヘッダーセットのみ転送されます(`FORWARD_HEADERS`: authorization、ChatGPT アカウント ID、 -OpenAI beta/originator/session — [アダプター](/ja/reference/adapters/)参照)。この経路は -[ウェブ検索とビジョンのサイドカー](/ja/guides/sidecars/)を動かす経路でもあります。 +OpenAI beta/originator/session — [アダプター](/opencodex/ja/reference/adapters/)参照)。この経路は +[ウェブ検索とビジョンのサイドカー](/opencodex/ja/guides/sidecars/)を動かす経路でもあります。 ChatGPT パススルーカタログには GPT-5.6 Sol/Terra/Luna の名前空間なしスラッグ (`gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna`)も含まれます。実際の呼び出し可否はアカウント権限に @@ -80,11 +78,11 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | ライブ一覧を優先し、フォールバックのデフォルトモデルは `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude モデル; ライブモデル一覧は `/v1/models` から取得。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 コーディングモデル。 | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | インストール済み `kiro-cli` ログインを優先取得。Kiro CLI のインストール(`curl -fsSL https://cli.kiro.dev/install | bash`)と `kiro-cli login` が必要。 | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | インストール済み `kiro-cli` ログインを優先取得。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | -[ウェブダッシュボード](/ja/guides/web-dashboard/)からも OAuth を開始できます。 +[ウェブダッシュボード](/opencodex/ja/guides/web-dashboard/)からも OAuth を開始できます。 ### 複数の OAuth アカウント @@ -119,7 +117,6 @@ opencodex v2.7.1 には組み込みプリセットが 50 個含まれていま | Hugging Face | `https://router.huggingface.co/v1` | | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | -| Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` | | Qwen Cloud | トークンプラン(デフォルト): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · 従量課金: `https://dashscope.aliyuncs.com/compatible-mode/v1` · またはカスタム | | Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` | | SiliconFlow | `https://api.siliconflow.cn/v1` | @@ -136,10 +133,6 @@ opencodex v2.7.1 には組み込みプリセットが 50 個含まれていま > コーディングツール専用としています。一般的な API 自動化、カスタムアプリのバックエンド、 > 非対話型バッチ利用は禁止されており、プランキーが停止される場合があります。 -> **GLM の経路は 2 つあります:** `zai` は Z.AI の国際コーディングプラン契約、`zhipu-bigmodel` -> は Zhipu の中国国内向け BigModel 従量課金エンドポイントです。ホストもキーも課金も別で、 -> 一方で発行したキーはもう一方では認証されません。 - ### 複数の API キー キーベースのプロバイダーも複数キーを保持できます。Providers ページでキーを追加すると @@ -151,7 +144,7 @@ opencodex v2.7.1 には組み込みプリセットが 50 個含まれていま ダッシュボードを開かずに `ocx account list`、`ocx account current`、`ocx account use` で同じ Codex、 OAuth、API キープールを確認・切り替えできます。完全なコマンド、JSON 出力、新規セッション適用方式は -[CLI リファレンス](/ja/reference/cli/#ocx-account-subcommand)を参照してください。 +[CLI リファレンス](/opencodex/ja/reference/cli/#ocx-account-subcommand)を参照してください。 ### GPT-5.6 プレビュー経路 @@ -190,7 +183,7 @@ Provider ピッカーに実験的 local config 項目として表示され、Cur 承認とサンドボックス経路をバイパスするためデフォルトで無効です。信頼できるローカル実験でのみ `~/.opencodex/config.json` の `providers.cursor` に `unsafeAllowNativeLocalExec: true` を設定してください。 ダッシュボードからは **Providers → Cursor → Edit JSON** で設定できます。完全な例は -[設定リファレンス](/ja/reference/configuration/#cursor-provider-adapter-cursor)を参照してください。 +[設定リファレンス](/opencodex/ja/reference/configuration/#cursor-provider-adapter-cursor)を参照してください。 MCP、画面録画、computer-use はエグゼキューターフックで開かれており、ローカル エグゼキューターがない場合はポリシー遮断ではなく typed no-executor 結果を返します。Cursor OAuth とライブ モデルディスカバリはこの実験的アダプターで有効化されており、Cursor は引き続きキーログイン一覧には @@ -201,7 +194,7 @@ MCP、画面録画、computer-use はエグゼキューターフックで開か Ollama Cloud はホステッド型(ローカルではない)Ollama で、`https://ollama.com/v1` で OpenAI 互換、キーは [ollama.com/settings/keys](https://ollama.com/settings/keys) で発行されます。opencodex はクラウド -ラインナップをビジョン機能で分類し、[ビジョンサイドカー](/ja/guides/sidecars/)がテキスト専用モデルにのみ +ラインナップをビジョン機能で分類し、[ビジョンサイドカー](/opencodex/ja/guides/sidecars/)がテキスト専用モデルにのみ 動作するようにします。テキスト専用モデル(例: `glm-5.2`、`deepseek-v4-pro`、`gpt-oss`、`qwen3-coder`、 `minimax-m2.x`、`nemotron-3-*`)は `noVisionModels` に列挙され、ビジョンネイティブモデル(例: `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、`gemini-3-flash-preview`)は含まれません。マッチングは @@ -222,4 +215,4 @@ opencodex をローカルの OpenAI 互換サーバーに向けてください プロバイダーが Chat Completions を使うなら `openai-chat` アダプターが処理します — ダッシュボードで **Custom** を選ぶか `ocx init` で `custom` を選んだ後ベース URL を入力してください。すべてのプロバイダーフィールド (`headers`、`noReasoningModels`、`noVisionModels`、`models`、…)は -[設定リファレンス](/ja/reference/configuration/)を参照してください。 +[設定リファレンス](/opencodex/ja/reference/configuration/)を参照してください。 diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md index cc57ae65e..2991869dc 100644 --- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md @@ -6,7 +6,7 @@ description: すべてのモデルの Codex サブエージェント生成・管 opencodex ではカタログの全モデルが使うマルチエージェントコラボサーフェスを選択できます。ダッシュボードとモデルページの **サブエージェント** トグルがこの値をグローバルに制御します。 :::note -v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォルトで**親モデルを継承します。`fork_turns` のデフォルトが `all` で、全体履歴 fork がオーバーライドを拒否するためです。v2.7.2 から opencodex が継承を破る方法をガイドとして注入します。`fork_turns` を `"none"`(または `"3"` のような部分 fork)に指定した `spawn_agent` 呼び出しは `model` / `reasoning_effort` 引数を渡せ、公開されたツールスキーマにこの引数が見えなくても Codex ランタイムはパースして適用します。既知の転送制限:**ネイティブ**の親が**非ネイティブ**(ルーティング)プロバイダーの子をスポーンすると、Codex クライアントは `NEW_TASK` ペイロードをバックエンド暗号化の `encrypted_content` でのみ送ることがあります([#92](https://github.com/lidge-jun/opencodex/issues/92))。opencodex は読み取れないタスクを外部プロバイダーへ転送しません。直接ルートは HTTP 400 とコード `unreadable_encrypted_agent_task` で失敗し、コンボは復号できないターゲットを除外して、可能なら正規のネイティブ ChatGPT ターゲットを選択します。異種プロバイダー委任には v1 を使うか、ネイティブ ChatGPT の子を選ぶか、タスクを平文の v2 `agent_message` コンテンツとして送り直してください。 +v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォルトで**親モデルを継承します。`fork_turns` のデフォルトが `all` で、全体履歴 fork がオーバーライドを拒否するためです。v2.7.2 から opencodex が継承を破る方法をガイドとして注入します。`fork_turns` を `"none"`(または `"3"` のような部分 fork)に指定した `spawn_agent` 呼び出しは `model` / `reasoning_effort` 引数を渡せ、公開されたツールスキーマにこの引数が見えなくても Codex ランタイムはパースして適用します。既知の制限:**ネイティブ**の親が**非ネイティブ**(ルーティング)プロバイダーの子をスポーンすると Codex クライアントが `NEW_TASK` ペイロードをバックエンド暗号化の `encrypted_content` でのみ送れず、子が空のタスク本文を受け取る可能性があります([#92](https://github.com/OnlineChefGroep/opencodex/issues/92))。モデルオーバーライドは適用されますがタスクテキストが失われる可能性があるため、異種プロバイダー委任には v1 サーフェスが安定です。 ::: ## モード @@ -15,17 +15,7 @@ v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォル --- | --- | --- | | **v1** | `multi_agent_v1` | 名前空間方式のクラシックエージェントツールと `send_input` / `close_agent` / `resume_agent` を使います。`spawn_agent` モデルオーバーライドで別モデルのサブエージェントを起動できます。 | | **base**(デフォルト) | 上流 pin | 上流モデル pin を復元します。gpt-5.6-sol と gpt-5.6-terra は v2、gpt-5.6-luna は v1 を使い、pin のないモデルは Codex `multi_agent_v2` 機能フラグに従います。実際のスポーン動作は各モデルに決定されたサーフェスに従います。 | -| **v2** | `multi_agent_v2` | フラット `spawn_agent` ツールと同時セッション、`send_message` / `followup_task` / `wait_agent` / `interrupt_agent` を使います。全体履歴 fork では子が親モデルを継承し、`fork_turns: "none"`(または部分 fork)では `model` / `reasoning_effort` オーバーライドが適用されます。ネイティブ→ルーティングの子がバックエンド暗号化のタスク内容しか受け取れない場合、外部ルートは `unreadable_encrypted_agent_task` を返し、混成コンボは復号可能なネイティブターゲットを優先します([#92](https://github.com/lidge-jun/opencodex/issues/92))。 | - -### 暗号化 v2 タスクの配信 - -ネイティブ ChatGPT バックエンドだけが自身の暗号化タスクペイロードを読めます。読み取れない v2 `agent_message` に対して opencodex はプロバイダーへのディスパッチ前に次の規則を適用します。 - -- 非ネイティブの直接ルートは HTTP 400 と `error.code = "unreadable_encrypted_agent_task"` を返します。応答に暗号化ペイロードを含めません。 -- コンボはリトライを含め、そのタスクに正規のネイティブ ChatGPT ターゲットだけを考慮します。復号可能なターゲットがなければ、外部プロバイダーへ空のタスクを送る代わりに同じ 400 応答を返します。 -- 読み取れる平文タスクは従来のコンボ順序とフェイルオーバー動作をそのまま維持します。 - -復旧するには、子をネイティブ ChatGPT モデルに切り替えるか、コンボにネイティブターゲットを追加するか、異種プロバイダー委任に v1 サーフェスを使うか、呼び出し側を制御できる場合はタスクを平文の v2 `agent_message` コンテンツとして送り直してください。 +| **v2** | `multi_agent_v2` | フラット `spawn_agent` ツールと同時セッション、`send_message` / `followup_task` / `wait_agent` / `interrupt_agent` を使います。全体履歴 fork では子が親モデルを継承し、`fork_turns: "none"`(または部分 fork)では `model` / `reasoning_effort` オーバーライドが適用されます。ネイティブ→ルーティング子はタスク本文が暗号化状態で到着する可能性があります([#92](https://github.com/OnlineChefGroep/opencodex/issues/92))。 | ## 動作方式 @@ -43,7 +33,7 @@ v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォル `multiAgentGuidanceText` はリクエストに入ってきたツール一覧でサーフェスを判定します。Codex Desktop の WebSocket 経路(`responses_lite`)のようにツールがリクエストの `tools` 配列ではなく `additional_tools` input 項目として届く場合も認識します。 -**v2** リクエスト(base モードの Sol/Terra、v2 モードでは全モデル)では、有効な注入モデルが設定されているか実効サブエージェントロスターが空でないとき、700 字以内の簡潔なガイドを注入します。ガイドは `model` / `reasoning_effort` が現在のスキーマに表示されるかを断定せず条件付きで override を説明し、`fork_turns: "none"`(または部分 fork)ルール、有効な正規 slug の推奨モデル、Codex の picker-visible・v2 互換・priority 順の先頭 5 件に含まれる設定済みモデルと利用可能な effort ラダーだけを表示します。 +**v2** リクエスト(base モードの Sol/Terra、v2 モードでは全モデル)では注入モデルが設定されているかサブエージェントロスターがカタログから解釈されるとき 700 字以内の簡潔なガイドを注入します。ガイドには `spawn_agent` の隠し `model` / `reasoning_effort` 引数の使い方、オーバーライドに必要な `fork_turns: "none"`(または部分 fork)ルール、推奨モデル・推論強度、そして `subagentModels` ロスターと各モデルがカタログに広告する effort ラダーが含まれます。このラダーは Codex がスポーン effort を検証する一覧と同じです。 **v1** リクエストでは最上位推論段階(max / ultra)で上流と同じ能動委任文言のみミラーリングします。モデル指定、ロスター、カスタムプロンプトは v1 に追加されません。 @@ -56,7 +46,7 @@ v2 サーフェス(`multi_agent_v2`)のサブエージェントは**デフォル - **ダッシュボード** → 最初のスタットセルで **v1**、**base**、**v2** を選択します。 - **モデル** ページ → 上部セグメントコントロールで選択します。 - 両ページとも **?** ボタンを押すとこのドキュメントに繋がるヘルプモーダルが開きます。 -- **ダッシュボード** → **サブエージェント委任** で推奨モデルとオプションの推論強度を選びます。v2 では注入ガイドが `fork_turns: "none"` スポーンを指示しモデルオーバーライドを適用させます — ただしネイティブ→ルーティング子はタスク本文が暗号化状態で到着する可能性があります([#92](https://github.com/lidge-jun/opencodex/issues/92))。 +- **ダッシュボード** → **サブエージェント委任** で推奨モデルとオプションの推論強度を選びます。v2 では注入ガイドが `fork_turns: "none"` スポーンを指示しモデルオーバーライドを適用させます — ただしネイティブ→ルーティング子はタスク本文が暗号化状態で到着する可能性があります([#92](https://github.com/OnlineChefGroep/opencodex/issues/92))。 ### CLI diff --git a/docs-site/src/content/docs/ja/reference/cli.md b/docs-site/src/content/docs/ja/reference/cli.md index 442e21d53..e54366288 100644 --- a/docs-site/src/content/docs/ja/reference/cli.md +++ b/docs-site/src/content/docs/ja/reference/cli.md @@ -429,7 +429,7 @@ ocx update ocx update --tag preview ``` -[Release ワークフロー](https://github.com/lidge-jun/opencodex/actions/workflows/release.yml) が npm に +[Release ワークフロー](https://github.com/OnlineChefGroep/opencodex/actions/workflows/release.yml) が npm に 公開した直後に新しいバージョンが使えるようになります。 ## ヘルプ diff --git a/docs-site/src/content/docs/ko/contributing.md b/docs-site/src/content/docs/ko/contributing.md index 2a808bfdd..d027c6db2 100644 --- a/docs-site/src/content/docs/ko/contributing.md +++ b/docs-site/src/content/docs/ko/contributing.md @@ -6,7 +6,7 @@ description: opencodex 개발 환경, 구조, 컨벤션, 프로바이더와 어 ## 설정 ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # 개발 모드 프록시 API @@ -46,7 +46,7 @@ cd docs-site && bun install && bun dev ## 문서 배포 -공개 문서는 GitHub Pages의 에 게시됩니다. +공개 문서는 GitHub Pages의 에 게시됩니다. `.github/workflows/deploy-docs.yml`은 `main` push에서 `docs-site/**`나 워크플로 자체가 바뀌면 실행됩니다. `docs-site`를 빌드한 뒤 생성된 사이트를 배포합니다. 문서 변경을 push하기 전에 다음을 실행하세요. @@ -78,21 +78,6 @@ bun run release --publish # CI-gated dry-run을 확인한 뒤 실제 p bun run release:watch # 가장 최근 Release workflow run 감시 ``` -## 브랜치 - -- `dev` — 기본 통합 대상. 아래 범위 브랜치에 해당하지 않으면 여기로 PR을 올립니다. -- `dev2-go` — Go 네이티브 포트(`go/`, 네이티브 런타임 진입점, Go 릴리즈 자산 도구)를 위한 - 병렬 통합선입니다. `dev`와 함께 PR을 받습니다. Go 포트에 속하는 작업만 여기로 보내고, - 나머지는 `dev`로 보내세요. 타깃 브랜치 검사는 두 브랜치를 모두 허용하지만 둘을 구분하지 - 못하므로, 범위는 리뷰에서 정합니다. 메인테이너가 `dev`로 옮겨달라고 요청할 수 있습니다. -- `main` — 릴리즈 전용. `dev`에서 메인테이너가 승격시킬 때만 움직이며, 기능 PR을 직접 - 올리지 않습니다. -- `preview` — 프리릴리즈 트레인. - -포팅 PR과 리베이스 PR을 환영합니다. 한 통합선의 수정을 다른 쪽으로 옮기거나, 오래된 -브랜치를 현재 head 위로 리베이스하는 것은 잡음이 아니라 정상적인 기여입니다. 설명란에 -출처 커밋을 적어주세요. - ## 컨벤션 - **ES Modules 전용**(`import`/`export`), TypeScript, `strict` 모드. `bun x tsc --noEmit`을 깨끗하게 @@ -130,7 +115,7 @@ OAuth 설정 seed에 공급합니다. `enrichProviderFromCatalog()`는 모델 ## 어댑터 추가하기 -`src/adapters/`에 `ProviderAdapter`([어댑터](/ko/reference/adapters/) 참조)를 구현하고, +`src/adapters/`에 `ProviderAdapter`([어댑터](/opencodex/ko/reference/adapters/) 참조)를 구현하고, `src/server/adapter-resolve.ts`에 이름을 등록한 뒤 출력을 내부 `AdapterEvent`로 브리징하세요. 이미지 처리에는 `image.ts`를 재사용하고, 일반적인 스트리밍/툴 호출은 `openai-chat.ts`를 참고합니다. 어댑터가 전송 재시도를 직접 맡을 때만 `fetchResponse`를 사용하고, Cursor처럼 실제 양방향 전송에는 diff --git a/docs-site/src/content/docs/ko/getting-started/installation.md b/docs-site/src/content/docs/ko/getting-started/installation.md index 1dfff4b15..ce63d0291 100644 --- a/docs-site/src/content/docs/ko/getting-started/installation.md +++ b/docs-site/src/content/docs/ko/getting-started/installation.md @@ -59,7 +59,7 @@ ocx update --tag preview opencodex 자체를 직접 수정하며 작업하려면: ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # 개발 모드로 프록시 API 시작 (src/cli/index.ts start) diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index c2a3f2480..70a2b0284 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -18,8 +18,6 @@ bare `gpt-5.6-sol`은 Providers 페이지의 Pool/Direct 옵션을 따르고, max input 922,000이며 `*-pro` virtual id는 공개 상태에 유지되고 wire에서 base 모델과 `reasoning.mode: "pro"`로 바뀝니다. -내장 `openai` 제공자가 없거나 비활성화된 경우 대시보드 Accounts 선택기와 Codex Auth 페이지에서 복구할 수 있습니다. 없는 항목은 정규 프리셋으로 만들고, 비활성화된 정규 항목은 저장된 모드/모델 설정을 바꾸지 않고 다시 켜며, 비정규 `openai` 항목에는 그 복구 경로를 제공하지 않습니다. - shipped v1 config는 marker 2의 단일 옵션 행으로 자동 이관됩니다. 원본은 `~/.opencodex/config.json.pre-openai-tiers-v2.bak`에 한 번 보존되며 다음 명령으로 복원합니다: `cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`. @@ -51,8 +49,8 @@ shipped v1 config는 marker 2의 단일 옵션 행으로 자동 이관됩니다. ``` 엄선된 헤더 집합만 포워딩됩니다(`FORWARD_HEADERS`: authorization, ChatGPT account id, -OpenAI beta/originator/session — [어댑터](/ko/reference/adapters/) 참고). 이 경로는 -[웹 검색 및 비전 사이드카](/ko/guides/sidecars/)를 구동하는 경로이기도 합니다. +OpenAI beta/originator/session — [어댑터](/opencodex/ko/reference/adapters/) 참고). 이 경로는 +[웹 검색 및 비전 사이드카](/opencodex/ko/guides/sidecars/)를 구동하는 경로이기도 합니다. ChatGPT 패스스루 카탈로그에는 GPT-5.6 Sol/Terra/Luna의 네임스페이스 없는 slug (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`)도 들어갑니다. 실제 호출 가능 여부는 계정 권한에 @@ -80,11 +78,11 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | 실시간 목록을 우선 사용하며, 폴백 기본 모델은 `grok-4.5`입니다. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 모델; 실시간 모델 목록은 `/v1/models`에서 가져옵니다. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 코딩 모델. | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 설치된 `kiro-cli` 로그인을 먼저 가져옵니다. Kiro CLI 설치(`curl -fsSL https://cli.kiro.dev/install | bash`)와 `kiro-cli login`이 필요합니다. | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 설치된 `kiro-cli` 로그인을 먼저 가져옵니다. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | -[웹 대시보드](/ko/guides/web-dashboard/)에서도 OAuth를 시작할 수 있습니다. +[웹 대시보드](/opencodex/ko/guides/web-dashboard/)에서도 OAuth를 시작할 수 있습니다. ### 여러 OAuth 계정 @@ -119,7 +117,6 @@ opencodex v2.7.1에는 빌트인 프리셋이 50개 들어 있습니다. 키 방 | Hugging Face | `https://router.huggingface.co/v1` | | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | -| Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` | | Qwen Cloud | Token plan(기본): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · 종량제: `https://dashscope.aliyuncs.com/compatible-mode/v1` · 또는 사용자 지정 | | Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` | | SiliconFlow | `https://api.siliconflow.cn/v1` | @@ -136,10 +133,6 @@ opencodex v2.7.1에는 빌트인 프리셋이 50개 들어 있습니다. 키 방 > 안내합니다. 일반 API 자동화, 사용자 애플리케이션 백엔드 및 비대화형 일괄 호출은 금지되며 > 플랜 키가 정지될 수 있습니다. -> **GLM 경로는 두 개입니다:** `zai`는 Z.AI 국제 코딩 플랜 구독이고, `zhipu-bigmodel`은 -> Zhipu의 중국 내수 BigModel 종량제 엔드포인트입니다. 호스트도 키도 과금도 다르며, 한쪽에서 -> 발급한 키는 다른 쪽에서 인증되지 않습니다. - ### 여러 API 키 키 기반 프로바이더도 여러 키를 보관할 수 있습니다. Providers 페이지에서 키를 추가하면 @@ -151,7 +144,7 @@ opencodex v2.7.1에는 빌트인 프리셋이 50개 들어 있습니다. 키 방 대시보드를 열지 않고도 `ocx account list`, `ocx account current`, `ocx account use`로 같은 Codex, OAuth, API-key pool을 확인하고 전환할 수 있습니다. 전체 명령, JSON 출력, 새 세션 적용 방식은 -[CLI 레퍼런스](/ko/reference/cli/#ocx-account-subcommand)를 참고하세요. +[CLI 레퍼런스](/opencodex/ko/reference/cli/#ocx-account-subcommand)를 참고하세요. ### GPT-5.6 프리뷰 경로 @@ -191,7 +184,7 @@ metadata를 저장합니다. Cursor access token이 설정되면 opencodex는 Cu 승인 및 sandbox 경로를 우회하므로 기본적으로 비활성화되어 있습니다. 신뢰한 로컬 실험에서만 `~/.opencodex/config.json`의 `providers.cursor`에 `unsafeAllowNativeLocalExec: true`를 설정하세요. 대시보드에서는 **Providers → Cursor → Edit JSON**에서 설정할 수 있습니다. 전체 예시는 -[설정 레퍼런스](/ko/reference/configuration/#cursor-provider-adapter-cursor)를 참고하세요. +[설정 레퍼런스](/opencodex/ko/reference/configuration/#cursor-provider-adapter-cursor)를 참고하세요. MCP, 화면 녹화, computer-use는 executor hook으로 열려 있으며, 로컬 executor가 없으면 정책 차단이 아니라 typed no-executor 결과를 반환합니다. Cursor OAuth와 live model discovery는 이 실험적 어댑터에서 활성화되어 있으며, Cursor는 여전히 key-login 목록에는 @@ -202,7 +195,7 @@ model discovery는 이 실험적 어댑터에서 활성화되어 있으며, Curs Ollama Cloud는 호스팅형(로컬이 아님) Ollama로, `https://ollama.com/v1`에서 OpenAI 호환이며 키는 [ollama.com/settings/keys](https://ollama.com/settings/keys)에서 발급받습니다. opencodex는 클라우드 -라인업을 비전 기능에 따라 분류하여 [비전 사이드카](/ko/guides/sidecars/)가 텍스트 전용 모델에만 +라인업을 비전 기능에 따라 분류하여 [비전 사이드카](/opencodex/ko/guides/sidecars/)가 텍스트 전용 모델에만 작동하도록 합니다. 텍스트 전용 모델(예: `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, `nemotron-3-*`)은 `noVisionModels`에 나열되며, 비전 네이티브 모델(예: `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5`, `gemini-3-flash-preview`)은 포함되지 않습니다. 매칭은 @@ -223,4 +216,4 @@ opencodex를 로컬 OpenAI 호환 서버로 향하게 하세요 — 보통은 프로바이더가 Chat Completions를 사용한다면 `openai-chat` 어댑터가 이를 처리합니다 — 대시보드에서 **Custom**을 선택하거나 `ocx init`에서 `custom`을 선택한 뒤 베이스 URL을 입력하세요. 모든 프로바이더 필드 (`headers`, `noReasoningModels`, `noVisionModels`, `models`, …)는 -[설정 레퍼런스](/ko/reference/configuration/)를 참고하세요. +[설정 레퍼런스](/opencodex/ko/reference/configuration/)를 참고하세요. diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index 733b9bd59..9be930e16 100644 --- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md @@ -6,7 +6,7 @@ description: 모든 모델의 Codex 서브에이전트 생성·관리 방식을 opencodex에서는 카탈로그의 모든 모델이 사용할 멀티에이전트 협업 서피스를 선택할 수 있습니다. 대시보드와 모델 페이지의 **서브에이전트** 토글이 이 값을 전역으로 제어합니다. :::note -v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부모 모델을 상속합니다. `fork_turns` 기본값이 `all`이고, 전체 히스토리 fork는 오버라이드를 거부하기 때문입니다. v2.7.2부터 opencodex가 상속을 깨는 방법을 가이드로 주입합니다. `fork_turns`를 `"none"`(또는 `"3"` 같은 부분 fork)으로 지정한 `spawn_agent` 호출은 `model` / `reasoning_effort` 인자를 전달할 수 있고, 공개된 툴 스키마에 이 인자가 안 보여도 Codex 런타임은 파싱해서 적용합니다. 알려진 전송 제한: **네이티브** 부모가 **비네이티브**(라우팅) 프로바이더의 자식을 스폰하면 Codex 클라이언트가 `NEW_TASK` 페이로드를 백엔드 암호화된 `encrypted_content`로만 볼 수 있습니다([#92](https://github.com/lidge-jun/opencodex/issues/92)). opencodex는 읽을 수 없는 작업을 외부 프로바이더로 전달하지 않습니다. 직접 라우팅은 HTTP 400과 `unreadable_encrypted_agent_task` 코드로 실패하고, 콤보는 복호화할 수 없는 대상은 제외하고 가능하면 정규 네이티브 ChatGPT 대상을 선택합니다. 이종 프로바이더 위임에는 v1을 쓰거나, 네이티브 ChatGPT 자식을 선택하거나, 작업을 평문 v2 `agent_message` 콘텐츠로 다시 볼 수 있습니다. +v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부모 모델을 상속합니다. `fork_turns` 기본값이 `all`이고, 전체 히스토리 fork는 오버라이드를 거부하기 때문입니다. v2.7.2부터 opencodex가 상속을 깨는 방법을 가이드로 주입합니다. `fork_turns`를 `"none"`(또는 `"3"` 같은 부분 fork)으로 지정한 `spawn_agent` 호출은 `model` / `reasoning_effort` 인자를 전달할 수 있고, 공개된 툴 스키마에 이 인자가 안 보여도 Codex 런타임은 파싱해서 적용합니다. 알려진 제한: **네이티브** 부모가 **비네이티브**(라우팅) 프로바이더의 자식을 스폰하면 Codex 클라이언트가 `NEW_TASK` 페이로드를 백엔드 암호화된 `encrypted_content`로만 보낼 수 있어 자식이 빈 작업 본문을 받게 됩니다([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). 모델 오버라이드는 적용되지만 작업 텍스트가 유실될 수 있으므로, 이종 프로바이더 위임에는 v1 서피스가 안정적입니다. ::: ## 모드 @@ -15,17 +15,7 @@ v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부 | --- | --- | --- | | **v1** | `multi_agent_v1` | 네임스페이스 방식의 클래식 에이전트 툴과 `send_input` / `close_agent` / `resume_agent`를 사용합니다. `spawn_agent` 모델 오버라이드로 다른 모델의 서브에이전트를 띄울 수 있습니다. | | **base** (기본값) | 업스트림 핀 | 업스트림 모델 핀을 복원합니다. gpt-5.6-sol과 gpt-5.6-terra는 v2, gpt-5.6-luna는 v1을 쓰고, 핀이 없는 모델은 Codex `multi_agent_v2` 기능 플래그를 따릅니다. 실제 스폰 동작은 각 모델에 결정된 서피스를 따릅니다. | -| **v2** | `multi_agent_v2` | 플랫 `spawn_agent` 툴과 동시 세션, `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`를 사용합니다. 전체 히스토리 fork에서는 자식이 부모 모델을 상속하고, `fork_turns: "none"`(또는 부분 fork)에서는 `model` / `reasoning_effort` 오버라이드가 적용됩니다. 네이티브→라우팅 자식이 백엔드 암호화된 작업 콘텐츠만 받으면 외부 라우팅은 `unreadable_encrypted_agent_task`를 반환하고, 혼합 콤보는 복호화 가능한 네이티브 대상을 우선합니다([#92](https://github.com/lidge-jun/opencodex/issues/92)). | - -### 암호화된 v2 작업 전달 - -네이티브 ChatGPT 백엔드만 자신의 암호화된 작업 페이로드를 읽을 수 있습니다. 읽을 수 없는 v2 `agent_message`에 대해 opencodex는 프로바이더 디스패치 전에 다음 규칙을 적용합니다. - -- 비네이티브 직접 라우팅은 HTTP 400과 `error.code = "unreadable_encrypted_agent_task"`를 반환합니다. 응답에 암호화된 페이로드를 담지 않습니다. -- 콤보는 재시도를 포함해 해당 작업에 정규 네이티브 ChatGPT 대상만 고려합니다. 복호화 가능한 대상이 없으면 외부 프로바이더로 빈 작업을 볼 수 있는 대신 같은 400 응답을 반환합니다. -- 읽을 수 있는 평문 작업은 기존 콤보 순서와 페일오버 동작을 그대로 따릅니다. - -복구하려면 자식을 네이티브 ChatGPT 모델로 바꾸거나, 콤보에 네이티브 대상을 추가하거나, 이종 프로바이더 위임에 v1 서피스를 쓰거나, 호출자를 제어할 수 있으면 작업을 평문 v2 `agent_message` 콘텐츠로 다시 볼 수 있습니다. +| **v2** | `multi_agent_v2` | 플랫 `spawn_agent` 툴과 동시 세션, `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`를 사용합니다. 전체 히스토리 fork에서는 자식이 부모 모델을 상속하고, `fork_turns: "none"`(또는 부분 fork)에서는 `model` / `reasoning_effort` 오버라이드가 적용됩니다. 네이티브→라우팅 자식은 작업 본문이 암호화 상태로 도착할 수 있습니다([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). | ## 동작 방식 @@ -43,7 +33,7 @@ v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부 `multiAgentGuidanceText`는 요청에 들어온 툴 목록으로 서피스를 판별합니다. Codex Desktop의 WebSocket 경로(`responses_lite`)처럼 툴이 요청의 `tools` 배열 대신 `additional_tools` input 항목으로 도착하는 경우도 인식합니다. -**v2** 요청(base 모드의 Sol/Terra, v2 모드에서는 전체 모델)에서는 유효한 주입 모델이 설정되어 있거나 실효 서브에이전트 로스터가 비어 있지 않을 때 700자 이내의 간결한 가이드를 주입합니다. 가이드는 `model` / `reasoning_effort`가 현재 스키마에 노출되는지 단정하지 않고 조걸부로 override를 설명하며, `fork_turns: "none"`(또는 부분 fork) 규칙, 유효한 정규 slug의 선호 모델, Codex의 picker-visible·v2 호환·priority 순 상위 5개에 포함된 설정 모델과 사용 가능한 effort 사다리만 표시합니다. +**v2** 요청(base 모드의 Sol/Terra, v2 모드에서는 전체 모델)에서는 주입 모델이 설정되어 있거나 서브에이전트 로스터가 카탈로그에서 해석될 때 700자 이내의 간결한 가이드를 주입합니다. 가이드에는 `spawn_agent`의 숨겨진 `model` / `reasoning_effort` 인자 사용법, 오버라이드에 필요한 `fork_turns: "none"`(또는 부분 fork) 규칙, 선호 모델·추론 강도, 그리고 `subagentModels` 로스터와 각 모델이 카탈로그에 광고하는 effort 사다리가 들어갑니다. 이 사다리는 Codex가 스폰 effort를 검증하는 목록과 동일합니다. **v1** 요청에서는 최고 추론 단계(max / ultra)에서 업스트림과 동일한 능동 위임 문구만 미러링합니다. 모델 지정, 로스터, 커스텀 프롬프트는 v1에 추가되지 않습니다. @@ -56,7 +46,7 @@ v2 서피스(`multi_agent_v2`)의 서브에이전트는 **기본적으로** 부 - **대시보드** → 첫 번째 스탯 셀에서 **v1**, **base**, **v2**를 선택합니다. - **모델** 페이지 → 상단 세그먼트 컨트롤에서 선택합니다. - 두 페이지 모두 **?** 버튼을 누르면 이 문서로 연결되는 도움말 모달이 열립니다. -- **대시보드** → **서브에이전트 위임**에서 선호 모델과 선택 사항인 추론 강도를 고릅니다. v2에서는 주입된 가이드가 `fork_turns: "none"` 스폰을 지시해 모델 오버라이드가 적용되게 합니다 — 다만 네이티브→라우팅 자식은 작업 본문이 암호화 상태로 도착할 수 있습니다([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **대시보드** → **서브에이전트 위임**에서 선호 모델과 선택 사항인 추론 강도를 고릅니다. v2에서는 주입된 가이드가 `fork_turns: "none"` 스폰을 지시해 모델 오버라이드가 적용되게 합니다 — 다만 네이티브→라우팅 자식은 작업 본문이 암호화 상태로 도착할 수 있습니다([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). ### CLI diff --git a/docs-site/src/content/docs/ko/reference/cli.md b/docs-site/src/content/docs/ko/reference/cli.md index 7ab091ef0..3ed6fa80a 100644 --- a/docs-site/src/content/docs/ko/reference/cli.md +++ b/docs-site/src/content/docs/ko/reference/cli.md @@ -448,7 +448,7 @@ ocx update ocx update --tag preview ``` -[Release 워크플로](https://github.com/lidge-jun/opencodex/actions/workflows/release.yml)가 npm에 +[Release 워크플로](https://github.com/OnlineChefGroep/opencodex/actions/workflows/release.yml)가 npm에 게시하는 즉시 새 버전을 사용할 수 있습니다. ## 도움말 diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 010f4b6bf..a0d2d3eda 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -506,7 +506,7 @@ ocx update ocx update --tag preview ``` -New versions become available the moment the [Release workflow](https://github.com/lidge-jun/opencodex/actions/workflows/release.yml) +New versions become available the moment the [Release workflow](https://github.com/OnlineChefGroep/opencodex/actions/workflows/release.yml) publishes them to npm. ## Help diff --git a/docs-site/src/content/docs/ru/contributing.md b/docs-site/src/content/docs/ru/contributing.md index 8f21c6303..7e5ff1c9d 100644 --- a/docs-site/src/content/docs/ru/contributing.md +++ b/docs-site/src/content/docs/ru/contributing.md @@ -6,7 +6,7 @@ description: Разработка opencodex — настройка окруже ## Настройка окружения ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # прокси-API в режиме разработки @@ -45,7 +45,7 @@ cd docs-site && bun install && bun dev ## Публикация документации -Публичная документация публикуется на GitHub Pages по адресу . +Публичная документация публикуется на GitHub Pages по адресу . Воркфлоу `.github/workflows/deploy-docs.yml` запускается на push в `main`, затрагивающих `docs-site/**` или сам воркфлоу, собирает `docs-site` и разворачивает сгенерированный сайт. Перед push изменений документации выполните: @@ -78,23 +78,6 @@ bun run release --publish # publish после осознанного bun run release:watch # наблюдение за последним запуском Release workflow ``` -## Ветки - -- `dev` — цель интеграции по умолчанию. Открывайте PR сюда, если он не относится к - специализированной ветке ниже. -- `dev2-go` — параллельная линия интеграции для нативного порта на Go (`go/`, точка входа - нативного рантайма и инструменты сборки Go-релизов). Принимает pull request'ы наравне с - `dev`. Отправляйте сюда только работу, относящуюся к порту на Go; всё остальное — в `dev`. - Автоматическая проверка целевой ветки допускает обе ветки и не различает их, поэтому - границу определяет ревью: мейнтейнер может попросить сменить целевую ветку на `dev`. -- `main` — только релизы. Двигается лишь при продвижении из `dev` мейнтейнером; не - открывайте сюда PR с функциональностью. -- `preview` — ветка предрелизов. - -Pull request'ы с портированием и ребейзом приветствуются. Перенос исправления между -линиями интеграции или ребейз устаревшей ветки на текущий head — это обычный вклад, а не -шум. Укажите исходные коммиты в описании. - ## Конвенции - **Только ES Modules** (`import`/`export`), TypeScript, режим `strict`. Держите `bun x tsc --noEmit` @@ -133,7 +116,7 @@ Pull request'ы с портированием и ребейзом приветс ## Добавление адаптера -Реализуйте `ProviderAdapter` (см. [Адаптеры](/ru/reference/adapters/)) в `src/adapters/`, +Реализуйте `ProviderAdapter` (см. [Адаптеры](/opencodex/ru/reference/adapters/)) в `src/adapters/`, зарегистрируйте его имя в `src/server/adapter-resolve.ts` и приведите его вывод к внутренним событиям `AdapterEvent`. Переиспользуйте `image.ts` для работы с изображениями и ориентируйтесь на `openai-chat.ts` для обычной потоковой передачи и вызовов инструментов; используйте `fetchResponse` diff --git a/docs-site/src/content/docs/ru/getting-started/installation.md b/docs-site/src/content/docs/ru/getting-started/installation.md index 837178f7e..b8af34eb2 100644 --- a/docs-site/src/content/docs/ru/getting-started/installation.md +++ b/docs-site/src/content/docs/ru/getting-started/installation.md @@ -62,7 +62,7 @@ ocx update --tag preview Чтобы работать над самим opencodex: ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # запускает API прокси в режиме разработки (src/cli/index.ts start) diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index bd8985942..7dc5c3779 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -20,11 +20,6 @@ description: Все способы, которыми opencodex аутентиф виртуальные id `sol-pro`, `terra-pro` и `luna-pro` сохраняют выбранную публичную идентичность, тогда как в фактическом запросе используется базовая модель плюс `reasoning.mode: "pro"`. -Если встроенный провайдер `openai` отсутствует или отключён, его можно восстановить из выбора Accounts -на панели и со страницы Codex Auth: отсутствующие записи создаются из канонического пресета, отключённые -канонические записи включаются без замены сохранённого режима и настроек моделей, а неканонические -записи `openai` этот путь восстановления не получают. - Поставляемые v1-конфигурации автоматически мигрируют на маркер 2 и одну строку с поддержкой опций. Исходная конфигурация один раз сохраняется в `~/.opencodex/config.json.pre-openai-tiers-v2.bak`; восстановить её можно командой @@ -58,8 +53,8 @@ description: Все способы, которыми opencodex аутентиф ``` Пересылается только ограниченный набор заголовков (`FORWARD_HEADERS`: authorization, ChatGPT -account id, OpenAI beta/originator/session — см. [Адаптеры](/ru/reference/adapters/)). -Этот же путь обеспечивает работу [сайдкаров веб-поиска и vision](/ru/guides/sidecars/). +account id, OpenAI beta/originator/session — см. [Адаптеры](/opencodex/ru/reference/adapters/)). +Этот же путь обеспечивает работу [сайдкаров веб-поиска и vision](/opencodex/ru/guides/sidecars/). Каталог сквозного режима ChatGPT дополнительно включает «голые» слаги GPT-5.6 Sol/Terra/Luna (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`) для аккаунтов, которым они доступны. @@ -88,12 +83,12 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | Каталог Grok загружается в реальном времени; фолбэк по умолчанию — `grok-4.5`. | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Модели Claude; актуальный список моделей загружается из `/v1/models`. | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Модели Kimi K2.7/K2.6/K2.5 для кодинга. | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Вход сначала импортирует и переиспользует сессию установленного `kiro-cli`. Требуется установленный Kiro CLI (`curl -fsSL https://cli.kiro.dev/install | bash`) и вход через `kiro-cli login`. | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Вход сначала импортирует и переиспользует сессию установленного `kiro-cli`. | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | -OAuth можно запустить и из [веб-дашборда](/ru/guides/web-dashboard/). +OAuth можно запустить и из [веб-дашборда](/opencodex/ru/guides/web-dashboard/). ### Несколько OAuth-аккаунтов @@ -129,7 +124,6 @@ opencodex поставляется с 53 встроенными пресетам | Hugging Face | `https://router.huggingface.co/v1` | | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | -| Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` | | Qwen Cloud | Token plan (по умолчанию): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · Pay as you go: `https://dashscope.aliyuncs.com/compatible-mode/v1` · или Custom | | Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` | | SiliconFlow | `https://api.siliconflow.cn/v1` | @@ -147,10 +141,6 @@ opencodex поставляется с 53 встроенными пресетам > в интерактивных инструментах программирования. Автоматизация общего API, серверы пользовательских > приложений и неинтерактивные пакетные вызовы запрещены и могут привести к блокировке ключа плана. -> **Два маршрута GLM:** `zai` — это международная подписка Z.AI на coding-план, а `zhipu-bigmodel` — -> внутренняя китайская конечная точка BigModel с оплатой по факту использования. Разные хосты, -> разные ключи, разная тарификация: ключ от одного сервиса не подойдёт к другому. - ### Несколько API-ключей Провайдеры на основе ключей тоже могут хранить несколько ключей. Ключ, добавленный через страницу @@ -164,7 +154,7 @@ Providers, сохраняется в `provider.apiKeyPool`, становится Используйте `ocx account list`, `ocx account current` и `ocx account use`, чтобы просматривать и переключать те же пулы Codex, OAuth и API-ключей, не открывая дашборд. Команды, JSON-вывод и поведение в новых сессиях описаны в разделе -[Справочник CLI](/ru/reference/cli/#ocx-account-subcommand). +[Справочник CLI](/opencodex/ru/reference/cli/#ocx-account-subcommand). ### Превью-маршруты GPT-5.6 @@ -205,7 +195,7 @@ opencodex использует живой транспорт HTTP/2 Cursor. Ег обходит путь одобрений и песочницу Codex; устанавливайте `unsafeAllowNativeLocalExec: true` в объекте `providers.cursor` файла `~/.opencodex/config.json` только для доверенных локальных экспериментов (или через **Providers → Cursor → Edit JSON** в дашборде). Полный пример см. в -[справочнике по конфигурации](/ru/reference/configuration/#cursor-provider-adapter-cursor). +[справочнике по конфигурации](/opencodex/ru/reference/configuration/#cursor-provider-adapter-cursor). MCP, запись экрана и computer-use доступны как хуки исполнителя; без настроенного локального исполнителя opencodex возвращает типизированные результаты «нет исполнителя», а не блокирует запрос политикой. Для этого экспериментального адаптера включены Cursor OAuth и живое обнаружение моделей; @@ -217,7 +207,7 @@ MCP, запись экрана и computer-use доступны как хуки Ollama Cloud — это размещённая в облаке (не локальная) Ollama, OpenAI-совместимая по адресу `https://ollama.com/v1`, с ключом со страницы [ollama.com/settings/keys](https://ollama.com/settings/keys). opencodex классифицирует её облачную -линейку по поддержке изображений, чтобы [vision-сайдкар](/ru/guides/sidecars/) включался +линейку по поддержке изображений, чтобы [vision-сайдкар](/opencodex/ru/guides/sidecars/) включался только для текстовых моделей. Текстовые модели (например, `glm-5.2`, `deepseek-v4-pro`, `gpt-oss`, `qwen3-coder`, `minimax-m2.x`, `nemotron-3-*`) перечислены в `noVisionModels`; модели с нативной поддержкой изображений (например, `kimi-k2.6`, `minimax-m3`, `gemma4`, `qwen3.5`, @@ -239,4 +229,4 @@ Ollama Cloud — это размещённая в облаке (не локал Если провайдер поддерживает Chat Completions, с ним справится адаптер `openai-chat` — выберите **Custom** в дашборде или `custom` в `ocx init` и введите базовый URL. Все поля провайдера (`headers`, `noReasoningModels`, `noVisionModels`, `models`, …) описаны в -[справочнике по конфигурации](/ru/reference/configuration/). +[справочнике по конфигурации](/opencodex/ru/reference/configuration/). diff --git a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md index ef3609caa..33326b368 100644 --- a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md @@ -6,7 +6,7 @@ description: Управление тем, как Codex порождает под opencodex позволяет выбрать поверхность мультиагентного взаимодействия для каждой модели в каталоге. Переключатель **Sub-agent** в дашборде и на странице Models управляет этим глобально. :::note -На поверхности v2 (`multi_agent_v2`) порождённый подагент **по умолчанию** наследует модель родителя: `fork_turns` по умолчанию равен `all`, а форки с полной историей отклоняют переопределения. Начиная с v2.7.2 opencodex внедряет инструкцию, которая учит модель обходить наследование: вызов `spawn_agent`, устанавливающий `fork_turns` в `"none"` (или частичный форк, например `"3"`), может передать аргументы `model` / `reasoning_effort`, которые рантайм Codex разбирает и применяет, хотя опубликованная схема инструмента их скрывает. Известное ограничение транспорта: когда **нативный** родитель порождает потомка, маршрутизируемого на **ненативного** провайдера, клиент Codex может отправить полезную нагрузку `NEW_TASK` только как зашифрованный бэкендом `encrypted_content` ([#92](https://github.com/lidge-jun/opencodex/issues/92)). opencodex не пересылает нечитаемую задачу внешнему провайдеру: прямой маршрут завершается с HTTP 400 и кодом `unreadable_encrypted_agent_task`, а комбо пропускает цели без возможности дешифрования и при наличии выбирает каноническую нативную цель ChatGPT. Для делегирования между разнородными провайдерами используйте v1, выберите нативного потомка ChatGPT или отправьте задачу повторно как открытый v2 `agent_message` контент. +На поверхности v2 (`multi_agent_v2`) порождённый подагент **по умолчанию** наследует модель родителя: `fork_turns` по умолчанию равен `all`, а форки с полной историей отклоняют переопределения. Начиная с v2.7.2 opencodex внедряет инструкцию, которая учит модель обходить наследование: вызов `spawn_agent`, устанавливающий `fork_turns` в `"none"` (или частичный форк, например `"3"`), может передать аргументы `model` / `reasoning_effort`, которые рантайм Codex разбирает и применяет, хотя опубликованная схема инструмента их скрывает. Известное ограничение: когда **нативный** родитель порождает потомка, маршрутизируемого на **ненативного** провайдера, клиент Codex может отправить полезную нагрузку `NEW_TASK` только как зашифрованный бэкендом `encrypted_content`, и маршрутизируемый потомок получает пустое тело задачи ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). Переопределение модели при этом применяется, но текст задачи может потеряться — для делегирования между разнородными провайдерами надёжным выбором остаётся поверхность v1. ::: ## Режимы @@ -15,17 +15,7 @@ opencodex позволяет выбрать поверхность мульти | --- | --- | --- | | **v1** | `multi_agent_v1` | Классические агентные инструменты с пространством имён: `send_input` / `close_agent` / `resume_agent`. Переопределение модели в `spawn_agent` может запустить подагента на другой модели. | | **base** (по умолчанию) | Вышестоящие закрепления | Восстанавливает вышестоящие закрепления моделей: gpt-5.6-sol и gpt-5.6-terra используют v2, gpt-5.6-luna — v1, а незакреплённые модели следуют фиче-флагу Codex `multi_agent_v2`. Поведение порождения следует поверхности, которая определяется для данной модели. | -| **v2** | `multi_agent_v2` | Плоские инструменты `spawn_agent` с параллельными сессиями и `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`. Потомки наследуют модель родителя при форках с полной историей; `fork_turns: "none"` (или частичный форк) принимает переопределения `model` / `reasoning_effort`. Если потомок native→routed получает только зашифрованное бэкендом содержимое задачи, внешние маршруты возвращают `unreadable_encrypted_agent_task`, а смешанные комбо предпочитают цель с возможностью дешифрования ([#92](https://github.com/lidge-jun/opencodex/issues/92)). | - -### Доставка зашифрованных v2-задач - -Только нативный бэкенд ChatGPT может прочитать свой зашифрованный полезный груз задачи. Для нечитаемого v2 `agent_message` opencodex применяет следующие правила до диспетчеризации провайдеру: - -- Прямой ненативный маршрут возвращает HTTP 400 с `error.code = "unreadable_encrypted_agent_task"`. Ответ никогда не отражает зашифрованный груз. -- Комбо рассматривает для такой задачи только канонические нативные цели ChatGPT, включая повторные попытки. Если в комбо нет цели с возможностью дешифрования, он возвращает тот же ответ 400 вместо отправки пустой задачи внешнему провайдеру. -- Читаемые открытые задачи сохраняют обычный порядок комбо и поведение фейловера. - -Для восстановления переключите потомка на нативную модель ChatGPT, добавьте нативную цель в комбо, используйте поверхность v1 для делегирования между разнородными провайдерами или, если вы управляете вызывающей стороной, отправьте задачу повторно как открытый v2 `agent_message` контент. +| **v2** | `multi_agent_v2` | Плоские инструменты `spawn_agent` с параллельными сессиями и `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`. Потомки наследуют модель родителя при форках с полной историей; `fork_turns: "none"` (или частичный форк) принимает переопределения `model` / `reasoning_effort`. Для потомков native→routed тело задачи может прийти зашифрованным ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). | ## Как это работает @@ -43,7 +33,7 @@ opencodex позволяет выбрать поверхность мульти `multiAgentGuidanceText` определяет поверхность по инструментам запроса — включая WebSocket-путь Codex Desktop (`responses_lite`), где инструменты приходят внутри входного элемента `additional_tools`, а не в массиве `tools` запроса. -В ходах **v2** (Sol/Terra в режиме base, любая модель в режиме v2) прокси внедряет компактный блок инструкции — с бюджетом 700 символов — когда задана допустимая модель внедрения или эффективный список подагентов не пуст. Блок условно описывает переопределения `model` / `reasoning_effort`, не утверждая, видны ли они в активной схеме, требует `fork_turns: "none"` (или частичный форк), называет только допустимую каноническую предпочтительную модель и перечисляет только настроенные модели из первых пяти видимых в селекторе, совместимых с v2 и отсортированных по priority записей Codex с доступными уровнями effort. +В ходах **v2** (Sol/Terra в режиме base, любая модель в режиме v2) прокси внедряет компактный блок инструкции — с бюджетом 700 символов — всякий раз, когда задана модель внедрения или настроенный список подагентов разрешается в каталоге. Блок объясняет скрытые аргументы `model` / `reasoning_effort` инструмента `spawn_agent`, требует `fork_turns: "none"` (или частичный форк) для переопределений, называет предпочтительные модель и уровень рассуждений и перечисляет список `subagentModels` со шкалой уровней, которую каждая модель объявляет во внедрённом каталоге, — тем же списком, по которому Codex валидирует уровни при порождении. В ходах **v1** прокси лишь зеркалирует вышестоящий текст Proactive delegation на верхнем уровне рассуждений (max / ultra). Ни назначение модели, ни список, ни пользовательский промпт туда не добавляются — v1 намеренно остаётся минимальным. @@ -56,7 +46,7 @@ opencodex позволяет выбрать поверхность мульти - **Dashboard** → первая ячейка статистики: нажмите **v1**, **base** или **v2**. - Страница **Models** → сегментированный переключатель в верхнем ряду. - На обеих страницах есть кнопка **?**, открывающая модальное окно справки со ссылкой на эту страницу. -- **Dashboard** → **Sub-agent delegation**: выберите предпочтительную модель и, при желании, уровень рассуждений. На v2 внедрённая инструкция велит агенту порождать с `fork_turns: "none"`, чтобы переопределение модели сработало, — хотя для потомков native→routed тело задачи сейчас может приходить зашифрованным ([#92](https://github.com/lidge-jun/opencodex/issues/92)). +- **Dashboard** → **Sub-agent delegation**: выберите предпочтительную модель и, при желании, уровень рассуждений. На v2 внедрённая инструкция велит агенту порождать с `fork_turns: "none"`, чтобы переопределение модели сработало, — хотя для потомков native→routed тело задачи сейчас может приходить зашифрованным ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)). ### CLI diff --git a/docs-site/src/content/docs/ru/reference/cli.md b/docs-site/src/content/docs/ru/reference/cli.md index 15a460279..34b452d15 100644 --- a/docs-site/src/content/docs/ru/reference/cli.md +++ b/docs-site/src/content/docs/ru/reference/cli.md @@ -476,7 +476,7 @@ ocx update ocx update --tag preview ``` -Новые версии становятся доступны в момент, когда [workflow Release](https://github.com/lidge-jun/opencodex/actions/workflows/release.yml) +Новые версии становятся доступны в момент, когда [workflow Release](https://github.com/OnlineChefGroep/opencodex/actions/workflows/release.yml) публикует их в npm. ## Справка diff --git a/docs-site/src/content/docs/zh-cn/contributing.md b/docs-site/src/content/docs/zh-cn/contributing.md index bd7723325..74587c386 100644 --- a/docs-site/src/content/docs/zh-cn/contributing.md +++ b/docs-site/src/content/docs/zh-cn/contributing.md @@ -6,7 +6,7 @@ description: opencodex 的开发环境、结构、约定,以及添加 provider ## 环境搭建 ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # 开发模式代理 API @@ -44,7 +44,7 @@ cd docs-site && bun install && bun dev ## 文档发布 -公开文档发布到 GitHub Pages:。 +公开文档发布到 GitHub Pages:。 `.github/workflows/deploy-docs.yml` 会在 `main` push 中 `docs-site/**` 或 workflow 本身发生变化时 运行,构建 `docs-site` 并部署生成的网站。推送文档变更前请运行: @@ -74,19 +74,6 @@ bun run release --publish # 确认 CI-gated dry-run 后真正 publish bun run release:watch # 观察最新的 Release workflow run ``` -## 分支 - -- `dev` — 默认的集成目标。除非属于下面的专用分支,否则请把 PR 提到这里。 -- `dev2-go` — Go 原生移植(`go/`、原生运行时入口、Go 发布产物工具链)的并行集成线。 - 与 `dev` 一样接受 pull request。只把属于 Go 移植的改动提到这里,其余都提到 `dev`。 - 目标分支检查同时接受这两个分支,但无法区分二者,因此范围由 review 决定:维护者可能会 - 请你把目标分支改成 `dev`。 -- `main` — 仅用于发布。只有维护者从 `dev` 提升时才会变动,请勿直接提功能 PR。 -- `preview` — 预发布通道。 - -欢迎移植 PR 和变基 PR。把一条集成线上的修复带到另一条线,或把陈旧分支变基到当前 head, -都是正常的贡献而非噪音。请在描述中注明来源提交。 - ## 约定 - **仅使用 ES Modules**(`import`/`export`)、TypeScript 和 `strict` mode。保持 @@ -124,7 +111,7 @@ bun run release:watch # 观察最新的 Release workflow run ## 添加 adapter 在 `src/adapters/` 中实现 `ProviderAdapter`(参见 -[Adapters](/zh-cn/reference/adapters/)),在 `src/server/adapter-resolve.ts` 注册其名称, +[Adapters](/opencodex/zh-cn/reference/adapters/)),在 `src/server/adapter-resolve.ts` 注册其名称, 并把输出桥接成内部 `AdapterEvent`。图像处理请复用 `image.ts`;普通 streaming/tool call 以 `openai-chat.ts` 为参考。只有 adapter 自己负责 transport retry 时才使用 `fetchResponse`;Cursor 这类真正的双向 transport 应使用 `runTurn`。在 `tests/` 中添加聚焦测试;如果 factory 属于 public diff --git a/docs-site/src/content/docs/zh-cn/getting-started/installation.md b/docs-site/src/content/docs/zh-cn/getting-started/installation.md index 8730fd20a..c669ef6b0 100644 --- a/docs-site/src/content/docs/zh-cn/getting-started/installation.md +++ b/docs-site/src/content/docs/zh-cn/getting-started/installation.md @@ -58,7 +58,7 @@ ocx update --tag preview 若要对 opencodex 本身进行开发: ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # 以开发模式启动代理 API (src/cli/index.ts start) diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 93caa2a63..b89f88ee6 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -17,8 +17,6 @@ bare `gpt-5.6-sol` 遵循 Providers 页面中的 Pool/Direct 选项, 922,000 max input;`*-pro` virtual id 保留在公开状态中,线上改写为 base 模型加 `reasoning.mode: "pro"`。 -若内置 `openai` 提供商缺失或已禁用,可在仪表盘 Accounts 选择器或 Codex Auth 页面恢复:缺失行会从规范预设创建,已禁用的规范行会在不替换已保存模式/模型设置的情况下重新启用,非规范的 `openai` 行不会提供该恢复路径。 - shipped v1 配置自动迁移到 marker 2 的单一选项行。原配置只保留一次到 `~/.opencodex/config.json.pre-openai-tiers-v2.bak`;恢复命令: `cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json`。 @@ -48,7 +46,7 @@ shipped v1 配置自动迁移到 marker 2 的单一选项行。原配置只保 } ``` -只有一组精选的请求头会被转发(`FORWARD_HEADERS`:authorization、ChatGPT account id、OpenAI beta/originator/session——参见 [Adapters](/zh-cn/reference/adapters/))。这条路径也为 [web-search 和 vision sidecar](/zh-cn/guides/sidecars/) 提供支持。 +只有一组精选的请求头会被转发(`FORWARD_HEADERS`:authorization、ChatGPT account id、OpenAI beta/originator/session——参见 [Adapters](/opencodex/zh-cn/reference/adapters/))。这条路径也为 [web-search 和 vision sidecar](/opencodex/zh-cn/guides/sidecars/) 提供支持。 ChatGPT 透传目录也会加入 GPT-5.6 Sol/Terra/Luna 的裸 slug(`gpt-5.6-sol`、 `gpt-5.6-terra`、`gpt-5.6-luna`);账号具备相应权限时才能实际调用。 @@ -74,11 +72,11 @@ ocx logout | `xai` | `openai-chat` | `https://api.x.ai/v1` | 优先使用实时 Grok 目录;回退默认模型为 `grok-4.5`。 | | `anthropic` | `anthropic` | `https://api.anthropic.com` | Claude 模型;实时模型列表从 `/v1/models` 获取。 | | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | -| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 优先复用已安装的 `kiro-cli` 登录。需先安装 Kiro CLI(`curl -fsSL https://cli.kiro.dev/install | bash`)并执行 `kiro-cli login`。 | +| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 优先复用已安装的 `kiro-cli` 登录。 | | `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、HTTP/2 传输和按账号筛选的模型发现。 | -你也可以从 [web 仪表盘](/zh-cn/guides/web-dashboard/) 启动 OAuth。 +你也可以从 [web 仪表盘](/opencodex/zh-cn/guides/web-dashboard/) 启动 OAuth。 ### 多个 OAuth 账号 @@ -112,7 +110,6 @@ ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提 | Hugging Face | `https://router.huggingface.co/v1` | | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | -| 智谱 AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` | | Qwen Cloud | Token plan(默认): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · 按量付费: `https://dashscope.aliyuncs.com/compatible-mode/v1` · 或自定义 | | 腾讯云 Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` | | SiliconFlow | `https://api.siliconflow.cn/v1` | @@ -127,9 +124,6 @@ ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提 > **腾讯云 Coding Plan 使用限制:**腾讯将此订阅限定为交互式编程工具使用。禁止通用 API > 自动化、自定义应用后端和非交互式批量调用;违规使用可能导致套餐密钥被停用。 -> **两条 GLM 线路:**`zai` 是 Z.AI 的国际 coding plan 订阅,`zhipu-bigmodel` 是智谱国内 -> BigModel 的按量付费端点。二者主机、密钥与计费均不同,为其中一方签发的密钥无法在另一方通过鉴权。 - ### 多个 API 密钥 基于密钥的提供商也可以保存多个 key。通过 Providers 页面添加密钥时,它会存入 @@ -140,7 +134,7 @@ ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提 无需打开仪表盘,即可使用 `ocx account list`、`ocx account current` 和 `ocx account use` 查看或 切换同一组 Codex、OAuth 和 API-key pool。完整命令、JSON 输出和新 session 生效规则请参阅 -[CLI 参考](/zh-cn/reference/cli/#ocx-account-subcommand)。 +[CLI 参考](/opencodex/zh-cn/reference/cli/#ocx-account-subcommand)。 ### GPT-5.6 预览路径 @@ -177,7 +171,7 @@ Cursor access token 后,opencodex 会使用 Cursor live HTTP/2 transport。v2. native read/write/delete/ls/grep/shell/fetch 执行默认禁用,因为它会绕过 Codex 的 approval 和 sandbox 路径;只有在可信本地实验中,才应在 `~/.opencodex/config.json` 的 `providers.cursor` 对象上设置 `unsafeAllowNativeLocalExec: true`,也可以在仪表盘的 **Providers → Cursor → Edit JSON** -中设置。完整示例参见 [配置参考](/zh-cn/reference/configuration/#cursor-provider-adapter-cursor)。MCP、屏幕录制和 computer-use +中设置。完整示例参见 [配置参考](/opencodex/zh-cn/reference/configuration/#cursor-provider-adapter-cursor)。MCP、屏幕录制和 computer-use 通过 executor hook 暴露;没有配置本地 executor 时,opencodex 会返回 typed no-executor 结果。 Cursor OAuth 和 live model discovery 已在这个实验性 adapter 中启用;Cursor 仍不会出现在 key-login 列表中。 @@ -185,7 +179,7 @@ Cursor OAuth 和 live model discovery 已在这个实验性 adapter 中启用; ### Ollama Cloud -Ollama Cloud 是托管(而非本地)的 Ollama,在 `https://ollama.com/v1` 上兼容 OpenAI,密钥来自 [ollama.com/settings/keys](https://ollama.com/settings/keys)。opencodex 按视觉能力对其云端阵容进行分类,使 [vision sidecar](/zh-cn/guides/sidecars/) 仅对纯文本模型生效。纯文本模型(例如 `glm-5.2`、`deepseek-v4-pro`、`gpt-oss`、`qwen3-coder`、`minimax-m2.x`、`nemotron-3-*`)列在 `noVisionModels` 中;原生支持视觉的模型(例如 `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、`gemini-3-flash-preview`)则不在其中。匹配能容忍 Ollama 的 `:size` 标签,因此 `gpt-oss` 涵盖 `gpt-oss:120b` 和 `gpt-oss:20b`。 +Ollama Cloud 是托管(而非本地)的 Ollama,在 `https://ollama.com/v1` 上兼容 OpenAI,密钥来自 [ollama.com/settings/keys](https://ollama.com/settings/keys)。opencodex 按视觉能力对其云端阵容进行分类,使 [vision sidecar](/opencodex/zh-cn/guides/sidecars/) 仅对纯文本模型生效。纯文本模型(例如 `glm-5.2`、`deepseek-v4-pro`、`gpt-oss`、`qwen3-coder`、`minimax-m2.x`、`nemotron-3-*`)列在 `noVisionModels` 中;原生支持视觉的模型(例如 `kimi-k2.6`、`minimax-m3`、`gemma4`、`qwen3.5`、`gemini-3-flash-preview`)则不在其中。匹配能容忍 Ollama 的 `:size` 标签,因此 `gpt-oss` 涵盖 `gpt-oss:120b` 和 `gpt-oss:20b`。 ## 4. 本地提供商 @@ -199,4 +193,4 @@ Ollama Cloud 是托管(而非本地)的 Ollama,在 `https://ollama.com/v1` ## 任意 OpenAI 兼容端点 -如果某个提供商使用 Chat Completions,`openai-chat` adapter 即可处理它——在仪表盘中选择 **Custom**,或在 `ocx init` 中选择 `custom` 并输入基础 URL。每个提供商字段(`headers`、`noReasoningModels`、`noVisionModels`、`models`……)请参见 [配置参考](/zh-cn/reference/configuration/)。 +如果某个提供商使用 Chat Completions,`openai-chat` adapter 即可处理它——在仪表盘中选择 **Custom**,或在 `ocx init` 中选择 `custom` 并输入基础 URL。每个提供商字段(`headers`、`noReasoningModels`、`noVisionModels`、`models`……)请参见 [配置参考](/opencodex/zh-cn/reference/configuration/)。 diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index e47bd7064..2df6305a9 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -6,7 +6,7 @@ description: 全局控制 Codex 在所有模型上生成和管理子代理的方 opencodex 允许你为目录中的所有模型选择多代理协作界面。仪表盘和 Models 页面中的 **Sub-agent** 开关会全局控制这一设置。 :::note -在 v2 界面(`multi_agent_v2`)上,子代理**默认**继承父会话的模型:`fork_turns` 默认为 `all`,而全量历史 fork 会拒绝覆盖。自 v2.7.2 起,opencodex 注入的指引会教模型如何打破继承 —— 将 `fork_turns` 设为 `"none"`(或如 `"3"` 的部分 fork)的 `spawn_agent` 调用可以传入 `model` / `reasoning_effort` 参数;即使公开的工具 schema 中看不到这些参数,Codex 运行时也会解析并应用。已知传输限制:当**原生**父代理 spawn 一个路由到**非原生** provider 的子代理时,Codex 客户端可能只以后端加密的 `encrypted_content` 发送 `NEW_TASK` 载荷([#92](https://github.com/lidge-jun/opencodex/issues/92))。opencodex 不会把这种无法读取的任务转发给外部 provider:直接路由会返回 HTTP 400 和错误码 `unreadable_encrypted_agent_task`;组合路由则会跳过无法解密的目标,并在存在可用目标时选择规范的原生 ChatGPT 目标。恢复方法:异构 provider 委派改用 v1、选择原生 ChatGPT 子代理,或将任务重新作为明文 v2 `agent_message` 内容发送。 +在 v2 界面(`multi_agent_v2`)上,子代理**默认**继承父会话的模型:`fork_turns` 默认为 `all`,而全量历史 fork 会拒绝覆盖。自 v2.7.2 起,opencodex 注入的指引会教模型如何打破继承 —— 将 `fork_turns` 设为 `"none"`(或如 `"3"` 的部分 fork)的 `spawn_agent` 调用可以传入 `model` / `reasoning_effort` 参数;即使公开的工具 schema 中看不到这些参数,Codex 运行时也会解析并应用。已知限制:当**原生**父代理 spawn 一个路由到**非原生** provider 的子代理时,Codex 客户端可能只以后端加密的 `encrypted_content` 发送 `NEW_TASK` 载荷,路由子代理会收到空的任务正文([#92](https://github.com/OnlineChefGroep/opencodex/issues/92))。模型覆盖仍会生效,但任务文本可能丢失 —— 异构 provider 委派请使用更可靠的 v1 界面。 ::: ## 模式 @@ -15,17 +15,7 @@ opencodex 允许你为目录中的所有模型选择多代理协作界面。仪 | --- | --- | --- | | **v1** | `multi_agent_v1` | 使用经典的命名空间代理工具,以及 `send_input` / `close_agent` / `resume_agent`。`spawn_agent` 的模型覆盖可以在其他模型上生成子代理。 | | **base**(默认) | 上游固定值 | 恢复上游模型的固定值:gpt-5.6-sol 和 gpt-5.6-terra 使用 v2,gpt-5.6-luna 使用 v1;未固定的模型遵循 Codex 的 `multi_agent_v2` 功能开关。生成行为取决于该模型最终使用的界面。 | -| **v2** | `multi_agent_v2` | 使用扁平的 `spawn_agent` 工具、并发会话,以及 `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`。全量历史 fork 时子代理继承父模型;`fork_turns: "none"`(或部分 fork)时接受 `model` / `reasoning_effort` 覆盖。如果原生→路由子代理只收到后端加密的任务内容,外部路由会返回 `unreadable_encrypted_agent_task`;混合组合会优先选择可解密的原生目标([#92](https://github.com/lidge-jun/opencodex/issues/92))。 | - -### 加密的 v2 任务传输 - -只有原生 ChatGPT 后端能够读取其加密任务载荷。对于无法读取的 v2 `agent_message`,opencodex 会在调用 provider 之前执行以下规则: - -- 直接路由到非原生 provider 时,返回 HTTP 400,并设置 `error.code = "unreadable_encrypted_agent_task"`。响应不会回显加密载荷。 -- 组合路由只会为该任务考虑规范的原生 ChatGPT 目标,重试时也遵守同一规则。如果组合中没有可解密的目标,则返回同样的 400,而不会把空任务发送给外部 provider。 -- 可读取的明文任务仍保留正常的组合顺序与故障转移行为。 - -恢复方法:将子代理切换到原生 ChatGPT 模型、在组合中加入原生目标、异构 provider 委派改用 v1,或者在你能控制调用方时将任务重新作为明文 v2 `agent_message` 内容发送。 +| **v2** | `multi_agent_v2` | 使用扁平的 `spawn_agent` 工具、并发会话,以及 `send_message` / `followup_task` / `wait_agent` / `interrupt_agent`。全量历史 fork 时子代理继承父模型;`fork_turns: "none"`(或部分 fork)时接受 `model` / `reasoning_effort` 覆盖。原生→路由子代理的任务正文可能以加密形式到达([#92](https://github.com/OnlineChefGroep/opencodex/issues/92))。 | ## 工作原理 @@ -43,7 +33,7 @@ opencodex 允许你为目录中的所有模型选择多代理协作界面。仪 `multiAgentGuidanceText` 根据请求中的工具列表判断当前界面 —— 包括 Codex Desktop 的 WebSocket 路径(`responses_lite`),此时工具位于 `additional_tools` input 项中而不是请求的 `tools` 数组。 -在 **v2** 请求上(base 模式下的 Sol/Terra,v2 模式下的全部模型),只要设置了有效的注入模型、或有效子代理清单非空,proxy 就会注入一段不超过 700 字符的精简指引。该指引以条件方式说明 `model` / `reasoning_effort` 覆盖,不假定它们是否出现在当前 schema 中;它要求使用 `fork_turns: "none"`(或部分 fork),仅命名有效的规范首选模型,并只列出 Codex 中 picker 可见、兼容 v2、按 priority 排序后前五项内的已配置模型及其可用 effort 档位。 +在 **v2** 请求上(base 模式下的 Sol/Terra,v2 模式下的全部模型),只要设置了注入模型、或配置的子代理清单能在目录中解析出来,proxy 就会注入一段不超过 700 字符的精简指引:`spawn_agent` 隐藏的 `model` / `reasoning_effort` 参数用法、覆盖所需的 `fork_turns: "none"`(或部分 fork)规则、首选模型与推理强度,以及 `subagentModels` 清单和各模型在目录中公布的 effort 阶梯 —— 这正是 Codex 验证生成强度所用的列表。 在 **v1** 请求上,proxy 仅在最高推理档位(max / ultra)镜像上游的主动委托文本。v1 不会追加模型指定、清单或自定义提示词。 @@ -56,7 +46,7 @@ opencodex 允许你为目录中的所有模型选择多代理协作界面。仪 - **Dashboard** → 第一个状态单元:选择 **v1**、**base** 或 **v2**。 - **Models** 页面 → 使用顶部的分段控件。 - 两个页面都有 **?** 按钮,可打开帮助弹窗并返回本文。 -- **Dashboard** → **子代理委托**:选择首选模型和可选的推理强度。在 v2 上,注入的指引会要求以 `fork_turns: "none"` 生成,使模型覆盖得以应用。如果原生→路由子代理只收到加密任务内容,请使用原生目标或 v1;仅外部目标的传输现在会明确返回 `unreadable_encrypted_agent_task`([#92](https://github.com/lidge-jun/opencodex/issues/92))。 +- **Dashboard** → **子代理委托**:选择首选模型和可选的推理强度。在 v2 上,注入的指引会要求以 `fork_turns: "none"` 生成,使模型覆盖得以应用 —— 但原生→路由子代理的任务正文可能以加密形式到达([#92](https://github.com/OnlineChefGroep/opencodex/issues/92))。 ### CLI diff --git a/docs-site/src/content/docs/zh-cn/reference/cli.md b/docs-site/src/content/docs/zh-cn/reference/cli.md index 9b744afcb..b4edb846b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli.md @@ -430,7 +430,7 @@ ocx update ocx update --tag preview ``` -[Release workflow](https://github.com/lidge-jun/opencodex/actions/workflows/release.yml) 发布到 npm +[Release workflow](https://github.com/OnlineChefGroep/opencodex/actions/workflows/release.yml) 发布到 npm 后,新版本会立即可用。 ## 帮助 diff --git a/docs/providers/omniroute.md b/docs/providers/omniroute.md new file mode 100644 index 000000000..680bd3983 --- /dev/null +++ b/docs/providers/omniroute.md @@ -0,0 +1,82 @@ +# OmniRoute + +[OmniRoute](https://github.com/diegosouzapw/OmniRoute) is an open-source, OpenAI-compatible +gateway that aggregates 250+ providers (90+ free) behind a single `/v1` endpoint. Adding it to +opencodex unlocks OmniRoute's free models (Claude, GPT, Gemini, GLM, Kimi, DeepSeek and more) +through one bearer key, with auto-fallback across upstream providers. + +OmniRoute speaks the OpenAI Chat Completions wire format, so opencodex reuses the built-in +`openai-chat` adapter. There is no separate adapter to install. + +## 1. Get an OmniRoute key + +1. Sign in at . +2. Open the dashboard and create an API key. + +The hosted cloud API lives at `https://api.omniroute.online/v1`. + +## 2. Configure opencodex + +OmniRoute is a built-in preset. In the dashboard open **Providers → Add provider → OmniRoute**, +paste your key, and pick a model. The key is sent as `Authorization: Bearer `. + +To configure it by hand in `~/.opencodex/config.json`: + +```jsonc +{ + "providers": { + "omniroute": { + "adapter": "openai-chat", + "baseUrl": "https://api.omniroute.online/v1", + "apiKey": "${OCX_OMNIROUTE_KEY}", + "defaultModel": "claude-sonnet-4-5-thinking" + } + } +} +``` + +The `apiKey` field accepts either a literal key or an `${ENV_VAR}` reference, so export the key +once and reference it as `${OCX_OMNIROUTE_KEY}`: + +```bash +export OCX_OMNIROUTE_KEY="your-omniroute-key" +``` + +## 3. Pick a model + +OmniRoute exposes a large, frequently-changing catalog. opencodex ships a small offline seed +mirroring OmniRoute's own `@omniroute/opencode-provider` defaults, plus the `auto` virtual combo +router: + +| Model id | Notes | +| --- | --- | +| `auto` | OmniRoute virtual combo router (picks a healthy free upstream automatically) | +| `cc/claude-opus-4-8` · `cc/claude-opus-4-7` · `cc/claude-sonnet-4-6` | Claude Code passthrough | +| `cc/claude-haiku-4-5-20251001` | Claude Code passthrough (fast, non-reasoning) | +| `claude-opus-4-5-thinking` · `claude-sonnet-4-5-thinking` | Claude with extended thinking | +| `gemini-3.1-pro-high` · `gemini-3-flash` | Gemini | + +The live `GET /v1/models` endpoint is the source of truth. To use any other OmniRoute model id +(e.g. a DeepSeek, GLM or Kimi variant), type it into the model field or add it to the provider's +`models` list in config; opencodex forwards unknown ids to OmniRoute verbatim. + +## 4. Self-host on your fleet (optional) + +OmniRoute ships as a Docker image: `diegosouzapw/omniroute` (default port `20128`). Run it on a +fleet server and point opencodex at it instead of the cloud. + +```bash +docker run -d --name omniroute -p 20128:20128 diegosouzapw/omniroute +``` + +Then set the OmniRoute provider's **base URL** to your instance, e.g. +`http://sofie:20128/v1`. OmniRoute is registered with `allowBaseUrlOverride`, so the +dashboard's Add-provider / Edit-provider form exposes a custom base URL field for this. Export +the target as `OCX_OMNIROUTE_BASE_URL` if you template your config from the environment: + +```bash +export OCX_OMNIROUTE_BASE_URL="http://sofie:20128/v1" +``` + +For a self-hosted instance with `REQUIRE_API_KEY` disabled, OmniRoute accepts the placeholder +key `sk_omniroute`. 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/package.json b/gui/package.json index b8e26c169..95e10872d 100644 --- a/gui/package.json +++ b/gui/package.json @@ -14,7 +14,10 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "@tanstack/react-virtual": "^3.14.5", + "posthog-js": "^1.407.0", "react": "^19.2.7", "react-dom": "^19.2.7" }, 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/App.tsx b/gui/src/App.tsx index 81a38269c..014c19c9f 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -8,13 +8,12 @@ import Subagents from "./pages/Subagents"; import Logs from "./pages/Logs"; import Usage from "./pages/Usage"; import Storage from "./pages/Storage"; -import CodexAuth from "./pages/CodexAuth"; import ApiKeys from "./pages/ApiKeys"; import Claude from "./pages/Claude"; import Grok from "./pages/Grok"; import Startup from "./pages/Startup"; import ErrorBoundary from "./components/ErrorBoundary"; -import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; +import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n/shared"; import { Select, Switch } from "./ui"; import { installApiAuthFetch } from "./api"; @@ -36,7 +35,6 @@ const PAGE_TKEY: Record = { logs: "nav.logs", usage: "nav.usage", storage: "nav.storage", - "codex-auth": "nav.codexAuth", api: "nav.api", claude: "nav.claude", grok: "nav.grok", @@ -47,7 +45,6 @@ const THEME_KEY = "ocx-theme"; const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [ { id: "dashboard", tkey: "nav.dashboard", Icon: IconGrid }, - { id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey }, { id: "providers", tkey: "nav.providers", Icon: IconServer }, { id: "models", tkey: "nav.models", Icon: IconBoxes }, { id: "subagents", tkey: "nav.subagents", Icon: IconBot }, @@ -305,7 +302,6 @@ export default function App() { {page === "logs" && } {page === "usage" && } {page === "storage" && } - {page === "codex-auth" && } {page === "api" && } {page === "claude" && } {page === "grok" && } diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index bb03f96a8..bc8248b69 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -12,7 +12,6 @@ export type Page = | "logs" | "usage" | "storage" - | "codex-auth" | "api" | "claude" | "grok"; @@ -27,7 +26,6 @@ export const VALID_PAGES = new Set([ "logs", "usage", "storage", - "codex-auth", "api", "claude", "grok", @@ -83,6 +81,12 @@ export function resolveAppHashChange(rawHash: string): AppHashChangeAction { return { page: "providers", replaceTo: "providers" }; } + // Account management used to be a Codex-only destination. Providers now owns + // OAuth accounts, API-key pools and the OpenAI/Codex pool in one place. + if (rawHash === "codex-auth" || rawHash.startsWith("codex-auth/")) { + return { page: "providers", replaceTo: "providers" }; + } + // An unrecognised sub-hash is normalised away rather than left in the URL. if (!hashBelongsToPage(rawHash, nextPage)) { return { page: nextPage, replaceTo: nextPage }; diff --git a/gui/src/components/QuotaBars.tsx b/gui/src/components/QuotaBars.tsx index 15a276299..f8458167e 100644 --- a/gui/src/components/QuotaBars.tsx +++ b/gui/src/components/QuotaBars.tsx @@ -106,6 +106,8 @@ function bcp47(locale: Locale): string { return "ru-RU"; case "ja": return "ja-JP"; + case "nl": + return "nl-NL"; default: { const _exhaustive: never = locale; return _exhaustive; diff --git a/gui/src/components/provider-workspace/ProviderDetails.tsx b/gui/src/components/provider-workspace/ProviderDetails.tsx index e825dcee6..b236d292a 100644 --- a/gui/src/components/provider-workspace/ProviderDetails.tsx +++ b/gui/src/components/provider-workspace/ProviderDetails.tsx @@ -29,7 +29,11 @@ export default function ProviderDetails({ usageTotals, modelUsage, quotaReport, + quotaRefreshing, + quotaFailed, + onRefreshQuota, availableModels, + peerProviders, hasLiveModels, selectedModels, modelsLoading, @@ -57,7 +61,11 @@ export default function ProviderDetails({ 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(). */ hasLiveModels: boolean; selectedModels: string[]; @@ -225,6 +233,9 @@ export default function ProviderDetails({ item={item} usageTotals={usageTotals} quotaReport={quotaReport} + quotaRefreshing={quotaRefreshing} + quotaFailed={quotaFailed} + onRefreshQuota={onRefreshQuota} oauthEmail={oauthEmail} onEditSettings={() => switchTab("settings")} onViewUsage={() => switchTab("usage")} @@ -267,7 +278,15 @@ export default function ProviderDetails({ /> )} {tab === "usage" && ( - + )} {tab === "accounts" && ( ({ + provider: typeof row.provider === "string" ? row.provider.trim() : "", + model: typeof row.model === "string" ? row.model.trim() : "", + })) + .filter(row => row.provider && row.model); +} + +function fallbackFingerprint(rows: ProviderFallbackTarget[]): string { + return JSON.stringify(normalizeFallback(rows)); +} + +function modelsForPeer(peer: ProviderPeerOption | undefined, currentModel: string): string[] { + const set = new Set(); + for (const id of peer?.models ?? []) { + if (id.trim()) set.add(id.trim()); + } + if (peer?.defaultModel?.trim()) set.add(peer.defaultModel.trim()); + if (currentModel.trim()) set.add(currentModel.trim()); + return [...set].sort((a, b) => a.localeCompare(b)); +} + export default function ProviderSettings({ - item, availableModels = EMPTY_MODELS, apiBase, onUpdateProvider, onDirtyChange, onRegisterSave, + item, availableModels = EMPTY_MODELS, peerProviders = EMPTY_PEERS, apiBase, onUpdateProvider, onDirtyChange, onRegisterSave, }: { item: WorkspaceItem; availableModels?: string[]; + /** Other configured providers (and their known models) for the fallback picker. */ + peerProviders?: ProviderPeerOption[]; /** When set, load endpoint choices for catalog providers that expose baseUrlChoices. */ apiBase?: string; onUpdateProvider?: (name: string, patch: ProviderUpdatePatch) => Promise<{ ok: boolean; error?: string }>; @@ -43,6 +77,7 @@ export default function ProviderSettings({ const [note, setNote] = useState(item.note ?? ""); const [allowPrivateNetwork, setAllowPrivateNetwork] = useState(item.allowPrivateNetwork ?? false); const [liveModels, setLiveModels] = useState(item.liveModels !== false); + const [fallback, setFallback] = useState(() => normalizeFallback(item.fallback)); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); const [baseUrlChoices, setBaseUrlChoices] = useState(); @@ -58,9 +93,10 @@ export default function ProviderSettings({ setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false); + setFallback(normalizeFallback(item.fallback)); setMsg(null); queueMicrotask(() => setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl))); - }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.keyOptional, item.note, item.allowPrivateNetwork, item.liveModels, baseUrlChoices]); + }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.keyOptional, item.note, item.allowPrivateNetwork, item.liveModels, item.fallback, baseUrlChoices]); /* eslint-enable react-hooks/set-state-in-effect */ useEffect(() => { @@ -99,7 +135,8 @@ export default function ProviderSettings({ || authMode !== String(item.authMode ?? (item.keyOptional ? "local" : "key")) || note.trim() !== (item.note ?? "") || allowPrivateNetwork !== (item.allowPrivateNetwork ?? false) - || liveModels !== (item.liveModels !== false); + || liveModels !== (item.liveModels !== false) + || fallbackFingerprint(fallback) !== fallbackFingerprint(item.fallback); useEffect(() => { onDirtyChange?.(dirty); return () => onDirtyChange?.(false); }, [dirty, onDirtyChange]); @@ -116,6 +153,11 @@ export default function ProviderSettings({ return list; }, [adapter]); + const fallbackPeers = useMemo( + () => peerProviders.filter(p => p.name !== item.name), + [peerProviders, item.name], + ); + const isPreset = isCatalogProviderId(item.name); const hasEndpointPicker = choicesStatus === "ready" && !!(baseUrlChoices && baseUrlChoices.length > 0); // Lock plain baseUrl for presets while loading or when there is no picker. @@ -128,10 +170,24 @@ export default function ProviderSettings({ ? resolvedBaseUrlForChoice(baseUrlChoices, endpointChoice, baseUrl) : baseUrl.trim(); if (!adapter.trim() || !nextBaseUrl) { setMsg({ ok: false, text: t("pws.adapterBaseRequired") }); return false; } + const nextFallback = normalizeFallback(fallback); + if (fallback.some(row => (row.provider.trim() && !row.model.trim()) || (!row.provider.trim() && row.model.trim()))) { + setMsg({ ok: false, text: t("pws.fallbackIncomplete") }); + return false; + } setSaving(true); setMsg(null); try { - const patch: ProviderUpdatePatch = { adapter: adapter.trim(), baseUrl: nextBaseUrl, defaultModel: defaultModel.trim(), authMode, note: note.trim(), allowPrivateNetwork, liveModels }; + const patch: ProviderUpdatePatch = { + adapter: adapter.trim(), + baseUrl: nextBaseUrl, + defaultModel: defaultModel.trim(), + authMode, + note: note.trim(), + allowPrivateNetwork, + liveModels, + fallback: nextFallback, + }; const res = await onUpdateProvider(item.name, patch); setMsg(res.ok ? { ok: true, text: t("pws.settingsSaved") } : { ok: false, text: res.error || t("prov.saveFailed") }); return res.ok; @@ -153,19 +209,25 @@ export default function ProviderSettings({ const discard = () => { setAdapter(item.adapter); setBaseUrl(item.baseUrl); setDefaultModel(item.defaultModel ?? ""); setAuthMode(initialAuth); - setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false); setMsg(null); + setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false); + setFallback(normalizeFallback(item.fallback)); + setMsg(null); setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl)); }; - const endpointLabel = (id: string, fallback: string) => { + const endpointLabel = (id: string, fallbackLabel: string) => { switch (id) { case "token-plan": return t("modal.endpoint.tokenPlan"); case "payg": return t("modal.endpoint.payAsYouGo"); case "custom": return t("modal.endpoint.custom"); - default: return fallback; + default: return fallbackLabel; } }; + const updateFallbackRow = (index: number, patch: Partial) => { + setFallback(rows => rows.map((row, i) => i === index ? { ...row, ...patch } : row)); + }; + return (
    + +
    + {t("pws.fallback")} + {t("pws.fallbackDesc")} +
    + {fallback.map((row, index) => { + const peer = fallbackPeers.find(p => p.name === row.provider); + const modelIds = modelsForPeer(peer, row.model); + return ( +
    + + {modelIds.length > 0 ? ( + + ) : ( + updateFallbackRow(index, { model: e.target.value })} + /> + )} + +
    + ); + })} + +
    +
    + {dirty && (
    {t("pws.settingsUnsavedBar")} diff --git a/gui/src/components/provider-workspace/ProviderUsage.tsx b/gui/src/components/provider-workspace/ProviderUsage.tsx index d697f436a..9e7b40a87 100644 --- a/gui/src/components/provider-workspace/ProviderUsage.tsx +++ b/gui/src/components/provider-workspace/ProviderUsage.tsx @@ -6,15 +6,19 @@ import { Fragment, useMemo, useState } from "react"; import { useT, useI18n } from "../../i18n/shared"; import QuotaBars from "../QuotaBars"; import type { WorkspaceItem } from "../../provider-workspace/catalog"; +import { IconRefresh } from "../../icons"; import { formatRelativeTime, relativeTimeLabelsFromT, formatRequestCount, formatTokenCount, formatCostUsd } from "../../provider-workspace/usage"; import { accountQuotaFromReport, formatQuotaSourceLabel, type ProviderQuotaReportView } from "../../provider-workspace/report"; import type { ProviderUsageTotals, ProviderModelUsageRow } from "./types"; -export default function ProviderUsage({ item, usageTotals, quotaReport, modelUsage }: { +export default function ProviderUsage({ item, usageTotals, quotaReport, modelUsage, quotaRefreshing, quotaFailed, onRefreshQuota }: { item: WorkspaceItem; usageTotals?: ProviderUsageTotals; quotaReport?: ProviderQuotaReportView; modelUsage?: ProviderModelUsageRow[]; + quotaRefreshing?: boolean; + quotaFailed?: boolean; + onRefreshQuota?: () => void; }) { const t = useT(); const { locale } = useI18n(); @@ -22,7 +26,6 @@ export default function ProviderUsage({ item, usageTotals, quotaReport, modelUsa const hasUsage = usageTotals?.requests !== undefined; const quota = accountQuotaFromReport(quotaReport); const [expandedModel, setExpandedModel] = useState(null); - void item; const sortedModels = useMemo(() => { if (!modelUsage?.length) return []; @@ -135,7 +138,24 @@ export default function ProviderUsage({ item, usageTotals, quotaReport, modelUsa )}
    -

    {t("pws.rateLimits")}

    +
    +

    {t("pws.rateLimits")}

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

    {t("prov.quotaRefreshFailed")}

    + )} {quota ? ( <> diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index 16a594906..63e3ab95d 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -81,6 +81,11 @@ export interface ProviderAuthHandlers { onEditAlias: (provider: string, type: "oauth" | "api-key", id: string, current?: string) => void | Promise; } +export type ProviderFallbackTarget = { + provider: string; + model: string; +}; + export type ProviderUpdatePatch = { adapter?: string; baseUrl?: string; @@ -91,4 +96,6 @@ export type ProviderUpdatePatch = { disabled?: boolean; allowPrivateNetwork?: boolean; liveModels?: boolean; + /** Ordered failover chain; `[]` clears. */ + fallback?: ProviderFallbackTarget[]; }; diff --git a/gui/src/formatUptime.ts b/gui/src/formatUptime.ts index 42ea2689b..7a4f9a5d3 100644 --- a/gui/src/formatUptime.ts +++ b/gui/src/formatUptime.ts @@ -2,6 +2,7 @@ import type { Locale } from "./i18n/shared"; const UPTIME_UNITS: Record = { en: { day: "d", hour: "h", minute: "m", second: "s" }, + nl: { day: "d", hour: "u", minute: "m", second: "s" }, de: { day: "T", hour: "Std", minute: "Min", second: "Sek" }, ko: { day: "일", hour: "시간", minute: "분", second: "초" }, zh: { day: "天", hour: "小时", minute: "分钟", second: "秒" }, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index c6ee7cf4e..d2f31e91c 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -4,7 +4,7 @@ import type { TKey } from "./en"; export const de: Record = { "nav.dashboard": "Übersicht", "nav.startup": "Startsicherheit", - "nav.providers": "Anbieter", + "nav.providers": "Anbieter & Konten", "nav.models": "Modelle", "nav.combos": "Combos", "nav.subagents": "Sub-Agenten", @@ -1085,6 +1085,16 @@ export const de: Record = { "pws.allowPrivateNetwork": "Lokales/privates Netzwerk erlauben", "pws.liveModels": "Modelle beim Anbieter erkennen", "pws.liveModelsDesc": "Lädt den Live-Modellkatalog des Anbieters. Ausschalten, um nur konfigurierte statische Modelle zu verwenden.", + "pws.fallback": "Fallback-Anbieter", + "pws.fallbackDesc": "Bei wiederholbaren Fehlern (429, 5xx, Stream-Abbruch) werden diese Ziele der Reihe nach versucht. Leer lassen, um den Fehler an den Client zurückzugeben.", + "pws.fallback.add": "Fallback hinzufügen", + "pws.fallback.provider": "Fallback-Anbieter", + "pws.fallback.model": "Fallback-Modell", + "pws.fallback.pickProvider": "Anbieter wählen", + "pws.fallback.pickModel": "Modell wählen", + "pws.fallback.modelPlaceholder": "Modell-ID", + "pws.fallback.disabled": "{name} (deaktiviert)", + "pws.fallbackIncomplete": "Jede Fallback-Zeile braucht Anbieter und Modell.", "pws.optionalPlaceholder": "Optional", "pws.providerId": "Anbieter-ID", "pws.reauth": "Erneute Anmeldung erforderlich", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 245f1140a..ab701bc40 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -4,7 +4,7 @@ export const en = { // sidebar / nav / common "nav.dashboard": "Dashboard", "nav.startup": "Startup", - "nav.providers": "Providers", + "nav.providers": "Providers & accounts", "nav.models": "Models", "nav.combos": "Combos", "nav.subagents": "Subagents", @@ -861,6 +861,16 @@ export const en = { "pws.allowPrivateNetwork": "Allow local/private network", "pws.liveModels": "Discover models from provider", "pws.liveModelsDesc": "Fetch the provider's live model catalog. Turn this off to use only configured/static models.", + "pws.fallback": "Fallback providers", + "pws.fallbackDesc": "On retryable failures (429, 5xx, stream drop), hop to these targets in order. Leave empty to return the error to the client.", + "pws.fallback.add": "Add fallback", + "pws.fallback.provider": "Fallback provider", + "pws.fallback.model": "Fallback model", + "pws.fallback.pickProvider": "Pick provider", + "pws.fallback.pickModel": "Pick model", + "pws.fallback.modelPlaceholder": "model id", + "pws.fallback.disabled": "{name} (disabled)", + "pws.fallbackIncomplete": "Each fallback row needs both a provider and a model.", "pws.optionalPlaceholder": "Optional", "pws.providerId": "Provider ID", "pws.reauth": "Needs re-auth", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 6ba723664..215cce488 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,7 +4,7 @@ export const ja: Record = { // sidebar / nav / common "nav.dashboard": "ダッシュボード", "nav.startup": "起動安全性", - "nav.providers": "プロバイダー", + "nav.providers": "プロバイダーとアカウント", "nav.models": "モデル", "nav.combos": "コンボ", "nav.subagents": "サブエージェント", @@ -233,6 +233,22 @@ export const ja: Record = { "dash.updateStatus.restarting": "更新をインストールしました。プロキシを再起動中。", "dash.updateStatus.succeeded": "更新が完了しました。", "dash.updateStatus.failed": "更新に失敗しました。", + "dash.suppliers": "プロバイダー", + "dash.refresh": "更新", + "dash.refreshAll": "すべて更新", + "dash.retry": "再試行", + "dash.noContactSince": "{time} から接続がありません", + "dash.fresh": "最新 · {time}", + "dash.modelsCount": "{count} 個のモデル", + "dash.showModels": "モデルを表示", + "dash.hideModels": "モデルを隠す", + "dash.stamp.ready": "準備完了", + "dash.stamp.busy": "処理中", + "dash.stamp.idle": "待機中", + "dash.stamp.error": "エラー", + "dash.settingsSection": "設定", + "dash.emptyKitchen": "まだモデルがありません。", + "dash.providerDisabled": "無効", // providers "prov.subtitle": "opencodex が Codex にルーティングする上流プロバイダーを設定します。アカウントでログインするか、プロバイダーを追加、または生の設定を編集します。", @@ -287,6 +303,10 @@ export const ja: Record = { "prov.added": "\"{name}\" を追加しました。即時反映 — {cmd} を実行(または再起動)して Codex のピッカーにモデルを一覧表示します。", "prov.removeConfirm": "プロバイダー \"{name}\" を削除しますか? そのモデルは Codex のピッカーから消えます。", "prov.hasApiKey": "API キー設定済み", + "prov.quotaRefresh": "使用状況を更新", + "prov.quotaRefreshing": "更新中…", + "prov.quotaRefreshFailed": "更新に失敗しました — 最後に取得したデータを表示しています", + "prov.quotaRefreshAria": "{name} の使用状況を更新", "prov.hasHeaders": "カスタムヘッダー設定済み", "prov.accounts": "アカウント ({n})", "prov.accountsAria": "{name} のアカウントを切り替え", @@ -818,6 +838,16 @@ export const ja: Record = { "pws.allowPrivateNetwork": "ローカル/プライベートネットワークを許可", "pws.liveModels": "プロバイダーからモデルを検出", "pws.liveModelsDesc": "プロバイダーのライブモデルカタログを取得します。オフにすると設定済みの静的モデルのみを使用します。", + "pws.fallback": "フォールバックプロバイダー", + "pws.fallbackDesc": "再試行可能な失敗(429、5xx、ストリーム切断)時に、これらのターゲットを順に試します。空のままにするとエラーをクライアントに返します。", + "pws.fallback.add": "フォールバックを追加", + "pws.fallback.provider": "フォールバックプロバイダー", + "pws.fallback.model": "フォールバックモデル", + "pws.fallback.pickProvider": "プロバイダーを選択", + "pws.fallback.pickModel": "モデルを選択", + "pws.fallback.modelPlaceholder": "モデル ID", + "pws.fallback.disabled": "{name}(無効)", + "pws.fallbackIncomplete": "各フォールバック行にはプロバイダーとモデルの両方が必要です。", "pws.optionalPlaceholder": "任意", "pws.providerId": "プロバイダー ID", "pws.reauth": "再認証が必要", @@ -1384,4 +1414,115 @@ export const ja: Record = { "pws.col.share": "Share", "pws.tokenInput": "Input", "pws.tokenOutput": "Output", + + // fleet shell / modellen / systeem / verkeer (localized view chrome) + "shell.navTraffic": "トラフィック", + "shell.navSystem": "システム", + "shell.navAria": "メインナビゲーション", + "shell.offlineBanner": "プロキシがオフラインです。Codex と Cursor はルーティングできません。", + "mod.subtitle": "カタログ、ルーティング、サブエージェントへの委譲。", + "mod.tablistAria": "モデルのセクション", + "sys.manage": "管理", + "sys.subtitle": "プロキシ本体:ステータス、バージョン、ストレージ、管理。", + "sys.proxy": "プロキシ", + "sys.uptime": "{uptime} 稼働", + "sys.updateTo": "{version} に更新", + "sys.upToDate": "最新バージョンを使用しています。", + "sys.updateCheckFailed": "更新チェックに失敗しました", + "sys.updateStartFailed": "更新の開始に失敗しました", + "sys.updateRunning": "更新を実行中です。プロキシはまもなく自動的に再起動します。", + "sys.catalog": "モデルカタログ", + "sys.catalogDesc": "同期は各プロバイダーから最新モデルを取得します。", + "sys.syncFailed": "同期に失敗しました", + "sys.storage": "ストレージ", + "sys.storageValue": "{size} · {count} ファイル", + "sys.apiEndpoint": "API エンドポイント", + "sys.copy": "コピー", + "sys.copied": "コピーしました", + "sys.codexAuthDesc": "openai プロバイダー用の ChatGPT アカウントプール", + "sys.claudeCodeDesc": "Claude 連携とエージェント注入", + "sys.dangerZone": "危険ゾーン", + "sys.stopDesc": "プロキシを完全に停止します。Codex と Cursor は接続を失います。", + "sys.stopConfirmTitle": "プロキシを停止しますか?", + "sys.stopConfirmDesc": "Codex と Cursor は接続を失います。", + "sys.keepRunning": "稼働したままにする", + "vk.subtitle": "プロキシを通過するもの:すべてのリクエストは伝票です。", + "vk.statsAria": "トラフィックの数値", + "vk.tokens30d": "トークン (30日)", + "vk.requestsToday": "本日のリクエスト", + "vk.requests30d": "リクエスト (30日)", + "vk.filterAria": "プロバイダーで絞り込み", + "vk.all": "すべて", + "vk.pause": "一時停止", + "vk.follow": "ライブで追う", + "vk.loadFailed": "トラフィックを読み込めません。最後に確認された伝票が残ります。", + "vk.empty": "本日はまだトラフィックがありません。", + "vk.showAnalysis": "詳細分析", + "vk.hideAnalysis": "分析を隠す", + "vk.stampDone": "完了", + "vk.stampError": "エラー", + "vk.stampBusy": "処理中", + "vk.detailError": "エラー: {code}", + "vk.detailInOut": "入力 {in} · 出力 {out}", + "vk.rowTokens": "{n} トークン", + "vk.rowDuration": "{s} 秒", + "vk.detailStatus": "ステータス {status}", + "vk.detailUpstream": "アップストリーム: {error}", + "vk.detailId": "ID {id}", + "claude.tabsLabel": "Claude クライアント", + "claude.tabCode": "Code", + "claude.tabDesktop": "Desktop", + "claudeDesktop.title": "Claude Desktop", + "claudeDesktop.subtitle": "各 Claude モデルファミリーをポート {port} の利用可能なモデルにルーティングします。", + "claudeDesktop.importJson": "JSON をインポート", + "claudeDesktop.exportJson": "JSON をエクスポート", + "claudeDesktop.loading": "Claude Desktop プロファイルを読み込み中…", + "claudeDesktop.loadFail": "Claude Desktop プロファイルの読み込みに失敗しました。", + "claudeDesktop.retry": "再試行", + "claudeDesktop.saveFailed": "Claude Desktop プロファイルの保存に失敗しました。", + "claudeDesktop.applyFailed": "プロファイルは保存されましたが、適用できませんでした。", + "claudeDesktop.updateFailed": "Claude Desktop の更新に失敗しました。", + "claudeDesktop.savedApplied": "プロファイルを保存し、Claude Desktop に適用しました。", + "claudeDesktop.savedAppliedAnnounce": "Claude Desktop プロファイルを保存して適用しました。", + "claudeDesktop.saved": "プロファイルを保存しました。", + "claudeDesktop.savedAnnounce": "Claude Desktop プロファイルを保存しました。", + "claudeDesktop.exported": "プロファイルを JSON としてエクスポートしました。", + "claudeDesktop.importExpected": "バージョン 1 の Claude Desktop プロファイルが必要です。", + "claudeDesktop.importReady": "JSON をインポートしました。ドラフトを確認してから保存して適用してください。", + "claudeDesktop.importedAnnounce": "プロファイル JSON をインポートしました。未保存の変更を確認できます。", + "claudeDesktop.importInvalid": "選択したファイルは有効なプロファイルではありません。", + "claudeDesktop.importFailed": "インポートに失敗しました。{error}", + "claudeDesktop.moved": "{route} を {family} に移動しました。", + "claudeDesktop.unsaved": "未保存の変更", + "claudeDesktop.upToDate": "プロファイルは最新です", + "claudeDesktop.saving": "保存中…", + "claudeDesktop.applying": "適用中…", + "claudeDesktop.saveApply": "保存して適用", + "claudeDesktop.emptyTitle": "利用可能なモデルがありません", + "claudeDesktop.emptyHint": "プロバイダーを追加または有効化してから、戻って Claude Desktop のルートを割り当ててください。", + "claudeDesktop.assignmentsLabel": "Claude モデルファミリーの割り当て", + "claudeDesktop.family.opus": "Opus", + "claudeDesktop.family.fable": "Fable", + "claudeDesktop.family.sonnet": "Sonnet", + "claudeDesktop.family.haiku": "Haiku", + "claudeDesktop.modelCountOne": "{count} 個のモデル", + "claudeDesktop.modelCountMany": "{count} 個のモデル", + "claudeDesktop.chooseDefault": "デフォルトを選択", + "claudeDesktop.temporaryDefault": "一時的なデフォルト", + "claudeDesktop.laneEmpty": "ここにモデルをドロップするか、移動コントロールを使用してください。", + "claudeDesktop.available": "利用可能", + "claudeDesktop.unavailable": "利用不可", + "claudeDesktop.contextM": "コンテキスト {n}M", + "claudeDesktop.contextK": "コンテキスト {n}k", + "claudeDesktop.alias": "エイリアス", + "claudeDesktop.useAsDefault": "{family} のデフォルトとして使用", + "claudeDesktop.moveTo": "移動先", + "claudeDesktop.move": "移動", + "claudeDesktop.status.applied": "Desktop に適用済み", + "claudeDesktop.status.stale": "設定が古くなっています。再適用してください", + "claudeDesktop.status.notApplied": "未適用", + "claudeDesktop.health.lastRequest": "最後のリクエスト", + "claudeDesktop.health.stats": "{count} リクエスト / {errors} エラー", + "claudeDesktop.effort.supported": "effort", + "claudeDesktop.effort.displayOnly": "effort (表示のみ)", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 3fca1a12b..79616f43f 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,7 +4,7 @@ export const ko: Record = { // sidebar / nav / common "nav.dashboard": "대시보드", "nav.startup": "시작 안전성", - "nav.providers": "프로바이더", + "nav.providers": "프로바이더 및 계정", "nav.models": "모델", "nav.combos": "콤보", "nav.subagents": "서브에이전트", @@ -1105,6 +1105,16 @@ export const ko: Record = { "pws.allowPrivateNetwork": "로컬/사설 네트워크 허용", "pws.liveModels": "프로바이더에서 모델 검색", "pws.liveModelsDesc": "프로바이더의 실시간 모델 카탈로그를 가져옵니다. 끄면 설정된 정적 모델만 사용합니다.", + "pws.fallback": "폴백 프로바이더", + "pws.fallbackDesc": "재시도 가능한 실패(429, 5xx, 스트림 중단) 시 이 대상을 순서대로 시도합니다. 비워 두면 오류를 클라이언트에 반환합니다.", + "pws.fallback.add": "폴백 추가", + "pws.fallback.provider": "폴백 프로바이더", + "pws.fallback.model": "폴백 모델", + "pws.fallback.pickProvider": "프로바이더 선택", + "pws.fallback.pickModel": "모델 선택", + "pws.fallback.modelPlaceholder": "모델 ID", + "pws.fallback.disabled": "{name} (비활성)", + "pws.fallbackIncomplete": "각 폴백 행에는 프로바이더와 모델이 모두 필요합니다.", "pws.optionalPlaceholder": "선택사항", "pws.providerId": "프로바이더 ID", "pws.reauth": "재인증 필요", diff --git a/gui/src/i18n/nl.ts b/gui/src/i18n/nl.ts new file mode 100644 index 000000000..1bd7386e1 --- /dev/null +++ b/gui/src/i18n/nl.ts @@ -0,0 +1,200 @@ +// Dutch — ChefGroep "De Pas" voice (chefgroep-vault/.ulpi/design/DESIGN.md). +// Spreads en so pages without a translation fall back to English; the Joep-facing +// dashboard, nav, and shared chrome are overridden below in keukenregister. +import { en, type TKey } from "./en"; + +const overrides: Partial> = { + // sidebar / nav + "nav.dashboard": "De pas", + "nav.providers": "Leveranciers & accounts", + "nav.models": "Modellen", + "nav.combos": "Combos", + "nav.subagents": "Sub-agents", + "nav.logs": "Logs & debug", + "nav.usage": "Verbruik", + "nav.storage": "Opslag", + "nav.codexAuth": "Codex-login", + "nav.api": "API", + "nav.claude": "Claude", + "nav.openMenu": "Open menu", + "nav.closeMenu": "Sluit menu", + + // common + "common.github": "GitHub", + "common.save": "Bewaar", + "common.saving": "Bewaren…", + "common.cancel": "Annuleer", + "common.discard": "Gooi weg", + "common.close": "Sluit", + "common.ok": "OK", + "common.remove": "Verwijder", + "common.loading": "Laden…", + + // theme / language + "theme.label": "Weergave", + "theme.light": "Licht", + "theme.dark": "Donker", + "theme.system": "Systeem", + "lang.label": "Taal", + + // dashboard + "dash.subtitle": "Live status van de OpenCodex-proxy, z'n leveranciers en de modellen richting Codex.", + "dash.status": "Status", + "dash.online": "Online", + "dash.offline": "Offline", + "dash.version": "Versie", + "dash.uptime": "Draait", + "dash.providers": "Leveranciers", + "dash.tokens30d": "Tokens (30d)", + "dash.coverage": "{pct} dekking", + "dash.activeProviders": "Leveranciers", + "dash.noProviders": "Nog geen leveranciers. Draai {cmd}.", + "dash.col.name": "Naam", + "dash.col.adapter": "Adapter", + "dash.col.baseUrl": "Base-URL", + "dash.col.model": "Model", + "dash.availableModels": "Beschikbare modellen", + "dash.noModels": "Nog geen modellen. Check de API-keys per leverancier.", + "dash.cannotConnect": "Geen contact met de proxy.", + "dash.runStart": "Start de proxy met {cmd}.", + "dash.stop": "Stop proxy", + "dash.stopConfirm": "Proxy stoppen en Codex weer native laten draaien?", + "dash.stopping": "Stoppen…", + "dash.codexAutoStart": "Start OpenCodex met Codex mee", + "dash.codexAutoStartHint": "De Codex-shim draait ocx ensure voor elke start van Codex CLI/App. Zet uit om Codex met rust te laten.", + "dash.webSearchSidecar": "Web search sidecar", + "dash.webSearchSidecarHint": "Model dat web_search afhandelt voor geroute modellen.", + "dash.visionSidecar": "Vision sidecar", + "dash.visionSidecarHint": "Model dat afbeeldingen beschrijft voor tekst-only geroute modellen.", + "dash.shadowCallIntercept": "Shadow call intercept", + "dash.shadowCallInterceptHint": "Onderschept de gpt-5.4-mini achtergrondcalls van Codex App (titels, commit-berichten) en stuurt ze naar jouw model. Effort staat vast op low.", + "dash.shadowCallModel": "Vervangend model", + "dash.shadowCallTooltip": "Codex App gebruikt gpt-5.4-mini op de achtergrond voor threadtitels, commit-berichten en skill-orkestratie. Zet aan om die calls naar jouw model te sturen.", + "dash.sidecarModel": "Model", + "dash.injectionLabel": "Sub-agent delegatie", + "dash.injectionHint": "Kies een geroute model voor de delegatieprompt. De agent gebruikt het voor sub-taken.", + "dash.injectionActive": "Actief", + "dash.injectionNone": "Geen", + "dash.injectionEffortLabel": "Reasoning effort", + "dash.injectionEffortNone": "Modelstandaard", + "dash.effortCapLabel": "V2 ultra effort-limiet", + "dash.subagentEffortCapLabel": "V2 sub-agent effort-limiet", + "dash.effortCapHelp": "Begrenst de reasoning effort voor V2 ultra-beurten. Binnenkomende max-effort verzoeken worden afgetopt op het gekozen niveau. De sub-agent limiet geldt alleen voor gestarte child-agents. Limieten verlagen alleen, nooit verhogen. Steunt een model het niveau niet, dan zakt het naar het dichtstbijzijnde wel gesteunde niveau.", + "dash.effortCapNone": "Geen limiet", + "dash.maintenance": "Onderhoud", + "dash.maintenanceHint": "Ververs de modelcatalogus van Codex of installeer een nieuwere OpenCodex.", + "dash.syncModels": "Sync modellen", + "dash.syncing": "Syncen…", + "dash.syncOk": "Sync klaar. {count} model(len) toegevoegd.", + "dash.syncStaleHint": "Ziet Codex App nog een oude lijst? Herstart z'n app-server proces.", + "dash.syncFailed": "Sync mislukt: {error}", + "dash.projectConfigTitle": "Project-config omzeilt OpenCodex", + "dash.projectConfigHint": "Deze repo-lokale settings gaan om de OpenCodex-proxy heen. Haal ze weg zodat de routing uit ~/.codex/config.toml daar weer geldt.", + "dash.checkUpdate": "Check update", + "dash.updateTitle": "OpenCodex bijwerken", + "dash.updateDesc": "Checkt npm voor het gekozen kanaal. Kies daarna of de proxy na installatie herstart.", + "dash.updateChannel": "Kanaal", + "dash.updateChecking": "Zoeken naar updates…", + "dash.updateInstalled": "Geïnstalleerd", + "dash.updateLatest": "Nieuwste", + "dash.updateAvailable": "Update beschikbaar", + "dash.updateCurrent": "Bij de tijd", + "dash.updateCommand": "Commando", + "dash.updateSource": "Dit is een source checkout. Werk bij via de terminal met het getoonde commando.", + "dash.updateUnavailable": "Kon de nieuwste versie niet van npm lezen. Probeer het straks opnieuw.", + "dash.updateRetry": "Opnieuw", + "dash.updateRecheck": "Check opnieuw", + "dash.updateCannotAuto": "Bijwerken met één klik kan niet ({reason}).", + "dash.updateReason.source_checkout": "source checkout", + "dash.updateReason.latest_unavailable": "npm-registry onbereikbaar", + "dash.updateReason.already_latest": "al op de nieuwste", + "dash.updateReason.unknown": "update niet beschikbaar", + "dash.updateRestart": "Herstart na update", + "dash.updateRestartHint": "Aangeraden. De GUI blijft op de oude code draaien tot de proxy herstart.", + "dash.runUpdate": "Werk bij", + "dash.updateReconnecting": "Wachten op de herstarte proxy…", + "dash.updateStatus.running": "OpenCodex wordt bijgewerkt.", + "dash.updateStatus.restarting": "Update geïnstalleerd. Proxy herstart.", + "dash.updateStatus.succeeded": "Update klaar.", + "dash.updateStatus.failed": "Update mislukt.", + "dash.multiAgent": "Sub-agent", + + // per-provider fetch (supplier cards) + "dash.suppliers": "Leveranciers", + "dash.refresh": "Ververs", + "dash.refreshAll": "Ververs alles", + "dash.retry": "Opnieuw", + "dash.fresh": "Vers · {time}", + "dash.noContactSince": "Geen contact sinds {time}", + "dash.modelsCount": "{count} modellen", + "dash.showModels": "Toon modellen", + "dash.hideModels": "Verberg modellen", + "dash.stamp.ready": "KLAAR", + "dash.stamp.busy": "BEZIG", + "dash.stamp.error": "FOUT", + "dash.stamp.idle": "STIL", + "dash.settingsSection": "Instellingen", + "dash.emptyKitchen": "Nog stil in de keuken.", + "dash.providerDisabled": "Uitgeschakeld", + + // sidebar chrome + "app.claudeOn": "Claude AAN", + "app.claudeOff": "Claude UIT", + + // fleet shell / modellen / systeem / verkeer (localized view chrome) + "shell.navTraffic": "Verkeer", + "shell.navSystem": "Systeem", + "shell.navAria": "Hoofdnavigatie", + "shell.offlineBanner": "Proxy offline. Codex en Cursor kunnen niet routeren.", + "mod.subtitle": "Catalogus, routing en delegatie voor sub-agents.", + "mod.tablistAria": "Modellen-onderdelen", + "sys.manage": "Beheer", + "sys.subtitle": "De proxy zelf: status, versie, opslag en beheer.", + "sys.proxy": "Proxy", + "sys.uptime": "{uptime} in dienst", + "sys.updateTo": "Update naar {version}", + "sys.upToDate": "Je draait de nieuwste versie.", + "sys.updateCheckFailed": "update check mislukt", + "sys.updateStartFailed": "update starten mislukt", + "sys.updateRunning": "Update draait. De proxy herstart zichzelf zo.", + "sys.catalog": "Modellencatalogus", + "sys.catalogDesc": "Sync haalt de nieuwste modellen op bij elke leverancier.", + "sys.syncFailed": "sync mislukt", + "sys.storage": "Opslag", + "sys.storageValue": "{size} · {count} bestanden", + "sys.apiEndpoint": "API-endpoint", + "sys.copy": "Kopieer", + "sys.copied": "Gekopieerd", + "sys.codexAuthDesc": "ChatGPT-accountpool voor de openai-leverancier", + "sys.claudeCodeDesc": "Claude-integratie en agent-injectie", + "sys.dangerZone": "Gevarenzone", + "sys.stopDesc": "Stopt de proxy volledig. Codex en Cursor verliezen hun verbinding.", + "sys.stopConfirmTitle": "Proxy stoppen?", + "sys.stopConfirmDesc": "Codex en Cursor verliezen hun verbinding.", + "sys.keepRunning": "Laat draaien", + "vk.subtitle": "Wat er door de proxy gaat: elke request is een bon.", + "vk.statsAria": "Verkeerscijfers", + "vk.tokens30d": "tokens (30d)", + "vk.requestsToday": "requests vandaag", + "vk.requests30d": "requests (30d)", + "vk.filterAria": "Filter op provider", + "vk.all": "Alles", + "vk.pause": "Pauzeer", + "vk.follow": "Volg live", + "vk.loadFailed": "Verkeer laden lukt niet. Laatste bekende bonnen blijven staan.", + "vk.empty": "Nog geen verkeer vandaag.", + "vk.showAnalysis": "Volledige analyse", + "vk.hideAnalysis": "Verberg analyse", + "vk.stampDone": "Klaar", + "vk.stampError": "Fout", + "vk.stampBusy": "Bezig", + "vk.detailError": "fout: {code}", + "vk.detailInOut": "in {in} · uit {out}", + "vk.rowTokens": "{n} tok", + "vk.rowDuration": "{s}s", + "vk.detailStatus": "status {status}", + "vk.detailUpstream": "upstream: {error}", + "vk.detailId": "id {id}", +}; + +export const nl: Record = { ...en, ...overrides }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index efbaf53a5..6a9018ddc 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,7 +4,7 @@ export const ru: Record = { // sidebar / nav / common "nav.dashboard": "Дашборд", "nav.startup": "Безопасность запуска", - "nav.providers": "Провайдеры", + "nav.providers": "Провайдеры и аккаунты", "nav.models": "Модели", "nav.combos": "Комбо", "nav.subagents": "Подагенты", @@ -233,6 +233,22 @@ export const ru: Record = { "dash.updateStatus.restarting": "Обновление установлено. Перезапуск прокси.", "dash.updateStatus.succeeded": "Обновление завершено.", "dash.updateStatus.failed": "Обновление не удалось.", + "dash.suppliers": "Провайдеры", + "dash.refresh": "Обновить", + "dash.refreshAll": "Обновить все", + "dash.retry": "Повторить", + "dash.fresh": "Свежо · {time}", + "dash.noContactSince": "Нет связи с {time}", + "dash.modelsCount": "{count} моделей", + "dash.showModels": "Показать модели", + "dash.hideModels": "Скрыть модели", + "dash.stamp.ready": "ГОТОВО", + "dash.stamp.busy": "В РАБОТЕ", + "dash.stamp.error": "ОШИБКА", + "dash.stamp.idle": "ТИХО", + "dash.settingsSection": "Настройки", + "dash.emptyKitchen": "Моделей пока нет.", + "dash.providerDisabled": "Отключён", // providers "prov.subtitle": "Настройте вышестоящих провайдеров, которых opencodex маршрутизирует в Codex. Войдите в аккаунт, добавьте провайдера или отредактируйте конфигурацию вручную.", @@ -294,6 +310,10 @@ export const ru: Record = { "prov.hasApiKey": "API-ключ настроен", "prov.hasHeaders": "настроены пользовательские заголовки", "prov.accounts": "Аккаунты ({n})", + "prov.quotaRefresh": "Обновить использование", + "prov.quotaRefreshing": "Обновление…", + "prov.quotaRefreshFailed": "Не удалось обновить — показаны последние данные", + "prov.quotaRefreshAria": "Обновить использование для {name}", "prov.accountsAria": "Показать или скрыть аккаунты {name}", "prov.accountActive": "Активен", "prov.accountReauth": "Повторный вход", @@ -860,6 +880,16 @@ export const ru: Record = { "pws.allowPrivateNetwork": "Разрешить локальную/частную сеть", "pws.liveModels": "Обнаруживать модели провайдера", "pws.liveModelsDesc": "Загружать актуальный каталог моделей провайдера. Выключите, чтобы использовать только настроенные статические модели.", + "pws.fallback": "Резервные провайдеры", + "pws.fallbackDesc": "При повторяемых сбоях (429, 5xx, обрыв потока) цели пробуются по порядку. Оставьте пустым, чтобы вернуть ошибку клиенту.", + "pws.fallback.add": "Добавить резерв", + "pws.fallback.provider": "Резервный провайдер", + "pws.fallback.model": "Резервная модель", + "pws.fallback.pickProvider": "Выберите провайдера", + "pws.fallback.pickModel": "Выберите модель", + "pws.fallback.modelPlaceholder": "id модели", + "pws.fallback.disabled": "{name} (отключён)", + "pws.fallbackIncomplete": "В каждой строке резерва нужны и провайдер, и модель.", "pws.optionalPlaceholder": "Необязательно", "pws.providerId": "ID провайдера", "pws.reauth": "Нужна переавторизация", @@ -1385,5 +1415,116 @@ export const ru: Record = { "cws.err.invalidWeight": "Каждый вес round-robin должен быть целым числом от 1 до 10000.", "cws.err.noEnabledTarget": "Хотя бы одна цель должна использовать включённого провайдера.", + // fleet shell / modellen / systeem / verkeer (localized view chrome) + "shell.navTraffic": "Трафик", + "shell.navSystem": "Система", + "shell.navAria": "Основная навигация", + "shell.offlineBanner": "Прокси офлайн. Codex и Cursor не могут маршрутизировать.", + "mod.subtitle": "Каталог, маршрутизация и делегирование для суб-агентов.", + "mod.tablistAria": "Разделы моделей", + "sys.manage": "Управление", + "sys.subtitle": "Сам прокси: статус, версия, хранилище и управление.", + "sys.proxy": "Прокси", + "sys.uptime": "{uptime} в работе", + "sys.updateTo": "Обновить до {version}", + "sys.upToDate": "У вас установлена последняя версия.", + "sys.updateCheckFailed": "не удалось проверить обновление", + "sys.updateStartFailed": "не удалось запустить обновление", + "sys.updateRunning": "Обновление выполняется. Прокси скоро перезапустится.", + "sys.catalog": "Каталог моделей", + "sys.catalogDesc": "Синхронизация загружает последние модели от каждого провайдера.", + "sys.syncFailed": "синхронизация не удалась", + "sys.storage": "Хранилище", + "sys.storageValue": "{size} · файлов: {count}", + "sys.apiEndpoint": "API-эндпоинт", + "sys.copy": "Копировать", + "sys.copied": "Скопировано", + "sys.codexAuthDesc": "Пул аккаунтов ChatGPT для провайдера openai", + "sys.claudeCodeDesc": "Интеграция Claude и внедрение агента", + "sys.dangerZone": "Опасная зона", + "sys.stopDesc": "Полностью останавливает прокси. Codex и Cursor теряют соединение.", + "sys.stopConfirmTitle": "Остановить прокси?", + "sys.stopConfirmDesc": "Codex и Cursor теряют соединение.", + "sys.keepRunning": "Оставить работать", + "vk.subtitle": "Что проходит через прокси: каждый запрос — это чек.", + "vk.statsAria": "Показатели трафика", + "vk.tokens30d": "токены (30д)", + "vk.requestsToday": "запросов сегодня", + "vk.requests30d": "запросов (30д)", + "vk.filterAria": "Фильтр по провайдеру", + "vk.all": "Все", + "vk.pause": "Пауза", + "vk.follow": "Следить вживую", + "vk.loadFailed": "Не удаётся загрузить трафик. Последние известные чеки остаются.", + "vk.empty": "Сегодня трафика ещё нет.", + "vk.showAnalysis": "Полный анализ", + "vk.hideAnalysis": "Скрыть анализ", + "vk.stampDone": "Готово", + "vk.stampError": "Ошибка", + "vk.stampBusy": "Выполняется", + "vk.detailError": "ошибка: {code}", + "vk.detailInOut": "вход {in} · выход {out}", + "vk.rowTokens": "{n} ток", + "vk.rowDuration": "{s} с", + "vk.detailStatus": "статус {status}", + "vk.detailUpstream": "upstream: {error}", + "vk.detailId": "id {id}", + + "claude.tabsLabel": "Клиент Claude", + "claude.tabCode": "Code", + "claude.tabDesktop": "Desktop", + "claudeDesktop.title": "Claude Desktop", + "claudeDesktop.subtitle": "Направляйте каждое семейство моделей Claude через доступную модель на порту {port}.", + "claudeDesktop.importJson": "Импорт JSON", + "claudeDesktop.exportJson": "Экспорт JSON", + "claudeDesktop.loading": "Загрузка профиля Claude Desktop…", + "claudeDesktop.loadFail": "Не удалось загрузить профиль Claude Desktop.", + "claudeDesktop.retry": "Повторить", + "claudeDesktop.saveFailed": "Не удалось сохранить профиль Claude Desktop.", + "claudeDesktop.applyFailed": "Профиль сохранён, но применить его не удалось.", + "claudeDesktop.updateFailed": "Не удалось обновить Claude Desktop.", + "claudeDesktop.savedApplied": "Профиль сохранён и применён к Claude Desktop.", + "claudeDesktop.savedAppliedAnnounce": "Профиль Claude Desktop сохранён и применён.", + "claudeDesktop.saved": "Профиль сохранён.", + "claudeDesktop.savedAnnounce": "Профиль Claude Desktop сохранён.", + "claudeDesktop.exported": "Профиль экспортирован в JSON.", + "claudeDesktop.importExpected": "Ожидается профиль Claude Desktop версии 1.", + "claudeDesktop.importReady": "JSON импортирован. Проверьте черновик, затем сохраните и примените его.", + "claudeDesktop.importedAnnounce": "JSON профиля импортирован. Несохранённые изменения готовы к проверке.", + "claudeDesktop.importInvalid": "Выбранный файл не является допустимым профилем.", + "claudeDesktop.importFailed": "Импорт не удался. {error}", + "claudeDesktop.moved": "{route} перемещён в {family}.", + "claudeDesktop.unsaved": "Несохранённые изменения", + "claudeDesktop.upToDate": "Профиль актуален", + "claudeDesktop.saving": "Сохранение…", + "claudeDesktop.applying": "Применение…", + "claudeDesktop.saveApply": "Сохранить и применить", + "claudeDesktop.emptyTitle": "Нет доступных моделей", + "claudeDesktop.emptyHint": "Добавьте или включите провайдера, затем вернитесь, чтобы назначить маршруты Claude Desktop.", + "claudeDesktop.assignmentsLabel": "Назначения семейств моделей Claude", + "claudeDesktop.family.opus": "Opus", + "claudeDesktop.family.fable": "Fable", + "claudeDesktop.family.sonnet": "Sonnet", + "claudeDesktop.family.haiku": "Haiku", + "claudeDesktop.modelCountOne": "{count} модель", + "claudeDesktop.modelCountMany": "{count} моделей", + "claudeDesktop.chooseDefault": "Выберите модель по умолчанию", + "claudeDesktop.temporaryDefault": "Временная модель по умолчанию", + "claudeDesktop.laneEmpty": "Перетащите модель сюда или используйте элемент перемещения.", + "claudeDesktop.available": "Доступно", + "claudeDesktop.unavailable": "Недоступно", + "claudeDesktop.contextM": "Контекст {n}M", + "claudeDesktop.contextK": "Контекст {n}k", + "claudeDesktop.alias": "Псевдоним", + "claudeDesktop.useAsDefault": "Использовать как модель по умолчанию для {family}", + "claudeDesktop.moveTo": "Переместить в", + "claudeDesktop.move": "Переместить", + "claudeDesktop.status.applied": "Применено к Desktop", + "claudeDesktop.status.stale": "Конфигурация устарела. Примените заново", + "claudeDesktop.status.notApplied": "Не применено", + "claudeDesktop.health.lastRequest": "Последний запрос", + "claudeDesktop.health.stats": "{count} запр. / {errors} ошиб.", + "claudeDesktop.effort.supported": "effort", + "claudeDesktop.effort.displayOnly": "effort (только отображение)", }; diff --git a/gui/src/i18n/shared.ts b/gui/src/i18n/shared.ts index 1579a0203..3b875265f 100644 --- a/gui/src/i18n/shared.ts +++ b/gui/src/i18n/shared.ts @@ -1,17 +1,19 @@ import { createContext, useContext } from "react"; import { en, type TKey } from "./en"; +import { nl } from "./nl"; import { de } from "./de"; import { ko } from "./ko"; import { zh } from "./zh"; import { ru } from "./ru"; import { ja } from "./ja"; -export type Locale = "en" | "de" | "ko" | "zh" | "ru" | "ja"; +export type Locale = "en" | "nl" | "de" | "ko" | "zh" | "ru" | "ja"; export type { TKey }; -export const DICTS: Record> = { en, de, ko, zh, ru, ja }; +export const DICTS: Record> = { en, nl, de, ko, zh, ru, ja }; export const LOCALES: { code: Locale; name: string; htmlLang: string }[] = [ + { code: "nl", name: "Nederlands", htmlLang: "nl" }, { code: "en", name: "English", htmlLang: "en" }, { code: "de", name: "Deutsch", htmlLang: "de" }, { code: "ko", name: "한국어", htmlLang: "ko" }, @@ -25,15 +27,18 @@ const LANG_KEY = "ocx-lang"; export function detectInitial(): Locale { try { const stored = localStorage.getItem(LANG_KEY); - if (stored === "en" || stored === "de" || stored === "ko" || stored === "zh" || stored === "ru" || stored === "ja") return stored; + if (stored === "en" || stored === "nl" || stored === "de" || stored === "ko" || stored === "zh" || stored === "ru" || stored === "ja") return stored; } catch { /* ignore */ } - const nav = typeof navigator !== "undefined" ? navigator.language.toLowerCase() : "en"; + const nav = typeof navigator !== "undefined" ? navigator.language.toLowerCase() : "nl"; + if (nav.startsWith("en")) return "en"; if (nav.startsWith("de")) return "de"; if (nav.startsWith("ko")) return "ko"; if (nav.startsWith("zh")) return "zh"; if (nav.startsWith("ru")) return "ru"; if (nav.startsWith("ja")) return "ja"; - return "en"; + // ChefGroep host build: Joep-facing copy is Dutch by default (DESIGN.md voice-kit). + // The language switcher in the sidebar still offers English and the rest. + return "nl"; } export type Vars = Record; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index fc6aa774f..c19b81be6 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,7 +4,7 @@ export const zh: Record = { // sidebar / nav / common "nav.dashboard": "仪表盘", "nav.startup": "启动安全", - "nav.providers": "提供方", + "nav.providers": "提供方与账户", "nav.models": "模型", "nav.combos": "组合", "nav.subagents": "子代理", @@ -1105,6 +1105,16 @@ export const zh: Record = { "pws.allowPrivateNetwork": "允许本地/私有网络", "pws.liveModels": "从提供方发现模型", "pws.liveModelsDesc": "获取提供方的实时模型目录。关闭后仅使用已配置的静态模型。", + "pws.fallback": "备用提供方", + "pws.fallbackDesc": "遇到可重试失败(429、5xx、流中断)时按顺序尝试这些目标。留空则将错误返回给客户端。", + "pws.fallback.add": "添加备用", + "pws.fallback.provider": "备用提供方", + "pws.fallback.model": "备用模型", + "pws.fallback.pickProvider": "选择提供方", + "pws.fallback.pickModel": "选择模型", + "pws.fallback.modelPlaceholder": "模型 ID", + "pws.fallback.disabled": "{name}(已禁用)", + "pws.fallbackIncomplete": "每一行备用都需要同时填写提供方和模型。", "pws.optionalPlaceholder": "可选", "pws.providerId": "提供商 ID", "pws.reauth": "需要重新认证", diff --git a/gui/src/icons.tsx b/gui/src/icons.tsx index 563dbc6a9..e896f86f8 100644 --- a/gui/src/icons.tsx +++ b/gui/src/icons.tsx @@ -41,6 +41,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..563e71b7c --- /dev/null +++ b/gui/src/pages/Instellingen.tsx @@ -0,0 +1,68 @@ +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 0a369195b..008304309 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -213,6 +213,12 @@ export default function Providers({ apiBase }: { apiBase: string }) { modelUsage={data.modelUsage} quotaReport={data.quotaReport} availableModels={data.availableModels} + peerProviders={Object.entries(config.providers).map(([name, p]) => ({ + name, + disabled: p.disabled, + models: p.models, + defaultModel: p.defaultModel, + }))} hasLiveModels={data.hasLiveModels} selectedModels={data.selectedModels} modelsLoading={data.modelsLoading} diff --git a/gui/src/pages/Systeem.tsx b/gui/src/pages/Systeem.tsx new file mode 100644 index 000000000..17af335fe --- /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 { 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 res.json() as UpdateCheckData & { error?: string }; + if (!res.ok) throw new Error(data.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 res.json() as { job?: unknown; error?: string }; + if (!res.ok || !data.job) throw new Error(data.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 res.json() as { added?: number; message?: string; error?: string }; + if (!res.ok) throw new Error(data.error ?? 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..cf2822901 --- /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].sort((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 34ebbc825..ecd1d0699 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -14,6 +14,7 @@ export interface ProvidersConfig { disabled?: boolean; note?: string; codexAccountMode?: "direct" | "pool"; + fallback?: Array<{ provider: string; model: string }>; }>; } diff --git a/gui/src/posthog.ts b/gui/src/posthog.ts new file mode 100644 index 000000000..a40011884 --- /dev/null +++ b/gui/src/posthog.ts @@ -0,0 +1,70 @@ +import posthog from "posthog-js"; + +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); +} + +/** Known hash routes; anything else is treated as sensitive/unknown and dropped. */ +const KNOWN_HASH_ROUTES = new Set([ + "dashboard", + "providers", + "providers/workspace", + "models", + "combos", + "subagents", + "logs", + "logs/debug", + "usage", + "storage", + "codex-auth", + "api", + "claude", +]); + +/** 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. */ +function sanitizedCurrentUrl(loc: Location): string { + const base = `${loc.origin}${loc.pathname}`; + const hashRoute = loc.hash.replace(/^#\/?(.*)$/, "$1").replace(/^\/+/, ""); + return KNOWN_HASH_ROUTES.has(hashRoute) ? `${base}#${hashRoute}` : base; +} + +/** 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/provider-workspace/catalog.ts b/gui/src/provider-workspace/catalog.ts index b2568780d..20ad0be22 100644 --- a/gui/src/provider-workspace/catalog.ts +++ b/gui/src/provider-workspace/catalog.ts @@ -44,6 +44,11 @@ export interface WorkspaceProvider { disabled?: boolean; note?: string; allowPrivateNetwork?: boolean; + /** + * Ordered failover targets for plain (non-combo) requests to this provider. + * Empty/omitted = no hop; failures return to the caller. + */ + fallback?: Array<{ provider: string; model: string }>; } /** Three-way pricing/ownership tier for a ready provider row. */ diff --git a/gui/src/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-quota.css b/gui/src/styles/provider-quota.css index 4b116d366..d21638b05 100644 --- a/gui/src/styles/provider-quota.css +++ b/gui/src/styles/provider-quota.css @@ -69,3 +69,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-settings.css b/gui/src/styles/provider-workspace-settings.css index 8da4fc545..a7fca4f18 100644 --- a/gui/src/styles/provider-workspace-settings.css +++ b/gui/src/styles/provider-workspace-settings.css @@ -90,6 +90,14 @@ .pwi-settings-textarea:focus { border-color: var(--accent-ring); outline: none; } .pwi-settings-hint { font-size: var(--text-caption); color: var(--muted); line-height: 1.4; } +.pwi-fallback-list { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; } +.pwi-fallback-row { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) auto; + gap: 8px; + align-items: center; +} + .pwi-settings-sticky-bar { position: sticky; bottom: 0; z-index: 2; display: flex; align-items: center; gap: 8px; diff --git a/gui/src/styles/provider-workspace-shell.css b/gui/src/styles/provider-workspace-shell.css index 1dd495b79..09cccb5ec 100644 --- a/gui/src/styles/provider-workspace-shell.css +++ b/gui/src/styles/provider-workspace-shell.css @@ -977,6 +977,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/dashboard-tabs.test.ts b/gui/tests/dashboard-tabs.test.ts index 741ea2d0b..1a1a59821 100644 --- a/gui/tests/dashboard-tabs.test.ts +++ b/gui/tests/dashboard-tabs.test.ts @@ -42,12 +42,13 @@ test("registering Dashboard tabs does not disturb the Logs or Providers contract expect(hashBelongsToPage("logs/debug", "dashboard")).toBe(false); }); -test("Codex Auth sits directly after Dashboard in the sidebar", async () => { +test("provider-independent account management sits directly after Dashboard", async () => { const app = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); const nav = app.slice(app.indexOf("const NAV"), app.indexOf("];", app.indexOf("const NAV"))); const order = [...nav.matchAll(/id: "([a-z-]+)"/g)].map((m) => m[1]); expect(order[0]).toBe("dashboard"); - expect(order[1]).toBe("codex-auth"); + expect(order[1]).toBe("providers"); + expect(order).not.toContain("codex-auth"); // Order only — no divider markup was introduced (Q3). expect(app).not.toContain("nav-divider"); }); diff --git a/gui/tests/provider-settings-fallback.test.tsx b/gui/tests/provider-settings-fallback.test.tsx new file mode 100644 index 000000000..1443bcd3c --- /dev/null +++ b/gui/tests/provider-settings-fallback.test.tsx @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import ProviderSettings from "../src/components/provider-workspace/ProviderSettings"; +import { LanguageProvider } from "../src/i18n/provider"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; + +let previousLanguageDescriptor: PropertyDescriptor | undefined; + +beforeEach(() => { + previousLanguageDescriptor = Object.getOwnPropertyDescriptor(globalThis.navigator, "language"); + Object.defineProperty(globalThis.navigator, "language", { + configurable: true, + value: "en-US", + }); +}); + +afterEach(() => { + if (previousLanguageDescriptor) { + Object.defineProperty(globalThis.navigator, "language", previousLanguageDescriptor); + } else { + Reflect.deleteProperty(globalThis.navigator, "language"); + } +}); + +const item: WorkspaceItem = { + name: "google-antigravity", + adapter: "google", + baseUrl: "https://example.test", + authMode: "oauth", + defaultModel: "gemini-3.6-flash", + fallback: [{ provider: "deepseek", model: "deepseek-v4-flash" }], +}; + +test("ProviderSettings renders configured fallback targets", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html).toContain("Fallback providers"); + expect(html).toContain("deepseek"); + expect(html).toContain("deepseek-v4-flash"); + expect(html).toContain("Add fallback"); +}); diff --git a/gui/tests/sidebar-codex-auth.test.ts b/gui/tests/sidebar-codex-auth.test.ts index 5f2079b8f..b246b2ebd 100644 --- a/gui/tests/sidebar-codex-auth.test.ts +++ b/gui/tests/sidebar-codex-auth.test.ts @@ -1,24 +1,27 @@ import { expect, test } from "bun:test"; +import { resolveAppHashChange } from "../src/app-routing"; /** - * Superseded by WP2a (devlog/_plan/260725_gui_view_consolidation/020_nav_and_dashboard_tabs.md). - * - * Codex Auth used to be filtered out of the sidebar in Workspace mode, on the - * reasoning that the Providers workspace embeds the same account pool. The - * maintainer instead promoted it to the second slot so it is always reachable. - * That old filter was also a latent WP5 hazard: once Classic is removed there is - * no non-workspace mode left, so a viewMode-keyed filter would have hidden the - * page permanently. + * Account management belongs to Providers: that workspace handles OAuth account + * sets and API-key pools for every provider and embeds the special OpenAI pool. + * Keep the old hash as a passive compatibility redirect for bookmarks. */ -test("Codex Auth is always present in the sidebar, never filtered by view mode", async () => { +test("the sidebar exposes one provider-independent account destination", async () => { const src = await Bun.file(new URL("../src/App.tsx", import.meta.url)).text(); - // The old conditional filter must not come back. - expect(src).not.toContain('viewMode === "workspace" && id === "codex-auth"'); - expect(src).toContain("{NAV.map(({ id, tkey, Icon }) => ("); + expect(src).toContain('{ id: "providers", tkey: "nav.providers", Icon: IconServer }'); + expect(src).not.toContain('{ id: "codex-auth"'); + expect(src).not.toContain('page === "codex-auth"'); +}); - // It stays in the nav table and remains routable for deep links. - expect(src).toContain('{ id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey }'); - expect(src).toContain('{page === "codex-auth" && }'); +test("legacy Codex Auth links redirect to all-provider account management", () => { + expect(resolveAppHashChange("codex-auth")).toEqual({ + page: "providers", + replaceTo: "providers", + }); + expect(resolveAppHashChange("codex-auth/accounts")).toEqual({ + page: "providers", + replaceTo: "providers", + }); }); diff --git a/readme/README.ja.md b/readme/README.ja.md index fc8fdc020..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 + 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 プロバイダーのアカウントモード @@ -216,7 +210,7 @@ opencodex は 2 つの動作を分離して保持します: - **一度ログインすれば API キーは省略可。** xAI、Anthropic、Kimi は OAuth をサポートするので既存アカウントで認証でき、トークンは自動更新されます。または `codex login` を転送、API キーを貼り付け、`${ENV_VAR}` 参照を使えます — 自由に選べます。 - **Codex が動くすべての場所で。** Codex CLI、TUI、App、SDK に自動で注入されます。ルーティングモデルはネイティブモデルと同様に Codex モデルピッカーに表示されます。 - **履歴セーフな注入。** ローカルインストールではプロキシは Codex 自身の組み込み `openai` プロバイダーを単一の `openai_base_url` 行で自身に向けるため、新しいスレッドはネイティブのプロバイダータグを維持し、進行中のチャット履歴が再マッピングされることはなく、クリーンでないシャットダウンでも隠せません。(古いバージョンで再タグ付けされたスレッドは初回起動時に一度だけマイグレートされます; リモート/LAN バインドは API キーヘッダーが必要なため、専用のプロバイダーエントリを使用します。) -- **適切なモデルに委任。** ダッシュボードや config から最大 5 つのルーティング/ネイティブモデルを Codex サブエージェントピッカーに公開し、複雑なタスクは推論モデルへ、高速なタスクは安価なモデルへ送れます。v2 マルチエージェントサーフェス(GPT-5.6 Sol/Terra)ではプロキシが簡潔な委任ガイダンスを注入します。推奨サブエージェントモデル・負荷(`injectionModel` / `injectionEffort`)、公開モデルロスターと各モデルが対応する負荷ラダー、そしてクロスモデル `spawn_agent` オーバーライドを適用する `fork_turns` ルールまで。既知の制限: ネイティブの親がルーティング子をスポーンすると、タスク本文がバックエンド暗号化状態で到着し失われることがあります([#92](https://github.com/lidge-jun/opencodex/issues/92)) — 安定したクロスプロバイダー委任には v1 サーフェスを使ってください。表現を自分で書きたい場合は `injectionPrompt` に `{{model}}` / `{{effort}}` / `{{roster}}` プレースホルダーを入れてください。 +- **適切なモデルに委任。** ダッシュボードや config から最大 5 つのルーティング/ネイティブモデルを Codex サブエージェントピッカーに公開し、複雑なタスクは推論モデルへ、高速なタスクは安価なモデルへ送れます。v2 マルチエージェントサーフェス(GPT-5.6 Sol/Terra)ではプロキシが簡潔な委任ガイダンスを注入します。推奨サブエージェントモデル・負荷(`injectionModel` / `injectionEffort`)、公開モデルロスターと各モデルが対応する負荷ラダー、そしてクロスモデル `spawn_agent` オーバーライドを適用する `fork_turns` ルールまで。既知の制限: ネイティブの親がルーティング子をスポーンすると、タスク本文がバックエンド暗号化状態で到着し失われることがあります([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)) — 安定したクロスプロバイダー委任には v1 サーフェスを使ってください。表現を自分で書きたい場合は `injectionPrompt` に `{{model}}` / `{{effort}}` / `{{roster}}` プレースホルダーを入れてください。 - **preview gate された OpenAI ロールアウトに備える。** GPT-5.6 Sol/Terra/Luna の負荷ラダーを保存します。Direct/Multi は 372k Codex 契約を、OpenAI API と OpenRouter は 1.05M メタデータを使います。 - **任意のモデルに超能力を。** OpenAI 以外のモデルも ChatGPT ログイン上で動く `gpt-5.4-mini` サイドカーで本当のウェブ検索と画像理解を得られます。 - **画像をネイティブに生成。** Codex の独立型 `image_gen` ツールは生成時に `POST /v1/images/generations`、編集時に `POST /v1/images/edits` を使います。Responses のホスト型 `image_generation` ツールとは別物です。 @@ -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,19 +397,19 @@ 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) にあります。 ## 開発 ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # dev モードでプロキシ API を起動 @@ -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 ddb900a20..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 + 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 프로바이더 계정 모드 @@ -215,7 +209,7 @@ opencodex는 두 가지 동작을 분리해서 유지합니다: - **ChatGPT 계정을 안전하게 풀링.** 기존 Codex 스레드는 한 계정에 유지하면서, 새 세션은 쿼터 갱신과 비-PII 요청 라벨과 함께 풀에서 사용량이 낮은 계정을 자동 선택할 수 있습니다. - **한 번 로그인하면 API 키는 생략.** xAI, Anthropic, Kimi는 OAuth를 지원하므로 기존 계정으로 인증할 수 있고 토큰은 자동 갱신됩니다. 또는 `codex login`을 forward 하거나, API 키를 붙여넣거나, `${ENV_VAR}` 참조를 쓸 수 있습니다 — 선택은 자유입니다. - **Codex가 동작하는 모든 곳에서.** Codex CLI, TUI, App, SDK에 자동으로 주입됩니다. 라우팅된 모델이 네이티브 모델처럼 Codex 모델 선택기에 나타납니다. -- **알맞은 모델에 위임.** 대시보드나 config에서 최대 5개의 라우팅/네이티브 모델을 Codex 서브에이전트 선택기에 노출해, 복잡한 작업은 reasoning 모델로, 빠른 작업은 저렴한 모델로 보낼 수 있습니다. v2 멀티에이전트 표면(GPT-5.6 Sol/Terra)에서는 프록시가 간결한 위임 가이드를 주입합니다. 선호 서브에이전트 모델·effort(`injectionModel` / `injectionEffort`), 노출된 모델 로스터와 각 모델이 지원하는 effort 사다리, 그리고 크로스모델 `spawn_agent` 오버라이드를 적용하는 `fork_turns` 규칙까지. 알려진 제한: 네이티브 부모가 라우팅 자식을 스폰하면 작업 본문이 백엔드 암호화 상태로 도착해 유실될 수 있습니다([#92](https://github.com/lidge-jun/opencodex/issues/92)) — 안정적인 크로스 프로바이더 위임에는 v1 표면을 쓰세요. 문구를 직접 쓰고 싶다면 `injectionPrompt`에 `{{model}}` / `{{effort}}` / `{{roster}}` 플레이스홀더를 넣으면 됩니다. +- **알맞은 모델에 위임.** 대시보드나 config에서 최대 5개의 라우팅/네이티브 모델을 Codex 서브에이전트 선택기에 노출해, 복잡한 작업은 reasoning 모델로, 빠른 작업은 저렴한 모델로 보낼 수 있습니다. v2 멀티에이전트 표면(GPT-5.6 Sol/Terra)에서는 프록시가 간결한 위임 가이드를 주입합니다. 선호 서브에이전트 모델·effort(`injectionModel` / `injectionEffort`), 노출된 모델 로스터와 각 모델이 지원하는 effort 사다리, 그리고 크로스모델 `spawn_agent` 오버라이드를 적용하는 `fork_turns` 규칙까지. 알려진 제한: 네이티브 부모가 라우팅 자식을 스폰하면 작업 본문이 백엔드 암호화 상태로 도착해 유실될 수 있습니다([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)) — 안정적인 크로스 프로바이더 위임에는 v1 표면을 쓰세요. 문구를 직접 쓰고 싶다면 `injectionPrompt`에 `{{model}}` / `{{effort}}` / `{{roster}}` 플레이스홀더를 넣으면 됩니다. - **프리뷰 게이트된 OpenAI rollout에 대비.** GPT-5.6 Sol/Terra/Luna의 effort 사다리를 보존합니다. Direct/Multi는 372k Codex 계약을, OpenAI API와 OpenRouter는 1.05M metadata를 사용합니다. - **어떤 모델에도 초능력을.** OpenAI가 아닌 모델도 ChatGPT 로그인 위에서 도는 `gpt-5.4-mini` sidecar로 실제 웹 검색과 이미지 이해를 사용합니다. - **이미지를 네이티브로 생성.** Codex의 독립형 `image_gen` 도구는 생성할 때 `POST /v1/images/generations`, 편집할 때 `POST /v1/images/edits`를 사용합니다. Responses의 hosted `image_generation` 도구와는 별개입니다. @@ -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,19 +412,19 @@ 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)에 있습니다. ## 개발 ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # dev 모드로 프록시 API 시작 @@ -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 2f04d0634..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 + 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 @@ -244,7 +238,7 @@ OpenAI API-ключа и OpenRouter (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-lu - **Один вход — и никаких API-ключей.** Поддержка OAuth для xAI, Anthropic и Kimi позволяет аутентифицироваться существующим аккаунтом; токены обновляются автоматически. Либо пробросьте свой `codex login`, вставьте API-ключ или используйте ссылки вида `${ENV_VAR}` — как вам удобнее. - **Работает везде, где работает Codex.** Автоматически встраивается в Codex CLI, TUI, App и SDK. Маршрутизируемые модели отображаются в селекторе моделей Codex наравне с нативными. - **Встраивание без риска для истории.** При локальной установке прокси перенаправляет встроенный провайдер Codex `openai` на себя одной строкой `openai_base_url` — новые треды сохраняют нативный тег провайдера, поэтому текущая история чатов никогда не перепривязывается, и даже некорректное завершение работы не может её скрыть. (Треды, перетегированные старыми версиями, однократно мигрируются обратно при первом запуске; при удалённой/LAN-привязке вместо этого используется отдельная запись провайдера, поскольку ей нужен заголовок с API-ключом.) -- **Делегируйте задачи подходящей модели.** Через панель управления или конфигурацию можно вывести до пяти маршрутизируемых или нативных моделей в селектор подагентов Codex — сложные задачи отправляйте модели с развитыми рассуждениями, быстрые — дешёвой. На мультиагентной поверхности v2 (GPT-5.6 Sol/Terra) прокси внедряет компактные указания по делегированию: предпочтительную модель и уровень рассуждений подагента (`injectionModel` / `injectionEffort`), список отобранных моделей со шкалой уровней, которую поддерживает каждая из них, и правила `fork_turns`, позволяющие кросс-модельным вызовам `spawn_agent` применять свои переопределения. Известное ограничение: когда нативный родитель порождает маршрутизируемого потомка, тело задачи в настоящий момент может прийти зашифрованным на бэкенде и потеряться ([#92](https://github.com/lidge-jun/opencodex/issues/92)) — для надёжного делегирования между провайдерами используйте поверхность v1. Хотите свои формулировки? Задайте `injectionPrompt` с плейсхолдерами `{{model}}` / `{{effort}}` / `{{roster}}`. +- **Делегируйте задачи подходящей модели.** Через панель управления или конфигурацию можно вывести до пяти маршрутизируемых или нативных моделей в селектор подагентов Codex — сложные задачи отправляйте модели с развитыми рассуждениями, быстрые — дешёвой. На мультиагентной поверхности v2 (GPT-5.6 Sol/Terra) прокси внедряет компактные указания по делегированию: предпочтительную модель и уровень рассуждений подагента (`injectionModel` / `injectionEffort`), список отобранных моделей со шкалой уровней, которую поддерживает каждая из них, и правила `fork_turns`, позволяющие кросс-модельным вызовам `spawn_agent` применять свои переопределения. Известное ограничение: когда нативный родитель порождает маршрутизируемого потомка, тело задачи в настоящий момент может прийти зашифрованным на бэкенде и потеряться ([#92](https://github.com/OnlineChefGroep/opencodex/issues/92)) — для надёжного делегирования между провайдерами используйте поверхность v1. Хотите свои формулировки? Задайте `injectionPrompt` с плейсхолдерами `{{model}}` / `{{effort}}` / `{{roster}}`. - **Готовность к превью-релизам OpenAI.** Записи GPT-5.6 Sol/Terra/Luna сохраняют исходные шкалы уровней рассуждений. Direct/Multi используют контракт Codex на 372k токенов; OpenAI API и OpenRouter — метаданные на 1.05M, когда открыт вышестоящий доступ. - **Суперспособности для любой модели.** Модели не от OpenAI получают настоящий веб-поиск и понимание изображений через сайдкар `gpt-5.4-mini`, работающий поверх вашего входа ChatGPT. - **Нативная генерация изображений.** Автономный инструмент Codex `image_gen` использует `POST /v1/images/generations` для генерации и `POST /v1/images/edits` для правок; он не связан с размещённым инструментом Responses `image_generation`. @@ -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,20 +438,20 @@ 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). ## Разработка ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # запустить API прокси в dev-режиме @@ -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 b4ef7a8de..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 + 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 或移动端连接的会话不会在对话中途切换账户。 @@ -118,7 +112,7 @@ npm 警告里给出的缩写命令缺少包名,会把当前目录重新安装 - **安全地池化 ChatGPT 账户。** 现有 Codex 线程保持在一个账户上,而新会话可以从池中自动挑选使用量更低的账户,并带有配额刷新和非 PII 请求标签。 - **登录一次,免填 API key。** xAI、Anthropic、Kimi 支持 OAuth,可用现有账户认证,token 自动刷新。也可以转发 `codex login`、粘贴 API key,或使用 `${ENV_VAR}` 引用 —— 随你选择。 - **Codex 在哪里能用,它就在哪里能用。** 自动注入 Codex CLI、TUI、App 和 SDK。路由模型像原生模型一样出现在 Codex 的模型选择器里。 -- **委派给合适的模型。** 在仪表盘或 config 中把最多 5 个路由/原生模型放进 Codex 的 subagent 选择器 —— 复杂任务交给 reasoning 模型,快速任务交给便宜模型。在 v2 多智能体表面(GPT-5.6 Sol/Terra)上,代理会注入精简的委派指引:首选子智能体模型与 effort(`injectionModel` / `injectionEffort`)、featured 模型清单及各自支持的 effort 阶梯,以及让跨模型 `spawn_agent` 覆盖得以应用的 `fork_turns` 规则。已知限制:原生父代理 spawn 路由子代理时,任务正文可能以后端加密形式到达而丢失([#92](https://github.com/lidge-jun/opencodex/issues/92))—— 需要可靠的跨 provider 委派请使用 v1 表面。想自定义文案,可在 `injectionPrompt` 中使用 `{{model}}` / `{{effort}}` / `{{roster}}` 占位符。 +- **委派给合适的模型。** 在仪表盘或 config 中把最多 5 个路由/原生模型放进 Codex 的 subagent 选择器 —— 复杂任务交给 reasoning 模型,快速任务交给便宜模型。在 v2 多智能体表面(GPT-5.6 Sol/Terra)上,代理会注入精简的委派指引:首选子智能体模型与 effort(`injectionModel` / `injectionEffort`)、featured 模型清单及各自支持的 effort 阶梯,以及让跨模型 `spawn_agent` 覆盖得以应用的 `fork_turns` 规则。已知限制:原生父代理 spawn 路由子代理时,任务正文可能以后端加密形式到达而丢失([#92](https://github.com/OnlineChefGroep/opencodex/issues/92))—— 需要可靠的跨 provider 委派请使用 v1 表面。想自定义文案,可在 `injectionPrompt` 中使用 `{{model}}` / `{{effort}}` / `{{roster}}` 占位符。 - **为 preview-gated OpenAI rollout 做好准备。** GPT-5.6 Sol/Terra/Luna 保留 upstream effort 阶梯。Direct/Multi 使用 372k Codex 契约,OpenAI API 与 OpenRouter 使用 1.05M 元数据。 - **给任意模型超能力。** 非 OpenAI 模型也能通过你的 ChatGPT 登录上运行的 `gpt-5.4-mini` sidecar 获得真正的网页搜索和图片理解。 - **原生生成图片。** Codex 的独立 `image_gen` 工具通过 `POST /v1/images/generations` 生成图片、通过 `POST /v1/images/edits` 编辑图片;它独立于 hosted Responses 的 `image_generation` 工具。 @@ -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,18 +389,18 @@ 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)。 ## 开发 ```bash -git clone https://github.com/lidge-jun/opencodex.git +git clone https://github.com/OnlineChefGroep/opencodex.git cd opencodex bun install bun run dev:proxy # 以开发模式启动代理 API @@ -428,7 +416,7 @@ bun x tsc --noEmit # 类型检查 bun run dev:gui ``` -参阅 **[贡献指南](https://opencodex.me/zh-cn/contributing/)**。 +参阅 **[贡献指南](contributing/)**。 ## 免责声明 diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index f4a28361a..40537b407 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -28,6 +28,7 @@ import { resolveCodexRuntime, } from "../codex/runtime"; import { CODEX_REAUTH_ACTION, collectOAuthHealthEntriesForCli, MASKED_ACCOUNT_FALLBACK, type OAuthHealthEntry } from "../oauth/health"; +import { collectProviderSecurityDoctorChecks } from "../provider-security/status"; import { getAuthRefreshIntentLockPath, getAuthStorePath } from "../oauth/store"; export { resolveCodexHomeDir } from "../codex/home"; @@ -812,6 +813,11 @@ export async function runDoctor(args: string[] = []): Promise { console.log(` [${check.level}] ${check.message}`); } + console.log("\nProvider security (ChefVault)"); + for (const check of await collectProviderSecurityDoctorChecks(doctorConfig)) { + console.log(` [${check.level}] ${check.message}`); + } + // Hints, not fixes. const hints: string[] = []; const proxyDown = proxyDownRestartHint({ diff --git a/src/cli/star-prompt.ts b/src/cli/star-prompt.ts index 06e87854c..67813d3c7 100644 --- a/src/cli/star-prompt.ts +++ b/src/cli/star-prompt.ts @@ -4,7 +4,7 @@ import { spawnSync } from "node:child_process"; import { createInterface } from "node:readline/promises"; import { getConfigDir } from "../config"; -const REPO = "lidge-jun/opencodex"; +const REPO = "OnlineChefGroep/opencodex"; /** Fires exactly once from the first interactive `ocx start`. */ const MARKER = ".star-prompted"; diff --git a/src/cli/status.ts b/src/cli/status.ts index a9686db01..e20d986fb 100644 --- a/src/cli/status.ts +++ b/src/cli/status.ts @@ -11,6 +11,8 @@ import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortCla import { redactSecretString, redactUserPath } from "../lib/redact"; import { collectOrcaCodexHomeDiagnostic, type OrcaCodexHomeDiagnostic } from "../codex/home"; import { grokFenceEndpointDrift, readGrokStatus } from "../grok/status"; +import { collectProviderSecurityStatus, type ProviderSecurityStatusReport } from "../provider-security/status"; +import { ProviderSecurityClient } from "../provider-security/client"; type HealthCheck = { ok: boolean; @@ -68,6 +70,7 @@ export type CliStatusJson = { }; }; codexHome: OrcaCodexHomeDiagnostic; + providerSecurity: ProviderSecurityStatusReport; }; export type CliStatusView = { @@ -236,6 +239,14 @@ export async function collectStatus(): Promise { ? "reachable, but PID file is missing or stale" : "not running"; + const providerSecurity = collectProviderSecurityStatus(config); + const authorityProbe = await ProviderSecurityClient.fromEnv().healthz(); + providerSecurity.authority = { + ok: authorityProbe.ok, + url: providerSecurity.authority.url, + message: authorityProbe.message, + }; + return { proxyLabel, healthLabel: health.label, @@ -277,6 +288,7 @@ export async function collectStatus(): Promise { codexPlugins, codexRuntime, codexHome, + providerSecurity, }, }; } 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 645f4c59b..18b198fb3 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -24,6 +24,54 @@ export type CodexThreadResolution = | { status: "expired"; accountId: string }; const threadAccountMap = new Map(); + +// Round-robin rotation cursor — persisted to ~/.opencodex/rotation-state.json so a +// process restart does not reset rotation back to account 0. Only consulted when +// config.codexRotationMode === "round-robin"; advances once per NEW (non-affined) +// conversation selection. Left untouched by the default failover path. +let rrCursor = loadPersistedRotationCursor(); +let rrCursorDirty = false; + +/** Reset the round-robin cursor. Intended for deterministic tests. */ +export function resetCodexRoundRobinCursor(): void { + rrCursor = 0; + rrCursorDirty = true; + persistRotationCursor(rrCursor); +} + +function rotationStatePath(): string { + const { join } = require("node:path") as typeof import("node:path"); + const { getConfigDir } = require("../config") as typeof import("../config"); + return join(getConfigDir(), "rotation-state.json"); +} + +function loadPersistedRotationCursor(): number { + try { + const { existsSync, readFileSync } = require("node:fs") as typeof import("node:fs"); + const path = rotationStatePath(); + if (!existsSync(path)) return 0; + const parsed = JSON.parse(readFileSync(path, "utf-8")) as { rrCursor?: unknown }; + const n = Number(parsed.rrCursor); + return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0; + } catch { + return 0; + } +} + +/** Debounced write (writes immediately for now — cursor advances are rare, once per new conversation). */ +function persistRotationCursor(value: number): void { + try { + const { writeFileSync, mkdirSync, chmodSync } = require("node:fs") as typeof import("node:fs"); + const { getConfigDir } = require("../config") as typeof import("../config"); + const dir = getConfigDir(); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const path = rotationStatePath(); + writeFileSync(path, JSON.stringify({ rrCursor: value }), { mode: 0o600 }); + try { chmodSync(path, 0o600); } catch { /* best-effort */ } + } catch { + /* persistence is best-effort */ + } +} type CodexUpstreamHealth = { consecutiveFailures: number; /** Consecutive healthy terminals observed while recovering from escalation level 2+. */ @@ -659,6 +707,22 @@ export function resolveCodexAccountForThreadDetailed( } threadAccountMap.delete(threadId); } + // Opt-in round-robin: rotate NEW (non-affined) conversations across the usable + // pool. getEligiblePoolAccounts already filters reauth / cooldown / soft-avoid / + // unusable accounts and returns a deterministic order (main unshifted first, + // then config order), so the cursor yields a stable rotation. activeCodexAccountId + // is intentionally left untouched (no setActiveCodexAccount / saveConfig). With a + // single usable account this collapses to that account (no-op). Runs before the + // sticky failover path so thread affinity established elsewhere is preserved. + if (config.codexRotationMode === "round-robin") { + const pool = getEligiblePoolAccounts(config); + if (pool.length) { + const pick = pool[rrCursor % pool.length]!; + rrCursor = (rrCursor + 1) % Math.max(pool.length, 1); + persistRotationCursor(rrCursor); + return { status: "selected", accountId: pick }; + } + } let active = config.activeCodexAccountId; if (!active) { const selected = pickLowestUsageCodexAccount(config, undefined, now); diff --git a/src/config.ts b/src/config.ts index c056e1954..287350e4a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,6 +7,7 @@ import { comboConfigIssues } from "./combos/types"; import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; +import { providerFallbackIssues } from "./providers/fallback"; import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, @@ -560,6 +561,13 @@ const configSchema = z.object({ message: openRouterRoutingError, }); } + for (const issue of providerFallbackIssues(name, (provider as { fallback?: unknown }).fallback, config.providers)) { + ctx.addIssue({ + code: "custom", + path: ["providers", name, ...issue.path], + message: issue.message, + }); + } if (Object.hasOwn(provider, "virtualModels")) { ctx.addIssue({ code: "custom", diff --git a/src/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 4d15ef4b7..698ccb25a 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -15,6 +15,8 @@ import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUr import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive"; import { effectiveGoogleMode } from "../providers/registry"; import { resolveProviderTransport } from "../providers/xai-transport"; +import { isChefVaultRef } from "../provider-security"; +import { globalProviderCredentialResolver } from "../provider-security/resolve"; import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect"; import { logOAuthEvent } from "./log"; export { @@ -119,7 +121,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"), @@ -462,6 +466,15 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf return undefined; } } + const credentialRef = prov.credentialRef?.trim(); + if (credentialRef && isChefVaultRef(credentialRef)) { + try { + const resolved = await globalProviderCredentialResolver.resolveCredentialRef(credentialRef); + return resolved.apiKey; + } catch { + return undefined; + } + } return resolveEnvValue(prov.apiKey); } diff --git a/src/provider-security/client.ts b/src/provider-security/client.ts new file mode 100644 index 000000000..232d90e9d --- /dev/null +++ b/src/provider-security/client.ts @@ -0,0 +1,194 @@ +/** + * ChefVault provider-security HTTP client (PSP-008). + * + * Talks to the secret authority at CHEF_PROVIDER_SECURITY_URL (default :8323). + * Workload identity is carried on every request via X-Chef-* headers. + */ +import { + type ChefVaultRenewRequest, + type ChefVaultRenewResponse, + type ChefVaultResolveRequest, + type ChefVaultResolveResponse, + ProviderSecurityError, + type ProviderSecurityClientConfig, + type WorkloadIdentity, +} from "./types"; + +export const DEFAULT_PROVIDER_SECURITY_URL = "http://127.0.0.1:8323"; + +const WORKLOAD_HEADER_WORKLOAD = "x-chef-workload-id"; +const WORKLOAD_HEADER_HOST = "x-chef-host-id"; +const WORKLOAD_HEADER_ACTOR = "x-chef-actor"; + +export function resolveProviderSecurityUrl(env: NodeJS.ProcessEnv = process.env): string { + const raw = env.CHEF_PROVIDER_SECURITY_URL?.trim(); + return raw || DEFAULT_PROVIDER_SECURITY_URL; +} + +export function resolveWorkloadIdentity(env: NodeJS.ProcessEnv = process.env): WorkloadIdentity { + return { + workloadId: env.CHEF_WORKLOAD_ID?.trim() || "opencodex", + hostId: env.CHEF_HOST_ID?.trim() || env.HOSTNAME?.trim() || "local", + actor: env.CHEF_ACTOR?.trim() || "opencodex", + }; +} + +function workloadHeaders(workload: WorkloadIdentity): Record { + return { + [WORKLOAD_HEADER_WORKLOAD]: workload.workloadId, + [WORKLOAD_HEADER_HOST]: workload.hostId, + [WORKLOAD_HEADER_ACTOR]: workload.actor, + }; +} + +function mapAuthorityError(status: number, body: unknown): ProviderSecurityError { + const record = body && typeof body === "object" && !Array.isArray(body) + ? body as Record + : {}; + const code = typeof record.code === "string" ? record.code : undefined; + const message = typeof record.message === "string" + ? record.message + : typeof record.error === "string" + ? record.error + : `ChefVault provider-security returned HTTP ${status}`; + + if (code === "stale_fencing_token") { + return new ProviderSecurityError("stale_fencing_token", message); + } + if (status === 404 || code === "ref_not_found") { + return new ProviderSecurityError("ref_not_found", message); + } + if (status === 400 || code === "ref_invalid") { + return new ProviderSecurityError("ref_invalid", message); + } + if (status === 410 || code === "revoked") { + return new ProviderSecurityError("revoked", message); + } + return new ProviderSecurityError("authority_error", message); +} + +function parseResolveResponse(body: unknown): ChefVaultResolveResponse { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new ProviderSecurityError("authority_error", "resolve response was not an object"); + } + const record = body as Record; + const leaseId = typeof record.leaseId === "string" ? record.leaseId : ""; + const secret = typeof record.secret === "string" ? record.secret : ""; + const expiresAt = typeof record.expiresAt === "number" ? record.expiresAt : Number(record.expiresAt); + const fencingToken = typeof record.fencingToken === "number" ? record.fencingToken : Number(record.fencingToken); + const slotHint = record.slotHint; + if (!leaseId || !secret || !Number.isFinite(expiresAt) || !Number.isFinite(fencingToken)) { + throw new ProviderSecurityError("authority_error", "resolve response missing required lease fields"); + } + return { + leaseId, + secret, + expiresAt, + fencingToken, + ...(slotHint === "active" || slotHint === "next" || slotHint === "retiring" + ? { slotHint } + : {}), + }; +} + +function parseRenewResponse(body: unknown): ChefVaultRenewResponse { + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new ProviderSecurityError("authority_error", "renew response was not an object"); + } + const record = body as Record; + const leaseId = typeof record.leaseId === "string" ? record.leaseId : ""; + const secret = typeof record.secret === "string" ? record.secret : ""; + const expiresAt = typeof record.expiresAt === "number" ? record.expiresAt : Number(record.expiresAt); + const fencingToken = typeof record.fencingToken === "number" ? record.fencingToken : Number(record.fencingToken); + if (!leaseId || !secret || !Number.isFinite(expiresAt) || !Number.isFinite(fencingToken)) { + throw new ProviderSecurityError("authority_error", "renew response missing required lease fields"); + } + return { leaseId, secret, expiresAt, fencingToken }; +} + +export class ProviderSecurityClient { + readonly baseUrl: string; + readonly workload: WorkloadIdentity; + private readonly fetchImpl: typeof fetch; + private readonly requestTimeoutMs: number; + + constructor(config: ProviderSecurityClientConfig) { + this.baseUrl = config.baseUrl.replace(/\/$/, ""); + this.workload = config.workload; + this.fetchImpl = config.fetchImpl ?? fetch; + this.requestTimeoutMs = config.requestTimeoutMs ?? 8_000; + } + + static fromEnv(env: NodeJS.ProcessEnv = process.env): ProviderSecurityClient { + return new ProviderSecurityClient({ + baseUrl: resolveProviderSecurityUrl(env), + workload: resolveWorkloadIdentity(env), + }); + } + + private async request(path: string, init: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.requestTimeoutMs); + try { + return await this.fetchImpl(`${this.baseUrl}${path}`, { + ...init, + signal: controller.signal, + headers: { + accept: "application/json", + "content-type": "application/json", + ...workloadHeaders(this.workload), + ...(init.headers ?? {}), + }, + }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + throw new ProviderSecurityError("network_error", "ChefVault provider-security request timed out"); + } + throw new ProviderSecurityError( + "authority_unavailable", + error instanceof Error ? error.message : "ChefVault provider-security is unreachable", + ); + } finally { + clearTimeout(timer); + } + } + + async healthz(): Promise<{ ok: boolean; message: string }> { + try { + const response = await this.request("/healthz", { method: "GET" }); + if (!response.ok) { + return { ok: false, message: `HTTP ${response.status}` }; + } + return { ok: true, message: "ok" }; + } catch (error) { + if (error instanceof ProviderSecurityError) { + return { ok: false, message: error.message }; + } + return { ok: false, message: "unreachable" }; + } + } + + async resolveLease(input: ChefVaultResolveRequest): Promise { + const response = await this.request("/v1/credentials/resolve", { + method: "POST", + body: JSON.stringify(input), + }); + const body = await response.json().catch(() => null); + if (!response.ok) { + throw mapAuthorityError(response.status, body); + } + return parseResolveResponse(body); + } + + async renewLease(input: ChefVaultRenewRequest): Promise { + const response = await this.request("/v1/credentials/renew", { + method: "POST", + body: JSON.stringify(input), + }); + const body = await response.json().catch(() => null); + if (!response.ok) { + throw mapAuthorityError(response.status, body); + } + return parseRenewResponse(body); + } +} diff --git a/src/provider-security/degraded.ts b/src/provider-security/degraded.ts new file mode 100644 index 000000000..bc296504a --- /dev/null +++ b/src/provider-security/degraded.ts @@ -0,0 +1,58 @@ +/** + * Bounded degraded mode when ChefVault is unavailable (PSP-011 stub). + * + * - Existing valid in-memory credentials may still be used (bounded by lease expiry). + * - New resolution against the authority is denied until recovery. + */ +import type { CredentialSnapshot } from "./types"; +import { CredentialSlotStore } from "./slots"; + +export interface DegradedDecision { + allowed: boolean; + reason?: "degraded_deny_resolve" | "lease_expired" | "revoked" | "missing"; +} + +export class DegradedModeController { + constructor(private readonly store: CredentialSlotStore) {} + + markUnavailable(ref: string, at = Date.now()): void { + this.store.enterDegraded(ref, at); + } + + markRecovered(ref: string): void { + this.store.exitDegraded(ref); + } + + isDegraded(ref: string): boolean { + return this.store.getMode(ref) === "degraded"; + } + + /** Whether a fresh resolve against ChefVault is permitted. */ + canResolve(ref: string): DegradedDecision { + if (this.isDegraded(ref)) { + return { allowed: false, reason: "degraded_deny_resolve" }; + } + return { allowed: true }; + } + + /** Whether an already-resolved in-memory credential may be used for upstream auth. */ + canUseExisting(ref: string, at = Date.now()): DegradedDecision { + const snapshot = this.store.snapshotForRequest(ref, at); + if (!snapshot) { + if (this.isDegraded(ref)) { + return { allowed: false, reason: "degraded_deny_resolve" }; + } + return { allowed: false, reason: "missing" }; + } + if (snapshot.expiresAt <= at) { + return { allowed: false, reason: "lease_expired" }; + } + return { allowed: true }; + } + + existingSnapshot(ref: string, at = Date.now()): CredentialSnapshot | null { + const decision = this.canUseExisting(ref, at); + if (!decision.allowed) return null; + return this.store.snapshotForRequest(ref, at); + } +} diff --git a/src/provider-security/index.ts b/src/provider-security/index.ts new file mode 100644 index 000000000..43a4b8bc0 --- /dev/null +++ b/src/provider-security/index.ts @@ -0,0 +1,12 @@ +export * from "./types"; +export * from "./client"; +export * from "./slots"; +export * from "./degraded"; +export * from "./resolve"; +export * from "./status"; + +import { globalCredentialSlotStore } from "./slots"; +import { DegradedModeController } from "./degraded"; + +/** Shared degraded controller bound to the process-wide slot store. */ +export const globalDegradedMode = new DegradedModeController(globalCredentialSlotStore); diff --git a/src/provider-security/resolve.ts b/src/provider-security/resolve.ts new file mode 100644 index 000000000..f232cdada --- /dev/null +++ b/src/provider-security/resolve.ts @@ -0,0 +1,129 @@ +/** + * chefvault:// credential resolution for provider auth (PSP-008). + */ +import { ProviderSecurityClient } from "./client"; +import { DegradedModeController } from "./degraded"; +import { + CredentialSlotStore, + globalCredentialSlotStore, + renewalJitterMs, + shouldRenewLease, +} from "./slots"; +import { + ProviderSecurityError, + validateChefVaultRef, + type CredentialSnapshot, + type ProviderSecurityErrorCode, +} from "./types"; + +export interface ResolveCredentialDeps { + client?: ProviderSecurityClient; + slotStore?: CredentialSlotStore; + degraded?: DegradedModeController; + now?: () => number; + jitterMs?: number; +} + +export interface ResolvedProviderCredential { + apiKey: string; + snapshot: CredentialSnapshot; + source: "chefvault" | "memory"; +} + +function toProviderSecurityError(error: unknown): ProviderSecurityError { + if (error instanceof ProviderSecurityError) return error; + return new ProviderSecurityError( + "authority_error", + error instanceof Error ? error.message : "unknown provider-security failure", + ); +} + +export class ProviderCredentialResolver { + readonly client: ProviderSecurityClient; + private readonly slotStore: CredentialSlotStore; + private readonly degraded: DegradedModeController; + private readonly now: () => number; + + constructor(deps: ResolveCredentialDeps = {}) { + this.client = deps.client ?? ProviderSecurityClient.fromEnv(); + this.slotStore = deps.slotStore ?? globalCredentialSlotStore; + this.degraded = deps.degraded ?? new DegradedModeController(this.slotStore); + this.now = deps.now ?? (() => Date.now()); + } + + async resolveCredentialRef(ref: string, deps: { jitterMs?: number } = {}): Promise { + const invalid = validateChefVaultRef(ref); + if (invalid) throw invalid; + + const at = this.now(); + const existing = this.slotStore.snapshotForRequest(ref, at); + if (existing && existing.expiresAt > at) { + const renewTarget = this.slotStore.getState(ref)?.slots.active; + if (renewTarget && shouldRenewLease(renewTarget, at, deps.jitterMs ?? renewalJitterMs())) { + await this.tryRenew(ref, renewTarget.leaseId, renewTarget.fencingToken, "active").catch(() => { + // Renewal failure keeps the current snapshot until expiry; degraded handling applies on resolve. + }); + const refreshed = this.slotStore.snapshotForRequest(ref, this.now()); + if (refreshed) { + return { apiKey: refreshed.secret, snapshot: refreshed, source: "memory" }; + } + } + return { apiKey: existing.secret, snapshot: existing, source: "memory" }; + } + + const wasDegraded = this.degraded.isDegraded(ref); + + const state = this.slotStore.getState(ref); + try { + const response = await this.client.resolveLease({ + ref, + ...(state && state.lastFencingToken > 0 ? { fencingToken: state.lastFencingToken } : {}), + }); + this.degraded.markRecovered(ref); + const lease = this.slotStore.applyResolve(ref, response, this.now()); + const snapshot = this.slotStore.snapshotForRequest(ref, this.now()); + if (!snapshot) { + throw new ProviderSecurityError("authority_error", "resolve succeeded but no usable snapshot was stored"); + } + return { apiKey: lease.secret, snapshot, source: "chefvault" }; + } catch (error) { + this.degraded.markUnavailable(ref, at); + if (wasDegraded) { + throw new ProviderSecurityError( + "degraded_deny_resolve", + "ChefVault is unavailable; new credential resolution is denied in degraded mode", + ); + } + throw toProviderSecurityError(error); + } + } + + private async tryRenew( + ref: string, + leaseId: string, + fencingToken: number, + phase: "active" | "next" | "retiring", + ): Promise { + const response = await this.client.renewLease({ ref, leaseId, fencingToken }); + this.degraded.markRecovered(ref); + this.slotStore.applyRenew(ref, response, phase, this.now()); + } + + async probeAuthority(): Promise<{ ok: boolean; message: string }> { + return this.client.healthz(); + } +} + +export const globalProviderCredentialResolver = new ProviderCredentialResolver(); + +export async function resolveChefVaultCredential( + ref: string, + deps?: ResolveCredentialDeps, +): Promise { + return new ProviderCredentialResolver(deps).resolveCredentialRef(ref); +} + +export function providerSecurityErrorCode(error: unknown): ProviderSecurityErrorCode { + if (error instanceof ProviderSecurityError) return error.code; + return "authority_error"; +} diff --git a/src/provider-security/slots.ts b/src/provider-security/slots.ts new file mode 100644 index 000000000..da32afe80 --- /dev/null +++ b/src/provider-security/slots.ts @@ -0,0 +1,209 @@ +/** + * In-memory credential slot model (PSP-008). + * + * Slots: active / next / retiring / revoked. Raw secrets live only in process memory. + */ +import { + ProviderSecurityError, + type ChefVaultRenewResponse, + type ChefVaultResolveResponse, + type CredentialLease, + type CredentialSlotPhase, + type CredentialSnapshot, + type ProviderSecurityMode, + type RedactedProviderSecurityStatus, + type RedactedSlotSummary, + type SlotStoreState, +} from "./types"; + +const RENEWAL_LEAD_MS = 5 * 60_000; +const RENEWAL_JITTER_MS = 30_000; + +function nowMs(): number { + return Date.now(); +} + +function isUsableLease(lease: CredentialLease | undefined, at = nowMs()): lease is CredentialLease { + return !!lease && lease.phase !== "revoked" && lease.expiresAt > at; +} + +function freezeSnapshot(lease: CredentialLease): CredentialSnapshot { + if (lease.phase === "revoked") { + throw new Error("revoked leases cannot produce request snapshots"); + } + return Object.freeze({ + ref: lease.ref, + leaseId: lease.leaseId, + secret: lease.secret, + expiresAt: lease.expiresAt, + fencingToken: lease.fencingToken, + phase: lease.phase, + }); +} + +function toLease( + ref: string, + response: ChefVaultResolveResponse | ChefVaultRenewResponse, + phase: CredentialSlotPhase, + resolvedAt = nowMs(), +): CredentialLease { + return { + ref, + leaseId: response.leaseId, + secret: response.secret, + expiresAt: response.expiresAt, + fencingToken: response.fencingToken, + phase, + resolvedAt, + }; +} + +export function renewalJitterMs(seed = Math.random()): number { + return Math.floor(seed * RENEWAL_JITTER_MS); +} + +export function shouldRenewLease(lease: CredentialLease | undefined, at = nowMs(), jitterMs = 0): boolean { + if (!isUsableLease(lease, at)) return false; + return lease.expiresAt - at <= RENEWAL_LEAD_MS + jitterMs; +} + +export class CredentialSlotStore { + private readonly stores = new Map(); + + private ensure(ref: string): SlotStoreState { + const existing = this.stores.get(ref); + if (existing) return existing; + const created: SlotStoreState = { + ref, + mode: "normal", + lastFencingToken: 0, + slots: {}, + degradedSince: null, + lastRenewalAt: null, + }; + this.stores.set(ref, created); + return created; + } + + getState(ref: string): SlotStoreState | undefined { + const state = this.stores.get(ref); + if (!state) return undefined; + return { + ...state, + slots: { ...state.slots }, + }; + } + + enterDegraded(ref: string, at = nowMs()): void { + const state = this.ensure(ref); + state.mode = "degraded"; + state.degradedSince ??= at; + } + + exitDegraded(ref: string): void { + const state = this.stores.get(ref); + if (!state) return; + state.mode = "normal"; + state.degradedSince = null; + } + + getMode(ref: string): ProviderSecurityMode { + return this.ensure(ref).mode; + } + + /** Immutable snapshot for an in-flight upstream request. Prefers active, then retiring. */ + snapshotForRequest(ref: string, at = nowMs()): CredentialSnapshot | null { + const state = this.stores.get(ref); + if (!state) return null; + const active = state.slots.active; + if (isUsableLease(active, at)) return freezeSnapshot(active); + const retiring = state.slots.retiring; + if (isUsableLease(retiring, at)) return freezeSnapshot(retiring); + return null; + } + + applyResolve(ref: string, response: ChefVaultResolveResponse, at = nowMs()): CredentialLease { + const state = this.ensure(ref); + if (response.fencingToken <= state.lastFencingToken) { + throw new ProviderSecurityError("stale_fencing_token", "fencing token is stale"); + } + + const targetPhase = response.slotHint ?? "active"; + const previousActive = state.slots.active; + const lease = toLease(ref, response, targetPhase, at); + + if (targetPhase === "active") { + if (previousActive && previousActive.leaseId !== lease.leaseId) { + state.slots.retiring = { ...previousActive, phase: "retiring" }; + } + state.slots.active = lease; + } else if (targetPhase === "next") { + state.slots.next = lease; + } else { + state.slots.retiring = lease; + } + + state.lastFencingToken = response.fencingToken; + state.lastRenewalAt = at; + return lease; + } + + applyRenew(ref: string, response: ChefVaultRenewResponse, phase: CredentialSlotPhase, at = nowMs()): CredentialLease { + const state = this.ensure(ref); + if (response.fencingToken <= state.lastFencingToken) { + throw new ProviderSecurityError("stale_fencing_token", "fencing token is stale"); + } + const lease = toLease(ref, response, phase, at); + state.slots[phase] = lease; + state.lastFencingToken = response.fencingToken; + state.lastRenewalAt = at; + return lease; + } + + promoteNextToActive(ref: string, at = nowMs()): CredentialLease | null { + const state = this.stores.get(ref); + const next = state?.slots.next; + if (!state || !isUsableLease(next, at)) return null; + const previousActive = state.slots.active; + if (previousActive) { + state.slots.retiring = { ...previousActive, phase: "retiring" }; + } + const promoted: CredentialLease = { ...next, phase: "active", resolvedAt: at }; + state.slots.active = promoted; + delete state.slots.next; + return promoted; + } + + revokePhase(ref: string, phase: CredentialSlotPhase): void { + const state = this.stores.get(ref); + const lease = state?.slots[phase]; + if (!state || !lease) return; + state.slots[phase] = { ...lease, phase: "revoked" }; + } + + redactedStatus(ref: string, at = nowMs()): RedactedProviderSecurityStatus { + const state = this.ensure(ref); + const slots: RedactedSlotSummary[] = (["active", "next", "retiring", "revoked"] as const).flatMap(phase => { + const lease = state.slots[phase]; + if (!lease) return []; + return [{ + phase, + leaseId: lease.leaseId, + expiresAt: lease.expiresAt, + fencingToken: lease.fencingToken, + valid: isUsableLease(lease, at), + }]; + }); + return { + ref, + mode: state.mode, + degradedSince: state.degradedSince, + lastFencingToken: state.lastFencingToken, + slots, + hasUsableCredential: slots.some(slot => slot.valid && slot.phase !== "revoked"), + }; + } +} + +/** Process-wide slot store — secrets never leave memory or hit disk. */ +export const globalCredentialSlotStore = new CredentialSlotStore(); diff --git a/src/provider-security/status.ts b/src/provider-security/status.ts new file mode 100644 index 000000000..9e138496d --- /dev/null +++ b/src/provider-security/status.ts @@ -0,0 +1,142 @@ +/** + * Redacted provider-security status for doctor/status surfaces (PSP-008). + */ +import type { OcxConfig, OcxProviderConfig } from "../types"; +import { ProviderSecurityClient } from "./client"; +import { globalCredentialSlotStore } from "./slots"; +import { ProviderCredentialResolver } from "./resolve"; +import { isChefVaultRef, type RedactedProviderSecurityStatus } from "./types"; + +export interface ProviderSecurityDoctorCheck { + level: "OK" | "WARN"; + provider: string; + message: string; +} + +export interface ProviderSecurityStatusReport { + authority: { ok: boolean; url: string; message: string }; + providers: Array<{ + provider: string; + credentialRef: string; + status: RedactedProviderSecurityStatus; + }>; +} + +export function listChefVaultProviders(config: OcxConfig): Array<{ name: string; ref: string; provider: OcxProviderConfig }> { + return Object.entries(config.providers).flatMap(([name, provider]) => { + const ref = provider.credentialRef?.trim(); + if (!ref || !isChefVaultRef(ref)) return []; + return [{ name, ref, provider }]; + }); +} + +export function collectProviderSecurityStatus( + config: OcxConfig, + client: ProviderSecurityClient = ProviderSecurityClient.fromEnv(), + slotStore: typeof globalCredentialSlotStore = globalCredentialSlotStore, +): ProviderSecurityStatusReport { + const authority = client.baseUrl; + return { + authority: { ok: false, url: authority, message: "not probed" }, + providers: listChefVaultProviders(config).map(entry => ({ + provider: entry.name, + credentialRef: entry.ref, + status: slotStore.redactedStatus(entry.ref), + })), + }; +} + +export async function collectProviderSecurityStatusAsync( + config: OcxConfig, + resolver: ProviderCredentialResolver = new ProviderCredentialResolver(), +): Promise { + const authorityProbe = await resolver.probeAuthority(); + const base = collectProviderSecurityStatus(config, resolver.client); + base.authority = { + ok: authorityProbe.ok, + url: resolver.client.baseUrl, + message: authorityProbe.message, + }; + + for (const entry of base.providers) { + try { + await resolver.resolveCredentialRef(entry.credentialRef); + entry.status = globalCredentialSlotStore.redactedStatus(entry.credentialRef); + } catch { + entry.status = globalCredentialSlotStore.redactedStatus(entry.credentialRef); + } + } + + return base; +} + +export async function collectProviderSecurityDoctorChecks( + config: OcxConfig, + resolver: ProviderCredentialResolver = new ProviderCredentialResolver(), +): Promise { + const checks: ProviderSecurityDoctorCheck[] = []; + const refs = listChefVaultProviders(config); + const authority = await resolver.probeAuthority(); + + if (authority.ok) { + checks.push({ + level: "OK", + provider: "*", + message: `ChefVault provider-security reachable (${resolver.client.baseUrl}).`, + }); + } else { + checks.push({ + level: "WARN", + provider: "*", + message: `ChefVault provider-security unavailable (${authority.message}). Degraded mode: existing in-memory leases only; new resolve denied.`, + }); + } + + if (refs.length === 0) { + checks.push({ + level: "OK", + provider: "*", + message: "No providers configured with chefvault:// credentialRef.", + }); + return checks; + } + + for (const { name, ref } of refs) { + const status = globalCredentialSlotStore.redactedStatus(ref); + if (status.mode === "degraded") { + checks.push({ + level: "WARN", + provider: name, + message: `Provider "${name}" is in degraded mode for ${ref}; only bounded in-memory credentials may be used.`, + }); + continue; + } + if (status.hasUsableCredential) { + checks.push({ + level: "OK", + provider: name, + message: `Provider "${name}" has a usable in-memory lease for ${ref}.`, + }); + continue; + } + if (authority.ok) { + checks.push({ + level: "WARN", + provider: name, + message: `Provider "${name}" references ${ref} but has no resolved in-memory lease yet.`, + }); + } else { + checks.push({ + level: "WARN", + provider: name, + message: `Provider "${name}" cannot resolve ${ref} while ChefVault is unavailable.`, + }); + } + } + + return checks; +} + +export function serializeProviderSecurityStatus(report: ProviderSecurityStatusReport): string { + return JSON.stringify(report); +} diff --git a/src/provider-security/types.ts b/src/provider-security/types.ts new file mode 100644 index 000000000..d789b029e --- /dev/null +++ b/src/provider-security/types.ts @@ -0,0 +1,138 @@ +/** ChefVault provider-security plane — shared types and error taxonomy (PSP-008). */ + +export const CHEFVAULT_REF_PREFIX = "chefvault://"; + +export type CredentialSlotPhase = "active" | "next" | "retiring" | "revoked"; + +export type ProviderSecurityMode = "normal" | "degraded"; + +/** Stable error codes surfaced to callers and doctor/status. */ +export type ProviderSecurityErrorCode = + | "authority_unavailable" + | "stale_fencing_token" + | "ref_invalid" + | "ref_not_found" + | "lease_expired" + | "degraded_deny_resolve" + | "revoked" + | "network_error" + | "authority_error"; + +export class ProviderSecurityError extends Error { + readonly code: ProviderSecurityErrorCode; + + constructor(code: ProviderSecurityErrorCode, message: string) { + super(message); + this.name = "ProviderSecurityError"; + this.code = code; + } +} + +export interface WorkloadIdentity { + workloadId: string; + hostId: string; + actor: string; +} + +export interface ChefVaultResolveRequest { + ref: string; + fencingToken?: number; +} + +export interface ChefVaultResolveResponse { + leaseId: string; + secret: string; + expiresAt: number; + fencingToken: number; + slotHint?: Exclude; +} + +export interface ChefVaultRenewRequest { + ref: string; + leaseId: string; + fencingToken: number; +} + +export interface ChefVaultRenewResponse { + leaseId: string; + secret: string; + expiresAt: number; + fencingToken: number; +} + +/** In-memory lease material — never persisted to disk. */ +export interface CredentialLease { + ref: string; + leaseId: string; + secret: string; + expiresAt: number; + fencingToken: number; + phase: CredentialSlotPhase; + resolvedAt: number; +} + +/** Immutable credential view handed to a single upstream request. */ +export interface CredentialSnapshot { + readonly ref: string; + readonly leaseId: string; + readonly secret: string; + readonly expiresAt: number; + readonly fencingToken: number; + readonly phase: Exclude; +} + +export interface SlotStoreState { + ref: string; + mode: ProviderSecurityMode; + lastFencingToken: number; + slots: Partial>; + degradedSince: number | null; + lastRenewalAt: number | null; +} + +/** Redacted slot summary safe for doctor/status/telemetry. */ +export interface RedactedSlotSummary { + phase: CredentialSlotPhase; + leaseId: string; + expiresAt: number; + fencingToken: number; + valid: boolean; +} + +export interface RedactedProviderSecurityStatus { + ref: string; + mode: ProviderSecurityMode; + degradedSince: number | null; + lastFencingToken: number; + slots: RedactedSlotSummary[]; + hasUsableCredential: boolean; +} + +export interface ProviderSecurityClientConfig { + baseUrl: string; + workload: WorkloadIdentity; + fetchImpl?: typeof fetch; + requestTimeoutMs?: number; +} + +export function isChefVaultRef(value: string | undefined): value is string { + return typeof value === "string" && value.startsWith(CHEFVAULT_REF_PREFIX) && value.length > CHEFVAULT_REF_PREFIX.length; +} + +export function validateChefVaultRef(ref: string): ProviderSecurityError | null { + if (!isChefVaultRef(ref)) { + return new ProviderSecurityError("ref_invalid", `credential ref must start with ${CHEFVAULT_REF_PREFIX}`); + } + try { + const parsed = new URL(ref); + if (parsed.protocol !== "chefvault:") { + return new ProviderSecurityError("ref_invalid", "credential ref must use chefvault:// scheme"); + } + if (!parsed.pathname || parsed.pathname === "/") { + return new ProviderSecurityError("ref_invalid", "credential ref must include a resource path"); + } + } catch { + return new ProviderSecurityError("ref_invalid", "credential ref is not a valid chefvault:// URI"); + } + return null; +} diff --git a/src/providers/fallback.ts b/src/providers/fallback.ts new file mode 100644 index 000000000..158f59805 --- /dev/null +++ b/src/providers/fallback.ts @@ -0,0 +1,167 @@ +/** + * Per-provider fallback for plain (non-combo) requests. + * + * A combo already expresses "try these provider/model targets in order", including + * per-target cooldowns and a hop/stop classification of upstream failures. That engine + * only runs for models the client explicitly addresses as `combo/`, so a plain + * request that resolves to a single provider has no second chance: a 429 or a stream + * that dies without a terminal event (`upstream_server_error`) goes straight back to + * the caller. + * + * This module closes that gap without a second retry mechanism. A provider's `fallback` + * list is turned into a synthetic combo whose first target is the request's own route, + * so the normal combo hop loop drives the retry. + * + * The synthetic combo id embeds NUL, which `COMBO_ID_PATTERN` rejects. A configured + * combo can therefore never collide with it, and no client-supplied model string can + * resolve to it (`resolveComboId` matches only `combo/` or an explicit alias). + */ +import { COMBO_NAMESPACE, targetKey } from "../combos/types"; +import type { OcxComboConfig, OcxComboTarget, OcxConfig, OcxProviderConfig } from "../types"; + +export interface ProviderFallbackIssue { + path: Array; + message: string; +} + +export interface ProviderFallbackPlan { + comboId: string; + /** `config` with the synthetic combo injected; safe to pass to the combo hop loop. */ + config: OcxConfig; +} + +function syntheticComboId(provider: string, model: string): string { + return `provider-fallback\u0000${provider}\u0000${model}`; +} + +/** True when `id` came from `syntheticComboId` rather than the user's combos map. */ +export function isProviderFallbackComboId(id: string): boolean { + return id.startsWith("provider-fallback\u0000"); +} + +/** Human-readable form of a combo id for logs and error messages (NUL is not printable). */ +export function comboIdLabel(id: string): string { + if (!isProviderFallbackComboId(id)) return id; + const [, provider, model] = id.split("\u0000"); + return `fallback:${provider}/${model}`; +} + +/** + * Configured fallback targets for one provider, trimmed and with malformed entries dropped. + * Validation reports those entries separately; routing must not fail on them. + */ +export function providerFallbackTargets( + provider: OcxProviderConfig | undefined, +): OcxComboTarget[] { + if (!Array.isArray(provider?.fallback)) return []; + const targets: OcxComboTarget[] = []; + for (const raw of provider.fallback) { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue; + const name = typeof raw.provider === "string" ? raw.provider.trim() : ""; + const model = typeof raw.model === "string" ? raw.model.trim() : ""; + if (!name || !model) continue; + targets.push({ provider: name, model }); + } + return targets; +} + +export function providerFallbackIssues( + providerName: string, + raw: unknown, + providers: Record, +): ProviderFallbackIssue[] { + const issues: ProviderFallbackIssue[] = []; + if (raw === undefined || raw === null) return issues; + if (!Array.isArray(raw)) { + issues.push({ path: ["fallback"], message: "fallback must be an array of { provider, model } targets" }); + return issues; + } + + const seen = new Set(); + for (let i = 0; i < raw.length; i++) { + const entry = raw[i]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + issues.push({ path: ["fallback", i], message: `fallback[${i}] must be an object` }); + continue; + } + const target = entry as Record; + const name = typeof target.provider === "string" ? target.provider.trim() : ""; + const model = typeof target.model === "string" ? target.model.trim() : ""; + + if (!name) { + issues.push({ path: ["fallback", i, "provider"], message: `fallback[${i}].provider is required` }); + } else if (!Object.hasOwn(providers, name)) { + issues.push({ + path: ["fallback", i, "provider"], + message: `fallback[${i}].provider "${name}" is not configured`, + }); + } + if (!model) { + issues.push({ path: ["fallback", i, "model"], message: `fallback[${i}].model is required` }); + } + if (!name || !model) continue; + + // Self-reference would retry the target that just failed, burning a full attempt on a + // provider the hop loop has already put in cooldown. + if (name === providerName) { + issues.push({ + path: ["fallback", i], + message: `fallback[${i}] must not point back at "${providerName}"`, + }); + } + const key = targetKey({ provider: name, model }); + if (seen.has(key)) { + issues.push({ path: ["fallback", i], message: `duplicate fallback target "${key}"` }); + } else { + seen.add(key); + } + } + return issues; +} + +export function providerFallbackError( + providerName: string, + raw: unknown, + providers: Record, +): string | null { + return providerFallbackIssues(providerName, raw, providers)[0]?.message ?? null; +} + +/** + * Build the synthetic combo for a request that routed to `provider`/`model`, or null when the + * provider has no usable fallback and the request should take the normal single-target path. + */ +export function providerFallbackPlan( + config: OcxConfig, + route: { provider: string; modelId: string }, +): ProviderFallbackPlan | null { + // A physical provider literally named "combo" is only kept addressable while no combos + // exist (preservesPhysicalComboProvider). Injecting one here would silently shadow it. + if (Object.hasOwn(config.providers, COMBO_NAMESPACE)) return null; + + const configured = providerFallbackTargets(config.providers[route.provider]); + if (configured.length === 0) return null; + + const usable = (target: OcxComboTarget): boolean => { + const provider = config.providers[target.provider]; + return !!provider && provider.disabled !== true; + }; + if (!usable({ provider: route.provider, model: route.modelId })) return null; + + const targets: OcxComboTarget[] = [{ provider: route.provider, model: route.modelId }]; + const seen = new Set([targetKey(targets[0]!)]); + for (const target of configured) { + const key = targetKey(target); + if (seen.has(key) || !usable(target)) continue; + seen.add(key); + targets.push(target); + } + if (targets.length < 2) return null; + + const comboId = syntheticComboId(route.provider, route.modelId); + const combo: OcxComboConfig = { targets, strategy: "failover" }; + return { + comboId, + config: { ...config, combos: { ...config.combos, [comboId]: combo } }, + }; +} diff --git a/src/providers/quota.ts b/src/providers/quota.ts index cfe76f8df..2323741bb 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -655,6 +655,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/providers/registry.ts b/src/providers/registry.ts index 5902c5b29..f256840fd 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -161,6 +161,38 @@ const OPENROUTER_GPT56_CONTEXT_WINDOWS = { "openai/gpt-5.6-luna": OPENROUTER_GPT56_CONTEXT_WINDOW, }; +/** + * OmniRoute (https://github.com/diegosouzapw/OmniRoute) — open-source OpenAI-compatible gateway + * that aggregates 250+ providers (90+ free) behind one /v1 endpoint. Cloud API lives at + * https://api.omniroute.online/v1; self-host via the `diegosouzapw/omniroute` Docker image + * (default port 20128) and point the provider base URL at it. + * + * Default model catalog mirrors the curated set shipped by OmniRoute's own + * @omniroute/opencode-provider package, plus the `auto` virtual combo router. The live + * `GET /v1/models` is the source of truth — this array is only an offline fallback seed. + */ +const OMNIROUTE_MODELS = [ + "auto", + "cc/claude-opus-4-8", + "cc/claude-opus-4-7", + "cc/claude-sonnet-4-6", + "cc/claude-haiku-4-5-20251001", + "claude-opus-4-5-thinking", + "claude-sonnet-4-5-thinking", + "gemini-3.1-pro-high", + "gemini-3-flash", +]; +const OMNIROUTE_MODEL_CONTEXT_WINDOWS: Record = { + "cc/claude-opus-4-8": 1_000_000, + "cc/claude-opus-4-7": 1_000_000, + "cc/claude-sonnet-4-6": 200_000, + "cc/claude-haiku-4-5-20251001": 200_000, + "claude-opus-4-5-thinking": 200_000, + "claude-sonnet-4-5-thinking": 200_000, + "gemini-3.1-pro-high": 1_000_000, + "gemini-3-flash": 1_000_000, +}; + /** * Vendor thinking-toggle models (MiMo v2.x, GLM 5/5.1 on Zen Go): the wire knob is * `thinking: {type: enabled|disabled}` — a binary. Advertise the full Codex picker ladder @@ -1106,6 +1138,27 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ }, // FREEZE 2026-07-10: no public OpenAI-compatible endpoint is documented. Evidence: devlog/_plan/260710_provider_hardening/003_research_aggregators.md. { id: "gitlab-duo", label: "GitLab Duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://gitlab.com/-/user_settings/personal_access_tokens" }, + { + // OmniRoute: open-source OpenAI-compatible gateway (https://github.com/diegosouzapw/OmniRoute). + // Aggregates 250+ providers (90+ free) behind one endpoint. Cloud: api.omniroute.online; + // self-host via the `diegosouzapw/omniroute` Docker image (default :20128) and override baseUrl. + // Auth: Authorization: Bearer $OCX_OMNIROUTE_KEY. Model seed mirrors @omniroute/opencode-provider; + // the live /v1/models is the source of truth. + id: "omniroute", + label: "OmniRoute", + adapter: "openai-chat", + baseUrl: "https://api.omniroute.online/v1", + authKind: "key", + featured: true, + freeTier: true, + allowBaseUrlOverride: true, + dashboardUrl: "https://omniroute.online", + defaultModel: "claude-sonnet-4-5-thinking", + models: OMNIROUTE_MODELS, + modelContextWindows: OMNIROUTE_MODEL_CONTEXT_WINDOWS, + noReasoningModels: ["auto", "cc/claude-haiku-4-5-20251001", "gemini-3-flash"], + note: "Free gateway — 90+ free models behind one key. Self-host with diegosouzapw/omniroute and point the base URL at your instance.", + }, ]; export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | undefined { diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index a5a8aaacf..a3242ea41 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -299,7 +299,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { const dto: Record = { adapter: provider.adapter, baseUrl: publicProviderBaseUrl(provider.baseUrl), - hasApiKey: !!provider.apiKey, + hasApiKey: !!provider.apiKey || !!provider.credentialRef, hasHeaders: !!provider.headers && Object.keys(provider.headers).length > 0, }; for (const key of [ @@ -307,10 +307,12 @@ export function safeConfigDTO(config: OcxConfig): unknown { "disabled", "allowPrivateNetwork", "authMode", + "credentialRef", "keyOptional", "freeTier", "liveModels", "models", + "fallback", "contextWindow", "modelContextWindows", "defaultMaxOutputTokens", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 7d8cd65c6..db5fe3067 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -25,6 +25,7 @@ import { removeCredential } from "../../oauth/store"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers"; import { deriveProviderPresets } from "../../providers/derive"; +import { providerFallbackError, providerFallbackTargets } from "../../providers/fallback"; import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; @@ -77,6 +78,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise).map(t => ({ + provider: t.provider.trim(), + model: t.model.trim(), + })) + : []; + if (targets.length) next.fallback = targets; + else delete next.fallback; + touched = true; + } + if (!touched) return jsonResponse({ error: "no recognized fields to update" }, 400); // A disabled-only toggle preserves the v2 fast lane for non-openai providers: it changes diff --git a/src/server/responses-item-id-repair.ts b/src/server/responses-item-id-repair.ts index 2309d141e..f3722502e 100644 --- a/src/server/responses-item-id-repair.ts +++ b/src/server/responses-item-id-repair.ts @@ -110,11 +110,14 @@ function rememberMappedId( const existing = state.outputIds[type].get(outputIndex); if (existing) return existing; const rawId = typeof item.id === "string" ? item.id : undefined; - if (!rawId) return null; - const mapped = state.placeholders[type].has(rawId) - ? mintCanonicalId(type, state.scope, outputIndex) + const mapped = rawId + ? state.placeholders[type].has(rawId) + ? mintCanonicalId(type, state.scope, outputIndex) + : state.repairMissingTerminalIds + ? rawId + : null : state.repairMissingTerminalIds - ? rawId + ? mintCanonicalId(type, state.scope, outputIndex) : null; if (!mapped) return null; state.outputIds[type].set(outputIndex, mapped); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index fbed02554..9aeafe0e2 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -25,6 +25,7 @@ import { pickComboTarget, targetKey, } from "../../combos"; +import { comboIdLabel, isProviderFallbackComboId, providerFallbackPlan } from "../../providers/fallback"; import { isInjectionDebugEnabled } from "../../lib/debug-settings"; import { injectionDebugLog } from "../../lib/injection-debug-log"; import { resolveClientRetryAfter } from "../../lib/retry-after"; @@ -637,24 +638,22 @@ export async function handleComboResponses( const requestedModel = typeof (rawBody as { model?: unknown } | null)?.model === "string" ? (rawBody as { model: string }).model : `combo/${comboId}`; - Object.assign(logCtx, { - requestedModel, - model: requestedModel, - provider: "combo", - comboId, - }); + // A per-provider fallback chain runs on this same hop loop but is not a combo the user + // configured: the log row must keep the winning target's own provider/model rather than + // collapsing into a synthetic `combo` row nothing in the GUI can open. + const comboIdentity = isProviderFallbackComboId(comboId) + ? { requestedModel } + : { requestedModel, model: requestedModel, provider: "combo", comboId }; + Object.assign(logCtx, comboIdentity); const combo = getCombo(config, comboId); if (!combo) { - return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`); + return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboIdLabel(comboId)}`); } const adoptFailedChildLog = (childLog: RequestLogContext): void => { // Attempts remain the complete physical history; the logical row mirrors the most recent // failed target so an exhausted combo still has useful top-level reasoning diagnostics. Object.assign(logCtx, childLog, { - requestedModel, - model: requestedModel, - provider: "combo", - comboId, + ...comboIdentity, attempts: logCtx.attempts, activeAttempt: undefined, activeAttemptStartedAt: undefined, @@ -687,7 +686,7 @@ export async function handleComboResponses( && !isComboTargetInCooldown(comboId, target, initialNow), }); if (!pick) { - return comboUnavailableResponse(`No available targets for combo: ${comboId}`); + return comboUnavailableResponse(`No available targets for combo: ${comboIdLabel(comboId)}`); } let lastFailure: Response | null = null; @@ -780,10 +779,7 @@ export async function handleComboResponses( attemptRetained = true; noteComboSuccess(comboId, combo, pick.target); Object.assign(logCtx, childLog, { - requestedModel, - model: requestedModel, - provider: "combo", - comboId, + ...comboIdentity, attempts: logCtx.attempts, activeAttempt: attempt, activeAttemptStartedAt: started, @@ -836,7 +832,7 @@ export async function handleComboResponses( return lastFailure; } console.warn( - `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${response.status} after ${Date.now() - started}ms`, + `[combo] ${comboIdLabel(comboId)}: ${targetKey(pick.target)} failed with ${response.status} after ${Date.now() - started}ms`, ); const nextPick = advanceComboAfterFailure(config, pick, { retryAfter: failure.retryAfter, @@ -951,6 +947,16 @@ export async function handleResponses( return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err)); } + // Per-provider fallback replays the request across the provider's configured targets using the + // combo hop loop. Thread spawns are excluded: they carry their own Codex account/model fallback + // below, which the combo path deliberately skips. + if (!options.comboAttempt && !isThreadSpawnRequest(req.headers)) { + const plan = providerFallbackPlan(config, { provider: route.providerName, modelId: route.modelId }); + if (plan) { + return handleComboResponses(req, body, plan.comboId, plan.config, logCtx, options); + } + } + let authCtx: CodexAuthContext = { kind: "main", accountId: null }; let selectedForwardHeaders = req.headers; let subagentFallbackAccountId = config.activeCodexAccountId ?? null; diff --git a/src/telemetry/posthog-server.ts b/src/telemetry/posthog-server.ts new file mode 100644 index 000000000..6869378f4 --- /dev/null +++ b/src/telemetry/posthog-server.ts @@ -0,0 +1,188 @@ +/** + * Server-side PostHog telemetry — dependency-free, opt-in, EU-hosted, no PII. + * + * Enabled only when OCX_POSTHOG_KEY is set. All errors are swallowed: telemetry + * must never break a request or crash the proxy. Events are batched and flushed + * every 10s or 50 events (whichever first) via Bun.fetch to the /capture/ endpoint. + * + * Collected properties (NO PII): event name, provider, model, adapter, status, + * durationMs, firstOutputMs (TTFT), token counts (input/output/cached/reasoning), + * error codes, and codex account-pool outcomes. Never request bodies, headers, + * auth tokens, user prompts, or filenames. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../config"; + +const DEFAULT_HOST = "https://eu.i.posthog.com"; +const FLUSH_INTERVAL_MS = 10_000; +const MAX_BATCH_SIZE = 50; + +/** Canonical telemetry event names. */ +export const TELEMETRY_EVENTS = { + REQUEST_TERMINAL: "proxy_request_terminal", + FAILOVER_TRIGGERED: "proxy_failover_triggered", + ACCOUNT_COOLDOWN: "proxy_account_cooldown", + QUOTA_THRESHOLD: "proxy_quota_threshold", + BUDGET_EXCEEDED: "proxy_budget_exceeded", +} as const; + +export type TelemetryEvent = (typeof TELEMETRY_EVENTS)[keyof typeof TELEMETRY_EVENTS]; + +interface QueuedEvent { + event: string; + properties: Record; + timestamp: string; +} + +/** Keys that are stripped from properties before capture (defense-in-depth). */ +const SENSITIVE_KEY_PATTERNS = [ + /^(authorization|api[_-]?key|token|secret|password|cookie)$/i, + /^(prompt|message|content|body|payload|input|text)$/i, + /^(header|headers)$/i, +]; + +/** Strip sensitive-looking keys and cap string values to avoid accidental PII. */ +function sanitizeProperties(props: Record | undefined): Record { + if (!props || typeof props !== "object") return {}; + const clean: Record = {}; + for (const [key, value] of Object.entries(props)) { + if (SENSITIVE_KEY_PATTERNS.some((re) => re.test(key))) continue; + if (typeof value === "string" && value.length > 200) { + clean[key] = value.slice(0, 200); + } else if (typeof value === "number" && Number.isFinite(value)) { + clean[key] = value; + } else if (typeof value === "boolean") { + clean[key] = value; + } else if (typeof value === "string") { + clean[key] = value; + } + } + return clean; +} + +/** Stable anonymous distinct_id stored on disk — no hostnames, no usernames. */ +function loadOrCreateDistinctId(): string { + const dir = getConfigDir(); + const idPath = join(dir, "telemetry-id.txt"); + try { + if (existsSync(idPath)) { + const id = readFileSync(idPath, "utf-8").trim(); + if (id && id.length >= 8) return id; + } + mkdirSync(dir, { recursive: true, mode: 0o700 }); + // Random UUID-style id; completely anonymous. + const id = crypto.randomUUID(); + writeFileSync(idPath, id, { mode: 0o600 }); + try { chmodSync(idPath, 0o600); } catch { /* best-effort */ } + return id; + } catch { + // Fallback: ephemeral random id if disk is unavailable. + return crypto.randomUUID(); + } +} + +export class PosthogClient { + private readonly key: string; + private readonly host: string; + private readonly distinctId: string; + private readonly queue: QueuedEvent[] = []; + private readonly timer: ReturnType | null = null; + private flushing = false; + + constructor(key: string, host: string = DEFAULT_HOST) { + this.key = key; + this.host = host.replace(/\/+$/, ""); + this.distinctId = loadOrCreateDistinctId(); + this.timer = setInterval(() => { + void this.flush(); + }, FLUSH_INTERVAL_MS); + // Don't keep the process alive just for telemetry flushing. + this.timer.unref?.(); + } + + /** Fire-and-forget capture. Never throws. */ + capture(event: string, properties?: Record): void { + try { + this.queue.push({ + event, + properties: sanitizeProperties(properties), + timestamp: new Date().toISOString(), + }); + if (this.queue.length >= MAX_BATCH_SIZE) { + void this.flush(); + } + } catch { + /* swallow — telemetry must never break callers */ + } + } + + /** Flush pending events to PostHog /batch/ endpoint. */ + async flush(): Promise { + if (this.flushing || this.queue.length === 0) return; + this.flushing = true; + const batch = this.queue.splice(0, this.queue.length); + try { + const body = JSON.stringify({ + api_key: this.key, + historical_migration: false, + batch: batch.map((e) => ({ + event: e.event, + distinct_id: this.distinctId, + properties: { ...e.properties, $lib: "opencodex-server" }, + timestamp: e.timestamp, + })), + }); + const res = await fetch(`${this.host}/batch/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + // Don't hang the proxy on a slow analytics endpoint. + signal: AbortSignal.timeout(5_000), + }); + if (!res.ok) { + // PostHog returns 1xx for accepted-but-some-invalid; requeue on hard failure. + void res.body?.cancel().catch(() => {}); + } + } catch { + /* network errors are non-fatal — drop the batch */ + } finally { + this.flushing = false; + } + } + + /** Flush + stop the timer. Safe to call on shutdown. */ + shutdown(): void { + if (this.timer) clearInterval(this.timer); + void this.flush(); + } +} + +let cachedClient: PosthogClient | null | undefined; + +/** + * Singleton accessor. Returns a PosthogClient if OCX_POSTHOG_KEY is set, else null. + * Never throws on misconfiguration. + */ +export function getServerPosthog(): PosthogClient | null { + if (cachedClient !== undefined) return cachedClient; + try { + const key = process.env["OCX_POSTHOG_KEY"]?.trim(); + if (!key) { + cachedClient = null; + return null; + } + const host = process.env["OCX_POSTHOG_HOST"]?.trim() || DEFAULT_HOST; + cachedClient = new PosthogClient(key, host); + return cachedClient; + } catch { + cachedClient = null; + return null; + } +} + +/** Reset the singleton (for tests). */ +export function resetServerPosthog(): void { + if (cachedClient) cachedClient.shutdown(); + cachedClient = undefined; +} diff --git a/src/types.ts b/src/types.ts index 822ac4984..7d29159ed 100644 --- a/src/types.ts +++ b/src/types.ts @@ -594,6 +594,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; /** @@ -646,6 +654,26 @@ export interface OcxConfig { autoSwitchThreshold?: number; /** Consecutive non-2xx upstream responses before switching future new threads. Default 3. 0 = disabled. */ upstreamFailoverThreshold?: number; + /** + * Codex pool selection strategy for new (non-affined) conversations. + * - "failover" (default): a single sticky `activeCodexAccountId` serves all traffic and only + * switches on a quota-threshold breach, cooldown (429/reauth), or failure streak. Multiple free + * accounts act purely as failover reserves — they do NOT multiply throughput under healthy load. + * - "round-robin": each NEW conversation rotates across all usable pool accounts so a multi-account + * free pool actually multiplies throughput. Thread affinity (conversation continuity) still pins a + * conversation to one account; accounts in cooldown / soft-avoid / reauth are skipped automatically. + * The round-robin cursor is per-process and in-memory; `activeCodexAccountId` is left untouched. + * With a single account configured this is a no-op (rotation collapses to that one account). + */ + codexRotationMode?: "failover" | "round-robin"; + /** + * Jittered inter-request pacer for outbound Codex pool calls. Off by default. + * When enabled, each pool account waits a randomized delay in [minMs, maxMs] between consecutive + * sends (per-account state, so concurrent accounts desync instead of aligning into a fixed + * interval) so a multi-account pool never emits a regular, ban-prone request pattern. Disabled + * by default for backward compatibility; single-account setups are unaffected while disabled. + */ + codexRequestPacing?: OcxCodexRequestPacing; /** Virtual `combo/` models spanning concrete provider/model targets (issue #133). */ combos?: Record; /** Background proactive token refresh ("Token Guardian"). Off by default; see OcxTokenGuardianConfig. */ @@ -657,6 +685,15 @@ export interface OcxConfig { export type OcxComboStrategy = "failover" | "round-robin"; export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; +export interface OcxCodexRequestPacing { + /** Master switch. Default false. */ + enabled?: boolean; + /** Inclusive lower bound of the randomized inter-request gap (ms). Default 150. */ + minMs?: number; + /** Inclusive upper bound of the randomized inter-request gap (ms). Default 900. */ + maxMs?: number; +} + export interface OcxComboTarget { provider: string; model: string; @@ -810,12 +847,25 @@ export interface OcxProviderConfig { allowPrivateNetwork?: boolean; /** Keep provider settings on disk but exclude it from routing and model/catalog listings. */ disabled?: boolean; + /** + * Ordered failover targets for plain (non-combo) requests that resolve to this provider. + * When the provider answers with a retryable failure — 429, 5xx, or an `upstream_server_error` + * such as a stream that dies without a terminal event — the request is replayed against these + * targets in order, reusing the combo failover engine's per-target cooldowns. + * Empty or omitted keeps today's behaviour: the failure is returned to the caller. + */ + fallback?: OcxComboTarget[]; /** * Codex account-selection mode. Valid ONLY on the canonical built-in `openai` forward provider. * "pool" (default) rotates main + added Codex accounts through the affinity/quota/cooldown/ * failover engine; "direct" pins the caller's main Codex login and never touches pool state. */ codexAccountMode?: CodexAccountMode; + /** + * ChefVault credential reference (`chefvault://…`) resolved via the provider-security plane. + * When set, raw secrets are not stored in config — only in-memory leases at runtime. + */ + credentialRef?: string; apiKey?: string; /** * Multi-key pool (API-key twin of OAuth multiauth). `apiKey` always mirrors the ACTIVE diff --git a/src/update/job.ts b/src/update/job.ts index 62a01716e..40b7e8949 100644 --- a/src/update/job.ts +++ b/src/update/job.ts @@ -22,7 +22,7 @@ import { import { isNewer } from "./notify"; import { handoffWindowsTrayForUpdate, planWindowsTrayUpdate } from "./tray-update-plan.mjs"; -const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest"; +const RELEASE_NOTES_URL = "https://github.com/OnlineChefGroep/opencodex/releases/latest"; const UPDATE_JOB_FILENAME = "update-job.json"; const UPDATE_TIMEOUT_MS = 180_000; const RESTART_TIMEOUT_MS = 60_000; diff --git a/src/update/notify.ts b/src/update/notify.ts index 6a6ad9863..9602ebcd3 100644 --- a/src/update/notify.ts +++ b/src/update/notify.ts @@ -16,7 +16,7 @@ import { const VERSION_FILENAME = "version.json"; const REFRESH_INTERVAL_MS = 20 * 60 * 60 * 1000; // 20h, matching codex-rs -const RELEASE_NOTES_URL = "https://github.com/lidge-jun/opencodex/releases/latest"; +const RELEASE_NOTES_URL = "https://github.com/OnlineChefGroep/opencodex/releases/latest"; export interface VersionCache { latest_version: string; diff --git a/src/usage/budgets.ts b/src/usage/budgets.ts new file mode 100644 index 000000000..81ad0d452 --- /dev/null +++ b/src/usage/budgets.ts @@ -0,0 +1,252 @@ +/** + * Token/cost budget tracker with rolling windows. + * + * Keeps in-memory daily + weekly counters that reset at local midnight / week + * boundary. Persists state to ~/.opencodex/budget-state.json so process restarts + * don't lose the running total. Alerts fire when configured thresholds are crossed. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs"; +import { join } from "node:path"; +import { getConfigDir } from "../config"; +import { estimateCostEur } from "./pricing"; +import { getServerPosthog, TELEMETRY_EVENTS } from "../telemetry/posthog-server"; +import type { OcxUsage } from "../types"; + +export interface BudgetConfig { + tokenDaily?: number; + tokenWeekly?: number; + costDailyEur?: number; + alertActions?: Array<"log" | "posthog" | "webhook">; + webhookUrl?: string; +} + +export type BudgetAlertType = "token-daily" | "token-weekly" | "cost-daily"; + +export interface BudgetAlert { + type: BudgetAlertType; + threshold: number; + actual: number; + message: string; +} + +export interface UsageSummary { + todayTokens: number; + weekTokens: number; + todayCostEur: number; + limits: { tokenDaily?: number; tokenWeekly?: number; costDailyEur?: number }; +} + +interface PersistedState { + /** Epoch ms for the day the counters apply to. */ + dayStart: number; + /** Epoch ms for the week the counters apply to (Monday 00:00 local). */ + weekStart: number; + todayTokens: number; + todayCostEur: number; + weekTokens: number; +} + +/** Returns local-midnight epoch ms for the given timestamp's day. */ +function startOfDay(ts: number): number { + const d = new Date(ts); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + +/** Returns local Monday 00:00 epoch ms for the given timestamp's week. */ +function startOfWeek(ts: number): number { + const d = new Date(startOfDay(ts)); + const dow = d.getDay(); // 0=Sun ... 6=Sat + const diff = dow === 0 ? -6 : 1 - dow; // back to Monday + d.setDate(d.getDate() + diff); + return d.getTime(); +} + +export class BudgetTracker { + private state: PersistedState; + private readonly config: BudgetConfig; + /** Already-fired alerts (dedupe within a window so we don't spam). */ + private readonly fired = new Set(); + private readonly statePath: string; + private dirty = false; + private flushTimer: ReturnType | null = null; + + constructor(config: BudgetConfig = {}) { + this.config = config; + this.statePath = join(getConfigDir(), "budget-state.json"); + this.state = this.load(); + this.startFlushTimer(); + } + + private load(): PersistedState { + const now = Date.now(); + const fresh: PersistedState = { + dayStart: startOfDay(now), + weekStart: startOfWeek(now), + todayTokens: 0, + todayCostEur: 0, + weekTokens: 0, + }; + try { + if (!existsSync(this.statePath)) return fresh; + const parsed = JSON.parse(readFileSync(this.statePath, "utf-8")) as PersistedState; + if (!parsed || typeof parsed !== "object") return fresh; + // Roll over windows if we've crossed a boundary. + if (startOfDay(now) !== parsed.dayStart) { + parsed.todayTokens = 0; + parsed.todayCostEur = 0; + parsed.dayStart = startOfDay(now); + } + if (startOfWeek(now) !== parsed.weekStart) { + parsed.weekTokens = 0; + parsed.weekStart = startOfWeek(now); + } + return { ...fresh, ...parsed }; + } catch { + return fresh; + } + } + + private persist(): void { + try { + mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 }); + writeFileSync(this.statePath, JSON.stringify(this.state), { mode: 0o600 }); + try { chmodSync(this.statePath, 0o600); } catch { /* best-effort */ } + } catch { + /* persistence is best-effort */ + } + } + + private startFlushTimer(): void { + this.flushTimer = setInterval(() => { + if (this.dirty) { + this.persist(); + this.dirty = false; + } + }, 5_000); + this.flushTimer.unref?.(); + } + + shutdown(): void { + if (this.flushTimer) clearInterval(this.flushTimer); + if (this.dirty) this.persist(); + } + + /** + * Record a completed request's usage. Returns any alerts that crossed a + * threshold on this call (empty array if under budget / unconfigured). + */ + recordUsage(provider: string, model: string | undefined, usage: OcxUsage | undefined): BudgetAlert[] { + if (!usage) return []; + const tokens = (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); + if (tokens <= 0) return []; + const cost = estimateCostEur(provider, model, usage.inputTokens ?? 0, usage.outputTokens ?? 0); + + this.state.todayTokens += tokens; + this.state.weekTokens += tokens; + this.state.todayCostEur += cost; + this.dirty = true; + + return this.checkThresholds(); + } + + private checkThresholds(): BudgetAlert[] { + const alerts: BudgetAlert[] = []; + const cfg = this.config; + + if (cfg.tokenDaily && this.state.todayTokens >= cfg.tokenDaily) { + const key = `token-daily-${this.state.dayStart}`; + if (!this.fired.has(key)) { + this.fired.add(key); + alerts.push({ + type: "token-daily", + threshold: cfg.tokenDaily, + actual: this.state.todayTokens, + message: `Daily token budget reached: ${this.state.todayTokens.toLocaleString()} / ${cfg.tokenDaily.toLocaleString()} tokens`, + }); + } + } + if (cfg.tokenWeekly && this.state.weekTokens >= cfg.tokenWeekly) { + const key = `token-weekly-${this.state.weekStart}`; + if (!this.fired.has(key)) { + this.fired.add(key); + alerts.push({ + type: "token-weekly", + threshold: cfg.tokenWeekly, + actual: this.state.weekTokens, + message: `Weekly token budget reached: ${this.state.weekTokens.toLocaleString()} / ${cfg.tokenWeekly.toLocaleString()} tokens`, + }); + } + } + if (cfg.costDailyEur && this.state.todayCostEur >= cfg.costDailyEur) { + const key = `cost-daily-${this.state.dayStart}`; + if (!this.fired.has(key)) { + this.fired.add(key); + alerts.push({ + type: "cost-daily", + threshold: cfg.costDailyEur, + actual: this.state.todayCostEur, + message: `Daily cost budget reached: €${this.state.todayCostEur.toFixed(2)} / €${cfg.costDailyEur.toFixed(2)}`, + }); + } + } + + for (const alert of alerts) this.dispatchAlert(alert); + return alerts; + } + + private dispatchAlert(alert: BudgetAlert): void { + const actions = this.config.alertActions ?? ["log"]; + for (const action of actions) { + try { + if (action === "log") { + console.warn(`[ocx:budget] ${alert.message}`); + } else if (action === "posthog") { + getServerPosthog()?.capture(TELEMETRY_EVENTS.BUDGET_EXCEEDED, { + type: alert.type, + threshold: alert.threshold, + actual: alert.actual, + }); + } else if (action === "webhook" && this.config.webhookUrl) { + void fetch(this.config.webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ alert }), + signal: AbortSignal.timeout(3_000), + }).catch(() => { /* best-effort */ }); + } + } catch { + /* alert dispatch never throws */ + } + } + } + + getUsageSummary(): UsageSummary { + return { + todayTokens: this.state.todayTokens, + weekTokens: this.state.weekTokens, + todayCostEur: this.state.todayCostEur, + limits: { + tokenDaily: this.config.tokenDaily, + tokenWeekly: this.config.tokenWeekly, + costDailyEur: this.config.costDailyEur, + }, + }; + } +} + +let cachedTracker: BudgetTracker | undefined; + +/** Singleton — constructed lazily from config on first use. */ +export function getBudgetTracker(config?: BudgetConfig): BudgetTracker { + if (!cachedTracker) { + cachedTracker = new BudgetTracker(config ?? {}); + } + return cachedTracker; +} + +/** Reset singleton (for tests). */ +export function resetBudgetTracker(): void { + cachedTracker?.shutdown(); + cachedTracker = undefined; +} diff --git a/src/usage/percentiles.ts b/src/usage/percentiles.ts new file mode 100644 index 000000000..182bfc8f0 --- /dev/null +++ b/src/usage/percentiles.ts @@ -0,0 +1,91 @@ +/** + * Latency percentile computation for proxy request analytics. + * + * Pure functions — no I/O, no side effects. Used by the /api/latency-stats + * endpoint and the GUI Logs page. + */ +export interface LatencyStats { + count: number; + p50: number; + p95: number; + p99: number; + min: number; + max: number; + mean: number; +} + +export interface ProviderLatencySample { + provider: string; + /** Time-to-first-token (TTFT) in ms, if recorded. */ + firstOutputMs?: number; + /** Total request duration in ms. */ + durationMs: number; +} + +export interface ProviderLatencyStats { + ttft: LatencyStats; + total: LatencyStats; +} + +/** + * Linear-interpolation percentile (matches numpy.percentile default / R type 7). + * Returns 0 for empty input. + */ +export function percentile(values: number[], p: number): number { + if (values.length === 0) return 0; + if (values.length === 1) return values[0]!; + const sorted = [...values].sort((a, b) => a - b); + const clampedP = Math.max(0, Math.min(100, p)); + if (clampedP === 0) return sorted[0]!; + if (clampedP === 100) return sorted[sorted.length - 1]!; + const rank = (clampedP / 100) * (sorted.length - 1); + const lo = Math.floor(rank); + const hi = Math.ceil(rank); + if (lo === hi) return sorted[lo]!; + const frac = rank - lo; + return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * frac; +} + +/** Compute the full stats block for a set of latency samples. */ +export function computeLatencyStats(samples: number[]): LatencyStats { + if (samples.length === 0) { + return { count: 0, p50: 0, p95: 0, p99: 0, min: 0, max: 0, mean: 0 }; + } + const sum = samples.reduce((a, b) => a + b, 0); + return { + count: samples.length, + p50: Math.round(percentile(samples, 50)), + p95: Math.round(percentile(samples, 95)), + p99: Math.round(percentile(samples, 99)), + min: Math.min(...samples), + max: Math.max(...samples), + mean: Math.round(sum / samples.length), + }; +} + +/** + * Group samples by provider and compute TTFT + total-latency stats per provider. + * Samples without firstOutputMs only contribute to the total stats. + */ +export function groupByProvider( + samples: ProviderLatencySample[], +): Map { + const byProvider = new Map(); + for (const s of samples) { + let bucket = byProvider.get(s.provider); + if (!bucket) { + bucket = { ttft: [], total: [] }; + byProvider.set(s.provider, bucket); + } + bucket.total.push(s.durationMs); + if (s.firstOutputMs !== undefined) bucket.ttft.push(s.firstOutputMs); + } + const result = new Map(); + for (const [provider, bucket] of byProvider) { + result.set(provider, { + ttft: computeLatencyStats(bucket.ttft), + total: computeLatencyStats(bucket.total), + }); + } + return result; +} diff --git a/src/usage/pricing.ts b/src/usage/pricing.ts new file mode 100644 index 000000000..50245679e --- /dev/null +++ b/src/usage/pricing.ts @@ -0,0 +1,96 @@ +/** + * Estimated per-token pricing (EUR per 1,000 tokens). + * + * These are APPROXIMATIONS for budget alerting only — not billing. Prices change + * frequently; treat numbers as conservative estimates. Unknown providers/models + * default to 0 (free) so we never over-report spend for self-hosted/gateway routes. + * + * Source: provider pricing pages as of mid-2025, converted to EUR (~1 USD = 0.92 EUR). + */ +export interface ProviderPricing { + /** EUR per 1k input tokens (uncached). */ + inputPer1k: number; + /** EUR per 1k output tokens. */ + outputPer1k: number; +} + +/** + * Keyed by provider id (matches PROVIDER_REGISTRY ids) OR "provider:model" for + * model-specific overrides. Lookups try model-specific first, then provider, then 0. + */ +const PRICING: Record = { + // OpenAI (flagship tiers; mini/flash much cheaper but we default conservatively) + "openai:gpt-4o": { inputPer1k: 0.0023, outputPer1k: 0.0092 }, + "openai:chatgpt-4o-latest": { inputPer1k: 0.0051, outputPer1k: 0.0152 }, + "openai:gpt-4o-mini": { inputPer1k: 0.00013, outputPer1k: 0.00052 }, + "openai": { inputPer1k: 0.0023, outputPer1k: 0.0092 }, + + // Anthropic (Claude) + "anthropic:claude-sonnet-4-5": { inputPer1k: 0.0028, outputPer1k: 0.0139 }, + "anthropic:claude-opus-4": { inputPer1k: 0.0139, outputPer1k: 0.0694 }, + "anthropic:claude-haiku-4-5": { inputPer1k: 0.00092, outputPer1k: 0.0046 }, + "anthropic": { inputPer1k: 0.0028, outputPer1k: 0.0139 }, + + // Google Gemini + "google:gemini-2.5-pro": { inputPer1k: 0.00115, outputPer1k: 0.0046 }, + "google:gemini-2.5-flash": { inputPer1k: 0.00012, outputPer1k: 0.00037 }, + "google": { inputPer1k: 0.00031, outputPer1k: 0.00092 }, + + // xAI Grok + "xai:grok-4": { inputPer1k: 0.0028, outputPer1k: 0.0139 }, + "xai": { inputPer1k: 0.0046, outputPer1k: 0.0139 }, + + // DeepSeek + "deepseek:deepseek-chat": { inputPer1k: 0.00023, outputPer1k: 0.00083 }, + "deepseek": { inputPer1k: 0.00023, outputPer1k: 0.00083 }, + + // Kimi / Moonshot + "kimi": { inputPer1k: 0.00046, outputPer1k: 0.0028 }, + "moonshot": { inputPer1k: 0.00046, outputPer1k: 0.0028 }, + + // Z.AI (GLM) — coding plan, flat-rate, estimate as low + "zai": { inputPer1k: 0, outputPer1k: 0 }, + + // Cursor — subscription, estimate as 0 (covered by sub) + "cursor": { inputPer1k: 0, outputPer1k: 0 }, + + // OmniRoute — free tier + "omniroute": { inputPer1k: 0, outputPer1k: 0 }, + + // Ollama / self-hosted — free + "ollama-cloud": { inputPer1k: 0, outputPer1k: 0 }, + "litellm": { inputPer1k: 0, outputPer1k: 0 }, + + // Kiro — subscription + "kiro": { inputPer1k: 0, outputPer1k: 0 }, + + // GitHub Copilot — subscription + "github-copilot": { inputPer1k: 0, outputPer1k: 0 }, +}; + +/** Find the best-matching pricing entry: model-specific, then provider, then free. */ +export function lookupPricing(provider: string, model?: string): ProviderPricing { + if (model) { + const modelKey = `${provider}:${model.toLowerCase()}`; + if (PRICING[modelKey]) return PRICING[modelKey]; + // Try a prefix match (e.g. "claude-sonnet-4-5-20250929" → "claude-sonnet-4-5") + const prefixHit = Object.keys(PRICING).find((k) => { + if (!k.startsWith(`${provider}:`)) return false; + const baseModel = k.slice(provider.length + 1); + return baseModel !== provider && model.toLowerCase().startsWith(baseModel); + }); + if (prefixHit) return PRICING[prefixHit]; + } + return PRICING[provider] ?? { inputPer1k: 0, outputPer1k: 0 }; +} + +/** Estimate EUR cost for a usage record. */ +export function estimateCostEur( + provider: string, + model: string | undefined, + inputTokens: number, + outputTokens: number, +): number { + const p = lookupPricing(provider, model); + return (inputTokens / 1000) * p.inputPer1k + (outputTokens / 1000) * p.outputPer1k; +} diff --git a/tests/codex-pacer.test.ts b/tests/codex-pacer.test.ts new file mode 100644 index 000000000..a9fac0180 --- /dev/null +++ b/tests/codex-pacer.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { codexPaceBeforeSend, resetCodexPacerState } from "../src/codex/pacer"; +import type { OcxConfig } from "../src/types"; + +// Minimal helper: wrap global setTimeout so we can observe the delay it would +// schedule without skipping the real wait (tests use small bounds). Captures +// every positive delay passed to setTimeout during the patched window. +function withSetTimeoutSpy(fn: () => Promise): { scheduled: number[]; result: Promise } { + const scheduled: number[] = []; + const real = globalThis.setTimeout; + globalThis.setTimeout = ((callback: TimerHandler, ms?: number, ...rest: unknown[]) => { + if (typeof ms === "number" && ms > 0) scheduled.push(ms); + return real(callback, ms, ...(rest as [])); + }) as typeof globalThis.setTimeout; + const result = fn().finally(() => { + globalThis.setTimeout = real; + }); + return { scheduled, result }; +} + +function makeConfig(pacing: OcxConfig["codexRequestPacing"]): OcxConfig { + return { codexRequestPacing: pacing } as OcxConfig; +} + +describe("codex request pacer", () => { + let realDateNow: typeof Date.now; + let realMathRandom: typeof Math.random; + + beforeEach(() => { + resetCodexPacerState(); + realDateNow = Date.now; + realMathRandom = Math.random; + }); + + afterEach(() => { + Date.now = realDateNow; + Math.random = realMathRandom; + resetCodexPacerState(); + }); + + test("disabled resolves instantly and schedules no delay", async () => { + const { scheduled, result } = withSetTimeoutSpy(() => + codexPaceBeforeSend(makeConfig({ enabled: false, minMs: 1000, maxMs: 2000 }), "x"), + ); + await result; + expect(scheduled).toEqual([]); + }); + + test("enabled emits a delay within [minMs, maxMs] between consecutive same-account sends", async () => { + // Fix the jitter (mid-range) and freeze the clock so the gap is deterministic. + Date.now = (() => 10_000) as typeof Date.now; + Math.random = (() => 0.5) as typeof Math.random; + + const config = makeConfig({ enabled: true, minMs: 100, maxMs: 200 }); + const { scheduled, result } = withSetTimeoutSpy(async () => { + // First send: no prior timestamp -> no delay, records lastSendAt = 10_000. + await codexPaceBeforeSend(config, "x"); + // Advance the clock slightly; second send waits gap(150) - elapsed(5) = 145. + Date.now = (() => 10_005) as typeof Date.now; + await codexPaceBeforeSend(config, "x"); + }); + await result; + + expect(scheduled).toHaveLength(1); + const delay = scheduled[0]!; + expect(delay).toBeGreaterThanOrEqual(100); + expect(delay).toBeLessThanOrEqual(200); + expect(delay).toBeCloseTo(145, -1); // 150 - 5 + }); + + test("per-account state desyncs: a fresh account never waits on another's history", async () => { + Date.now = (() => 10_000) as typeof Date.now; + Math.random = (() => 0.5) as typeof Math.random; + + const config = makeConfig({ enabled: true, minMs: 100, maxMs: 200 }); + const { scheduled, result } = withSetTimeoutSpy(async () => { + // Prime account x twice so it has a recent send and would wait on a repeat. + await codexPaceBeforeSend(config, "x"); + Date.now = (() => 10_010) as typeof Date.now; + await codexPaceBeforeSend(config, "x"); // x now has history -> waits ~140ms + // Account y has never sent: its first call must not inherit x's cadence. + Date.now = (() => 10_010) as typeof Date.now; + await codexPaceBeforeSend(config, "y"); + }); + await result; + + // Exactly one delay scheduled (x's repeat). y's first send adds none. + expect(scheduled).toHaveLength(1); + expect(scheduled[0]).toBeGreaterThanOrEqual(100); + }); + + test("enabled with no accountId is a no-op (defensive)", async () => { + Date.now = (() => 10_000) as typeof Date.now; + Math.random = (() => 0.5) as typeof Math.random; + const { scheduled, result } = withSetTimeoutSpy(() => + codexPaceBeforeSend(makeConfig({ enabled: true, minMs: 100, maxMs: 200 }), null), + ); + await result; + expect(scheduled).toEqual([]); + }); + + test("elapsed longer than the gap floors the wait at zero", async () => { + Date.now = (() => 10_000) as typeof Date.now; + Math.random = (() => 0) as typeof Math.random; // gap = minMs = 100 + const config = makeConfig({ enabled: true, minMs: 100, maxMs: 200 }); + const { scheduled, result } = withSetTimeoutSpy(async () => { + await codexPaceBeforeSend(config, "x"); // records 10_000 + // Far more than the gap has elapsed -> no wait. + Date.now = (() => 100_000) as typeof Date.now; + await codexPaceBeforeSend(config, "x"); + }); + await result; + expect(scheduled).toEqual([]); + }); +}); diff --git a/tests/codex-rotation.test.ts b/tests/codex-rotation.test.ts new file mode 100644 index 000000000..cca2fc642 --- /dev/null +++ b/tests/codex-rotation.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test, beforeEach, afterEach } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { + clearCodexUpstreamHealth, + clearThreadAccountMap, + recordCodexUpstreamOutcome, + resetCodexRoundRobinCursor, + resolveCodexAccountForThread, +} from "../src/codex/routing"; +import { saveCodexAccountCredential } from "../src/codex/account-store"; +import { clearAccountNeedsReauth, clearAccountQuota, updateAccountQuota } from "../src/codex/auth-api"; +import type { OcxConfig } from "../src/types"; + +const TEST_DIR = join(import.meta.dir, ".tmp-codex-rotation-test"); +let previousOpencodexHome: string | undefined; +let previousCodexHome: string | undefined; + +function makeConfig(overrides: Partial = {}): OcxConfig { + return { + providers: {}, + codexAccounts: [ + { id: "a", email: "a@test", isMain: false }, + { id: "b", email: "b@test", isMain: false }, + { id: "c", email: "c@test", isMain: false }, + ], + activeCodexAccountId: "a", + autoSwitchThreshold: 80, + upstreamFailoverThreshold: 3, + codexRotationMode: "round-robin", + ...overrides, + } as OcxConfig; +} + +function saveTestCredential(id: string): void { + saveCodexAccountCredential(id, { + accessToken: `access-${id}`, + refreshToken: `refresh-${id}`, + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: `acct-${id}`, + }); +} + +describe("codex round-robin rotation", () => { + beforeEach(() => { + previousOpencodexHome = process.env.OPENCODEX_HOME; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + // No auth.json in TEST_DIR -> main account deterministically absent, so the + // eligible pool is exactly the configured non-main accounts in config order. + previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = TEST_DIR; + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + clearAccountQuota(); + resetCodexRoundRobinCursor(); + for (const id of ["a", "b", "c"]) { + clearAccountNeedsReauth(id); + saveTestCredential(id); + } + }); + + afterEach(() => { + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + resetCodexRoundRobinCursor(); + for (const id of ["a", "b", "c"]) clearAccountNeedsReauth(id); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + }); + + test("cycles across all usable pool accounts as the cursor advances", () => { + const config = makeConfig(); + // Cursor walks a -> b -> c, then wraps back to a. + expect(resolveCodexAccountForThread("rr-1", config)).toBe("a"); + expect(resolveCodexAccountForThread("rr-2", config)).toBe("b"); + expect(resolveCodexAccountForThread("rr-3", config)).toBe("c"); + expect(resolveCodexAccountForThread("rr-4", config)).toBe("a"); + expect(resolveCodexAccountForThread("rr-5", config)).toBe("b"); + // activeCodexAccountId is left untouched by round-robin. + expect(config.activeCodexAccountId).toBe("a"); + }); + + test("a cooldown'd account is skipped by the rotation", () => { + const config = makeConfig(); + // Put b into hard cooldown via a 429 quota outcome. + recordCodexUpstreamOutcome(config, "b", 429, { retryAfter: "120" }); + // Pool is now [a, c]; rotation walks a -> c -> a -> c, never b. + expect(resolveCodexAccountForThread("skip-1", config)).toBe("a"); + expect(resolveCodexAccountForThread("skip-2", config)).toBe("c"); + expect(resolveCodexAccountForThread("skip-3", config)).toBe("a"); + expect(resolveCodexAccountForThread("skip-4", config)).toBe("c"); + }); + + test("a single usable account collapses to a no-op (always that account)", () => { + const config = makeConfig({ + codexAccounts: [{ id: "a", email: "a@test", isMain: false }], + activeCodexAccountId: "a", + }); + expect(resolveCodexAccountForThread("solo-1", config)).toBe("a"); + expect(resolveCodexAccountForThread("solo-2", config)).toBe("a"); + expect(resolveCodexAccountForThread("solo-3", config)).toBe("a"); + expect(config.activeCodexAccountId).toBe("a"); + }); + + test("default failover mode is unaffected when rotation mode is not set", () => { + // No codexRotationMode -> sticky failover path, cursor never advances. + const config = makeConfig({ codexRotationMode: undefined }); + updateAccountQuota("a", 10); + updateAccountQuota("b", 20); + updateAccountQuota("c", 30); + expect(resolveCodexAccountForThread("failover-1", config)).toBe("a"); + expect(resolveCodexAccountForThread("failover-2", config)).toBe("a"); + }); + + test("round-robin does not mutate persisted active account selection state", () => { + const config = makeConfig(); + resolveCodexAccountForThread("no-mutate-1", config); + resolveCodexAccountForThread("no-mutate-2", config); + // Neither config.activeCodexAccountId nor the in-memory affinity map is + // repointed by round-robin (it returns early before setActiveCodexAccount). + expect(config.activeCodexAccountId).toBe("a"); + }); +}); diff --git a/tests/cursor-oauth.test.ts b/tests/cursor-oauth.test.ts index 509528dcd..1be953daf 100644 --- a/tests/cursor-oauth.test.ts +++ b/tests/cursor-oauth.test.ts @@ -30,10 +30,18 @@ describe("Cursor OAuth core flow", () => { expect(url.searchParams.get("challenge")).toBe(p.challenge); expect(url.searchParams.get("mode")).toBe("login"); expect(url.searchParams.get("redirectTarget")).toBe("cli"); + expect(url.searchParams.has("prompt")).toBe(false); expect(url.searchParams.has("verifier")).toBe(false); expect(p.loginUrl).not.toContain(p.verifier); }); + test("generateCursorAuthParams adds prompt=select_account when forceAccountSelect is set", async () => { + const p = await generateCursorAuthParams({ forceAccountSelect: true }); + const url = new URL(p.loginUrl); + expect(url.searchParams.get("prompt")).toBe("select_account"); + expect(url.searchParams.has("verifier")).toBe(false); + }); + test("pollCursorAuth returns tokens after a 404 (pending) then 200", async () => { let calls = 0; globalThis.fetch = (async () => { @@ -132,12 +140,35 @@ describe("Cursor OAuth core flow", () => { JSON.stringify({ accessToken: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), refreshToken: "ref" }), { status: 200 }, )) as typeof fetch; - const creds = await loginCursor({ onAuth: ({ url }) => { authedUrl = url; }, onProgress: () => {} }, 1); + const creds = await loginCursor({ onAuth: ({ url }) => { authedUrl = url; }, onProgress: () => {} }, { pollBaseDelayMs: 1 }); expect(authedUrl).toContain("cursor.com/loginDeepControl"); + expect(new URL(authedUrl).searchParams.has("prompt")).toBe(false); expect(creds.access).toBeTruthy(); expect(creds.refresh).toBe("ref"); }); + test("loginCursor with forceAccountSelect asks the browser to pick an account", async () => { + let authedUrl = ""; + let instructions = ""; + globalThis.fetch = (async () => + new Response( + JSON.stringify({ accessToken: jwtWithExp(Math.floor(Date.now() / 1000) + 3600), refreshToken: "ref" }), + { status: 200 }, + )) as typeof fetch; + await loginCursor( + { + onAuth: ({ url, instructions: text }) => { + authedUrl = url; + instructions = text ?? ""; + }, + onProgress: () => {}, + }, + { forceAccountSelect: true, pollBaseDelayMs: 1 }, + ); + expect(new URL(authedUrl).searchParams.get("prompt")).toBe("select_account"); + expect(instructions.toLowerCase()).toContain("choose"); + }); + test("credentialsFromCursorTokens extracts JWT sub as accountId for multiauth", () => { const exp = Math.floor(Date.now() / 1000) + 3600; const access = jwtWithExp(exp, { sub: "google-oauth2|user_01ABC", email: "dev@example.com" }); diff --git a/tests/oauth-tos-warning.test.ts b/tests/oauth-tos-warning.test.ts index abcd4b8f7..52865eba7 100644 --- a/tests/oauth-tos-warning.test.ts +++ b/tests/oauth-tos-warning.test.ts @@ -15,10 +15,12 @@ describe("oauth ToS risk map", () => { test("flags elevated unofficial bridges", () => { expect(oauthTosRisk("github-copilot")).toBe("elevated"); - expect(oauthTosRisk("cursor")).toBe("elevated"); }); test("leaves lower-risk OAuth providers unmarked", () => { + // ChefGroep host patch: "cursor" is intentionally dropped from the elevated set so the ToS + // warning modal never blocks the multi-account Cursor login flow (see gui/src/oauth-tos-risk.ts). + expect(oauthTosRisk("cursor")).toBeNull(); expect(oauthTosRisk("xai")).toBeNull(); expect(oauthTosRisk("kimi")).toBeNull(); expect(oauthTosRisk("kiro")).toBeNull(); diff --git a/tests/pacer-default.test.ts b/tests/pacer-default.test.ts new file mode 100644 index 000000000..5a7d51250 --- /dev/null +++ b/tests/pacer-default.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { resolveEffectivePacing, resetCodexPacerState } from "../src/codex/pacer"; + +describe("resolveEffectivePacing", () => { + beforeEach(() => resetCodexPacerState()); + + it("returns null when pacing is off and not round-robin multi-account", () => { + expect(resolveEffectivePacing({ codexRotationMode: undefined }, 1)).toBeNull(); + expect(resolveEffectivePacing({ codexRotationMode: "round-robin" }, 1)).toBeNull(); + expect(resolveEffectivePacing({}, 3)).toBeNull(); + }); + + it("auto-enables for round-robin + multi-account pool", () => { + const eff = resolveEffectivePacing({ codexRotationMode: "round-robin" }, 3); + expect(eff).not.toBeNull(); + expect(eff!.enabled).toBe(true); + expect(eff!.minMs).toBeGreaterThan(0); + expect(eff!.maxMs).toBeGreaterThan(eff!.minMs); + }); + + it("does NOT auto-enable for round-robin with single account", () => { + expect(resolveEffectivePacing({ codexRotationMode: "round-robin" }, 1)).toBeNull(); + }); + + it("respects explicit enabled config", () => { + const eff = resolveEffectivePacing({ codexRequestPacing: { enabled: true, minMs: 200, maxMs: 400 }, codexRotationMode: undefined }, 1); + expect(eff).not.toBeNull(); + expect(eff!.enabled).toBe(true); + expect(eff!.minMs).toBe(200); + expect(eff!.maxMs).toBe(400); + }); + + it("respects explicit disabled config even for round-robin multi-account", () => { + // Explicit disabled wins: { enabled: false } + const eff = resolveEffectivePacing({ codexRequestPacing: { enabled: false }, codexRotationMode: "round-robin" }, 3); + expect(eff).toBeNull(); + }); +}); diff --git a/tests/percentiles.test.ts b/tests/percentiles.test.ts new file mode 100644 index 000000000..edaf6dfaf --- /dev/null +++ b/tests/percentiles.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "bun:test"; +import { percentile, computeLatencyStats, groupByProvider } from "../src/usage/percentiles"; + +describe("percentile", () => { + it("returns 0 for empty input", () => { + expect(percentile([], 50)).toBe(0); + }); + + it("returns the single value for one-element input", () => { + expect(percentile([42], 50)).toBe(42); + expect(percentile([42], 99)).toBe(42); + }); + + it("returns min at p0 and max at p100", () => { + expect(percentile([10, 20, 30, 40, 50], 0)).toBe(10); + expect(percentile([10, 20, 30, 40, 50], 100)).toBe(50); + }); + + it("matches known linear-interpolation percentiles", () => { + // 1..10 — p50 = 5.5, p95 = 9.55, p99 = 9.91 + const vals = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + expect(percentile(vals, 50)).toBeCloseTo(5.5, 5); + expect(percentile(vals, 95)).toBeCloseTo(9.55, 5); + expect(percentile(vals, 99)).toBeCloseTo(9.91, 5); + }); + + it("handles unsorted input (sorts internally)", () => { + expect(percentile([50, 10, 30, 40, 20], 50)).toBe(30); + }); +}); + +describe("computeLatencyStats", () => { + it("returns zeroed stats for empty input", () => { + const s = computeLatencyStats([]); + expect(s.count).toBe(0); + expect(s.p50).toBe(0); + }); + + it("computes correct stats", () => { + const s = computeLatencyStats([100, 200, 300, 400, 500]); + expect(s.count).toBe(5); + expect(s.min).toBe(100); + expect(s.max).toBe(500); + expect(s.mean).toBe(300); + expect(s.p50).toBe(300); + }); +}); + +describe("groupByProvider", () => { + it("groups samples by provider and separates ttft from total", () => { + const samples = [ + { provider: "openai", firstOutputMs: 100, durationMs: 1000 }, + { provider: "openai", firstOutputMs: 200, durationMs: 1200 }, + { provider: "anthropic", durationMs: 800 }, // no ttft + ]; + const result = groupByProvider(samples); + expect(result.size).toBe(2); + expect(result.get("openai")!.total.count).toBe(2); + expect(result.get("openai")!.ttft.count).toBe(2); + expect(result.get("anthropic")!.total.count).toBe(1); + expect(result.get("anthropic")!.ttft.count).toBe(0); // no ttft samples + }); +}); diff --git a/tests/posthog-server.test.ts b/tests/posthog-server.test.ts new file mode 100644 index 000000000..82abc07d8 --- /dev/null +++ b/tests/posthog-server.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { getServerPosthog, resetServerPosthog, TELEMETRY_EVENTS } from "../src/telemetry/posthog-server"; + +describe("getServerPosthog", () => { + it("returns null when OCX_POSTHOG_KEY is unset", () => { + delete process.env["OCX_POSTHOG_KEY"]; + resetServerPosthog(); + expect(getServerPosthog()).toBeNull(); + }); + + it("returns a client when OCX_POSTHOG_KEY is set", () => { + process.env["OCX_POSTHOG_KEY"] = "test-key"; + resetServerPosthog(); + const client = getServerPosthog(); + expect(client).not.toBeNull(); + client!.shutdown(); + }); + + it("TELEMETRY_EVENTS has stable event names", () => { + expect(TELEMETRY_EVENTS.REQUEST_TERMINAL).toBe("proxy_request_terminal"); + expect(TELEMETRY_EVENTS.BUDGET_EXCEEDED).toBe("proxy_budget_exceeded"); + expect(TELEMETRY_EVENTS.FAILOVER_TRIGGERED).toBe("proxy_failover_triggered"); + }); +}); diff --git a/tests/pricing.test.ts b/tests/pricing.test.ts new file mode 100644 index 000000000..4b9bc2717 --- /dev/null +++ b/tests/pricing.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import { lookupPricing, estimateCostEur } from "../src/usage/pricing"; + +describe("lookupPricing", () => { + it("returns free for unknown provider", () => { + expect(lookupPricing("unknown-provider", "some-model")).toEqual({ inputPer1k: 0, outputPer1k: 0 }); + }); + + it("returns model-specific pricing when available", () => { + const p = lookupPricing("openai", "gpt-4o"); + expect(p.inputPer1k).toBeGreaterThan(0); + expect(p.outputPer1k).toBeGreaterThan(0); + }); + + it("falls back to provider-level pricing for unknown model", () => { + const p = lookupPricing("openai", "gpt-99-future"); + expect(p.inputPer1k).toBeGreaterThan(0); // falls back to openai default + }); + + it("prefix-matches model families (e.g. claude-sonnet-4-5-20250929)", () => { + const p = lookupPricing("anthropic", "claude-sonnet-4-5-20250929"); + expect(p.inputPer1k).toBeGreaterThan(0); + }); + + it("returns free for self-hosted providers (ollama, litellm)", () => { + expect(lookupPricing("ollama-cloud", "llama3")).toEqual({ inputPer1k: 0, outputPer1k: 0 }); + expect(lookupPricing("litellm", "my-model")).toEqual({ inputPer1k: 0, outputPer1k: 0 }); + }); +}); + +describe("estimateCostEur", () => { + it("estimates cost for a known provider/model", () => { + // openai:gpt-4o = 0.0023 in / 0.0092 out per 1k + // 1000 in + 500 out = 0.0023 + 0.0046 = 0.0069 + const cost = estimateCostEur("openai", "gpt-4o", 1000, 500); + expect(cost).toBeCloseTo(0.0069, 4); + }); + + it("returns 0 for free providers", () => { + expect(estimateCostEur("omniroute", "any-model", 1_000_000, 1_000_000)).toBe(0); + expect(estimateCostEur("ollama-cloud", "llama3", 1_000_000, 1_000_000)).toBe(0); + }); + + it("returns 0 for unknown providers", () => { + expect(estimateCostEur("unknown", "model", 1000, 1000)).toBe(0); + }); +}); diff --git a/tests/provider-fallback.test.ts b/tests/provider-fallback.test.ts new file mode 100644 index 000000000..72750360e --- /dev/null +++ b/tests/provider-fallback.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, test } from "bun:test"; +import { + comboIdLabel, + isProviderFallbackComboId, + providerFallbackError, + providerFallbackIssues, + providerFallbackPlan, + providerFallbackTargets, +} from "../src/providers/fallback"; +import { isValidComboId } from "../src/combos"; +import type { OcxConfig } from "../src/types"; + +function baseConfig(overrides: Partial = {}): OcxConfig { + return { + port: 10100, + defaultProvider: "a", + providers: { + a: { adapter: "openai-chat", baseUrl: "https://a.example/v1", apiKey: "ka", models: ["m1"] }, + b: { adapter: "openai-chat", baseUrl: "https://b.example/v1", apiKey: "kb", models: ["m2"] }, + c: { adapter: "openai-chat", baseUrl: "https://c.example/v1", apiKey: "kc", models: ["m3"] }, + }, + ...overrides, + }; +} + +function withFallback(fallback: unknown, overrides: Partial = {}): OcxConfig { + const config = baseConfig(overrides); + (config.providers.a as Record).fallback = fallback; + return config; +} + +describe("provider fallback validation", () => { + test("accepts an ordered list of configured targets", () => { + const providers = baseConfig().providers; + const issues = providerFallbackIssues("a", [ + { provider: "b", model: "m2" }, + { provider: "c", model: "m3" }, + ], providers); + expect(issues).toEqual([]); + }); + + test("omitted fallback is not an error", () => { + expect(providerFallbackIssues("a", undefined, baseConfig().providers)).toEqual([]); + }); + + test("rejects a non-array", () => { + expect(providerFallbackError("a", { provider: "b", model: "m2" }, baseConfig().providers)) + .toBe("fallback must be an array of { provider, model } targets"); + }); + + test("rejects an unconfigured provider", () => { + expect(providerFallbackError("a", [{ provider: "nope", model: "m2" }], baseConfig().providers)) + .toBe('fallback[0].provider "nope" is not configured'); + }); + + test("rejects a missing model", () => { + expect(providerFallbackError("a", [{ provider: "b" }], baseConfig().providers)) + .toBe("fallback[0].model is required"); + }); + + test("rejects a self-referencing target", () => { + expect(providerFallbackError("a", [{ provider: "a", model: "m1" }], baseConfig().providers)) + .toBe('fallback[0] must not point back at "a"'); + }); + + test("rejects duplicate targets", () => { + expect(providerFallbackError("a", [ + { provider: "b", model: "m2" }, + { provider: "b", model: "m2" }, + ], baseConfig().providers)).toBe('duplicate fallback target "b/m2"'); + }); +}); + +describe("provider fallback targets", () => { + test("trims entries and drops malformed ones", () => { + const config = withFallback([ + { provider: " b ", model: " m2 " }, + { provider: "", model: "m3" }, + null, + "c/m3", + ]); + expect(providerFallbackTargets(config.providers.a)).toEqual([{ provider: "b", model: "m2" }]); + }); + + test("no fallback yields an empty list", () => { + expect(providerFallbackTargets(baseConfig().providers.a)).toEqual([]); + }); +}); + +describe("provider fallback plan", () => { + test("puts the request's own route first, then the configured chain", () => { + const config = withFallback([{ provider: "b", model: "m2" }, { provider: "c", model: "m3" }]); + const plan = providerFallbackPlan(config, { provider: "a", modelId: "m1" }); + expect(plan).not.toBeNull(); + expect(plan!.config.combos![plan!.comboId]).toEqual({ + strategy: "failover", + targets: [ + { provider: "a", model: "m1" }, + { provider: "b", model: "m2" }, + { provider: "c", model: "m3" }, + ], + }); + }); + + test("leaves the caller's config untouched", () => { + const config = withFallback([{ provider: "b", model: "m2" }]); + providerFallbackPlan(config, { provider: "a", modelId: "m1" }); + expect(config.combos).toBeUndefined(); + }); + + test("preserves configured combos alongside the synthetic one", () => { + const config = withFallback([{ provider: "b", model: "m2" }], { + combos: { free: { targets: [{ provider: "a", model: "m1" }] } }, + }); + const plan = providerFallbackPlan(config, { provider: "a", modelId: "m1" })!; + expect(Object.keys(plan.config.combos!).sort()).toEqual([plan.comboId, "free"].sort()); + }); + + test("no plan when the provider has no fallback", () => { + expect(providerFallbackPlan(baseConfig(), { provider: "a", modelId: "m1" })).toBeNull(); + }); + + test("skips disabled fallback providers and yields no plan when none remain", () => { + const config = withFallback([{ provider: "b", model: "m2" }]); + config.providers.b!.disabled = true; + expect(providerFallbackPlan(config, { provider: "a", modelId: "m1" })).toBeNull(); + }); + + test("skips fallback targets whose provider was deleted", () => { + const config = withFallback([{ provider: "gone", model: "m9" }, { provider: "c", model: "m3" }]); + const plan = providerFallbackPlan(config, { provider: "a", modelId: "m1" })!; + expect(plan.config.combos![plan.comboId]!.targets).toEqual([ + { provider: "a", model: "m1" }, + { provider: "c", model: "m3" }, + ]); + }); + + test("declines to shadow a physical provider named \"combo\"", () => { + const config = withFallback([{ provider: "b", model: "m2" }]); + config.providers.combo = { adapter: "openai-chat", baseUrl: "https://combo.example/v1" }; + expect(providerFallbackPlan(config, { provider: "a", modelId: "m1" })).toBeNull(); + }); +}); + +describe("synthetic combo ids", () => { + test("cannot collide with a user-configurable combo id", () => { + const config = withFallback([{ provider: "b", model: "m2" }]); + const { comboId } = providerFallbackPlan(config, { provider: "a", modelId: "m1" })!; + expect(isProviderFallbackComboId(comboId)).toBe(true); + expect(isValidComboId(comboId)).toBe(false); + }); + + test("are distinct per provider/model so cooldowns do not bleed across routes", () => { + const config = withFallback([{ provider: "b", model: "m2" }]); + const first = providerFallbackPlan(config, { provider: "a", modelId: "m1" })!; + const second = providerFallbackPlan(config, { provider: "a", modelId: "m9" })!; + expect(first.comboId).not.toBe(second.comboId); + }); + + test("render readably in logs and error messages", () => { + const config = withFallback([{ provider: "b", model: "m2" }]); + const { comboId } = providerFallbackPlan(config, { provider: "a", modelId: "m1" })!; + expect(comboIdLabel(comboId)).toBe("fallback:a/m1"); + expect(comboIdLabel("free")).toBe("free"); + }); +}); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 9dfa5e5e0..578c30e71 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -36,6 +36,7 @@ const EXPECTED_KEY_PROVIDER_IDS = [ "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", "opencode-free", "xiaomi", "kilo", "mimo-free", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo", + "omniroute", ]; describe("provider registry parity", () => { @@ -439,7 +440,7 @@ describe("provider registry parity", () => { expect(nvidia?.freeTier).toBe(true); expect(nvidia?.authKind).toBe("key"); expect(nvidia?.keyOptional).toBeUndefined(); - expect(freeTierProviders).toEqual(["nvidia", "cloudflare-workers-ai"]); + expect(freeTierProviders).toEqual(["nvidia", "cloudflare-workers-ai", "omniroute"]); }); test("freeTier propagates through config seed, enrich backfill, and presets without overwriting user config", async () => { @@ -472,7 +473,7 @@ describe("provider registry parity", () => { test("base URL override permission is registry-only and limited to opted-in providers", () => { const optedIn = PROVIDER_REGISTRY.filter(entry => entry.allowBaseUrlOverride); - expect(optedIn.map(entry => entry.id)).toEqual(["ollama", "vllm", "lm-studio", "qwen-cloud", "alibaba-token-plan-intl", "litellm"]); + expect(optedIn.map(entry => entry.id)).toEqual(["ollama", "vllm", "lm-studio", "qwen-cloud", "alibaba-token-plan-intl", "litellm", "omniroute"]); for (const entry of optedIn) { expect(providerConfigSeed(entry)).not.toHaveProperty("allowBaseUrlOverride"); } @@ -642,6 +643,7 @@ describe("provider registry parity", () => { "openai", "xai", "anthropic", "anthropic-apikey", "kimi", "openai-apikey", "umans", "opencode-go", "openrouter", "groq", "google", "azure-openai", "ollama", "vllm", "lm-studio", "opencode-free", "mimo-free", + "omniroute", ]); const presets = deriveProviderPresets(); diff --git a/tests/provider-security.test.ts b/tests/provider-security.test.ts new file mode 100644 index 000000000..587afa08a --- /dev/null +++ b/tests/provider-security.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, test } from "bun:test"; +import { + CredentialSlotStore, + renewalJitterMs, + shouldRenewLease, +} from "../src/provider-security/slots"; +import { DegradedModeController } from "../src/provider-security/degraded"; +import { ProviderSecurityClient } from "../src/provider-security/client"; +import { ProviderCredentialResolver } from "../src/provider-security/resolve"; +import { + ProviderSecurityError, + validateChefVaultRef, +} from "../src/provider-security"; +import { + collectProviderSecurityStatus, + collectProviderSecurityDoctorChecks, + serializeProviderSecurityStatus, +} from "../src/provider-security/status"; +import type { OcxConfig } from "../src/types"; + +const REF = "chefvault://providers/demo/prod"; + +function leaseResponse( + overrides: Partial<{ leaseId: string; secret: string; expiresAt: number; fencingToken: number; slotHint?: "active" | "next" | "retiring" }> = {}, +) { + return { + leaseId: overrides.leaseId ?? "lease-1", + secret: overrides.secret ?? "skfix1", + expiresAt: overrides.expiresAt ?? Date.now() + 60_000, + fencingToken: overrides.fencingToken ?? 1, + ...(overrides.slotHint ? { slotHint: overrides.slotHint } : {}), + }; +} + +function mockFetch(handlers: { + healthz?: () => Response | Promise; + resolve?: (body: unknown) => Response | Promise; + renew?: (body: unknown) => Response | Promise; +}): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.endsWith("/healthz")) { + return handlers.healthz?.() ?? new Response("ok", { status: 200 }); + } + if (url.endsWith("/v1/credentials/resolve")) { + const body = init?.body ? JSON.parse(String(init.body)) : {}; + return handlers.resolve?.(body) ?? Response.json(leaseResponse(), { status: 200 }); + } + if (url.endsWith("/v1/credentials/renew")) { + const body = init?.body ? JSON.parse(String(init.body)) : {}; + return handlers.renew?.(body) ?? Response.json(leaseResponse({ fencingToken: 2 }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; +} + +describe("chefvault ref validation", () => { + test("accepts a scoped chefvault:// ref", () => { + expect(validateChefVaultRef(REF)).toBeNull(); + }); + + test("rejects bare paths", () => { + expect(validateChefVaultRef("providers/demo")?.code).toBe("ref_invalid"); + }); +}); + +describe("credential slot transitions", () => { + test("moves previous active lease to retiring on rotation", () => { + const store = new CredentialSlotStore(); + const at = 1_700_000_000_000; + store.applyResolve(REF, leaseResponse({ leaseId: "a", fencingToken: 1 }), at); + store.applyResolve(REF, leaseResponse({ leaseId: "b", fencingToken: 2, secret: "skn2" }), at + 1); + + const state = store.getState(REF)!; + expect(state.slots.active?.leaseId).toBe("b"); + expect(state.slots.retiring?.leaseId).toBe("a"); + expect(state.slots.retiring?.phase).toBe("retiring"); + }); + + test("promotes next slot to active", () => { + const store = new CredentialSlotStore(); + const at = 1_700_000_000_000; + store.applyResolve(REF, leaseResponse({ leaseId: "active", fencingToken: 1 }), at); + store.applyResolve(REF, leaseResponse({ leaseId: "queued", fencingToken: 2, slotHint: "next" }), at + 1); + + const promoted = store.promoteNextToActive(REF, at + 2); + expect(promoted?.leaseId).toBe("queued"); + expect(store.getState(REF)?.slots.active?.leaseId).toBe("queued"); + expect(store.getState(REF)?.slots.retiring?.leaseId).toBe("active"); + }); + + test("request snapshots are immutable", () => { + const store = new CredentialSlotStore(); + store.applyResolve(REF, leaseResponse(), Date.now()); + const snapshot = store.snapshotForRequest(REF)!; + expect(Object.isFrozen(snapshot)).toBe(true); + expect(() => { + (snapshot as { secret: string }).secret = "mutated"; + }).toThrow(); + }); + + test("rejects stale fencing tokens", () => { + const store = new CredentialSlotStore(); + store.applyResolve(REF, leaseResponse({ fencingToken: 5 }), Date.now()); + expect(() => store.applyResolve(REF, leaseResponse({ fencingToken: 4 }), Date.now())) + .toThrow(ProviderSecurityError); + try { + store.applyResolve(REF, leaseResponse({ fencingToken: 3 }), Date.now()); + } catch (error) { + expect((error as ProviderSecurityError).code).toBe("stale_fencing_token"); + } + }); + + test("renewal jitter stays within bound", () => { + expect(renewalJitterMs(0)).toBe(0); + expect(renewalJitterMs(0.999)).toBeLessThan(30_000); + }); + + test("shouldRenewLease triggers inside lead window", () => { + const store = new CredentialSlotStore(); + const at = 1_000_000; + store.applyResolve(REF, leaseResponse({ expiresAt: at + 4 * 60_000 }), at); + const active = store.getState(REF)?.slots.active; + expect(shouldRenewLease(active, at)).toBe(true); + store.applyResolve(REF, leaseResponse({ expiresAt: at + 60 * 60_000, fencingToken: 2 }), at); + const fresh = store.getState(REF)?.slots.active; + expect(shouldRenewLease(fresh, at)).toBe(false); + }); +}); + +describe("degraded mode", () => { + test("denies new resolve but allows bounded existing credentials", async () => { + const store = new CredentialSlotStore(); + const degraded = new DegradedModeController(store); + const at = 1_700_000_000_000; + store.applyResolve(REF, leaseResponse({ expiresAt: at + 60 * 60_000 }), at); + degraded.markUnavailable(REF, at); + + expect(degraded.canResolve(REF).allowed).toBe(false); + expect(degraded.canUseExisting(REF, at).allowed).toBe(true); + + const resolver = new ProviderCredentialResolver({ + slotStore: store, + degraded, + now: () => at, + client: new ProviderSecurityClient({ + baseUrl: "http://vault.test", + workload: { workloadId: "t", hostId: "h", actor: "a" }, + fetchImpl: mockFetch({ + resolve: () => Response.json({ code: "stale", message: "should not resolve" }, { status: 503 }), + }), + }), + }); + + const fromMemory = await resolver.resolveCredentialRef(REF); + expect(fromMemory.source).toBe("memory"); + + store.revokePhase(REF, "active"); + store.revokePhase(REF, "retiring"); + await expect(resolver.resolveCredentialRef(REF)).rejects.toMatchObject({ + code: "degraded_deny_resolve", + }); + }); + + test("recovers after authority returns", async () => { + const store = new CredentialSlotStore(); + const degraded = new DegradedModeController(store); + let calls = 0; + const client = new ProviderSecurityClient({ + baseUrl: "http://vault.test", + workload: { workloadId: "t", hostId: "h", actor: "a" }, + fetchImpl: mockFetch({ + resolve: () => { + calls += 1; + if (calls === 1) { + return new Response(JSON.stringify({ message: "down" }), { status: 503 }); + } + return Response.json(leaseResponse({ fencingToken: 1, leaseId: "fresh" }), { status: 200 }); + }, + }), + }); + const resolver = new ProviderCredentialResolver({ slotStore: store, degraded, client }); + + await expect(resolver.resolveCredentialRef(REF)).rejects.toBeInstanceOf(ProviderSecurityError); + expect(store.getMode(REF)).toBe("degraded"); + + const resolved = await resolver.resolveCredentialRef(REF); + expect(resolved.snapshot.leaseId).toBe("fresh"); + expect(store.getMode(REF)).toBe("normal"); + }); +}); + +describe("provider-security client headers", () => { + test("sends workload identity headers on resolve", async () => { + let headers: Record = {}; + const client = new ProviderSecurityClient({ + baseUrl: "http://vault.test", + workload: { workloadId: "ocx", hostId: "sofie", actor: "doctor" }, + fetchImpl: (async (_input, init) => { + headers = Object.fromEntries(new Headers(init?.headers).entries()); + return Response.json(leaseResponse(), { status: 200 }); + }) as typeof fetch, + }); + + await client.resolveLease({ ref: REF }); + expect(headers["x-chef-workload-id"]).toBe("ocx"); + expect(headers["x-chef-host-id"]).toBe("sofie"); + expect(headers["x-chef-actor"]).toBe("doctor"); + }); +}); + +describe("redacted status serialization", () => { + test("never includes raw secret material", async () => { + const store = new CredentialSlotStore(); + store.applyResolve(REF, leaseResponse({ secret: "skfix" }), Date.now()); + + const config: OcxConfig = { + port: 10100, + defaultProvider: "demo", + providers: { + demo: { + adapter: "openai-chat", + baseUrl: "https://example/v1", + credentialRef: REF, + }, + }, + }; + + const report = collectProviderSecurityStatus(config, undefined, store); + const serialized = serializeProviderSecurityStatus(report); + expect(serialized).not.toContain("skfix"); + expect(serialized).not.toContain("secret"); + expect(report.providers[0]?.status.slots[0]?.leaseId).toBe("lease-1"); + + const doctor = await collectProviderSecurityDoctorChecks(config, new ProviderCredentialResolver({ + slotStore: store, + client: new ProviderSecurityClient({ + baseUrl: "http://vault.test", + workload: { workloadId: "t", hostId: "h", actor: "a" }, + fetchImpl: mockFetch({ healthz: () => new Response("ok", { status: 200 }) }), + }), + })); + const doctorText = JSON.stringify(doctor); + expect(doctorText).not.toContain("skfix"); + }); +}); diff --git a/tests/responses-item-id-repair.test.ts b/tests/responses-item-id-repair.test.ts index 430251555..3dc029948 100644 --- a/tests/responses-item-id-repair.test.ts +++ b/tests/responses-item-id-repair.test.ts @@ -79,6 +79,44 @@ describe("Responses passthrough item-id repair", () => { expect(completed.output[2].call_id).toBe("call_redacted"); }); + test("mints and reuses canonical ids when terminal items omit ids", async () => { + const upstream = [ + 'event: response.output_item.done\ndata: {"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning"}}\n\n', + 'event: response.reasoning_summary_text.done\ndata: {"type":"response.reasoning_summary_text.done","output_index":0,"summary_index":0,"text":"done"}\n\n', + 'event: response.output_item.done\ndata: {"type":"response.output_item.done","output_index":1,"item":{"type":"message","role":"assistant"}}\n\n', + 'event: response.output_text.done\ndata: {"type":"response.output_text.done","output_index":1,"content_index":0,"text":"hello"}\n\n', + 'event: response.completed\ndata: {"type":"response.completed","response":{"id":"resp_gateway","status":"completed","output":[{"type":"reasoning"},{"type":"message","role":"assistant"}]}}\n\n', + "data: [DONE]\n\n", + ].join(""); + + const events = await parseSse(await readAll(relaySseWithResponsesItemIdRepair(streamFromText(upstream), { + repairMissingTerminalIds: true, + }))); + const reasoningDone = events[0].item as Record; + const messageDone = events[2].item as Record; + const completed = events[4].response as { output: Record[] }; + + expect(reasoningDone.id).toMatch(/^rs_ocx_[0-9a-f]+_0$/); + expect(events[1].item_id).toBe(reasoningDone.id); + expect(messageDone.id).toMatch(/^msg_ocx_[0-9a-f]+_1$/); + expect(events[3].item_id).toBe(messageDone.id); + expect(completed.output[0].id).toBe(reasoningDone.id); + expect(completed.output[1].id).toBe(messageDone.id); + }); + + test("leaves missing ids untouched when terminal-id repair is disabled", async () => { + const upstream = [ + 'data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant"}}\n\n', + 'data: {"type":"response.completed","response":{"id":"resp_gateway","status":"completed","output":[{"type":"message","role":"assistant"}]}}\n\n', + ].join(""); + + const repaired = await readAll(relaySseWithResponsesItemIdRepair(streamFromText(upstream), { + message: ["msg_0"], + })); + + expect(repaired).toBe(upstream); + }); + test("mints unique canonical ids across sequential passthrough streams", async () => { const upstream = [ 'event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_0"}}\n\n', diff --git a/tests/rotation-persist.test.ts b/tests/rotation-persist.test.ts new file mode 100644 index 000000000..e55f3d91f --- /dev/null +++ b/tests/rotation-persist.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, beforeEach, afterEach } from "bun:test"; +import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +// Override the config dir BEFORE importing the module under test. +const TMP_OC = join(tmpdir(), `ocx-rot-test-${process.pid}`); +process.env["OPENCODEX_HOME"] = TMP_OC; + +describe("rotation cursor persistence", () => { + beforeEach(() => { + rmSync(TMP_OC, { recursive: true, force: true }); + // Bust any require cache so loadPersistedRotationCursor re-reads from the new dir. + delete require.cache[require.resolve("../src/codex/routing")]; + }); + afterEach(() => { + rmSync(TMP_OC, { recursive: true, force: true }); + }); + + it("persists cursor across re-imports (simulated restart)", async () => { + // First import: cursor starts at 0. We can't directly call persist, but we can + // verify the state file is created when resetCodexRoundRobinCursor is called. + const mod1 = await import("../src/codex/routing"); + mod1.resetCodexRoundRobinCursor(); + // Write a non-zero cursor manually and re-import to simulate advance + restart. + writeFileSync(join(TMP_OC, "rotation-state.json"), JSON.stringify({ rrCursor: 5 })); + delete require.cache[require.resolve("../src/codex/routing")]; + const mod2 = await import("../src/codex/routing"); + // Accessing the private cursor isn't exposed; verify via the file contract: + // the module loaded without error and the file still exists. + expect(existsSync(join(TMP_OC, "rotation-state.json"))).toBe(true); + }); + + it("resets to 0 on corrupt state file", async () => { + mkdirSync(TMP_OC, { recursive: true }); + writeFileSync(join(TMP_OC, "rotation-state.json"), "not valid json{"); + delete require.cache[require.resolve("../src/codex/routing")]; + // Should not throw on import. + await import("../src/codex/routing"); + expect(true).toBe(true); // reached here = no throw + }); +}); diff --git a/tests/server-combo-failover-e2e.test.ts b/tests/server-combo-failover-e2e.test.ts index c80eb82a5..9991ab2f7 100644 --- a/tests/server-combo-failover-e2e.test.ts +++ b/tests/server-combo-failover-e2e.test.ts @@ -1619,6 +1619,119 @@ describe("server combo failover 030 activation matrix", () => { }, 10_000); }); +describe("per-provider fallback for plain models", () => { + function fallbackConfig( + providers: OcxConfig["providers"], + fallback: Array<{ provider: string; model: string }>, + ): OcxConfig { + const names = Object.keys(providers); + return { + port: 0, + defaultProvider: names[0]!, + providers: { ...providers, [names[0]!]: { ...providers[names[0]!]!, fallback } }, + }; + } + + test("a 502 on a plain model hops to the provider's configured fallback", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json({ error: { message: "upstream died" } }, { status: 502 }); + }); + const b = serve(() => { + hits.push("b"); + return chatSuccess("fallback backup", "m2"); + }); + const config = fallbackConfig({ + a: provider("openai-chat", baseUrl(a), "key-a"), + b: provider("openai-chat", baseUrl(b), "key-b"), + }, [{ provider: "b", model: "m2" }]); + + const response = await postModelLogged(config, "a/m1"); + expect(response.status).toBe(200); + expect(JSON.stringify(await response.json())).toContain("fallback backup"); + expect(hits).toEqual(["a", "b"]); + }); + + test("the log row keeps the winning target instead of collapsing into a combo row", async () => { + const a = serve(() => Response.json({ error: { message: "upstream died" } }, { status: 502 })); + const b = serve(() => chatSuccess("fallback backup", "m2")); + const config = fallbackConfig({ + a: provider("openai-chat", baseUrl(a), "key-a"), + b: provider("openai-chat", baseUrl(b), "key-b"), + }, [{ provider: "b", model: "m2" }]); + + expect((await postModelLogged(config, "a/m1")).status).toBe(200); + const { log } = await latestAttemptReceipts(config); + expect(log).toMatchObject({ requestedModel: "a/m1", provider: "b", model: "m2" }); + expect(log.attempts).toMatchObject([{ provider: "a", status: 502 }, { provider: "b", status: 200 }]); + }); + + test("a non-retryable 400 stops on the primary without touching the fallback", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json({ error: { message: "bad request", type: "invalid_request_error" } }, { status: 400 }); + }); + const b = serve(() => { + hits.push("b"); + return chatSuccess("must not be reached", "m2"); + }); + const config = fallbackConfig({ + a: provider("openai-chat", baseUrl(a), "key-a"), + b: provider("openai-chat", baseUrl(b), "key-b"), + }, [{ provider: "b", model: "m2" }]); + + expect((await postModelLogged(config, "a/m1")).status).toBe(400); + expect(hits).toEqual(["a"]); + }); + + test("without a configured fallback the failure still reaches the caller", async () => { + const a = serve(() => Response.json({ error: { message: "upstream died" } }, { status: 502 })); + const config: OcxConfig = { + port: 0, + defaultProvider: "a", + providers: { a: provider("openai-chat", baseUrl(a), "key-a") }, + }; + expect((await postModelLogged(config, "a/m1")).status).toBe(502); + }); + + test("an exhausted chain returns the last failure rather than a combo_unavailable", async () => { + const a = serve(() => Response.json({ error: { message: "a died" } }, { status: 502 })); + const b = serve(() => Response.json({ error: { message: "b overloaded" } }, { status: 503 })); + const config = fallbackConfig({ + a: provider("openai-chat", baseUrl(a), "key-a"), + b: provider("openai-chat", baseUrl(b), "key-b"), + }, [{ provider: "b", model: "m2" }]); + + const response = await postModelLogged(config, "a/m1"); + expect(response.status).toBe(503); + }); + + test("an explicit combo request is unaffected by provider fallback config", async () => { + const hits: string[] = []; + const a = serve(() => { + hits.push("a"); + return Response.json({ error: { message: "a died" } }, { status: 502 }); + }); + const b = serve(() => { + hits.push("b"); + return chatSuccess("combo backup", "m2"); + }); + const providers = { + a: provider("openai-chat", baseUrl(a), "key-a", { fallback: [{ provider: "b", model: "m2" }] }), + b: provider("openai-chat", baseUrl(b), "key-b"), + }; + const config = comboConfig(providers); + + const response = await postLogged(config); + expect(response.status).toBe(200); + expect(hits).toEqual(["a", "b"]); + const { log } = await latestAttemptReceipts(config); + expect(log).toMatchObject({ provider: "combo", model: "combo/free", resolvedModel: "m2" }); + }); +}); + describe("cursor conversation continuity across store:false chains", () => { function fakeCursorTransportFactory(seenConversationIds: string[]): CursorTransportFactory { return () => ({ diff --git a/tests/update-job.test.ts b/tests/update-job.test.ts index 82c85b1b9..61dbbb192 100644 --- a/tests/update-job.test.ts +++ b/tests/update-job.test.ts @@ -696,7 +696,7 @@ describe("GUI update execution decisions", () => { installer: "npm", restart: true, command: "node /pkg/bin/ocx.mjs update --tag latest", - releaseNotesUrl: "https://github.com/lidge-jun/opencodex/releases/latest", + releaseNotesUrl: "https://github.com/OnlineChefGroep/opencodex/releases/latest", log: [], }; writeFileSync(updateJobPath(), `${JSON.stringify(job)}\n`);