From 830aae0beefb80a82fea33c93d7cd511c9d483de Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Wed, 22 Jul 2026 07:34:01 +0000 Subject: [PATCH 01/57] docs: document maintainers and review ownership --- .github/CODEOWNERS | 18 ++++++++++ CONTRIBUTING.md | 1 + MAINTAINERS.md | 41 ++++++++++++++++++++++ docs-site/src/content/docs/contributing.md | 6 ++++ structure/06_docs-and-release.md | 16 +++++++++ 5 files changed, 82 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 MAINTAINERS.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..31b2c0d3d --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,18 @@ +# Default reviewers +* @lidge-jun @Ingwannu + +# Repository automation and release security +/.github/ @lidge-jun @Ingwannu +/scripts/release.ts @lidge-jun @Ingwannu +/package.json @lidge-jun @Ingwannu +/bun.lock @lidge-jun @Ingwannu + +# Authentication, credentials, and management API +/src/oauth/ @lidge-jun @Ingwannu +/src/codex/auth-context.ts @lidge-jun @Ingwannu +/src/server/auth-cors.ts @lidge-jun @Ingwannu +/src/server/management-api.ts @lidge-jun @Ingwannu + +# Governance and security policy +/MAINTAINERS.md @lidge-jun @Ingwannu +/SECURITY.md @lidge-jun @Ingwannu diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51a3af05f..5d97b24de 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,7 @@ Thanks for helping with opencodex. - Start with the canonical guide: [Contributing](https://lidge-jun.github.io/opencodex/contributing/) - Public user docs live in [`docs-site/`](./docs-site) - Current maintainer invariants live in [`structure/`](./structure) +- Maintainer roles and merge policy live in [`MAINTAINERS.md`](./MAINTAINERS.md) - Historical investigations live in [`docs/`](./docs) For local development commands, architecture notes, and release workflow details, use the hosted diff --git a/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 000000000..174409a20 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1,41 @@ +# Maintainers + +This document lists the people responsible for maintaining opencodex and defines the project's +review and merge policy. + +## Current maintainers + +| GitHub account | Project role | Responsibilities | +| --- | --- | --- | +| [@lidge-jun](https://github.com/lidge-jun) | Project owner | Project direction, releases, repository administration, and final governance decisions | +| [@Ingwannu](https://github.com/Ingwannu) | Maintainer | Issue and pull-request triage, `dev` integration, security review, and repository maintenance | + +The table describes project responsibilities. Actual repository permissions remain controlled +through GitHub repository settings. + +## Review and merge policy + +- Normal pull requests target `dev`. +- A pull request requires approval from at least one maintainer and successful required CI checks + before merge. +- Authors do not approve their own pull requests. +- Authentication, credential handling, GitHub Actions, release automation, dependency installation, + and other security-boundary changes require explicit security review. +- Security-sensitive and release-related changes should be reviewed by both maintainers when + practical. +- Direct pushes are reserved for maintainer-owned integration work, urgent repairs, or incident + recovery. The same CI and documentation requirements still apply. +- Promotion from `dev` to `main` and npm releases is maintainer-controlled. + +## Maintainer changes + +Adding or removing a maintainer requires: + +1. agreement from the project owner, +2. review by another current maintainer when available, and +3. updates to this file and [`.github/CODEOWNERS`](./.github/CODEOWNERS). + +## Security reports + +Private vulnerability reports are handled by the current maintainers according to +[`SECURITY.md`](./SECURITY.md). Do not disclose secrets or exploit details in a public issue. diff --git a/docs-site/src/content/docs/contributing.md b/docs-site/src/content/docs/contributing.md index ae97b755c..a5105e78f 100644 --- a/docs-site/src/content/docs/contributing.md +++ b/docs-site/src/content/docs/contributing.md @@ -77,6 +77,12 @@ bun run release --publish # publish after the CI-gated dry run is unde bun run release:watch # watch the newest Release workflow run ``` +## 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 +ownership for the repository and security-sensitive paths is declared in `.github/CODEOWNERS`. + ## Conventions - **ES Modules only** (`import`/`export`), TypeScript, `strict` mode. Keep `bun x tsc --noEmit` clean. diff --git a/structure/06_docs-and-release.md b/structure/06_docs-and-release.md index 269bc3284..e8030ec0c 100644 --- a/structure/06_docs-and-release.md +++ b/structure/06_docs-and-release.md @@ -53,6 +53,22 @@ invariants belong in `structure/`, not the README. manual. When an investigation graduates into a maintained invariant, summarize it here under `structure/` and link public workflows from `docs-site/`. +## Maintenance governance + +`MAINTAINERS.md` is the source of truth for current project roles and the review and merge policy. +`.github/CODEOWNERS` declares default reviewers and repeats ownership for authentication, repository +automation, release, and governance paths where an explicit security review is required. GitHub +repository settings remain the source of truth for actual account permissions and protected-branch +enforcement. + +[Decision Log] +- 목적과 의도: Make project ownership and review authority discoverable without exposing credentials or treating a documentation file as an access-control mechanism. +- 기존 구현 및 제약 조건: Contribution and security docs referred to maintainers generically, while the repository had no maintainer roster or CODEOWNERS policy. GitHub permissions can change independently of the source tree. +- 검토한 주요 대안: Keep the roster only in GitHub settings; introduce a larger standalone governance charter; list raw GitHub permission levels in the repository. +- 선택한 방식: Add a concise maintainer roster and merge policy, use CODEOWNERS for review routing, and keep actual permission state authoritative in GitHub settings. +- 다른 대안 대신 이 방식을 선택한 이유: A two-maintainer project needs clear ownership and sensitive-path review rules but does not yet need a separate governance framework. +- 장점, 단점 및 영향: Contributors can identify reviewers and merge expectations directly from the repository. The roster must be updated when responsibilities change, and CODEOWNERS still requires branch-protection configuration to enforce approvals. + ## Package runtime (bundled Bun) The source runs on Bun, but the published package does **not** require a user-installed Bun. From 340ec484b9c62569963d841843198bbc3b0394c7 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Wed, 22 Jul 2026 07:55:37 +0000 Subject: [PATCH 02/57] feat: add Tencent Coding Plan and SiliconFlow --- README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.ru.md | 2 +- README.zh-CN.md | 2 +- .../src/content/docs/guides/providers.md | 6 ++ .../src/content/docs/ja/guides/providers.md | 6 ++ .../src/content/docs/ko/guides/providers.md | 6 ++ .../src/content/docs/ru/guides/providers.md | 6 ++ .../content/docs/zh-cn/guides/providers.md | 5 + gui/src/provider-icons.ts | 2 + src/providers/registry.ts | 35 +++++++ structure/00_overview.md | 10 +- tests/provider-registry-parity.test.ts | 2 +- tests/tencent-siliconflow-providers.test.ts | 91 +++++++++++++++++++ 15 files changed, 172 insertions(+), 7 deletions(-) create mode 100644 tests/tencent-siliconflow-providers.test.ts diff --git a/README.ja.md b/README.ja.md index 1f8d59309..98143d404 100644 --- a/README.ja.md +++ b/README.ja.md @@ -233,7 +233,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 などがあります。完全な一覧は `ocx init` または[プロバイダードキュメント](https://lidge-jun.github.io/opencodex/ja/reference/configuration/)で確認してください。 +このほか DeepSeek、Groq、OpenRouter、Together、Fireworks、Cerebras、Mistral、Hugging Face、NVIDIA NIM、MiniMax、Qwen Cloud、Tencent Cloud Coding Plan、SiliconFlow などがあります。完全な一覧は `ocx init` または[プロバイダードキュメント](https://lidge-jun.github.io/opencodex/ja/reference/configuration/)で確認してください。 Cursor サポートは段階的な実験的ブリッジです: `ocx init` とダッシュボードの Add Provider ピッカーに Cursor の静的公開モデルカタログを持つローカル config として表示されます。Cursor アクセストークンを設定するとライブ HTTP/2 トランスポートが有効になります。Cursor サーバー駆動のネイティブ diff --git a/README.ko.md b/README.ko.md index 439f65b32..883bea988 100644 --- a/README.ko.md +++ b/README.ko.md @@ -232,7 +232,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 등이 있습니다. 전체 목록은 `ocx init` 또는 [프로바이더 문서](https://lidge-jun.github.io/opencodex/ko/reference/configuration/)에서 확인하세요. +그 외에 DeepSeek, Groq, OpenRouter, Together, Fireworks, Cerebras, Mistral, Hugging Face, NVIDIA NIM, MiniMax, Qwen Cloud, Tencent Cloud Coding Plan, SiliconFlow 등이 있습니다. 전체 목록은 `ocx init` 또는 [프로바이더 문서](https://lidge-jun.github.io/opencodex/ko/reference/configuration/)에서 확인하세요. ## CLI diff --git a/README.md b/README.md index fad3a4a03..0560bff72 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ next Codex session. opencodex keeps these behaviors: | Ollama / vLLM / LM Studio (local) | `openai-chat` | key (usually blank) | | Any OpenAI-compatible endpoint | `openai-chat` | key | -Plus DeepSeek, Groq, OpenRouter, Together, Fireworks, Cerebras, Mistral, Hugging Face, NVIDIA NIM, MiniMax, Qwen Cloud, and more. See the full list with `ocx init` or in the [provider docs](https://lidge-jun.github.io/opencodex/reference/configuration/). +Plus DeepSeek, Groq, OpenRouter, Together, Fireworks, Cerebras, Mistral, Hugging Face, NVIDIA NIM, MiniMax, Qwen Cloud, Tencent Cloud Coding Plan, SiliconFlow, and more. See the full list with `ocx init` or in the [provider docs](https://lidge-jun.github.io/opencodex/reference/configuration/). Cursor support is a staged experimental bridge: it appears in `ocx init` and the dashboard Add Provider picker as a local config with Cursor's static public model catalog. Live diff --git a/README.ru.md b/README.ru.md index aa73b4900..61a9cfa5b 100644 --- a/README.ru.md +++ b/README.ru.md @@ -262,7 +262,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 и другие. Полный список — в `ocx init` или в [документации по провайдерам](https://lidge-jun.github.io/opencodex/reference/configuration/). +А также DeepSeek, Groq, OpenRouter, Together, Fireworks, Cerebras, Mistral, Hugging Face, NVIDIA NIM, MiniMax, Qwen Cloud, Tencent Cloud Coding Plan, SiliconFlow и другие. Полный список — в `ocx init` или в [документации по провайдерам](https://lidge-jun.github.io/opencodex/reference/configuration/). Поддержка Cursor — поэтапный экспериментальный мост: он появляется в `ocx init` и в селекторе Add Provider панели управления как локальная конфигурация со статическим публичным каталогом diff --git a/README.zh-CN.md b/README.zh-CN.md index 6458b38ec..23e79e8df 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -225,7 +225,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 等等。完整列表可通过 `ocx init` 查看,或参阅 [provider 文档](https://lidge-jun.github.io/opencodex/zh-cn/reference/configuration/)。 +此外还有 DeepSeek、Groq、OpenRouter、Together、Fireworks、Cerebras、Mistral、Hugging Face、NVIDIA NIM、MiniMax、Qwen Cloud、腾讯云 Coding Plan、SiliconFlow 等等。完整列表可通过 `ocx init` 查看,或参阅 [provider 文档](https://lidge-jun.github.io/opencodex/zh-cn/reference/configuration/)。 ## CLI diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 055023c78..edfe6d843 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -121,6 +121,8 @@ validates the key, and stores it. Notable entries: | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/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` | | Xiaomi MiMo | `https://api.xiaomimimo.com/anthropic` | | Kilo | `https://api.kilo.ai/api/gateway` | | GitLab Duo | `https://cloud.gitlab.com/ai/v1/proxy/openai/v1` | @@ -130,6 +132,10 @@ validates the key, and stores it. Notable entries: Most use the `openai-chat` adapter with a bearer key; a few that expose only an Anthropic-compatible endpoint (e.g. **Xiaomi MiMo**) use the `anthropic` adapter (`x-api-key`). +> **Tencent Cloud Coding Plan usage restriction:** Tencent documents this subscription for +> 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. + ### Multiple API keys Key-based providers can also keep multiple keys. Adding a key through the Providers page stores it diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 9c9b70535..44e15dd69 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -118,6 +118,8 @@ opencodex v2.7.1 には組み込みプリセットが 50 個含まれていま | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/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` | | Xiaomi MiMo | `https://api.xiaomimimo.com/anthropic` | | Kilo | `https://api.kilo.ai/api/gateway` | | GitHub Copilot · GitLab Duo | `https://api.githubcopilot.com` · `https://cloud.gitlab.com/ai/v1/proxy/openai/v1` | @@ -127,6 +129,10 @@ opencodex v2.7.1 には組み込みプリセットが 50 個含まれていま 大半は bearer キーと共に `openai-chat` アダプターを使い、Anthropic 互換エンドポイントのみを公開する一部 (例: **Xiaomi MiMo**)は `anthropic` アダプター(`x-api-key`)を使います。 +> **Tencent Cloud Coding Plan の利用制限:** Tencent はこのサブスクリプションを対話型 +> コーディングツール専用としています。一般的な API 自動化、カスタムアプリのバックエンド、 +> 非対話型バッチ利用は禁止されており、プランキーが停止される場合があります。 + ### 複数の API キー キーベースのプロバイダーも複数キーを保持できます。Providers ページでキーを追加すると diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 7db693fa8..70a2b0284 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -118,6 +118,8 @@ opencodex v2.7.1에는 빌트인 프리셋이 50개 들어 있습니다. 키 방 | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/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` | | Xiaomi MiMo | `https://api.xiaomimimo.com/anthropic` | | Kilo | `https://api.kilo.ai/api/gateway` | | GitHub Copilot · GitLab Duo | `https://api.githubcopilot.com` · `https://cloud.gitlab.com/ai/v1/proxy/openai/v1` | @@ -127,6 +129,10 @@ opencodex v2.7.1에는 빌트인 프리셋이 50개 들어 있습니다. 키 방 대부분은 bearer 키와 함께 `openai-chat` 어댑터를 사용하며, Anthropic 호환 엔드포인트만 노출하는 일부 (예: **Xiaomi MiMo**)는 `anthropic` 어댑터(`x-api-key`)를 사용합니다. +> **Tencent Cloud Coding Plan 사용 제한:** Tencent는 이 구독을 대화형 코딩 도구 전용으로 +> 안내합니다. 일반 API 자동화, 사용자 애플리케이션 백엔드 및 비대화형 일괄 호출은 금지되며 +> 플랜 키가 정지될 수 있습니다. + ### 여러 API 키 키 기반 프로바이더도 여러 키를 보관할 수 있습니다. Providers 페이지에서 키를 추가하면 diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 7552dc80e..7dc5c3779 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -125,6 +125,8 @@ opencodex поставляется с 53 встроенными пресетам | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/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` | | Xiaomi MiMo | `https://api.xiaomimimo.com/anthropic` | | Kilo | `https://api.kilo.ai/api/gateway` | | GitLab Duo | `https://cloud.gitlab.com/ai/v1/proxy/openai/v1` | @@ -135,6 +137,10 @@ opencodex поставляется с 53 встроенными пресетам только Anthropic-совместимую конечную точку (например, **Xiaomi MiMo**), используют адаптер `anthropic` (`x-api-key`). +> **Ограничение Tencent Cloud Coding Plan:** Tencent разрешает использовать эту подписку только +> в интерактивных инструментах программирования. Автоматизация общего API, серверы пользовательских +> приложений и неинтерактивные пакетные вызовы запрещены и могут привести к блокировке ключа плана. + ### Несколько API-ключей Провайдеры на основе ключей тоже могут хранить несколько ключей. Ключ, добавленный через страницу 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 0a0ce59fe..b89f88ee6 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -111,6 +111,8 @@ ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提 | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/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` | | Xiaomi MiMo | `https://api.xiaomimimo.com/anthropic` | | Kilo | `https://api.kilo.ai/api/gateway` | | GitHub Copilot · GitLab Duo | `https://api.githubcopilot.com` · `https://cloud.gitlab.com/ai/v1/proxy/openai/v1` | @@ -119,6 +121,9 @@ ChatGPT 转发预设。仪表盘的 **Add provider** 选择器会打开密钥提 大多数使用带 bearer 密钥的 `openai-chat` adapter;少数仅暴露 Anthropic 兼容端点的提供商(例如 **Xiaomi MiMo**)使用 `anthropic` adapter(`x-api-key`)。 +> **腾讯云 Coding Plan 使用限制:**腾讯将此订阅限定为交互式编程工具使用。禁止通用 API +> 自动化、自定义应用后端和非交互式批量调用;违规使用可能导致套餐密钥被停用。 + ### 多个 API 密钥 基于密钥的提供商也可以保存多个 key。通过 Providers 页面添加密钥时,它会存入 diff --git a/gui/src/provider-icons.ts b/gui/src/provider-icons.ts index b200d9aab..ec9dfcb66 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -111,6 +111,8 @@ const PROVIDER_DISPLAY_NAMES: Record = { "lm-studio": "LM Studio", huggingface: "Hugging Face", "qwen-cloud": "Qwen Cloud", + siliconflow: "SiliconFlow", + "tencent-coding-plan": "Tencent Cloud Coding Plan", "vercel-ai-gateway": "Vercel AI Gateway", vllm: "vLLM", litellm: "LiteLLM", diff --git a/src/providers/registry.ts b/src/providers/registry.ts index f3c793ae2..7ca1e9f2c 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -219,6 +219,13 @@ const ALIBABA_INTL_TOKEN_PLAN_MODELS = [ const ALIBABA_INTL_TOKEN_PLAN_QWEN_MODELS = [ "qwen3.8-max-preview", "qwen3.7-max", "qwen3.7-plus", "qwen3.6-plus", "qwen3.6-flash", ]; + +// 260722 Tencent Cloud Coding Plan. The plan's model set is explicitly dynamic; these are the +// current documented ids and live discovery remains enabled so successful /models responses win. +// Tencent marks every Coding Plan model as text-only input and restricts plan keys to interactive +// coding tools (not custom application backends or non-interactive batch automation). +// Evidence: https://cloud.tencent.cn/document/product/1823/130092 +const TENCENT_CODING_PLAN_MODELS = ["tc-code-latest", "glm-5", "kimi-k2.5", "minimax-m2.5"]; const ALIBABA_INTL_TOKEN_PLAN_INPUT_MODALITIES: Record = { "qwen3.8-max-preview": ["text", "image"], "qwen3.7-max": ["text", "image"], @@ -743,6 +750,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ }, { id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://nano-gpt.com/api" }, { id: "synthetic", label: "Synthetic", baseUrl: "https://api.synthetic.new/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://synthetic.new" }, + // SiliconFlow publishes an OpenAI-compatible chat endpoint and a dynamic model catalog. Do not + // freeze reasoning controls here: enable_thinking/thinking_budget support and limits vary by + // model, so live metadata or an explicit user override must own those capabilities. + // Evidence: https://docs.siliconflow.cn/en/api-reference/chat-completions/chat-completions + { + id: "siliconflow", + label: "SiliconFlow", + baseUrl: "https://api.siliconflow.cn/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://cloud.siliconflow.cn/account/ak", + liveModels: true, + note: "OpenAI-compatible live model catalog; reasoning controls vary by model.", + }, // Qwen Cloud: token plan is the preset default; GUI offers pay-as-you-go + custom via baseUrlChoices. // Formerly `qwen-portal` / portal.qwen.ai — that host is outdated. { @@ -756,6 +777,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ dashboardUrl: "https://docs.qwencloud.com", note: "Pick token plan, pay as you go, or a custom compatible-mode base URL", }, + { + id: "tencent-coding-plan", + label: "Tencent Cloud Coding Plan", + baseUrl: "https://api.lkeap.cloud.tencent.com/coding/v3", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://console.cloud.tencent.com/tokenhub/codingplan", + defaultModel: "tc-code-latest", + models: TENCENT_CODING_PLAN_MODELS, + liveModels: true, + modelInputModalities: Object.fromEntries(TENCENT_CODING_PLAN_MODELS.map(id => [id, ["text"]])), + noVisionModels: TENCENT_CODING_PLAN_MODELS, + note: "Coding tools only. Tencent forbids general API automation, custom backends, and non-interactive batch use.", + }, // 2026-07-10: docs unverified; model data frozen. Evidence: devlog/_plan/260710_provider_hardening/002_research_cn.md. { id: "qianfan", label: "Qianfan (Baidu)", baseUrl: "https://qianfan.baidubce.com/v2", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.bce.baidu.com/iam/#/iam/apikey/list" }, // 2026-07-10: docs unverified; model data frozen. Evidence: devlog/_plan/260710_provider_hardening/002_research_cn.md. diff --git a/structure/00_overview.md b/structure/00_overview.md index d9fca2868..de2608ea3 100644 --- a/structure/00_overview.md +++ b/structure/00_overview.md @@ -33,10 +33,18 @@ The default install keeps native OpenAI/ChatGPT passthrough working through one `openai` provider. Pool is the default and selects across main plus added accounts; Direct uses only the current caller/main login. `openai-apikey` explicitly selects API-key transport, and the two credential routes never fall through into one another. Built-in provider presets include Anthropic, -Google, Azure, and Neuralwatt Cloud. Additional +Google, Azure, Neuralwatt Cloud, Tencent Cloud Coding Plan, and SiliconFlow. Additional providers are routed by explicit `provider/model`, provider model lists, or the configured `defaultProvider`. +[Decision Log] +- 목적과 의도: Add two widely used API-key providers through the canonical registry so CLI, GUI, login, routing, and documentation remain in parity. +- 기존 구현 및 제약 조건: Tencent Coding Plan is OpenAI-compatible but contractually restricted to interactive coding tools and has a dynamic, text-only model set. SiliconFlow exposes a dynamic OpenAI-compatible catalog whose reasoning controls vary by model. +- 검토한 주요 대안: Treat both as custom providers only; freeze a large SiliconFlow model list and reasoning map; expose Tencent without a usage warning. +- 선택한 방식: Add registry-derived key presets, keep live discovery enabled, seed only Tencent's currently documented coding-plan models, and surface Tencent's usage restriction in both the preset note and public docs. +- 다른 대안 대신 이 방식을 선택한 이유: Registry presets remove setup friction while live discovery avoids claiming that mutable catalogs are permanent. Avoiding speculative SiliconFlow reasoning metadata prevents invalid vendor-specific parameters. +- 장점, 단점 및 영향: Both providers appear consistently across supported setup surfaces. Tencent users receive an explicit policy warning; SiliconFlow reasoning controls remain conservative until model-specific limits can be represented safely. + ## Local state | Path | Owner | Notes | diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 089e30a89..78c4760fd 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -31,7 +31,7 @@ function nativeTemplate(): Record { const EXPECTED_KEY_PROVIDER_IDS = [ "anthropic-apikey", "openai-apikey", "umans", "opencode-go", "neuralwatt", "openrouter", "orcarouter", "groq", "google", "google-vertex", "azure-openai", "deepseek", "cerebras", "together", "fireworks", "firepass", "moonshot", - "huggingface", "nvidia", "venice", "zai", "nanogpt", "synthetic", "qwen-cloud", + "huggingface", "nvidia", "venice", "zai", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan", "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", diff --git a/tests/tencent-siliconflow-providers.test.ts b/tests/tencent-siliconflow-providers.test.ts new file mode 100644 index 000000000..41b583382 --- /dev/null +++ b/tests/tencent-siliconflow-providers.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from "bun:test"; +import { KEY_LOGIN_PROVIDERS } from "../src/oauth/key-providers"; +import { deriveProviderPresets } from "../src/providers/derive"; +import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { routeModel } from "../src/router"; +import type { OcxConfig } from "../src/types"; +import { formatProviderDisplayName, isCatalogProviderId } from "../gui/src/provider-icons"; + +describe("Tencent Cloud Coding Plan provider", () => { + test("publishes the coding-only OpenAI-compatible contract", () => { + const entry = PROVIDER_REGISTRY.find(provider => provider.id === "tencent-coding-plan"); + expect(entry).toMatchObject({ + label: "Tencent Cloud Coding Plan", + baseUrl: "https://api.lkeap.cloud.tencent.com/coding/v3", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://console.cloud.tencent.com/tokenhub/codingplan", + defaultModel: "tc-code-latest", + models: ["tc-code-latest", "glm-5", "kimi-k2.5", "minimax-m2.5"], + liveModels: true, + noVisionModels: ["tc-code-latest", "glm-5", "kimi-k2.5", "minimax-m2.5"], + }); + expect(entry?.note).toContain("Coding tools only"); + expect(entry?.note).toContain("non-interactive batch"); + expect(entry?.modelInputModalities).toEqual({ + "tc-code-latest": ["text"], + "glm-5": ["text"], + "kimi-k2.5": ["text"], + "minimax-m2.5": ["text"], + }); + }); + + test("derives key login, GUI preset, and route metadata from the registry", () => { + expect(KEY_LOGIN_PROVIDERS["tencent-coding-plan"]).toMatchObject({ + baseUrl: "https://api.lkeap.cloud.tencent.com/coding/v3", + defaultModel: "tc-code-latest", + liveModels: true, + }); + expect(deriveProviderPresets().find(provider => provider.id === "tencent-coding-plan")).toMatchObject({ + auth: "key", + defaultModel: "tc-code-latest", + }); + + const config: OcxConfig = { + port: 10100, + defaultProvider: "tencent-coding-plan", + providers: { + "tencent-coding-plan": { + adapter: "openai-chat", + baseUrl: "https://api.lkeap.cloud.tencent.com/coding/v3", + apiKey: "test-key", + }, + }, + }; + const route = routeModel(config, "tencent-coding-plan/glm-5"); + expect(route.provider.noVisionModels).toContain("glm-5"); + expect(route.provider.modelInputModalities?.["glm-5"]).toEqual(["text"]); + }); +}); + +describe("SiliconFlow provider", () => { + test("uses the official live OpenAI-compatible endpoint without frozen reasoning claims", () => { + const entry = PROVIDER_REGISTRY.find(provider => provider.id === "siliconflow"); + expect(entry).toMatchObject({ + label: "SiliconFlow", + baseUrl: "https://api.siliconflow.cn/v1", + adapter: "openai-chat", + authKind: "key", + dashboardUrl: "https://cloud.siliconflow.cn/account/ak", + liveModels: true, + }); + expect(entry).not.toHaveProperty("models"); + expect(entry).not.toHaveProperty("thinkingBudgetModels"); + expect(entry).not.toHaveProperty("modelReasoningEfforts"); + expect(KEY_LOGIN_PROVIDERS.siliconflow).toMatchObject({ + baseUrl: "https://api.siliconflow.cn/v1", + liveModels: true, + }); + expect(deriveProviderPresets().find(provider => provider.id === "siliconflow")).toMatchObject({ + auth: "key", + baseUrl: "https://api.siliconflow.cn/v1", + }); + }); + + test("registers canonical GUI display names for both providers", () => { + expect(formatProviderDisplayName("siliconflow")).toBe("SiliconFlow"); + expect(formatProviderDisplayName("tencent-coding-plan")).toBe("Tencent Cloud Coding Plan"); + expect(isCatalogProviderId("siliconflow")).toBe(true); + expect(isCatalogProviderId("tencent-coding-plan")).toBe(true); + }); +}); From 04a52ecc6d7c13286741bd33c932f5163536008c Mon Sep 17 00:00:00 2001 From: OnlineChef <280567955+OnlineChef@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:19:48 +0200 Subject: [PATCH 03/57] feat(gui): add optional PostHog EU analytics CHE-785 Init posthog-js only when VITE_POSTHOG_KEY is set; capture hashchange pageviews without identify/PII. Co-authored-by: Cursor --- gui/.env.example | 8 ++++++++ gui/bun.lock | 23 ++++++++++++++++++++++ gui/package.json | 1 + gui/src/main.tsx | 3 +++ gui/src/posthog.ts | 44 +++++++++++++++++++++++++++++++++++++++++++ gui/src/vite-env.d.ts | 10 ++++++++++ 6 files changed, 89 insertions(+) create mode 100644 gui/.env.example create mode 100644 gui/src/posthog.ts 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/bun.lock b/gui/bun.lock index 11c7b8460..15301834e 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -6,6 +6,7 @@ "name": "gui", "dependencies": { "@tanstack/react-virtual": "^3.14.5", + "posthog-js": "^1.407.0", "react": "^19.2.7", "react-dom": "^19.2.7", }, @@ -104,6 +105,12 @@ "@oxc-project/types": ["@oxc-project/types@0.137.0", "", {}, "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA=="], + "@posthog/browser-common": ["@posthog/browser-common@0.2.0", "", { "dependencies": { "@posthog/core": "^1.44.0", "@posthog/types": "^1.397.1" } }, "sha512-w+/0/Be/x48uNM7d/M37ZimSGwhuh8WBSvx61xzKzFnuoV5HqmH7GNDhaM3E3exXoBZmWNPS8hM/Ufy8zoOQSg=="], + + "@posthog/core": ["@posthog/core@1.45.0", "", { "dependencies": { "@posthog/types": "^1.398.0" } }, "sha512-nP5FGwkIk8Ngy45BHzwN/kBGV6jqNZINn+iSge5CbdjMevZsEYK8OG3QOSyysml3yWn4tsRJroGi76+HrBIJuQ=="], + + "@posthog/types": ["@posthog/types@1.398.0", "", {}, "sha512-sJMkl4k+u8yS/0fjHsKqE9xTdsAh30a2WvgChiptellnVoE0e8QJKFgqOMD2sk8FaEArPdeFklAhXvmENAt3Sg=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.3", "", { "os": "android", "cpu": "arm64" }, "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw=="], @@ -154,6 +161,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/type-utils": "8.61.1", "@typescript-eslint/utils": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.61.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.61.1", "@typescript-eslint/types": "8.61.1", "@typescript-eslint/typescript-estree": "8.61.1", "@typescript-eslint/visitor-keys": "8.61.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg=="], @@ -194,6 +203,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "core-js": ["core-js@3.49.0", "", {}, "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -204,6 +215,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "dompurify": ["dompurify@3.4.12", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg=="], + "electron-to-chromium": ["electron-to-chromium@1.5.375", "", {}, "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -238,6 +251,8 @@ "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + "fflate": ["fflate@0.4.9", "", {}, "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw=="], + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -338,10 +353,16 @@ "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "posthog-js": ["posthog-js@1.407.0", "", { "dependencies": { "@posthog/browser-common": "^0.2.0", "@posthog/core": "^1.45.0", "@posthog/types": "^1.398.0", "core-js": "^3.49.0", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.29.3", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.3.0" } }, "sha512-HtVdHlnAVJ+s0XGS2n5V5HflJYnG0X1pqjcOUF5Goid+C+zjRrhUJbYiXeugMLpn2vUBsXXEXHj22bNmi9CeKQ=="], + + "preact": ["preact@10.29.7", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], @@ -378,6 +399,8 @@ "vite": ["vite@8.1.0", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "~1.1.2", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q=="], + "web-vitals": ["web-vitals@5.3.0", "", {}, "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], diff --git a/gui/package.json b/gui/package.json index cb29bbfbd..52a14254e 100644 --- a/gui/package.json +++ b/gui/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@tanstack/react-virtual": "^3.14.5", + "posthog-js": "^1.407.0", "react": "^19.2.7", "react-dom": "^19.2.7" }, diff --git a/gui/src/main.tsx b/gui/src/main.tsx index da7c9774f..639f9c868 100644 --- a/gui/src/main.tsx +++ b/gui/src/main.tsx @@ -2,8 +2,11 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; import { LanguageProvider } from "./i18n"; +import { initPostHog } from "./posthog"; import "./styles.css"; +initPostHog(); + ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/gui/src/posthog.ts b/gui/src/posthog.ts new file mode 100644 index 000000000..6d01fad84 --- /dev/null +++ b/gui/src/posthog.ts @@ -0,0 +1,44 @@ +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); +} + +/** Manual $pageview for hash routes (e.g. #leveranciers). */ +export function captureHashPageview(): void { + if (!posthogKey() || !posthog.__loaded) { + return; + } + + posthog.capture("$pageview", { + $current_url: window.location.href, + }); +} 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; +} From 82cfac43dce628683323d7586341fa30620fde54 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Fri, 24 Jul 2026 00:20:01 +0200 Subject: [PATCH 04/57] fix(gui): sanitize PostHog $current_url to known hash route only Greptile P1 on PR #2: window.location.href leaked the full URL (OAuth ?code=, invitation tokens, emails in query/hash) to PostHog as $current_url. Report origin + pathname plus only a known hash route; drop the query string and any unknown hash contents. --- gui/src/posthog.ts | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/gui/src/posthog.ts b/gui/src/posthog.ts index 6d01fad84..a40011884 100644 --- a/gui/src/posthog.ts +++ b/gui/src/posthog.ts @@ -32,6 +32,32 @@ export function initPostHog(): void { 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) { @@ -39,6 +65,6 @@ export function captureHashPageview(): void { } posthog.capture("$pageview", { - $current_url: window.location.href, + $current_url: sanitizedCurrentUrl(window.location), }); } From 1419a34e3bf5ad3dc4e02c0fa0c59b9a49fb027f Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Fri, 24 Jul 2026 04:32:33 +0200 Subject: [PATCH 05/57] feat(providers): add OmniRoute as first-class provider (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OmniRoute (open-source OpenAI-compatible free-model gateway, 250+ providers / 90+ free) wired into the opencodex provider registry as an OpenAI-compatible provider (reuses the existing adapter kind): - src/providers/registry.ts: OmniRoute entry (baseUrl https://api.omniroute.online/v1, configurable via OCX_OMNIROUTE_BASE_URL for self-hosted fleet instances; auth via OCX_OMNIROUTE_KEY), representative free model ids (kimi/glm/deepseek/qwen…). - gui: provider icon + icon mapping so it appears in the provider rail. - tests: provider-registry-parity updated for the new entry. - docs/providers/omniroute.md: setup (key, optional self-hosted Docker), model selection, note that live /v1/models is the source of truth. Additive — no change to existing providers. Local typecheck blocked by a missing bun-types devDep in this worktree (env only; reproduces without this change); CI verifies. Co-authored-by: OnlineChef <280567955+OnlineChef@users.noreply.github.com> --- docs/providers/omniroute.md | 82 +++++++++++++++++++ gui/public/provider-icons/omniroute-color.svg | 1 + gui/src/provider-icons.ts | 3 + src/providers/registry.ts | 53 ++++++++++++ tests/provider-registry-parity.test.ts | 6 +- 5 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 docs/providers/omniroute.md create mode 100644 gui/public/provider-icons/omniroute-color.svg 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/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/provider-icons.ts b/gui/src/provider-icons.ts index b200d9aab..a12549c3c 100644 --- a/gui/src/provider-icons.ts +++ b/gui/src/provider-icons.ts @@ -26,6 +26,7 @@ const PROVIDER_ICON_ALIASES: Record = { nvidia: "nvidia-color.svg", ollama: "ollama-color.svg", "ollama-cloud": "ollama-color.svg", + omniroute: "omniroute-color.svg", openai: "openai.svg", "openai-apikey": "openai.svg", "opencode-free": "opencode.svg", @@ -60,6 +61,7 @@ const PROVIDER_BRAND_COLORS: Record = { groq: "#F55036", mistral: "#FF7000", openrouter: "#6566F1", + omniroute: "#6C5CE7", google: "#8E75B2", "google-vertex": "#8E75B2", alibaba: "#FF6A00", @@ -95,6 +97,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { "github-copilot": "GitHub Copilot", "gitlab-duo": "GitLab Duo", openrouter: "OpenRouter", + omniroute: "OmniRoute", "opencode-go": "OpenCode Go", "opencode-free": "OpenCode Free", "opencode-zen": "OpenCode Zen", diff --git a/src/providers/registry.ts b/src/providers/registry.ts index f3c793ae2..955d23221 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -148,6 +148,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 @@ -971,6 +1003,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/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 089e30a89..caa7fe955 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -35,6 +35,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", () => { @@ -392,7 +393,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 () => { @@ -425,7 +426,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"); } @@ -595,6 +596,7 @@ describe("provider registry parity", () => { "openai", "xai", "anthropic", "anthropic-apikey", "kimi", "openai-apikey", "umans", "opencode-go", "openrouter", "groq", "google", "azure-openai", "ollama", "vllm", "lm-studio", "opencode-free", "mimo-free", + "omniroute", ]); const presets = deriveProviderPresets(); From b6f47333b8ef6414c411385c9940317d579db2ec Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Fri, 24 Jul 2026 04:21:54 +0000 Subject: [PATCH 06/57] feat(codex): round-robin pool rotation + jittered request pacing (free-pool max) (#4) * feat(codex): add rotation-mode + request-pacing config types Foundation for multi-account free-pool maximization (opt-in, backward-compat): - codexRotationMode: 'failover' (default, current sticky behavior) | 'round-robin' (new conversations rotate across usable pool accounts so a multi-account free pool multiplies throughput; thread affinity preserved; cooldown/reauth skipped). - codexRequestPacing: jittered per-account inter-request delay [minMs,maxMs], off by default, so a multi-account pool never emits a regular ban-prone pattern. Types only this commit; wiring follows. (Agent run aborted before impl.) * feat(codex): round-robin pool rotation + jittered request pacing Wire the opt-in multi-account free-pool maximization (types in prior commit): - routing.ts: when codexRotationMode === 'round-robin', NEW (non-affined) conversations rotate across getEligiblePoolAccounts via a per-process cursor, so a multi-account free pool multiplies throughput instead of only failing over. Cooldown/reauth/soft-avoid already filtered; thread affinity preserved; activeCodexAccountId untouched; single account = no-op. - pacer.ts: per-account jittered inter-request gap [minMs,maxMs] (defaults 150-900ms), off by default (codexRequestPacing.enabled). Per-account state so concurrent accounts desync instead of a fixed ban-prone cadence. - responses.ts: await codexPaceBeforeSend before the outbound Codex pool send (guarded by usesCodexForwardPoolAuth), no-op when disabled. Tests (bun): 5 pacer + 5 rotation, all pass. typecheck clean. Backward-compat (defaults preserve current failover + no-pacing behavior). --------- Co-authored-by: OnlineChef <280567955+OnlineChef@users.noreply.github.com> --- src/codex/pacer.ts | 59 ++++++++++++++++ src/codex/routing.ts | 25 +++++++ src/server/responses.ts | 6 ++ src/types.ts | 29 ++++++++ tests/codex-pacer.test.ts | 115 +++++++++++++++++++++++++++++++ tests/codex-rotation.test.ts | 129 +++++++++++++++++++++++++++++++++++ 6 files changed, 363 insertions(+) create mode 100644 src/codex/pacer.ts create mode 100644 tests/codex-pacer.test.ts create mode 100644 tests/codex-rotation.test.ts diff --git a/src/codex/pacer.ts b/src/codex/pacer.ts new file mode 100644 index 000000000..c69732716 --- /dev/null +++ b/src/codex/pacer.ts @@ -0,0 +1,59 @@ +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(); +} + +/** + * 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): Promise { + const pacing = config.codexRequestPacing; + if (!pacing?.enabled) return; + if (!accountId) return; + const now = Date.now(); + const last = lastSendAtByAccount.get(accountId); + if (last !== undefined) { + const wait = Math.max(0, paceGapMs(pacing) - (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 272f551b5..4c4fef606 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -23,6 +23,16 @@ export type CodexThreadResolution = | { status: "expired"; accountId: string }; const threadAccountMap = new Map(); + +// Round-robin rotation cursor (per-process, in-memory). Only consulted when +// config.codexRotationMode === "round-robin"; advances once per NEW (non-affined) +// conversation selection. Left untouched by the default failover path. +let rrCursor = 0; + +/** Reset the round-robin cursor. Intended for deterministic tests. */ +export function resetCodexRoundRobinCursor(): void { + rrCursor = 0; +} type CodexUpstreamHealth = { consecutiveFailures: number; /** Consecutive healthy terminals observed while recovering from escalation level 2+. */ @@ -417,6 +427,21 @@ 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); + return { status: "selected", accountId: pick }; + } + } let active = config.activeCodexAccountId; if (!active) { const selected = pickLowestUsageCodexAccount(config, undefined, now); diff --git a/src/server/responses.ts b/src/server/responses.ts index 616452caa..9354b3e49 100644 --- a/src/server/responses.ts +++ b/src/server/responses.ts @@ -55,6 +55,7 @@ import { recordCodexUpstreamOutcome, type CodexUpstreamOutcome, } from "../codex/routing"; +import { codexPaceBeforeSend } from "../codex/pacer"; import { fetchWithResetRetry, fetchWithTransientRetry, applyUpstreamRecoveryInit } from "../lib/upstream-retry"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar"; @@ -1122,6 +1123,11 @@ export async function handleResponses( const upstream = new AbortController(); linkAbortSignal(upstream, options.abortSignal); const connectMs = config.connectTimeoutMs ?? 200_000; + // Jittered inter-request pacing for outbound Codex pool calls. No-op when + // disabled (default) or for non-pool auth (main passthrough / other providers). + if (usesCodexForwardPoolAuth(authCtx, route.provider)) { + await codexPaceBeforeSend(config, authCtx.accountId); + } let upstreamResponse: Response; try { // Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010): diff --git a/src/types.ts b/src/types.ts index 82ceedfb9..63be28b53 100644 --- a/src/types.ts +++ b/src/types.ts @@ -494,6 +494,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. */ @@ -505,6 +525,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; 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"); + }); +}); From f763dfac8e3da1fb8669d881d6a9569eb521ce38 Mon Sep 17 00:00:00 2001 From: OnlineChef <280567955+OnlineChef@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:17:39 +0200 Subject: [PATCH 07/57] docs(fleet): pin joep host OpenCodex at 2.7.31 Record upstream, org fork, npm package, and the verified host pin for OnlineChefGroep so ocx upgrades stay explicit. Co-authored-by: Cursor --- FLEET.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 FLEET.md diff --git a/FLEET.md b/FLEET.md new file mode 100644 index 000000000..cf1ea9df2 --- /dev/null +++ b/FLEET.md @@ -0,0 +1,43 @@ +# OnlineChefGroep fleet pin — OpenCodex (`ocx`) + +| Field | Value | +|-------|-------| +| Upstream | https://github.com/lidge-jun/opencodex | +| Org fork | https://github.com/OnlineChefGroep/opencodex | +| npm package | `@bitkyc08/opencodex` | +| **Pinned host version (joep)** | **2.7.31** | +| Pin tag | `v2.7.31` | +| Pin commit | `304a1eab28cc114c182399db39ba91fb5af19d9d` | +| Verified | 2026-07-22 on `joep` (Ubuntu) | + +## Host install (joep) + +```text +CLI: ~/.local/bin/ocx → @bitkyc08/opencodex@2.7.31 +Config: ~/.opencodex/config.json +Proxy: http://127.0.0.1:10100 +Usage: ~/.opencodex/usage.jsonl +``` + +Do **not** auto-upgrade past the pin without an explicit fleet decision. Latest npm may be newer; this host stays on 2.7.31 until bumped here and in chefgroep-vault `PROVIDERS.md`. + +## Pin / reinstall + +```bash +npm install -g @bitkyc08/opencodex@2.7.31 +ocx --version # expect: opencodex 2.7.31 +``` + +Checkout this tag in the fork: + +```bash +git fetch --tags +git checkout v2.7.31 +``` + +## Role in the ChefGroep stack + +- **ocx** = runtime provider proxy + model routing for Codex (and friends). +- **chefgroep-vault** = source of truth for OAuth/file account capture & switch (`~/.codex/auth.json` ↔ profiles). +- After `chefvault accounts switch` of a **codex** account, vault forces ocx onto the main Codex login (`activeCodexAccountId = __main__`) so the proxy reads the newly installed live auth. +- **coding-provider-manager (`cpm`)** = API-key vault + tool adapters; OAuth account switching delegates to chefvault (driver `chefvault`). From a75ad51cdb2cc9a564b19bc443805bc42cdaabbc Mon Sep 17 00:00:00 2001 From: OnlineChef <280567955+OnlineChef@users.noreply.github.com> Date: Wed, 22 Jul 2026 12:46:16 +0200 Subject: [PATCH 08/57] fix(oauth): force Cursor account picker on Add account / reauth Without prompt=select_account an already-signed-in browser re-approves the same JWT sub, so multiauth updates the active row instead of appending. Co-authored-by: Cursor --- src/oauth/cursor.ts | 32 +++++++++++++++++++++++++++----- src/oauth/index.ts | 4 +++- tests/cursor-oauth.test.ts | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 7 deletions(-) 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 78c9cd06c..73edbf244 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -97,7 +97,9 @@ export const OAUTH_PROVIDERS: Record = { defaultModel: oauthDefaultModel("google-antigravity"), }, cursor: { - login: (ctrl) => loginCursor(ctrl), + // forceLogin (GUI "Add account" / reauth) opens the browser account picker so a second + // Cursor identity can be appended under multiauth instead of refreshing the active row. + login: (ctrl, opts) => loginCursor(ctrl, { forceAccountSelect: opts?.forceLogin === true }), refresh: refreshCursorToken, providerConfig: oauthConfig("cursor"), defaultModel: oauthDefaultModel("cursor"), diff --git a/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" }); From bada5090bdcc82f0248fcb9805b867dda3ee5d45 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 12 Jul 2026 03:36:24 +0900 Subject: [PATCH 09/57] feat(claude): add desktop family profiles --- src/claude/desktop-3p.ts | 86 ++++++++-- src/claude/desktop-profile.ts | 235 ++++++++++++++++++++++++++++ src/claude/model-info.ts | 12 +- src/cli/claude-desktop.ts | 152 ++++++++++++++++++ src/cli/index.ts | 40 +---- src/config.ts | 15 ++ src/server/index.ts | 14 +- src/server/management-api.ts | 83 +++++++++- src/types.ts | 15 ++ tests/claude-desktop-cli.test.ts | 85 ++++++++++ tests/claude-management-api.test.ts | 53 +++++++ tests/desktop-3p.test.ts | 37 +++++ tests/desktop-profile.test.ts | 84 ++++++++++ 13 files changed, 850 insertions(+), 61 deletions(-) create mode 100644 src/claude/desktop-profile.ts create mode 100644 src/cli/claude-desktop.ts create mode 100644 tests/claude-desktop-cli.test.ts create mode 100644 tests/desktop-profile.test.ts diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 98decb8c0..9588e676e 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -1,12 +1,19 @@ import { createHash, randomUUID } from "node:crypto"; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { atomicWriteFile } from "../config"; +import type { OcxClaudeDesktopProfile } from "../types"; +import { + reconcileDesktopProfile, + renderDesktopProfile, + type DesktopProfileModel, +} from "./desktop-profile"; export interface Desktop3pModelEntry { name: string; labelOverride: string; - anthropicFamilyTier: "opus"; + anthropicFamilyTier: "opus" | "fable" | "sonnet" | "haiku"; isFamilyDefault?: boolean; /** * Desktop's documented 1M-context capability assertion. Set ONLY from an @@ -59,6 +66,7 @@ interface Desktop3pMetadata { } let desktop3pRegistry = new Map(); +let desktop3pAliasesByRoute = new Map(); /** Derive a stable letter-first, three-character base36 code from a route key. */ export function deriveDesktop3pCode(route: string): string { @@ -102,6 +110,7 @@ function displayModelId(modelId: string): string { function collectDesktop3pModels( nativeSlugs: string[], routedModels: Array, + profile?: OcxClaudeDesktopProfile, ): { models: Desktop3pModelEntry[]; registry: Map } { const registry = new Map(); const models: Desktop3pModelEntry[] = []; @@ -110,6 +119,35 @@ function collectDesktop3pModels( ...routedModels, ]; + if (profile) { + const profileModels = candidates.map(({ provider, id, contextWindow }) => ({ + route: `${provider}/${id}`, + label: `${displayModelId(id)} (${provider})`, + ...(typeof contextWindow === "number" ? { contextWindow } : {}), + } satisfies DesktopProfileModel)); + const reconciled = reconcileDesktopProfile(profile, profileModels); + const rendered = renderDesktopProfile(reconciled, profileModels); + const aliasesByRoute = new Map(); + for (const model of rendered) { + aliasesByRoute.set(model.route, model.name); + if (!model.route.startsWith("anthropic/claude-")) registry.set(model.name, model.route); + const providerEnd = model.route.indexOf("/"); + const provider = model.route.slice(0, providerEnd); + const id = model.route.slice(providerEnd + 1); + const legacy = legacyDesktop3pAlias(provider, id); + if (!registry.has(legacy)) registry.set(legacy, model.route); + models.push({ + name: model.name, + labelOverride: model.label, + anthropicFamilyTier: model.family, + ...(model.isFamilyDefault ? { isFamilyDefault: true } : {}), + ...(model.supports1m ? { supports1m: true } : {}), + }); + } + desktop3pAliasesByRoute = aliasesByRoute; + return { models, registry }; + } + for (const { provider, id, contextWindow } of candidates) { const route = `${provider}/${id}`; const alias = desktop3pAlias(provider, id); @@ -147,6 +185,7 @@ function collectDesktop3pModels( } if (models[0]) models[0].isFamilyDefault = true; + desktop3pAliasesByRoute = new Map(candidates.map(({ provider, id }) => [`${provider}/${id}`, desktop3pAlias(provider, id)])); return { models, registry }; } @@ -154,8 +193,9 @@ function collectDesktop3pModels( export function buildDesktop3pRegistry( nativeSlugs: string[], routedModels: Array, + profile?: OcxClaudeDesktopProfile, ): Map { - const { registry } = collectDesktop3pModels(nativeSlugs, routedModels); + const { registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile); desktop3pRegistry = registry; return registry; } @@ -164,8 +204,9 @@ export function buildDesktop3pRegistry( export function generateDesktop3pModels( nativeSlugs: string[], routedModels: Array, + profile?: OcxClaudeDesktopProfile, ): Desktop3pModelEntry[] { - const { models, registry } = collectDesktop3pModels(nativeSlugs, routedModels); + const { models, registry } = collectDesktop3pModels(nativeSlugs, routedModels, profile); desktop3pRegistry = registry; return models; } @@ -175,6 +216,11 @@ export function resolveDesktop3pAlias(alias: string): string | null { return desktop3pRegistry.get(alias) ?? null; } +/** Alias selected by the installed profile registry, falling back to the legacy hash shape. */ +export function activeDesktop3pAlias(provider: string, modelId: string): string { + return desktop3pAliasesByRoute.get(`${provider}/${modelId}`) ?? desktop3pAlias(provider, modelId); +} + /** * Generate the complete Claude Desktop 3P gateway config. * @@ -189,6 +235,7 @@ export function generateDesktop3pConfig( routedModels: Array, apiKey = "ocx", mode: Desktop3pConfigMode = "static", + profile?: OcxClaudeDesktopProfile, ): object { const base = { inferenceProvider: "gateway", @@ -198,13 +245,13 @@ export function generateDesktop3pConfig( }; if (mode === "discovery") { // Build/refresh the decode registry even though no static list is emitted. - buildDesktop3pRegistry(nativeSlugs, routedModels); + buildDesktop3pRegistry(nativeSlugs, routedModels, profile); return { ...base, modelDiscoveryEnabled: true }; } return { ...base, modelDiscoveryEnabled: mode === "hybrid", - inferenceModels: generateDesktop3pModels(nativeSlugs, routedModels), + inferenceModels: generateDesktop3pModels(nativeSlugs, routedModels, profile), }; } @@ -222,8 +269,10 @@ export function writeDesktop3pConfig( routedModels: Array, apiKey?: string, mode: Desktop3pConfigMode = "static", + profile?: OcxClaudeDesktopProfile, ): { written: boolean; path: string; reason?: string } { - const libraryPath = join(homedir(), "Library", "Application Support", "Claude-3p", "configLibrary"); + const libraryPath = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR?.trim() + || join(homedir(), "Library", "Application Support", "Claude-3p", "configLibrary"); const metadataPath = join(libraryPath, "_meta.json"); let configPath = libraryPath; @@ -238,17 +287,24 @@ export function writeDesktop3pConfig( ? metadata.entries.map(current => current === existing ? entry : current) : [...metadata.entries, entry]; - writeFileSync(configPath, JSON.stringify(generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode), null, 2) + "\n", { - encoding: "utf8", - mode: 0o600, - }); - writeFileSync(metadataPath, JSON.stringify({ ...metadata, appliedId: id, entries }, null, 2) + "\n", { - encoding: "utf8", - mode: 0o600, - }); + const configJson = JSON.stringify(generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode, profile), null, 2) + "\n"; + atomicReplaceDesktopConfig(configPath, configJson); + atomicWriteFile(metadataPath, JSON.stringify({ ...metadata, appliedId: id, entries }, null, 2) + "\n"); return { written: true, path: configPath }; } catch (error) { const reason = error instanceof Error ? error.message : String(error); return { written: false, path: configPath, reason }; } } + +/** Backup an existing owned config then atomically replace it. Exported for failure-path tests. */ +export function atomicReplaceDesktopConfig( + path: string, + content: string, + writer: (path: string, content: string) => void = atomicWriteFile, +): { backupPath?: string } { + const backupPath = `${path}.bak`; + if (existsSync(path)) copyFileSync(path, backupPath); + writer(path, content); + return existsSync(backupPath) ? { backupPath } : {}; +} diff --git a/src/claude/desktop-profile.ts b/src/claude/desktop-profile.ts new file mode 100644 index 000000000..49bdd8a5e --- /dev/null +++ b/src/claude/desktop-profile.ts @@ -0,0 +1,235 @@ +import { createHash } from "node:crypto"; +import type { + OcxClaudeDesktopAssignment, + OcxClaudeDesktopFamily, + OcxClaudeDesktopProfile, +} from "../types"; + +export const DESKTOP_FAMILIES = ["opus", "fable", "sonnet", "haiku"] as const; +export type DesktopFamily = OcxClaudeDesktopFamily; +export type DesktopProfile = OcxClaudeDesktopProfile; + +export interface DesktopProfileModel { + route: string; + label: string; + contextWindow?: number; +} + +export interface RenderedDesktopModel extends DesktopProfileModel { + name: string; + family: DesktopFamily; + isFamilyDefault: boolean; + supports1m: boolean; +} + +const DATE_ALIAS = /^claude-opus-4-8-(2026\d{4})$/; +const DAY_COUNT_2026 = 365; + +export class DesktopProfileError extends Error { + constructor(message: string, readonly path = "profile") { + super(`${path}: ${message}`); + this.name = "DesktopProfileError"; + } +} + +export function emptyDesktopProfile(): DesktopProfile { + return { + version: 1, + assignments: {}, + defaults: { opus: null, fable: null, sonnet: null, haiku: null }, + }; +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function assertExactKeys(value: Record, keys: readonly string[], path: string): void { + const allowed = new Set(keys); + for (const key of Object.keys(value)) { + if (!allowed.has(key)) throw new DesktopProfileError(`unknown field "${key}"`, path); + } +} + +function isFamily(value: unknown): value is DesktopFamily { + return typeof value === "string" && (DESKTOP_FAMILIES as readonly string[]).includes(value); +} + +function routeModelId(route: string): string { + const slash = route.indexOf("/"); + return slash >= 0 ? route.slice(slash + 1) : route; +} + +function isRealAnthropicRoute(route: string): boolean { + return route.startsWith("anthropic/claude-"); +} + +function validDateAlias(alias: string): boolean { + const match = DATE_ALIAS.exec(alias); + if (!match) return false; + const year = Number(match[1]!.slice(0, 4)); + const month = Number(match[1]!.slice(4, 6)); + const day = Number(match[1]!.slice(6, 8)); + const date = new Date(Date.UTC(year, month - 1, day)); + return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; +} + +export function parseDesktopProfile(value: unknown): DesktopProfile { + if (!isPlainObject(value)) throw new DesktopProfileError("must be an object"); + assertExactKeys(value, ["version", "assignments", "defaults"], "profile"); + if (value.version !== 1) throw new DesktopProfileError("version must be 1", "profile.version"); + if (!isPlainObject(value.assignments)) throw new DesktopProfileError("must be an object", "profile.assignments"); + if (!isPlainObject(value.defaults)) throw new DesktopProfileError("must be an object", "profile.defaults"); + assertExactKeys(value.defaults, DESKTOP_FAMILIES, "profile.defaults"); + + const assignments: Record = {}; + const aliases = new Set(); + for (const [route, raw] of Object.entries(value.assignments)) { + if (!route.trim() || !route.includes("/")) throw new DesktopProfileError("route must be provider/model", `profile.assignments.${route || ""}`); + if (!isPlainObject(raw)) throw new DesktopProfileError("must be an object", `profile.assignments.${route}`); + assertExactKeys(raw, ["family", "alias"], `profile.assignments.${route}`); + if (!isFamily(raw.family)) throw new DesktopProfileError("unknown family", `profile.assignments.${route}.family`); + if (typeof raw.alias !== "string" || !raw.alias) throw new DesktopProfileError("must be a non-empty string", `profile.assignments.${route}.alias`); + if (isRealAnthropicRoute(route)) { + if (raw.alias !== routeModelId(route)) throw new DesktopProfileError("real Anthropic routes must keep their exact model id", `profile.assignments.${route}.alias`); + } else if (!validDateAlias(raw.alias)) { + throw new DesktopProfileError("must be a valid claude-opus-4-8-2026MMDD alias", `profile.assignments.${route}.alias`); + } + if (aliases.has(raw.alias)) throw new DesktopProfileError(`duplicate alias "${raw.alias}"`, `profile.assignments.${route}.alias`); + aliases.add(raw.alias); + assignments[route] = { family: raw.family, alias: raw.alias }; + } + + const defaults = {} as DesktopProfile["defaults"]; + for (const family of DESKTOP_FAMILIES) { + const route = value.defaults[family]; + if (route !== null && typeof route !== "string") throw new DesktopProfileError("must be a route or null", `profile.defaults.${family}`); + const members = Object.keys(assignments).filter(key => assignments[key]!.family === family).sort(); + if (members.length === 0) { + if (route !== null) throw new DesktopProfileError("must be null for an empty family", `profile.defaults.${family}`); + defaults[family] = null; + continue; + } + if (typeof route !== "string" || !assignments[route] || assignments[route]!.family !== family) { + throw new DesktopProfileError("must reference a member of this family", `profile.defaults.${family}`); + } + defaults[family] = route; + } + return { version: 1, assignments, defaults }; +} + +function dayOfYearAlias(dayIndex: number): string { + const date = new Date(Date.UTC(2026, 0, dayIndex + 1)); + const y = date.getUTCFullYear(); + const m = String(date.getUTCMonth() + 1).padStart(2, "0"); + const d = String(date.getUTCDate()).padStart(2, "0"); + return `claude-opus-4-8-${y}${m}${d}`; +} + +function routeStartDay(route: string): number { + return createHash("sha256").update(route).digest().readUInt32BE(0) % DAY_COUNT_2026; +} + +function allocateAlias(route: string, used: Set): string { + if (isRealAnthropicRoute(route)) return routeModelId(route); + const start = routeStartDay(route); + for (let offset = 0; offset < DAY_COUNT_2026; offset += 1) { + const alias = dayOfYearAlias((start + offset) % DAY_COUNT_2026); + if (!used.has(alias)) return alias; + } + throw new DesktopProfileError("all 365 encoded date slots are occupied", `profile.assignments.${route}.alias`); +} + +export function reconcileDesktopProfile( + stored: unknown, + models: readonly DesktopProfileModel[], +): DesktopProfile { + const profile = stored === undefined || stored === null ? emptyDesktopProfile() : parseDesktopProfile(stored); + const assignments: DesktopProfile["assignments"] = Object.fromEntries( + Object.entries(profile.assignments).map(([route, assignment]) => [route, { ...assignment }]), + ); + const used = new Set(Object.values(assignments).map(value => value.alias)); + for (const model of [...models].sort((a, b) => a.route.localeCompare(b.route))) { + if (assignments[model.route]) continue; + const alias = allocateAlias(model.route, used); + used.add(alias); + assignments[model.route] = { family: "opus", alias }; + } + const defaults = { ...profile.defaults }; + for (const family of DESKTOP_FAMILIES) { + const members = Object.keys(assignments).filter(route => assignments[route]!.family === family).sort(); + const current = defaults[family]; + defaults[family] = current && assignments[current]?.family === family ? current : (members[0] ?? null); + } + return parseDesktopProfile({ version: 1, assignments, defaults }); +} + +export function moveDesktopRoute( + profile: DesktopProfile, + route: string, + family: DesktopFamily, + makeDefault = false, +): DesktopProfile { + const parsed = parseDesktopProfile(profile); + const current = parsed.assignments[route]; + if (!current) throw new DesktopProfileError("route is not assigned", `profile.assignments.${route}`); + const oldFamily = current.family; + if (oldFamily === family) { + return makeDefault ? setDesktopFamilyDefault(parsed, family, route) : parsed; + } + const assignments = { ...parsed.assignments, [route]: { ...current, family } }; + const defaults = { ...parsed.defaults }; + if (defaults[oldFamily] === route) { + defaults[oldFamily] = Object.keys(assignments).filter(key => key !== route && assignments[key]!.family === oldFamily).sort()[0] ?? null; + } + const destinationMembers = Object.keys(assignments).filter(key => assignments[key]!.family === family).sort(); + if (makeDefault || !defaults[family] || assignments[defaults[family]!]?.family !== family) defaults[family] = route; + if (!defaults[family] && destinationMembers.length > 0) defaults[family] = destinationMembers[0]!; + return parseDesktopProfile({ version: 1, assignments, defaults }); +} + +export function setDesktopFamilyDefault( + profile: DesktopProfile, + family: DesktopFamily, + route: string | null, +): DesktopProfile { + const parsed = parseDesktopProfile(profile); + const members = Object.keys(parsed.assignments).filter(key => parsed.assignments[key]!.family === family); + if (route === null && members.length > 0) throw new DesktopProfileError("cannot clear a non-empty family default", `profile.defaults.${family}`); + if (route !== null && parsed.assignments[route]?.family !== family) throw new DesktopProfileError("route is not a member of this family", `profile.defaults.${family}`); + return parseDesktopProfile({ ...parsed, defaults: { ...parsed.defaults, [family]: route } }); +} + +export function renderDesktopProfile( + profile: DesktopProfile, + models: readonly DesktopProfileModel[], +): RenderedDesktopModel[] { + const parsed = parseDesktopProfile(profile); + const modelByRoute = new Map(models.map(model => [model.route, model])); + const activeByFamily = new Map(); + for (const family of DESKTOP_FAMILIES) activeByFamily.set(family, []); + for (const [route, assignment] of Object.entries(parsed.assignments)) { + if (modelByRoute.has(route)) activeByFamily.get(assignment.family)!.push(route); + } + for (const routes of activeByFamily.values()) routes.sort(); + const effectiveDefaults = {} as Record; + for (const family of DESKTOP_FAMILIES) { + const active = activeByFamily.get(family)!; + const stored = parsed.defaults[family]; + effectiveDefaults[family] = stored && active.includes(stored) ? stored : (active[0] ?? null); + } + const defaultOrder = DESKTOP_FAMILIES.map(family => effectiveDefaults[family]).filter((route): route is string => !!route); + const defaultSet = new Set(defaultOrder); + const rest = Object.keys(parsed.assignments).filter(route => modelByRoute.has(route) && !defaultSet.has(route)).sort(); + return [...defaultOrder, ...rest].map(route => { + const model = modelByRoute.get(route)!; + const assignment = parsed.assignments[route]!; + return { + ...model, + name: assignment.alias, + family: assignment.family, + isFamilyDefault: effectiveDefaults[assignment.family] === route, + supports1m: typeof model.contextWindow === "number" && model.contextWindow >= 1_000_000, + }; + }); +} diff --git a/src/claude/model-info.ts b/src/claude/model-info.ts index bc2eb4689..3981facb9 100644 --- a/src/claude/model-info.ts +++ b/src/claude/model-info.ts @@ -102,7 +102,13 @@ function modelInfo(id: string, displayName: string, ladder: readonly string[], i export type AnthropicIdStyle = "desktop3p" | "readable"; /** Build the full anthropic-flavor discovery list (ids are Desktop 3P aliases). */ -export function buildAnthropicModelInfos(nativeSlugs: readonly string[], routedModels: readonly CatalogModel[], auto: AutoContextMode = AUTO_CONTEXT_OFF, idStyle: AnthropicIdStyle = "desktop3p"): AnthropicModelInfo[] { +export function buildAnthropicModelInfos( + nativeSlugs: readonly string[], + routedModels: readonly CatalogModel[], + auto: AutoContextMode = AUTO_CONTEXT_OFF, + idStyle: AnthropicIdStyle = "desktop3p", + aliasForRoute: (provider: string, modelId: string) => string = desktop3pAlias, +): AnthropicModelInfo[] { const out: AnthropicModelInfo[] = []; const seen = new Set(); // [1m] picker variant (devlog 260712 B1): Claude Code accounts exactly 1M for ids @@ -122,7 +128,7 @@ export function buildAnthropicModelInfos(nativeSlugs: readonly string[], routedM out.push({ ...base, id, display_name: `${base.display_name} · ${label}`, max_input_tokens: Math.min(window, ONE_MILLION) }); }; for (const slug of nativeSlugs) { - const id = idStyle === "readable" ? claudeCodeNativeAlias(slug) : desktop3pAlias("native", slug); + const id = idStyle === "readable" ? claudeCodeNativeAlias(slug) : aliasForRoute("native", slug); if (seen.has(id)) continue; seen.add(id); const info = modelInfo(id, `${slug} (native)`, nativeEffectiveLadder(slug), true); @@ -130,7 +136,7 @@ export function buildAnthropicModelInfos(nativeSlugs: readonly string[], routedM push1mVariant(info, nativeOpenAiContextWindow(slug)); } for (const m of routedModels) { - const id = idStyle === "readable" ? claudeCodeAlias(m.provider, m.id) : desktop3pAlias(m.provider, m.id); + const id = idStyle === "readable" ? claudeCodeAlias(m.provider, m.id) : aliasForRoute(m.provider, m.id); if (seen.has(id)) continue; seen.add(id); const ladder = Array.isArray(m.reasoningEfforts) ? m.reasoningEfforts : []; diff --git a/src/cli/claude-desktop.ts b/src/cli/claude-desktop.ts new file mode 100644 index 000000000..d1f88dc35 --- /dev/null +++ b/src/cli/claude-desktop.ts @@ -0,0 +1,152 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { loadConfig, saveConfig } from "../config"; +import { + DESKTOP_FAMILIES, + moveDesktopRoute, + parseDesktopProfile, + setDesktopFamilyDefault, + type DesktopFamily, + type DesktopProfile, +} from "../claude/desktop-profile"; +import { writeDesktop3pConfig, type Desktop3pConfigMode, parseDesktop3pModeArgs } from "../claude/desktop-3p"; +import { filterCatalogVisibleModels, visibleNativeSlugs } from "../codex/catalog"; +import { buildClaudeDesktopState, fetchAllModels } from "../server/management-api"; +import { findLiveProxy } from "../server/proxy-liveness"; + +function isFamily(value: string | undefined): value is DesktopFamily { + return !!value && (DESKTOP_FAMILIES as readonly string[]).includes(value); +} + +function printDesktopHelp(): void { + console.log(`Usage: + ocx claude desktop [apply] [--static|--hybrid|--discovery-only] + ocx claude desktop show [--json] + ocx claude desktop move [--default] + ocx claude desktop default + ocx claude desktop export + ocx claude desktop import [--apply]`); +} + +async function applyProfile(profile: DesktopProfile, mode: Desktop3pConfigMode): Promise<{ ok: boolean; path: string; reason?: string }> { + const config = loadConfig(); + const state = await buildClaudeDesktopState(config, profile); + config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile }; + saveConfig(config); + const live = await findLiveProxy(); + const allModels = await fetchAllModels(config); + const routed = filterCatalogVisibleModels(allModels, config).map(model => ({ + provider: model.provider, + id: model.id, + contextWindow: model.contextWindow, + })); + const result = writeDesktop3pConfig( + live?.port ?? config.port ?? 10100, + [...visibleNativeSlugs(config)], + routed, + config.apiKeys?.[0]?.key, + mode, + state.profile, + ); + return { ok: result.written, path: result.path, reason: result.reason }; +} + +export async function handleClaudeDesktopCommand(argv: string[]): Promise { + const command = argv[0]; + if (command === "help" || command === "--help" || command === "-h") { + printDesktopHelp(); + return 0; + } + + // Legacy mode flags remain apply aliases and are parsed before subcommands. + const legacyFlags = argv.filter(arg => ["--static", "--hybrid", "--discovery-only"].includes(arg)); + const applyInvocation = argv.length === 0 || command === "apply" || legacyFlags.length > 0; + if (applyInvocation) { + const nonMode = argv.filter(arg => !["apply", "--static", "--hybrid", "--discovery-only"].includes(arg)); + if (nonMode.length > 0) { + console.error(`알 수 없는 인자: ${nonMode.join(" ")}`); + return 1; + } + const parsedMode = parseDesktop3pModeArgs(legacyFlags); + if ("error" in parsedMode) { console.error(parsedMode.error); return 1; } + try { + const config = loadConfig(); + const state = await buildClaudeDesktopState(config); + const result = await applyProfile(state.profile, parsedMode.mode); + if (!result.ok) { console.error(`설정 적용 실패: ${result.reason ?? "unknown error"}`); return 1; } + console.log(`Claude Desktop 설정을 적용했습니다: ${result.path}`); + console.log("Claude Desktop을 완전히 종료한 뒤 다시 열어 주세요."); + return 0; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } + } + + try { + const config = loadConfig(); + const state = await buildClaudeDesktopState(config); + if (command === "show") { + if (argv.length > 2 || (argv[1] && argv[1] !== "--json")) throw new Error("Usage: ocx claude desktop show [--json]"); + if (argv[1] === "--json") console.log(JSON.stringify(state)); + else { + for (const family of DESKTOP_FAMILIES) { + console.log(`${family.toUpperCase()}${state.profile.defaults[family] ? ` (default: ${state.profile.defaults[family]})` : ""}`); + for (const model of state.models.filter(item => item.assignment.family === family)) { + console.log(` ${model.available ? "•" : "○"} ${model.route} -> ${model.assignment.alias}${model.available ? "" : " (unavailable)"}`); + } + } + } + return 0; + } + if (command === "move") { + const [, route, familyRaw, ...flags] = argv; + if (!route || !isFamily(familyRaw) || flags.some(flag => flag !== "--default")) throw new Error("Usage: ocx claude desktop move [--default]"); + if (!state.models.some(model => model.route === route && model.available)) throw new Error(`현재 사용할 수 없는 모델입니다: ${route}`); + const profile = moveDesktopRoute(state.profile, route, familyRaw, flags.includes("--default")); + config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: profile }; + saveConfig(config); + console.log(`${route} 모델을 ${familyRaw} 그룹으로 옮겼습니다.`); + return 0; + } + if (command === "default") { + const [, familyRaw, routeRaw] = argv; + if (!isFamily(familyRaw) || !routeRaw || argv.length !== 3) throw new Error("Usage: ocx claude desktop default "); + const route = routeRaw === "none" ? null : routeRaw; + if (route && !state.models.some(model => model.route === route && model.available)) throw new Error(`현재 사용할 수 없는 모델입니다: ${route}`); + const profile = setDesktopFamilyDefault(state.profile, familyRaw, route); + config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: profile }; + saveConfig(config); + console.log(`${familyRaw} 기본 모델을 ${route ?? "없음"}으로 지정했습니다.`); + return 0; + } + if (command === "export") { + const target = argv[1]; + if (!target || argv.length !== 2) throw new Error("Usage: ocx claude desktop export "); + const json = JSON.stringify(state.profile, null, 2) + "\n"; + if (target === "-") process.stdout.write(json); + else writeFileSync(resolve(target), json, { encoding: "utf8", mode: 0o600 }); + return 0; + } + if (command === "import") { + const source = argv[1]; + const flags = argv.slice(2); + if (!source || flags.some(flag => flag !== "--apply")) throw new Error("Usage: ocx claude desktop import [--apply]"); + const profile = parseDesktopProfile(JSON.parse(readFileSync(resolve(source), "utf8"))); + const reconciled = (await buildClaudeDesktopState(config, profile)).profile; + config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: reconciled }; + saveConfig(config); + if (flags.includes("--apply")) { + const result = await applyProfile(reconciled, "static"); + if (!result.ok) { console.error(`프로필은 저장했지만 Desktop 적용에 실패했습니다: ${result.reason ?? "unknown error"}`); return 1; } + } + console.log("Claude Desktop 프로필을 가져왔습니다."); + return 0; + } + printDesktopHelp(); + return 1; + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + return 1; + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 7eebe41fb..e339c407e 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -254,6 +254,7 @@ async function handleStart(options: { block?: boolean } = {}) { buildDesktop3pRegistry( [...visibleNativeSlugs(config)], models.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })), + config.claudeCode?.desktopProfile, ); } catch { /* best-effort — registry rebuilds on first /v1/models call */ } if (options.block ?? true) { @@ -678,42 +679,9 @@ switch (command) { const { cmdClaude } = await import("./claude"); // "ocx claude desktop" → write Desktop 3P config if (args[1] === "desktop") { - const config = loadConfig(); - const { fetchAllModels } = await import("../server/management-api"); - const { visibleNativeSlugs, filterCatalogVisibleModels } = await import("../codex/catalog"); - const { parseDesktop3pModeArgs, writeDesktop3pConfig } = await import("../claude/desktop-3p"); - // Mutually-exclusive mode flags (devlog 138): default static (deterministic; the - // static list overrides discovery anyway — no merge). - const parsedMode = parseDesktop3pModeArgs(args.slice(2)); - if ("error" in parsedMode) { - console.error(`❌ ${parsedMode.error}`); - process.exit(1); - } - const mode = parsedMode.mode; - const live = await findLiveProxy(); - const port = live?.port ?? config.port ?? 10100; - const allModels = await fetchAllModels(config); - const models = filterCatalogVisibleModels(allModels, config); - const nativeSlugs = [...visibleNativeSlugs(config)]; - // contextWindow rides along so supports1m derives from authoritative data (감사 R1#1). - const routedModels = models.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })); - const result = writeDesktop3pConfig(port, nativeSlugs, routedModels, undefined, mode); - if (result.written) { - const oneM = routedModels.filter(m => typeof m.contextWindow === "number" && m.contextWindow >= 1_000_000).length; - console.log(`✅ Claude Desktop 3P 설정 완료: ${result.path}`); - console.log(` Gateway: http://127.0.0.1:${port}`); - if (mode === "discovery") { - console.log(` 모델 목록: 자동 발견만 (프록시 /v1/models에서 ${nativeSlugs.length + models.length}개)`); - } else { - const suffix = mode === "hybrid" ? " + 자동 발견 병행" : ""; - console.log(` 모델 ${nativeSlugs.length + models.length}개 고정 등록${suffix} (1M 컨텍스트 별도 행 ${oneM}개)`); - if (oneM > 0) console.log(` 1M을 쓰려면 Desktop 모델 피커에서 [1M] 붙은 행을 직접 선택하세요.`); - } - console.log(` Claude Desktop을 재시작하면 적용됩니다.`); - } else { - console.error(`❌ 설정 실패: ${result.reason}`); - process.exit(1); - } + const { handleClaudeDesktopCommand } = await import("./claude-desktop"); + const exitCode = await handleClaudeDesktopCommand(args.slice(2)); + if (exitCode !== 0) process.exit(exitCode); break; } process.exit(await cmdClaude(args.slice(1))); diff --git a/src/config.ts b/src/config.ts index 208ec458a..46dcab428 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,6 +8,7 @@ import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl"; import { providerDestinationConfigError } from "./lib/destination-policy"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; import { OPENAI_PROVIDER_TIER_VERSION, type OcxConfig } from "./types"; +import { parseDesktopProfile } from "./claude/desktop-profile"; let _atomicSeq = 0; @@ -404,6 +405,20 @@ const configSchema = z.object({ providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), }).passthrough().superRefine((config, ctx) => { + const claudeCode = (config as { claudeCode?: unknown }).claudeCode; + if (claudeCode !== undefined && (!claudeCode || typeof claudeCode !== "object" || Array.isArray(claudeCode))) { + ctx.addIssue({ code: "custom", path: ["claudeCode"], message: "claudeCode must be an object" }); + } else if (claudeCode && "desktopProfile" in claudeCode && (claudeCode as { desktopProfile?: unknown }).desktopProfile !== undefined) { + try { + parseDesktopProfile((claudeCode as { desktopProfile?: unknown }).desktopProfile); + } catch (error) { + ctx.addIssue({ + code: "custom", + path: ["claudeCode", "desktopProfile"], + message: error instanceof Error ? error.message : String(error), + }); + } + } for (const name of Object.keys(config.providers)) { if (!isValidProviderName(name)) { ctx.addIssue({ diff --git a/src/server/index.ts b/src/server/index.ts index 7ca676c52..a27680a38 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -267,8 +267,15 @@ export function startServer(port?: number) { || url.searchParams.get("flavor") === "anthropic"; if (wantsAnthropicList && !url.searchParams.has("client_version")) { if (config.claudeCode?.enabled === false) return jsonResponse({ data: [] }, 200, req, config); + // Build Desktop 3P registry so inbound alias resolution works for subsequent requests. + buildDesktop3pRegistry( + [...visibleNativeSlugs(config)], + goOrdered.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })), + config.claudeCode?.desktopProfile, + ); const { buildAnthropicModelInfos } = await import("../claude/model-info"); const { resolveAutoContext } = await import("../claude/context-windows"); + const { activeDesktop3pAlias } = await import("../claude/desktop-3p"); // Per-surface id family (devlog 050): explicit ?ids= wins; otherwise the // Claude Code CLI discovery UA (`claude-code/`, binary n_()) gets // readable claude-ocx ids and every other client (Desktop 3P) keeps the @@ -279,12 +286,7 @@ export function startServer(port?: number) { : idsParam === "desktop" ? "desktop3p" as const : (/^claude-code\//i.test(req.headers.get("user-agent") ?? "") ? "readable" as const : "desktop3p" as const); - const data = buildAnthropicModelInfos([...visibleNativeSlugs(config)], goOrdered, resolveAutoContext(config.claudeCode), idStyle); - // Build Desktop 3P registry so inbound alias resolution works for subsequent requests. - buildDesktop3pRegistry( - [...visibleNativeSlugs(config)], - goOrdered.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })), - ); + const data = buildAnthropicModelInfos([...visibleNativeSlugs(config)], goOrdered, resolveAutoContext(config.claudeCode), idStyle, activeDesktop3pAlias); return jsonResponse({ data }, 200, req, config); } if (url.searchParams.has("client_version")) { diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 9a842246c..ac8a49b4a 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -47,7 +47,8 @@ import { setDebugSettings, type DebugFlag, } from "../lib/debug-settings"; -import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../types"; +import type { OcxClaudeCodeConfig, OcxClaudeDesktopProfile, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../types"; +import type { DesktopProfileModel } from "../claude/desktop-profile"; import { drainAndShutdown } from "./lifecycle"; import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "./request-log"; import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../usage/cost"; @@ -1190,6 +1191,56 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon return jsonResponse({ ok: true, applied: chosen }); } + // Claude Code inbound settings (GUI "Claude ON" toggle + Claude page). + if (url.pathname === "/api/claude-desktop" && req.method === "GET") { + try { + return jsonResponse(await buildClaudeDesktopState(config)); + } catch (error) { + return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); + } + } + if (url.pathname === "/api/claude-desktop" && req.method === "PUT") { + let body: { profile?: unknown }; + try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); } + try { + const { parseDesktopProfile, reconcileDesktopProfile } = await import("../claude/desktop-profile"); + const parsed = parseDesktopProfile(body.profile); + const state = await buildClaudeDesktopState(config, parsed); + config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: reconcileDesktopProfile(state.profile, state.models) }; + saveConfig(config); + return jsonResponse({ ok: true, ...await buildClaudeDesktopState(config) }); + } catch (error) { + return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); + } + } + if (url.pathname === "/api/claude-desktop/apply" && req.method === "POST") { + try { + const state = await buildClaudeDesktopState(config); + config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile }; + saveConfig(config); + const { writeDesktop3pConfig } = await import("../claude/desktop-3p"); + const { visibleNativeSlugs } = await import("../codex/catalog"); + const routed = state.models + .filter(model => model.available && !model.route.startsWith("native/")) + .map(model => { + const slash = model.route.indexOf("/"); + return { provider: model.route.slice(0, slash), id: model.route.slice(slash + 1), contextWindow: model.contextWindow }; + }); + const result = writeDesktop3pConfig( + config.port, + [...visibleNativeSlugs(config)], + routed, + config.apiKeys?.[0]?.key, + "static", + state.profile, + ); + if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500); + return jsonResponse({ ok: true, saved: true, applied: true, path: result.path }); + } catch (error) { + return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); + } + } + // Claude Code inbound settings (GUI "Claude ON" toggle + Claude page). if (url.pathname === "/api/claude-code" && req.method === "GET") { const models = await fetchAllModels(config); @@ -1784,4 +1835,34 @@ function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProviderConfi if (!matchesRegistryStaticHeaders) return provider; const { headers: _headers, ...rest } = provider; return rest; + +/** Shared Desktop profile DTO builder for the management API and CLI. */ +export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxClaudeDesktopProfile) { + const { filterCatalogVisibleModels, visibleNativeSlugs } = await import("../codex/catalog"); + const { reconcileDesktopProfile, renderDesktopProfile } = await import("../claude/desktop-profile"); + const routed = filterCatalogVisibleModels(await fetchAllModels(config), config); + const profileModels: DesktopProfileModel[] = [ + ...visibleNativeSlugs(config).map(id => ({ route: `native/${id}`, label: `${id} (native)` })), + ...routed.map(model => ({ + route: `${model.provider}/${model.id}`, + label: `${model.id} (${model.provider})`, + ...(typeof model.contextWindow === "number" ? { contextWindow: model.contextWindow } : {}), + })), + ]; + const profile = reconcileDesktopProfile(stored ?? config.claudeCode?.desktopProfile, profileModels); + const available = new Set(profileModels.map(model => model.route)); + const modelByRoute = new Map(profileModels.map(model => [model.route, model])); + const models = Object.keys(profile.assignments).sort().map(route => ({ + route, + label: modelByRoute.get(route)?.label ?? route, + available: available.has(route), + ...(modelByRoute.get(route)?.contextWindow ? { contextWindow: modelByRoute.get(route)!.contextWindow } : {}), + assignment: profile.assignments[route]!, + })); + return { + profile, + models, + rendered: renderDesktopProfile(profile, profileModels), + port: config.port, + }; } diff --git a/src/types.ts b/src/types.ts index 63be28b53..a32efa530 100644 --- a/src/types.ts +++ b/src/types.ts @@ -345,6 +345,21 @@ export interface OcxClaudeCodeConfig { webSearchSidecar?: { backend?: "openai" | "anthropic"; model?: string }; /** Claude-originated vision override. Unset fields inherit the global sidecar settings. */ visionSidecar?: { backend?: "openai" | "anthropic"; model?: string }; + /** Persisted Claude Desktop four-family routing profile. */ + desktopProfile?: OcxClaudeDesktopProfile; +} + +export type OcxClaudeDesktopFamily = "opus" | "fable" | "sonnet" | "haiku"; + +export interface OcxClaudeDesktopAssignment { + family: OcxClaudeDesktopFamily; + alias: string; +} + +export interface OcxClaudeDesktopProfile { + version: 1; + assignments: Record; + defaults: Record; } /** 사용자가 대시보드에서 직접 추가한 커스텀 모델 정의. */ diff --git a/tests/claude-desktop-cli.test.ts b/tests/claude-desktop-cli.test.ts new file mode 100644 index 000000000..2bc390434 --- /dev/null +++ b/tests/claude-desktop-cli.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { handleClaudeDesktopCommand } from "../src/cli/claude-desktop"; +import { loadConfig, saveConfig } from "../src/config"; +import type { OcxConfig } from "../src/types"; + +let dir = ""; +let previousHome: string | undefined; +let previousDesktopDir: string | undefined; + +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + previousDesktopDir = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + dir = mkdtempSync(join(tmpdir(), "ocx-desktop-cli-")); + process.env.OPENCODEX_HOME = join(dir, "ocx"); + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(dir, "desktop"); + saveConfig({ + port: 10100, + defaultProvider: "mock", + providers: { + mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", models: ["test-model"] }, + }, + } as OcxConfig); +}); + +afterEach(() => { + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousDesktopDir === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousDesktopDir; + rmSync(dir, { recursive: true, force: true }); +}); + +test("show --json, move, default and export use the same persisted profile", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleClaudeDesktopCommand(["show", "--json"])).toBe(0); + const state = JSON.parse(String(log.mock.calls.at(-1)?.[0])); + expect(state.profile.assignments["mock/test-model"].family).toBe("opus"); + + expect(await handleClaudeDesktopCommand(["move", "mock/test-model", "sonnet", "--default"])).toBe(0); + expect(loadConfig().claudeCode?.desktopProfile?.defaults.sonnet).toBe("mock/test-model"); + + const target = join(dir, "profile.json"); + expect(await handleClaudeDesktopCommand(["export", target])).toBe(0); + const exported = JSON.parse(readFileSync(target, "utf8")); + expect(exported.assignments["mock/test-model"].family).toBe("sonnet"); + expect(error).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + error.mockRestore(); + } +}); + +test("import rejects invalid profiles without replacing saved state", async () => { + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + await handleClaudeDesktopCommand(["move", "mock/test-model", "haiku", "--default"]); + const before = structuredClone(loadConfig().claudeCode?.desktopProfile); + const source = join(dir, "bad.json"); + writeFileSync(source, JSON.stringify({ version: 1, assignments: {}, defaults: { opus: "missing", fable: null, sonnet: null, haiku: null } })); + expect(await handleClaudeDesktopCommand(["import", source])).toBe(1); + expect(loadConfig().claudeCode?.desktopProfile).toEqual(before); + expect(error).toHaveBeenCalled(); + } finally { + error.mockRestore(); + } +}); + +test("no-arg and legacy mode flags apply Desktop config", async () => { + const log = spyOn(console, "log").mockImplementation(() => {}); + const error = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(await handleClaudeDesktopCommand([])).toBe(0); + expect(await handleClaudeDesktopCommand(["--static"])).toBe(0); + expect(readFileSync(join(process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR!, "_meta.json"), "utf8")).toContain("opencodex"); + expect(error).not.toHaveBeenCalled(); + } finally { + log.mockRestore(); + error.mockRestore(); + } +}); diff --git a/tests/claude-management-api.test.ts b/tests/claude-management-api.test.ts index f51b65996..29c1569a8 100644 --- a/tests/claude-management-api.test.ts +++ b/tests/claude-management-api.test.ts @@ -11,17 +11,20 @@ import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isol let testDir = ""; let previousHome: string | undefined; let previousClaudeConfigDir: string | undefined; +let previousDesktopConfigDir: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; previousClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + previousDesktopConfigDir = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; isolatedCodexHome = installIsolatedCodexHome("ocx-claude-mgmt-"); testDir = mkdtempSync(join(tmpdir(), "ocx-claude-mgmt-")); process.env.OPENCODEX_HOME = testDir; // These API tests intentionally toggle agent injection off. Never let that // prune the developer's real ~/.claude/agents directory. process.env.CLAUDE_CONFIG_DIR = join(testDir, "claude"); + process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = join(testDir, "claude-desktop"); saveConfig({ port: 0, defaultProvider: "mock", @@ -36,6 +39,8 @@ afterEach(() => { else process.env.OPENCODEX_HOME = previousHome; if (previousClaudeConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR; else process.env.CLAUDE_CONFIG_DIR = previousClaudeConfigDir; + if (previousDesktopConfigDir === undefined) delete process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR; + else process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR = previousDesktopConfigDir; isolatedCodexHome?.restore(); isolatedCodexHome = null; if (testDir) rmSync(testDir, { recursive: true, force: true }); @@ -418,3 +423,51 @@ test("PUT validation rejects bad shapes", async () => { server.stop(true); } }); + +test("Claude Desktop profile GET, PUT and apply round-trip four-family assignments", async () => { + const server = startServer(0); + try { + const initial = await fetch(new URL("/api/claude-desktop", server.url)).then(r => r.json()) as Record; + expect(initial.profile.version).toBe(1); + expect(initial.models.some((model: { route: string }) => model.route === "mock/test-model")).toBe(true); + expect(initial.profile.assignments["mock/test-model"].family).toBe("opus"); + + const edited = structuredClone(initial.profile); + edited.assignments["mock/test-model"].family = "sonnet"; + edited.defaults.opus = Object.keys(edited.assignments) + .filter(route => edited.assignments[route].family === "opus") + .sort()[0] ?? null; + edited.defaults.sonnet = "mock/test-model"; + const put = await fetch(new URL("/api/claude-desktop", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: edited }), + }); + expect(put.status).toBe(200); + expect(loadConfig().claudeCode?.desktopProfile?.defaults.sonnet).toBe("mock/test-model"); + + const apply = await fetch(new URL("/api/claude-desktop/apply", server.url), { method: "POST" }); + expect(apply.status).toBe(200); + const result = await apply.json() as { path: string; applied: boolean }; + expect(result.applied).toBe(true); + expect(result.path.startsWith(process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR!)).toBe(true); + } finally { + server.stop(true); + } +}); + +test("Claude Desktop PUT rejects invalid JSON profile without mutating saved config", async () => { + const server = startServer(0); + try { + const before = structuredClone(loadConfig()); + const put = await fetch(new URL("/api/claude-desktop", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: { version: 1, assignments: {}, defaults: { opus: "missing", fable: null, sonnet: null, haiku: null } } }), + }); + expect(put.status).toBe(400); + expect(loadConfig()).toEqual(before); + } finally { + server.stop(true); + } +}); diff --git a/tests/desktop-3p.test.ts b/tests/desktop-3p.test.ts index c7683d47f..89cc5949b 100644 --- a/tests/desktop-3p.test.ts +++ b/tests/desktop-3p.test.ts @@ -1,5 +1,9 @@ import { describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { + atomicReplaceDesktopConfig, buildDesktop3pRegistry, deriveDesktop3pCode, desktop3pAlias, @@ -9,6 +13,7 @@ import { parseDesktop3pModeArgs, resolveDesktop3pAlias, } from "../src/claude/desktop-3p"; +import { moveDesktopRoute, reconcileDesktopProfile } from "../src/claude/desktop-profile"; import { resolveInboundModel } from "../src/claude/inbound"; describe("Claude Desktop 3P models", () => { @@ -181,4 +186,36 @@ describe("Claude Desktop 3P models", () => { expect(resolveDesktop3pAlias("claude-opus-4-8-ncb")).toBe("native/gpt-5.6-sol"); expect(resolveDesktop3pAlias("claude-opus-4-ncb")).toBe("native/gpt-5.6-sol"); }); + + test("renders persisted family/date assignments and installs their decode registry", () => { + const routed = [{ provider: "cursor", id: "gpt-5.6-luna", contextWindow: 1_000_000 }]; + let profile = reconcileDesktopProfile(undefined, [ + { route: "native/gpt-5.6-sol", label: "GPT 5.6 Sol" }, + { route: "cursor/gpt-5.6-luna", label: "GPT 5.6 Luna", contextWindow: 1_000_000 }, + ]); + profile = moveDesktopRoute(profile, "cursor/gpt-5.6-luna", "haiku", true); + const models = generateDesktop3pModels(["gpt-5.6-sol"], routed, profile); + const luna = models.find(model => model.labelOverride.includes("Luna")); + expect(luna).toMatchObject({ anthropicFamilyTier: "haiku", isFamilyDefault: true, supports1m: true }); + expect(luna?.name).toMatch(/^claude-opus-4-8-2026\d{4}$/); + expect(resolveDesktop3pAlias(luna!.name)).toBe("cursor/gpt-5.6-luna"); + }); + + test("backs up owned config and preserves old bytes when atomic replacement fails", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-desktop-atomic-")); + const path = join(dir, "owned.json"); + try { + writeFileSync(path, "old bytes\n"); + const success = atomicReplaceDesktopConfig(path, "new bytes\n"); + expect(readFileSync(path, "utf8")).toBe("new bytes\n"); + expect(readFileSync(success.backupPath!, "utf8")).toBe("old bytes\n"); + + writeFileSync(path, "stable bytes\n"); + expect(() => atomicReplaceDesktopConfig(path, "never written\n", () => { throw new Error("injected"); })).toThrow("injected"); + expect(readFileSync(path, "utf8")).toBe("stable bytes\n"); + expect(readFileSync(`${path}.bak`, "utf8")).toBe("stable bytes\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/tests/desktop-profile.test.ts b/tests/desktop-profile.test.ts new file mode 100644 index 000000000..da036a8cc --- /dev/null +++ b/tests/desktop-profile.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { + DesktopProfileError, + emptyDesktopProfile, + moveDesktopRoute, + parseDesktopProfile, + reconcileDesktopProfile, + renderDesktopProfile, + setDesktopFamilyDefault, + type DesktopProfileModel, +} from "../src/claude/desktop-profile"; + +const models: DesktopProfileModel[] = [ + { route: "native/gpt-5.6-sol", label: "GPT 5.6 Sol", contextWindow: 1_000_000 }, + { route: "cursor/gpt-5.6-luna", label: "GPT 5.6 Luna", contextWindow: 200_000 }, + { route: "anthropic/claude-fable-5", label: "Claude Fable 5", contextWindow: 1_000_000 }, +]; + +describe("Claude Desktop profile", () => { + test("reconciles new routes into Opus with stable unique date aliases", () => { + const first = reconcileDesktopProfile(undefined, models); + const second = reconcileDesktopProfile(first, [...models].reverse()); + expect(second).toEqual(first); + expect(first.defaults.opus).toBe("anthropic/claude-fable-5"); + expect(first.assignments["anthropic/claude-fable-5"]?.alias).toBe("claude-fable-5"); + expect(first.assignments["native/gpt-5.6-sol"]?.alias).toMatch(/^claude-opus-4-8-2026\d{4}$/); + expect(new Set(Object.values(first.assignments).map(value => value.alias)).size).toBe(3); + }); + + test("moves routes and maintains one default per non-empty family", () => { + const base = reconcileDesktopProfile(undefined, models); + const moved = moveDesktopRoute(base, "cursor/gpt-5.6-luna", "haiku", true); + expect(moved.assignments["cursor/gpt-5.6-luna"]?.family).toBe("haiku"); + expect(moved.defaults.haiku).toBe("cursor/gpt-5.6-luna"); + const selected = setDesktopFamilyDefault(moved, "opus", "native/gpt-5.6-sol"); + expect(selected.defaults.opus).toBe("native/gpt-5.6-sol"); + expect(() => setDesktopFamilyDefault(selected, "opus", null)).toThrow(DesktopProfileError); + }); + + test("retains unavailable routes and promotes an active sibling only while rendering", () => { + let profile = reconcileDesktopProfile(undefined, models); + profile = setDesktopFamilyDefault(profile, "opus", "native/gpt-5.6-sol"); + const withoutDefault = renderDesktopProfile(profile, models.filter(model => model.route !== "native/gpt-5.6-sol")); + expect(withoutDefault.find(model => model.family === "opus")?.isFamilyDefault).toBe(true); + expect(profile.defaults.opus).toBe("native/gpt-5.6-sol"); + const restored = renderDesktopProfile(profile, models); + expect(restored.find(model => model.route === "native/gpt-5.6-sol")?.isFamilyDefault).toBe(true); + }); + + test("renders family defaults first and only asserts 1M from authoritative metadata", () => { + let profile = reconcileDesktopProfile(undefined, models); + profile = moveDesktopRoute(profile, "cursor/gpt-5.6-luna", "haiku", true); + const rendered = renderDesktopProfile(profile, models); + expect(rendered.slice(0, 2).map(model => model.route)).toEqual([ + profile.defaults.opus, + profile.defaults.haiku, + ]); + expect(rendered.find(model => model.route === "native/gpt-5.6-sol")?.supports1m).toBe(true); + expect(rendered.find(model => model.route === "cursor/gpt-5.6-luna")?.supports1m).toBe(false); + }); + + test("rejects unknown fields, duplicate aliases and invalid defaults", () => { + const profile = reconcileDesktopProfile(undefined, models); + expect(() => parseDesktopProfile({ ...profile, extra: true })).toThrow("unknown field"); + const duplicate = structuredClone(profile); + duplicate.assignments["cursor/gpt-5.6-luna"]!.alias = duplicate.assignments["native/gpt-5.6-sol"]!.alias; + expect(() => parseDesktopProfile(duplicate)).toThrow("duplicate alias"); + const wrongDefault = structuredClone(profile); + wrongDefault.defaults.haiku = "native/gpt-5.6-sol"; + expect(() => parseDesktopProfile(wrongDefault)).toThrow("empty family"); + }); + + test("fills all 365 encoded slots then fails without mutating the saved profile", () => { + const encoded = Array.from({ length: 365 }, (_, index) => ({ + route: `test/model-${index}`, + label: `Model ${index}`, + })); + const full = reconcileDesktopProfile(emptyDesktopProfile(), encoded); + const snapshot = structuredClone(full); + expect(Object.keys(full.assignments)).toHaveLength(365); + expect(() => reconcileDesktopProfile(full, [...encoded, { route: "test/overflow", label: "Overflow" }])).toThrow("365 encoded date slots"); + expect(full).toEqual(snapshot); + }); +}); From b866dc5315cdbd728deaa54d9f8755c8ca6327ae Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 12 Jul 2026 03:38:25 +0900 Subject: [PATCH 10/57] docs(claude): explain desktop family routing --- README.ko.md | 26 +++++++++- README.md | 26 +++++++++- README.zh-CN.md | 25 +++++++++- .../src/content/docs/guides/claude-code.md | 49 +++++++++++++++++++ .../src/content/docs/ko/guides/claude-code.md | 10 ++++ .../content/docs/zh-cn/guides/claude-code.md | 8 +++ src/cli/help.ts | 14 +++++- 7 files changed, 154 insertions(+), 4 deletions(-) diff --git a/README.ko.md b/README.ko.md index 439f65b32..52fc82bc6 100644 --- a/README.ko.md +++ b/README.ko.md @@ -204,7 +204,7 @@ opencodex는 두 가지 동작을 분리해서 유지합니다: ## 주요 기능 - **어떤 LLM이든 Codex에서.** 5개의 프로토콜 adapter가 Anthropic Messages, Google Gemini, Azure, OpenAI Responses passthrough, 그리고 모든 OpenAI 호환 Chat Completions 엔드포인트를 커버합니다 — 즉 기본 제공 **40개 이상의 프로바이더**입니다. -- **Claude Code에서도 어떤 LLM이든.** 같은 데몬이 Anthropic Messages API(`/v1/messages` + `count_tokens`)를 제공합니다: `ocx claude`가 Claude Code를 완전히 연결된 상태로 실행하고, 라우팅 모델이 게이트웨이 모델 디스커버리로 네이티브 `/model` 피커에 나타납니다 (`claude-ocx---` 별칭, Claude Code 2.1.129+). 슬롯과 모델 매핑은 대시보드의 Claude 페이지에서 설정합니다. +- **Claude에서도 어떤 LLM이든.** `ocx claude`로 Claude Code를 프록시에 연결해 실행할 수 있습니다. Claude 대시보드에는 Opus, Fable, Sonnet, Haiku를 관리하는 별도 Desktop 프로필과 드래그/키보드 조작, JSON 가져오기/내보내기도 있습니다. - **ChatGPT 계정을 안전하게 풀링.** 기존 Codex 스레드는 한 계정에 유지하면서, 새 세션은 쿼터 갱신과 비-PII 요청 라벨과 함께 풀에서 사용량이 낮은 계정을 자동 선택할 수 있습니다. - **한 번 로그인하면 API 키는 생략.** xAI, Anthropic, Kimi는 OAuth를 지원하므로 기존 계정으로 인증할 수 있고 토큰은 자동 갱신됩니다. 또는 `codex login`을 forward 하거나, API 키를 붙여넣거나, `${ENV_VAR}` 참조를 쓸 수 있습니다 — 선택은 자유입니다. - **Codex가 동작하는 모든 곳에서.** Codex CLI, TUI, App, SDK에 자동으로 주입됩니다. 라우팅된 모델이 네이티브 모델처럼 Codex 모델 선택기에 나타납니다. @@ -250,11 +250,35 @@ ocx logout # 저장된 로그인 정보 삭제 ocx account # 계정/API key pool 조회·전환 (마스킹; refresh/auto-switch/remove/add-key 포함) ocx gui # 웹 대시보드 열기 ocx claude [args...] # 프록시에 연결된 Claude Code 실행 (모델 디스커버리 켜짐) +ocx claude desktop # Claude Desktop 4개 family 프로필 저장 및 적용 ocx codex-shim install # codex 실행 시 `ocx ensure` 실행 ocx service [install|start|stop|status|uninstall] # 백그라운드 서비스 설치/갱신/시작 ocx update [--tag preview] # opencodex 업데이트; preview 설치는 @preview 유지 ``` +### Claude Desktop 프로필 + +대시보드의 **Claude → Desktop** 화면은 라우트를 Opus, Fable, Sonnet, Haiku 네 family로 +나눕니다. 새 라우트는 Opus에 들어가고, 첫 Opus 라우트가 앱의 초기 기본값이 됩니다. 비어 있지 않은 +family마다 기본 라우트가 하나씩 있습니다. 라우트를 드래그하거나, 각 행의 이동 메뉴를 마우스·터치· +키보드로 사용할 수 있습니다. **저장하고 Desktop에 적용**을 누르면 Claude Desktop 설정에 +반영됩니다. JSON 가져오기/내보내기로 백업하거나 다른 머신에 같은 설정을 옮길 수도 있습니다. + +```bash +ocx claude desktop [apply] # 현재 프로필 저장 및 적용 +ocx claude desktop show [--json] # 라우트, family, 기본값 확인 +ocx claude desktop move [--default] +ocx claude desktop default +ocx claude desktop export # -를 쓰면 stdout으로 JSON 출력 +ocx claude desktop import [--apply] # 검증 후 저장, 선택적으로 바로 적용 +``` + +family 값은 `opus`, `fable`, `sonnet`, `haiku`입니다. Anthropic이 아닌 라우트에는 2026 날짜 슬롯을 +쓴 안정적인 Claude 형식 별칭이 붙습니다. 이 날짜는 내부 슬롯이며 모델 출시일이 아닙니다. 실제 +Anthropic Claude 라우트는 원래 모델 id를 유지합니다. `none`은 빈 family에만 쓸 수 있으며, +비어 있지 않은 family에는 항상 기본값이 필요합니다. 기존 적용 방식인 +`ocx claude desktop --static`, `--hybrid`, `--discovery-only`도 계속 지원됩니다. + ### 자동 시작: service vs shim opencodex에는 프록시를 자동 시작하는 두 가지 방법이 있습니다: diff --git a/README.md b/README.md index fad3a4a03..967ab10d3 100644 --- a/README.md +++ b/README.md @@ -223,7 +223,7 @@ next Codex session. opencodex keeps these behaviors: ## Highlights - **Use any LLM with Codex.** 5 protocol adapters cover Anthropic Messages, Google Gemini, Azure, OpenAI Responses passthrough, and every OpenAI-compatible Chat Completions endpoint — that's 40+ providers out of the box. -- **Use any LLM with Claude Code too.** The same daemon serves the Anthropic Messages API (`/v1/messages` + `count_tokens`): `ocx claude` launches Claude Code fully wired, and routed models appear in its native `/model` picker via gateway model discovery (`claude-ocx---` aliases, Claude Code 2.1.129+). Configure slots and model maps on the dashboard's Claude page. +- **Use any LLM with Claude too.** `ocx claude` launches Claude Code through the proxy. The Claude dashboard also has a separate Desktop profile with Opus, Fable, Sonnet, and Haiku families, accessible drag/keyboard controls, and JSON import/export. - **Pool ChatGPT accounts safely.** Keep existing Codex threads on one account while new sessions can auto-pick a lower-usage account from the pool, with quota refresh and non-PII request labels. - **Log in once, skip the API key.** OAuth support for xAI, Anthropic, and Kimi means you can authenticate with your existing account. Tokens auto-refresh. Or forward your `codex login`, paste an API key, or use `${ENV_VAR}` references — your call. @@ -283,10 +283,34 @@ ocx logout # remove a stored login ocx account # list/switch accounts & API-key pools (masked; also refresh/auto-switch/remove/add-key) ocx gui # open the web dashboard ocx claude [args...] # launch Claude Code wired to the proxy (model discovery on) +ocx claude desktop # save and apply the Claude Desktop four-family profile ocx service [install|start|stop|status|uninstall] # install/update/start background service ocx update [--tag preview] # update opencodex; preview installs stay on @preview ``` +### Claude Desktop profile + +The dashboard's **Claude → Desktop** view sorts routes into four families: Opus, Fable, Sonnet, +and Haiku. New routes start in Opus, and the first Opus route is the initial application default. +Every non-empty family has one default. You can drag a route, or use its visible move control with +a mouse, touch, or keyboard. **Save and apply** writes the profile to Claude Desktop. JSON export +and import are available for backup or moving the same setup to another machine. + +```bash +ocx claude desktop [apply] # save and apply the current profile +ocx claude desktop show [--json] # inspect routes, families, and defaults +ocx claude desktop move [--default] +ocx claude desktop default +ocx claude desktop export # use - to write JSON to stdout +ocx claude desktop import [--apply] # validate, then save; optionally apply +``` + +Families are `opus`, `fable`, `sonnet`, and `haiku`. Non-Anthropic routes receive stable +Claude-shaped aliases with a synthetic 2026 date slot; that date is an internal slot, not the +model's release date. Real Anthropic Claude routes keep their real model ids. Use `none` only for +an empty family; a non-empty family always needs a default. The older apply forms +`ocx claude desktop --static`, `--hybrid`, and `--discovery-only` remain supported. + ### Autostart: service vs shim opencodex has two ways to auto-start the proxy: diff --git a/README.zh-CN.md b/README.zh-CN.md index 6458b38ec..f0988883f 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -107,7 +107,7 @@ npm 警告里给出的缩写命令缺少包名,会把当前目录重新安装 ## 亮点 - **在 Codex 中使用任意 LLM。** 5 种协议 adapter 覆盖 Anthropic Messages、Google Gemini、Azure、OpenAI Responses 直通,以及所有 OpenAI 兼容 Chat Completions 端点 —— 即开箱即用的 **40+ provider**。 -- **在 Claude Code 中也能使用任意 LLM。** 同一个守护进程提供 Anthropic Messages API(`/v1/messages` + `count_tokens`):`ocx claude` 启动完全接线的 Claude Code,路由模型通过网关模型发现出现在原生 `/model` 选择器中(`claude-ocx---` 别名,Claude Code 2.1.129+)。槽位和模型映射在仪表盘的 Claude 页面配置。 +- **在 Claude 中也能使用任意 LLM。** `ocx claude` 可通过代理启动 Claude Code。Claude 仪表盘还提供独立的 Desktop 配置,可管理 Opus、Fable、Sonnet、Haiku 四个系列,并支持拖放、键盘操作和 JSON 导入/导出。 - **安全地池化 ChatGPT 账户。** 现有 Codex 线程保持在一个账户上,而新会话可以从池中自动挑选使用量更低的账户,并带有配额刷新和非 PII 请求标签。 - **登录一次,免填 API key。** xAI、Anthropic、Kimi 支持 OAuth,可用现有账户认证,token 自动刷新。也可以转发 `codex login`、粘贴 API key,或使用 `${ENV_VAR}` 引用 —— 随你选择。 - **Codex 在哪里能用,它就在哪里能用。** 自动注入 Codex CLI、TUI、App 和 SDK。路由模型像原生模型一样出现在 Codex 的模型选择器里。 @@ -243,11 +243,34 @@ ocx logout # 移除已保存的登录 ocx account # 查看/切换账号与 API-key pool(脱敏;含 refresh/auto-switch/remove/add-key) ocx gui # 打开 Web 仪表盘 ocx claude [args...] # 启动接入代理的 Claude Code(模型发现已开启) +ocx claude desktop # 保存并应用 Claude Desktop 四系列配置 ocx codex-shim install # 运行 codex 时自动启动代理 ocx service [install|start|stop|status|uninstall] # 安装/更新/启动后台服务 ocx update [--tag preview] # 更新 opencodex;preview 安装保持 @preview ``` +### Claude Desktop 配置 + +仪表盘的 **Claude → Desktop** 页面把路由分为 Opus、Fable、Sonnet、Haiku 四个系列。新路由 +默认放入 Opus,第一个 Opus 路由是应用的初始默认模型。每个非空系列都有一个默认路由。你可以 +拖动路由,也可以用鼠标、触控或键盘操作每一行中可见的移动控件。点击 **保存并应用到 Desktop** +后,配置会写入 Claude Desktop。还可以通过 JSON 导入/导出来备份配置,或迁移到另一台机器。 + +```bash +ocx claude desktop [apply] # 保存并应用当前配置 +ocx claude desktop show [--json] # 查看路由、系列和默认值 +ocx claude desktop move [--default] +ocx claude desktop default +ocx claude desktop export # 使用 - 将 JSON 输出到 stdout +ocx claude desktop import [--apply] # 验证后保存,可选择立即应用 +``` + +`family` 可取 `opus`、`fable`、`sonnet`、`haiku`。非 Anthropic 路由会获得带有合成 2026 日期 +槽位的稳定 Claude 格式别名;该日期是内部槽位,不是模型发布日期。真正的 Anthropic Claude +路由保留原始模型 id。`none` 只能用于空系列;非空系列始终需要一个默认值。旧的应用方式 +`ocx claude desktop --static`、`--hybrid` 和 +`--discovery-only` 仍然受支持。 + ### 自动启动:service vs shim opencodex 提供两种自动启动代理的方式: diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 199577ca4..d4c985aef 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -30,6 +30,43 @@ Variables you export yourself always win. Extra arguments pass through: `ocx cla ## System environment integration (macOS) +## Claude Desktop profile + +Claude Desktop uses a separate profile from Claude Code. Open **Claude → Desktop** in the +dashboard to place each available route in one of four families: Opus, Fable, Sonnet, or Haiku. +All routes start in Opus on a new profile. The first Opus route becomes the initial overall +default, and every non-empty family always has one family default. + +Drag a row to another family if you like. Dragging is optional: every row also has a visible move +control that works with a mouse, touch, or keyboard. Use **Make default** to choose a family's +default, then select **Save and apply to Desktop**. Empty families are allowed. If a saved default +is temporarily unavailable, the first available route in that family is used until it returns. + +You can also manage the same profile from the command line: + +```bash +ocx claude desktop [apply] +ocx claude desktop show [--json] +ocx claude desktop move [--default] +ocx claude desktop default +ocx claude desktop export +ocx claude desktop import [--apply] +``` + +`ocx claude desktop` and `apply` both write the current profile to Claude Desktop. `show` gives a +readable summary; add `--json` for scripts. `export -` writes versioned JSON to standard output. +Import validates the complete file before saving, so an invalid file leaves the current profile +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. + +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 +provider or model it calls. The legacy apply flags `--static`, `--hybrid`, and `--discovery-only` +remain available for existing scripts. + +## System Environment Integration + When `claudeCode.systemEnv` is set to `true` (default: **off**), `ocx start` uses `launchctl setenv` to inject `ANTHROPIC_BASE_URL` and the related Claude Code environment variables system-wide. New terminal windows and tabs therefore route plain `claude` commands through the proxy without @@ -76,6 +113,18 @@ with `claude` or `anthropic`, opencodex exposes routed models as stable, reversi The proxy picks the family per request: `?ids=cli` or `?ids=desktop` wins; otherwise the `claude-code/*` user-agent gets the readable CLI form and other clients get the Desktop hash. Both families decode forever — a model saved in `settings.json` under either form keeps working. +Each entry carries an honest display name such as `gemini-3-pro (gemini)`, plus full model +capabilities (reasoning-effort ladder, thinking types) in the official ModelInfo shape so Claude +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. +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. +Selecting one persists it to Claude Code's `settings.json` `model` field; inbound requests resolve +the alias back to the routed model. On older Claude Code versions the picker stays native — set +slots via +`ANTHROPIC_MODEL` or type any routed id with `/model` (Claude Code passes strings through). **Alias grammar rules:** provider must not contain `/` or `--` or equal `native`; model must not contain `/`. Routes the readable form cannot express fall back to the hashed alias. Model ids diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 10d57b7a9..6030d1eb5 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -62,6 +62,16 @@ ocx claude 지정할 수 있어요. ## /model 선택기("From gateway") +각 항목은 `gemini-3-pro (gemini)` 같은 정직한 표시 이름과 함께, 공식 ModelInfo 형태의 모델 +능력 정보(추론 강도 사다리, thinking 타입)를 실어 보냅니다 — Claude Desktop의 서드파티 +게이트웨이 모드가 추론 강도 선택 UI를 열 수 있게 하기 위해서입니다. 실제 Anthropic 모델은 +원래 id를 그대로 유지합니다. 합성된 2026 날짜는 내부 슬롯이며 출시일이 아닙니다. 구버전의 +해시 별칭과 `claude-ocx---` 별칭도 계속 해석됩니다. 컨텍스트가 1M인 모델에는 +`…[1m]` 행이 하나 더 생깁니다 — 이걸 고르면 Claude Code가 그 모델의 컨텍스트를 1M로 계산합니다 +(자동 요약 유지, 프록시가 표식을 떼고 라우팅). 선택하면 Claude Code의 +`settings.json` `model` 필드에 저장되고, 인바운드 요청에서 +별칭이 라우팅 모델로 되돌려집니다. 구버전 Claude Code에서는 `ANTHROPIC_MODEL`로 슬롯을 +지정하거나 `/model`에 라우팅 id를 직접 입력하세요 (Claude Code는 문자열을 그대로 통과시킵니다). Claude Code 2.1.129 이상은 `GET /v1/models?limit=1000`에서 게이트웨이 모델을 찾아 기본 `/model` 선택기의 "From gateway" 항목에 표시해요. 선택기는 `claude` 또는 `anthropic`으로 시작하는 ID만 diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index d3d022cca..1cb0d7889 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -62,6 +62,14 @@ macOS;在其他平台上,请使用 `ocx claude`。 `claudeCode.anthropicBaseUrl` 指向其他位置。 ## /model 选择器(“From gateway”) +每个条目带有诚实的显示名(如 `gemini-3-pro (gemini)`),并以官方 ModelInfo 形态附带模型能力 +信息(推理强度梯度、thinking 类型),使 Claude Desktop 的第三方网关模式能够启用推理强度选择 +UI。真实 Anthropic 模型保留其原始 id。合成的 2026 日期是内部槽位,不是发布日期。旧版哈希 +别名和 `claude-ocx---` 别名仍可解析。拥有 1M 上下文的模型会多出一行 `…[1m]`: +选中后 Claude Code 会按 1M 计算该模型的上下文(自动压缩保留,代理在路由前去掉该标记)。 +选中后会保存到 Claude Code 的 `settings.json` `model` 字段;入站请求会将别名解析回路由 +模型。旧版 Claude Code 中选择器保持原生 — 通过 `ANTHROPIC_MODEL` 设置槽位,或直接在 `/model` +中输入任意路由 id(Claude Code 会原样传递字符串)。 Claude Code 2.1.129+ 通过 `GET /v1/models?limit=1000` 发现网关模型,并在原生 `/model` 选择器中以“From gateway”标签列出。由于选择器只接受以 `claude` 或 `anthropic` 开头的 ID, diff --git a/src/cli/help.ts b/src/cli/help.ts index 190278022..08fa42042 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -113,9 +113,20 @@ const helpEntries: Record = { details: [ "Ensures the proxy is running, then execs `claude` with ANTHROPIC_BASE_URL/ANTHROPIC_AUTH_TOKEN,", "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 and model slots from config.claudeCode.", - "Routed models appear in the native /model picker as claude-ocx--- (Claude Code >= 2.1.129).", + "Routed models appear in the native /model picker with stable claude-opus-4-8-2026MMDD slot aliases (Claude Code >= 2.1.129).", "Older versions: pick models via ANTHROPIC_MODEL or /model directly (any string passes through).", "User-exported ANTHROPIC_* variables always take precedence.", + "", + "Claude Desktop profile:", + " ocx claude desktop [apply] Save and apply the four-family profile", + " ocx claude desktop show [--json] Show routes, families, and defaults", + " ocx claude desktop move [--default]", + " ocx claude desktop default ", + " ocx claude desktop export Export versioned JSON (`-` = stdout)", + " ocx claude desktop import [--apply] Validate and import JSON", + "Families: opus, fable, sonnet, haiku. New routes start in opus.", + "`none` is valid only when that family is empty.", + "Legacy apply flags remain supported: --static, --hybrid, --discovery-only.", ], }, restart: { @@ -182,6 +193,7 @@ Usage: ocx account Accounts/keys (list|current|use|refresh|auto-switch|remove|add-key) ocx models List models; manage custom models (add|remove|list-custom) ocx claude [args...] Launch Claude Code wired to the proxy (model discovery on) + ocx claude desktop [sub] Manage and apply Claude Desktop's four-family profile ocx help [command] Show help ocx --version | -v Print version From fabf35f1b2c669d8412e1a208b626f80d0b0c6d0 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 12 Jul 2026 03:40:00 +0900 Subject: [PATCH 11/57] fix(claude): protect unavailable desktop routes --- src/server/management-api.ts | 15 ++++++++++++ tests/claude-management-api.test.ts | 37 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/server/management-api.ts b/src/server/management-api.ts index ac8a49b4a..197c9b8d3 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -1205,6 +1205,21 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon try { const { parseDesktopProfile, reconcileDesktopProfile } = await import("../claude/desktop-profile"); const parsed = parseDesktopProfile(body.profile); + const current = await buildClaudeDesktopState(config); + for (const model of current.models.filter(item => !item.available)) { + const before = current.profile.assignments[model.route]; + const after = parsed.assignments[model.route]; + if (JSON.stringify(before) !== JSON.stringify(after)) { + throw new Error(`현재 사용할 수 없는 모델은 옮길 수 없습니다: ${model.route}`); + } + } + for (const family of ["opus", "fable", "sonnet", "haiku"] as const) { + const nextDefault = parsed.defaults[family]; + const target = nextDefault ? current.models.find(model => model.route === nextDefault) : undefined; + if (target && !target.available && current.profile.defaults[family] !== nextDefault) { + throw new Error(`현재 사용할 수 없는 모델은 기본값으로 지정할 수 없습니다: ${nextDefault}`); + } + } const state = await buildClaudeDesktopState(config, parsed); config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: reconcileDesktopProfile(state.profile, state.models) }; saveConfig(config); diff --git a/tests/claude-management-api.test.ts b/tests/claude-management-api.test.ts index 29c1569a8..fe361451e 100644 --- a/tests/claude-management-api.test.ts +++ b/tests/claude-management-api.test.ts @@ -446,6 +446,10 @@ test("Claude Desktop profile GET, PUT and apply round-trip four-family assignmen expect(put.status).toBe(200); expect(loadConfig().claudeCode?.desktopProfile?.defaults.sonnet).toBe("mock/test-model"); + const alias = loadConfig().claudeCode?.desktopProfile?.assignments["mock/test-model"]?.alias; + const discovery = await fetch(new URL("/v1/models?flavor=anthropic", server.url)).then(r => r.json()) as { data: Array<{ id: string }> }; + expect(discovery.data.some(model => model.id === alias)).toBe(true); + const apply = await fetch(new URL("/api/claude-desktop/apply", server.url), { method: "POST" }); expect(apply.status).toBe(200); const result = await apply.json() as { path: string; applied: boolean }; @@ -471,3 +475,36 @@ test("Claude Desktop PUT rejects invalid JSON profile without mutating saved con server.stop(true); } }); + +test("Claude Desktop PUT retains but cannot move an unavailable route", async () => { + const seeded = loadConfig(); + seeded.claudeCode = { + desktopProfile: { + version: 1, + assignments: { + "missing/old-model": { family: "opus", alias: "claude-opus-4-8-20260101" }, + }, + defaults: { opus: "missing/old-model", fable: null, sonnet: null, haiku: null }, + }, + }; + saveConfig(seeded); + const server = startServer(0); + try { + const state = await fetch(new URL("/api/claude-desktop", server.url)).then(r => r.json()) as Record; + expect(state.models.find((model: { route: string }) => model.route === "missing/old-model")?.available).toBe(false); + const edited = structuredClone(state.profile); + edited.assignments["missing/old-model"].family = "haiku"; + edited.defaults.opus = Object.keys(edited.assignments).filter(route => edited.assignments[route].family === "opus").sort()[0] ?? null; + edited.defaults.haiku = "missing/old-model"; + const put = await fetch(new URL("/api/claude-desktop", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: edited }), + }); + expect(put.status).toBe(400); + expect((await put.json() as { error: string }).error).toContain("사용할 수 없는 모델"); + expect(loadConfig().claudeCode?.desktopProfile?.assignments["missing/old-model"]?.family).toBe("opus"); + } finally { + server.stop(true); + } +}); From 6fcbad0ce9d90b0b6ef3170462e8e6cc28909aa5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 12 Jul 2026 03:44:51 +0900 Subject: [PATCH 12/57] feat(gui): add Claude Desktop family editor --- gui/src/App.tsx | 4 +- gui/src/i18n/de.ts | 50 +++++ gui/src/i18n/en.ts | 50 +++++ gui/src/i18n/ko.ts | 50 +++++ gui/src/i18n/zh.ts | 50 +++++ gui/src/pages/Claude.tsx | 57 +++++ gui/src/pages/ClaudeDesktop.tsx | 356 ++++++++++++++++++++++++++++++++ gui/src/styles.css | 86 ++++++++ 8 files changed, 701 insertions(+), 2 deletions(-) create mode 100644 gui/src/pages/Claude.tsx create mode 100644 gui/src/pages/ClaudeDesktop.tsx diff --git a/gui/src/App.tsx b/gui/src/App.tsx index eb9d5b806..44a728c34 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -9,7 +9,7 @@ import Usage from "./pages/Usage"; import Storage from "./pages/Storage"; import CodexAuth from "./pages/CodexAuth"; import ApiKeys from "./pages/ApiKeys"; -import ClaudeCode from "./pages/ClaudeCode"; +import Claude from "./pages/Claude"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n"; import { Select } from "./ui"; @@ -339,7 +339,7 @@ export default function App() { {page === "storage" && } {page === "codex-auth" && } {page === "api" && } - {page === "claude" && } + {page === "claude" && } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 03221c7fb..5ecb2c9ab 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1005,6 +1005,56 @@ export const de = { "cws.err.invalidStickyLimit": "Sticky-Erfolge müssen eine Ganzzahl von 1 bis 100 sein.", "cws.err.invalidWeight": "Jedes Round-Robin-Gewicht muss eine Ganzzahl von 1 bis 10000 sein.", "cws.err.noEnabledTarget": "Mindestens ein Ziel muss einen aktivierten Anbieter verwenden.", +||||||| parent of 3ba69135 (feat(gui): add Claude Desktop family editor) + "claude.tabsLabel": "Claude-Client", + "claude.tabCode": "Code", + "claude.tabDesktop": "Desktop", + "claudeDesktop.title": "Claude Desktop", + "claudeDesktop.subtitle": "Leite jede Claude-Modellfamilie über ein verfügbares Modell auf Port {port}.", + "claudeDesktop.importJson": "JSON importieren", + "claudeDesktop.exportJson": "JSON exportieren", + "claudeDesktop.loading": "Claude-Desktop-Profil wird geladen…", + "claudeDesktop.loadFail": "Claude-Desktop-Profil konnte nicht geladen werden.", + "claudeDesktop.retry": "Erneut versuchen", + "claudeDesktop.saveFailed": "Claude-Desktop-Profil konnte nicht gespeichert werden.", + "claudeDesktop.applyFailed": "Das Profil wurde gespeichert, konnte aber nicht angewendet werden.", + "claudeDesktop.updateFailed": "Claude-Desktop-Aktualisierung fehlgeschlagen.", + "claudeDesktop.savedApplied": "Profil gespeichert und auf Claude Desktop angewendet.", + "claudeDesktop.savedAppliedAnnounce": "Claude-Desktop-Profil gespeichert und angewendet.", + "claudeDesktop.saved": "Profil gespeichert.", + "claudeDesktop.savedAnnounce": "Claude-Desktop-Profil gespeichert.", + "claudeDesktop.exported": "Profil als JSON exportiert.", + "claudeDesktop.importExpected": "Ein Claude-Desktop-Profil der Version 1 wurde erwartet.", + "claudeDesktop.importReady": "JSON importiert. Prüfe den Entwurf und speichere und wende ihn dann an.", + "claudeDesktop.importedAnnounce": "Profil-JSON importiert. Ungespeicherte Änderungen können geprüft werden.", + "claudeDesktop.importInvalid": "Die ausgewählte Datei ist kein gültiges Profil.", + "claudeDesktop.importFailed": "Import fehlgeschlagen. {error}", + "claudeDesktop.moved": "{route} wurde nach {family} verschoben.", + "claudeDesktop.unsaved": "Ungespeicherte Änderungen", + "claudeDesktop.upToDate": "Profil ist aktuell", + "claudeDesktop.saving": "Speichert…", + "claudeDesktop.applying": "Wird angewendet…", + "claudeDesktop.saveApply": "Speichern & anwenden", + "claudeDesktop.emptyTitle": "Keine Modelle verfügbar", + "claudeDesktop.emptyHint": "Füge einen Anbieter hinzu oder aktiviere ihn und weise dann Claude-Desktop-Routen zu.", + "claudeDesktop.assignmentsLabel": "Zuweisungen der Claude-Modellfamilien", + "claudeDesktop.family.opus": "Opus", + "claudeDesktop.family.fable": "Fable", + "claudeDesktop.family.sonnet": "Sonnet", + "claudeDesktop.family.haiku": "Haiku", + "claudeDesktop.modelCountOne": "{count} Modell", + "claudeDesktop.modelCountMany": "{count} Modelle", + "claudeDesktop.chooseDefault": "Standard wählen", + "claudeDesktop.laneEmpty": "Modell hier ablegen oder die Verschieben-Steuerung verwenden.", + "claudeDesktop.available": "Verfügbar", + "claudeDesktop.unavailable": "Nicht verfügbar", + "claudeDesktop.contextM": "{n}M Kontext", + "claudeDesktop.contextK": "{n}k Kontext", + "claudeDesktop.alias": "Alias", + "claudeDesktop.useAsDefault": "Als {family}-Standard verwenden", + "claudeDesktop.moveTo": "Verschieben nach", + "claudeDesktop.move": "Verschieben", + } as const; export type TKey = keyof typeof de; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 2df6bd556..fa8840962 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1026,6 +1026,56 @@ export const en = { "cws.err.invalidStickyLimit": "Sticky successes must be an integer from 1 to 100.", "cws.err.invalidWeight": "Each round-robin weight must be an integer from 1 to 10000.", "cws.err.noEnabledTarget": "At least one target must use an enabled provider.", +||||||| parent of 3ba69135 (feat(gui): add Claude Desktop family editor) + "claude.tabsLabel": "Claude client", + "claude.tabCode": "Code", + "claude.tabDesktop": "Desktop", + "claudeDesktop.title": "Claude Desktop", + "claudeDesktop.subtitle": "Route each Claude model family through an available model on port {port}.", + "claudeDesktop.importJson": "Import JSON", + "claudeDesktop.exportJson": "Export JSON", + "claudeDesktop.loading": "Loading Claude Desktop profile…", + "claudeDesktop.loadFail": "Failed to load Claude Desktop profile.", + "claudeDesktop.retry": "Retry", + "claudeDesktop.saveFailed": "Failed to save Claude Desktop profile.", + "claudeDesktop.applyFailed": "Profile was saved, but could not be applied.", + "claudeDesktop.updateFailed": "Claude Desktop update failed.", + "claudeDesktop.savedApplied": "Profile saved and applied to Claude Desktop.", + "claudeDesktop.savedAppliedAnnounce": "Claude Desktop profile saved and applied.", + "claudeDesktop.saved": "Profile saved.", + "claudeDesktop.savedAnnounce": "Claude Desktop profile saved.", + "claudeDesktop.exported": "Profile exported as JSON.", + "claudeDesktop.importExpected": "Expected a version 1 Claude Desktop profile.", + "claudeDesktop.importReady": "JSON imported. Review the draft, then save and apply it.", + "claudeDesktop.importedAnnounce": "Profile JSON imported. Unsaved changes are ready for review.", + "claudeDesktop.importInvalid": "The selected file is not a valid profile.", + "claudeDesktop.importFailed": "Import failed. {error}", + "claudeDesktop.moved": "{route} moved to {family}.", + "claudeDesktop.unsaved": "Unsaved changes", + "claudeDesktop.upToDate": "Profile is up to date", + "claudeDesktop.saving": "Saving…", + "claudeDesktop.applying": "Applying…", + "claudeDesktop.saveApply": "Save & apply", + "claudeDesktop.emptyTitle": "No models available", + "claudeDesktop.emptyHint": "Add or enable a provider, then return to assign Claude Desktop routes.", + "claudeDesktop.assignmentsLabel": "Claude model family assignments", + "claudeDesktop.family.opus": "Opus", + "claudeDesktop.family.fable": "Fable", + "claudeDesktop.family.sonnet": "Sonnet", + "claudeDesktop.family.haiku": "Haiku", + "claudeDesktop.modelCountOne": "{count} model", + "claudeDesktop.modelCountMany": "{count} models", + "claudeDesktop.chooseDefault": "Choose a default", + "claudeDesktop.laneEmpty": "Drop a model here or use its Move control.", + "claudeDesktop.available": "Available", + "claudeDesktop.unavailable": "Unavailable", + "claudeDesktop.contextM": "{n}M context", + "claudeDesktop.contextK": "{n}k context", + "claudeDesktop.alias": "Alias", + "claudeDesktop.useAsDefault": "Use as {family} default", + "claudeDesktop.moveTo": "Move to", + "claudeDesktop.move": "Move", + } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 3355f0548..5b930c0ff 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1025,4 +1025,54 @@ export const ko: Record = { "cws.err.invalidStickyLimit": "sticky 성공 횟수는 1~100의 정수여야 합니다.", "cws.err.invalidWeight": "각 라운드로빈 가중치는 1~10000의 정수여야 합니다.", "cws.err.noEnabledTarget": "하나 이상의 대상이 활성화된 프로바이더를 사용해야 합니다.", +||||||| parent of 3ba69135 (feat(gui): add Claude Desktop family editor) + "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.laneEmpty": "모델을 여기에 놓거나 이동 컨트롤을 사용하세요.", + "claudeDesktop.available": "사용 가능", + "claudeDesktop.unavailable": "사용 불가", + "claudeDesktop.contextM": "컨텍스트 {n}M", + "claudeDesktop.contextK": "컨텍스트 {n}k", + "claudeDesktop.alias": "별칭", + "claudeDesktop.useAsDefault": "{family} 기본 모델로 사용", + "claudeDesktop.moveTo": "이동 위치", + "claudeDesktop.move": "이동", + }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e94e8543d..94a4e01ed 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1025,4 +1025,54 @@ export const zh: Record = { "cws.err.invalidStickyLimit": "粘性成功次数必须是 1 到 100 的整数。", "cws.err.invalidWeight": "每个轮询权重必须是 1 到 10000 的整数。", "cws.err.noEnabledTarget": "至少一个目标必须使用已启用的提供方。", +||||||| parent of 3ba69135 (feat(gui): add Claude Desktop family editor) + "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.laneEmpty": "将模型拖到这里,或使用移动控件。", + "claudeDesktop.available": "可用", + "claudeDesktop.unavailable": "不可用", + "claudeDesktop.contextM": "{n}M 上下文", + "claudeDesktop.contextK": "{n}k 上下文", + "claudeDesktop.alias": "别名", + "claudeDesktop.useAsDefault": "设为 {family} 默认模型", + "claudeDesktop.moveTo": "移动到", + "claudeDesktop.move": "移动", + }; diff --git a/gui/src/pages/Claude.tsx b/gui/src/pages/Claude.tsx new file mode 100644 index 000000000..48e85010f --- /dev/null +++ b/gui/src/pages/Claude.tsx @@ -0,0 +1,57 @@ +import { useState } from "react"; +import ClaudeCode from "./ClaudeCode"; +import ClaudeDesktop from "./ClaudeDesktop"; +import { useT } from "../i18n"; + +type ClaudeTab = "code" | "desktop"; + +export default function Claude({ apiBase }: { apiBase: string }) { + const [tab, setTab] = useState("code"); + const t = useT(); + + return ( +
+
+ + +
+ + + +
+ ); +} diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx new file mode 100644 index 000000000..d3d9ad246 --- /dev/null +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -0,0 +1,356 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type DragEvent } from "react"; +import { EmptyState, Notice } from "../ui"; +import { useT, type TFn, type TKey } from "../i18n"; + +const FAMILIES = ["opus", "fable", "sonnet", "haiku"] as const; +type Family = typeof FAMILIES[number]; + +interface Assignment { + family: Family; + alias: string; +} + +interface DesktopProfile { + version: 1; + assignments: Record; + defaults: Record; +} + +interface DesktopModel { + route: string; + label: string; + available: boolean; + contextWindow?: number; + assignment: Assignment; +} + +interface DesktopResponse { + profile: DesktopProfile; + models: DesktopModel[]; + rendered: unknown[]; + port: number; +} + +type PendingAction = "save" | "apply" | null; + +const FAMILY_KEYS: Record = { + opus: "claudeDesktop.family.opus", + fable: "claudeDesktop.family.fable", + sonnet: "claudeDesktop.family.sonnet", + haiku: "claudeDesktop.family.haiku", +}; + +function cloneProfile(profile: DesktopProfile): DesktopProfile { + return { + version: 1, + assignments: Object.fromEntries( + Object.entries(profile.assignments).map(([route, assignment]) => [route, { ...assignment }]), + ), + defaults: { ...profile.defaults }, + }; +} + +function normalizeProfile(data: DesktopResponse): DesktopProfile { + const assignments = { ...data.profile.assignments }; + for (const model of data.models) { + const current = assignments[model.route] ?? model.assignment; + assignments[model.route] = { + family: FAMILIES.includes(current?.family) ? current.family : "opus", + alias: typeof current?.alias === "string" ? current.alias : "", + }; + } + return { + version: 1, + assignments, + defaults: { + opus: data.profile.defaults.opus ?? null, + fable: data.profile.defaults.fable ?? null, + sonnet: data.profile.defaults.sonnet ?? null, + haiku: data.profile.defaults.haiku ?? null, + }, + }; +} + +function errorMessage(value: unknown, fallback: string): string { + if (value && typeof value === "object" && "error" in value && typeof value.error === "string") return value.error; + return fallback; +} + +function formatContextWindow(value: number | undefined, t: TFn): string | null { + if (!value) return null; + return value >= 1_000_000 + ? t("claudeDesktop.contextM", { n: value / 1_000_000 }) + : t("claudeDesktop.contextK", { n: Math.round(value / 1_000) }); +} + +export default function ClaudeDesktop({ apiBase }: { apiBase: string }) { + const t = useT(); + const [data, setData] = useState(null); + const [profile, setProfile] = useState(null); + const [savedProfile, setSavedProfile] = useState(null); + const [destinations, setDestinations] = useState>({}); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(""); + const [message, setMessage] = useState<{ tone: "ok" | "err"; text: string } | null>(null); + const [announcement, setAnnouncement] = useState(""); + const [pending, setPending] = useState(null); + const importRef = useRef(null); + + const load = useCallback(async () => { + setLoading(true); + setLoadError(""); + try { + const response = await fetch(`${apiBase}/api/claude-desktop`); + const payload = await response.json() as DesktopResponse | { error?: string }; + if (!response.ok || !("profile" in payload) || !("models" in payload)) { + throw new Error(errorMessage(payload, t("claudeDesktop.loadFail"))); + } + const normalized = normalizeProfile(payload); + setData(payload); + setProfile(normalized); + setSavedProfile(cloneProfile(normalized)); + setDestinations(Object.fromEntries(payload.models.map(model => [model.route, normalized.assignments[model.route]?.family ?? "opus"]))); + } catch (error) { + setLoadError(error instanceof Error ? error.message : t("claudeDesktop.loadFail")); + } finally { + setLoading(false); + } + }, [apiBase, t]); + + useEffect(() => { + const timer = window.setTimeout(() => { void load(); }, 0); + return () => window.clearTimeout(timer); + }, [load]); + + const dirty = useMemo( + () => profile !== null && savedProfile !== null && JSON.stringify(profile) !== JSON.stringify(savedProfile), + [profile, savedProfile], + ); + + const modelsByFamily = useMemo(() => { + const grouped = Object.fromEntries(FAMILIES.map(family => [family, [] as DesktopModel[]])) as Record; + if (!data || !profile) return grouped; + for (const model of data.models) grouped[profile.assignments[model.route]?.family ?? "opus"].push(model); + return grouped; + }, [data, profile]); + + const moveModel = (route: string, family: Family) => { + if (!profile || profile.assignments[route]?.family === family) return; + setProfile(current => { + if (!current) return current; + const previous = current.assignments[route]; + if (!previous || previous.family === family) return current; + const assignments = { ...current.assignments, [route]: { ...previous, family } }; + const defaults = { ...current.defaults }; + if (defaults[previous.family] === route) { + defaults[previous.family] = Object.keys(assignments) + .filter(key => key !== route && assignments[key].family === previous.family) + .sort()[0] ?? null; + } + if (defaults[family] === null) defaults[family] = route; + return { ...current, assignments, defaults }; + }); + setDestinations(current => ({ ...current, [route]: family })); + setAnnouncement(t("claudeDesktop.moved", { route, family: t(FAMILY_KEYS[family]) })); + }; + + const save = async (applyAfter: boolean) => { + if (!profile || pending) return; + setPending("save"); + setMessage(null); + try { + const response = await fetch(`${apiBase}/api/claude-desktop`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile }), + }); + const payload = await response.json().catch(() => ({})) as { error?: string }; + if (!response.ok) throw new Error(errorMessage(payload, t("claudeDesktop.saveFailed"))); + setSavedProfile(cloneProfile(profile)); + + if (applyAfter) { + setPending("apply"); + const applyResponse = await fetch(`${apiBase}/api/claude-desktop/apply`, { method: "POST" }); + const applyPayload = await applyResponse.json().catch(() => ({})) as { error?: string }; + if (!applyResponse.ok) throw new Error(errorMessage(applyPayload, t("claudeDesktop.applyFailed"))); + setMessage({ tone: "ok", text: t("claudeDesktop.savedApplied") }); + setAnnouncement(t("claudeDesktop.savedAppliedAnnounce")); + } else { + setMessage({ tone: "ok", text: t("claudeDesktop.saved") }); + setAnnouncement(t("claudeDesktop.savedAnnounce")); + } + } catch (error) { + const text = error instanceof Error ? error.message : t("claudeDesktop.updateFailed"); + setMessage({ tone: "err", text }); + setAnnouncement(text); + } finally { + setPending(null); + } + }; + + const exportProfile = () => { + if (!profile) return; + const url = URL.createObjectURL(new Blob([`${JSON.stringify(profile, null, 2)}\n`], { type: "application/json" })); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = "claude-desktop-profile.json"; + anchor.click(); + URL.revokeObjectURL(url); + setAnnouncement(t("claudeDesktop.exported")); + }; + + const importProfile = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + try { + const candidate = JSON.parse(await file.text()) as Partial; + if (candidate.version !== 1 || !candidate.assignments || !candidate.defaults) throw new Error(t("claudeDesktop.importExpected")); + const imported = normalizeProfile({ ...data!, profile: candidate as DesktopProfile }); + setProfile(imported); + setMessage({ tone: "ok", text: t("claudeDesktop.importReady") }); + setAnnouncement(t("claudeDesktop.importedAnnounce")); + } catch (error) { + const text = error instanceof Error ? error.message : t("claudeDesktop.importInvalid"); + setMessage({ tone: "err", text }); + setAnnouncement(t("claudeDesktop.importFailed", { error: text })); + } + }; + + const dropOnLane = (event: DragEvent, family: Family) => { + event.preventDefault(); + const route = event.dataTransfer.getData("text/plain"); + if (route) moveModel(route, family); + }; + + if (loading) return
{t("claudeDesktop.loading")}
; + if (loadError || !data || !profile) { + return ( +
+ {loadError || t("claudeDesktop.loadFail")} + +
+ ); + } + + return ( + <> +
+
+

{t("claudeDesktop.title")}

+

{t("claudeDesktop.subtitle", { port: data.port })}

+
+
+ void importProfile(event)} /> + + +
+
+ +
{announcement}
+ {message && {message.text}} + +
+ {dirty ? t("claudeDesktop.unsaved") : t("claudeDesktop.upToDate")} +
+ + +
+
+ + {data.models.length === 0 && ( + {t("claudeDesktop.emptyHint")} + )} + +
+ {FAMILIES.map(family => ( +
event.preventDefault()} + onDrop={event => dropOnLane(event, family)} + > +
+
+

{t(FAMILY_KEYS[family])}

+ {t(modelsByFamily[family].length === 1 ? "claudeDesktop.modelCountOne" : "claudeDesktop.modelCountMany", { count: modelsByFamily[family].length })} +
+ {modelsByFamily[family].length > 0 && profile.defaults[family] === null && {t("claudeDesktop.chooseDefault")}} +
+ +
+ {modelsByFamily[family].length === 0 ? ( +
{t("claudeDesktop.laneEmpty")}
+ ) : modelsByFamily[family].map(model => { + const assignment = profile.assignments[model.route]; + const context = formatContextWindow(model.contextWindow, t); + const destination = destinations[model.route] ?? "opus"; + return ( +
{ event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("text/plain", model.route); }} + > +
+
+ {model.label} + {model.route} +
+ + {model.available ? t("claudeDesktop.available") : t("claudeDesktop.unavailable")} + +
+ {context && {context}} + +
+ {t("claudeDesktop.alias")} + {assignment.alias} +
+ + + +
+ + + +
+
+ ); + })} +
+
+ ))} +
+ + ); +} diff --git a/gui/src/styles.css b/gui/src/styles.css index 2469f43d3..0e070f3b9 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1029,3 +1029,89 @@ button.prov-account-row.active { cursor: default; } .model-tip-key { color: var(--muted); } .model-tip-val { color: var(--text); font-family: var(--mono); font-size: var(--text-label); } .model-tip-actions { display: flex; gap: 6px; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border-soft); } + +/* ── Claude client tabs + Desktop profile editor ── */ +.claude-tabs { + display: inline-flex; gap: 2px; margin-bottom: 22px; padding: 3px; + border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface); +} +.claude-tabs button { + min-width: 88px; min-height: 34px; padding: 6px 14px; border: 0; border-radius: var(--radius-pill); + background: transparent; color: var(--muted); font: inherit; font-size: 13px; font-weight: 550; cursor: pointer; +} +.claude-tabs button:hover { color: var(--text); background: var(--hover); } +.claude-tabs button.active { color: var(--text); background: var(--raised); box-shadow: var(--shadow-sm); } +.claude-desktop-loading { padding: 28px 4px; color: var(--muted); } +.claude-desktop-error { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; } +.claude-desktop-head { align-items: flex-start; } +.claude-desktop-head .page-sub { margin-bottom: 0; } +.claude-profile-tools, .claude-save-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.claude-profile-bar { + position: sticky; top: 12px; z-index: 8; display: flex; align-items: center; justify-content: space-between; + gap: 12px; margin: 18px 0 16px; padding: 10px 12px; border: 1px solid var(--border); + border-radius: var(--radius); background: var(--glass-panel); backdrop-filter: var(--glass-blur); + -webkit-backdrop-filter: var(--glass-blur); box-shadow: var(--shadow-sm); +} +.claude-dirty { color: var(--muted); font-size: 12.5px; font-weight: 550; } +.claude-dirty.active { color: var(--amber); } +.claude-lanes { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: start; gap: 12px; } +.claude-lane { + min-width: 0; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); + overflow: hidden; +} +.claude-lane-head { + display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; + border-bottom: 1px solid var(--border-soft); background: var(--raised); +} +.claude-lane-head h3 { font-size: 14px; } +.claude-lane-head > div > span { display: block; margin-top: 1px; color: var(--muted); font-size: 11.5px; } +.claude-default-radio { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 11.5px; cursor: pointer; } +.claude-default-needed { color: var(--amber); font-size: 11.5px; font-weight: 550; } +.claude-lane-models { display: flex; flex-direction: column; gap: 9px; min-height: 104px; padding: 10px; } +.claude-lane-empty { + display: grid; place-items: center; min-height: 82px; padding: 12px; border: 1px dashed var(--border); + border-radius: var(--radius-sm); color: var(--muted); font-size: 12px; text-align: center; +} +.claude-model-card { padding: 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); box-shadow: var(--shadow-sm); } +.claude-model-card[draggable="true"] { cursor: grab; } +.claude-model-card[draggable="true"]:active { cursor: grabbing; } +.claude-model-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } +.claude-model-title > div { min-width: 0; } +.claude-model-title strong { display: block; overflow: hidden; color: var(--text); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; } +.claude-model-title code { display: block; overflow: hidden; margin-top: 2px; color: var(--muted); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } +.claude-model-context { display: inline-block; margin-top: 6px; color: var(--muted); font-size: 11px; } +.claude-field { display: block; margin-top: 10px; } +.claude-field > span, .claude-move-row > label { display: block; margin-bottom: 4px; color: var(--muted); font-size: 11.5px; font-weight: 550; } +.claude-alias { + display: block; width: 100%; min-height: 34px; padding: 7px 10px; overflow: hidden; + border: 1px solid var(--border); border-radius: var(--radius-xs); background: var(--raised); + color: var(--text); font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; +} +.claude-default-radio { margin-top: 10px; color: var(--text); } +.claude-move-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px; align-items: end; margin-top: 10px; } +.claude-move-row > label { grid-column: 1 / -1; margin: 0; } +.claude-move-row .input { min-width: 0; height: 34px; padding-block: 4px; } +.sr-only { + position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; + clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; +} + +@media (max-width: 1200px) { + .claude-lanes { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} + +@media (max-width: 900px) { + .claude-lanes { grid-template-columns: 1fr; } +} + +@media (max-width: 760px) { + .claude-tabs { display: flex; width: 100%; } + .claude-tabs button { flex: 1; min-height: 44px; } + .claude-desktop-head { flex-direction: column; } + .claude-profile-tools { width: 100%; } + .claude-profile-tools .btn { flex: 1; min-height: 44px; } + .claude-profile-bar { top: 8px; align-items: stretch; flex-direction: column; } + .claude-save-actions .btn { flex: 1; min-height: 44px; } + .claude-lane-head { align-items: flex-start; } + .claude-move-row .input, .claude-move-row .btn { min-height: 44px; } +} From bcfc47f59d28e8afa70b297c342396a0c769879c Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sun, 12 Jul 2026 03:53:19 +0900 Subject: [PATCH 13/57] fix(claude): stabilize desktop apply and legacy aliases --- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Claude.tsx | 32 ++++++++++++++++++++++++++--- gui/src/pages/ClaudeDesktop.tsx | 16 +++++++++++++++ gui/src/styles.css | 1 + src/claude/desktop-3p.ts | 20 +++++++++++++----- src/server/management-api.ts | 11 +++++++--- tests/claude-desktop-cli.test.ts | 2 +- tests/claude-management-api.test.ts | 4 +++- tests/desktop-3p.test.ts | 23 ++++++++++++++++++++- 12 files changed, 99 insertions(+), 14 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 5ecb2c9ab..309face63 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1045,6 +1045,7 @@ export const de = { "claudeDesktop.modelCountOne": "{count} Modell", "claudeDesktop.modelCountMany": "{count} Modelle", "claudeDesktop.chooseDefault": "Standard wählen", + "claudeDesktop.temporaryDefault": "Temporärer Standard", "claudeDesktop.laneEmpty": "Modell hier ablegen oder die Verschieben-Steuerung verwenden.", "claudeDesktop.available": "Verfügbar", "claudeDesktop.unavailable": "Nicht verfügbar", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index fa8840962..78877dfcf 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1066,6 +1066,7 @@ export const en = { "claudeDesktop.modelCountOne": "{count} model", "claudeDesktop.modelCountMany": "{count} models", "claudeDesktop.chooseDefault": "Choose a default", + "claudeDesktop.temporaryDefault": "Temporary default", "claudeDesktop.laneEmpty": "Drop a model here or use its Move control.", "claudeDesktop.available": "Available", "claudeDesktop.unavailable": "Unavailable", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5b930c0ff..846a603ef 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1065,6 +1065,7 @@ export const ko: Record = { "claudeDesktop.modelCountOne": "모델 {count}개", "claudeDesktop.modelCountMany": "모델 {count}개", "claudeDesktop.chooseDefault": "기본 모델 선택", + "claudeDesktop.temporaryDefault": "임시 기본 모델", "claudeDesktop.laneEmpty": "모델을 여기에 놓거나 이동 컨트롤을 사용하세요.", "claudeDesktop.available": "사용 가능", "claudeDesktop.unavailable": "사용 불가", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 94a4e01ed..b18a0e1e2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1065,6 +1065,7 @@ export const zh: Record = { "claudeDesktop.modelCountOne": "{count} 个模型", "claudeDesktop.modelCountMany": "{count} 个模型", "claudeDesktop.chooseDefault": "选择默认模型", + "claudeDesktop.temporaryDefault": "临时默认模型", "claudeDesktop.laneEmpty": "将模型拖到这里,或使用移动控件。", "claudeDesktop.available": "可用", "claudeDesktop.unavailable": "不可用", diff --git a/gui/src/pages/Claude.tsx b/gui/src/pages/Claude.tsx index 48e85010f..f8b611b5b 100644 --- a/gui/src/pages/Claude.tsx +++ b/gui/src/pages/Claude.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState, type KeyboardEvent } from "react"; import ClaudeCode from "./ClaudeCode"; import ClaudeDesktop from "./ClaudeDesktop"; import { useT } from "../i18n"; @@ -8,6 +8,26 @@ type ClaudeTab = "code" | "desktop"; export default function Claude({ apiBase }: { apiBase: string }) { const [tab, setTab] = useState("code"); const t = useT(); + const codeTabRef = useRef(null); + const desktopTabRef = useRef(null); + + const selectTab = (next: ClaudeTab) => { + setTab(next); + window.requestAnimationFrame(() => (next === "code" ? codeTabRef : desktopTabRef).current?.focus()); + }; + + const handleTabKey = (event: KeyboardEvent) => { + if (event.key === "ArrowLeft" || event.key === "ArrowRight") { + event.preventDefault(); + selectTab(tab === "code" ? "desktop" : "code"); + } else if (event.key === "Home") { + event.preventDefault(); + selectTab("code"); + } else if (event.key === "End") { + event.preventDefault(); + selectTab("desktop"); + } + }; return (
@@ -15,22 +35,28 @@ export default function Claude({ apiBase }: { apiBase: string }) { diff --git a/gui/src/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index d3d9ad246..d5a077c9f 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -134,6 +134,16 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) { return grouped; }, [data, profile]); + const effectiveDefaults = useMemo(() => { + const result = {} as Record; + for (const family of FAMILIES) { + const active = modelsByFamily[family].filter(model => model.available).map(model => model.route).sort(); + const stored = profile?.defaults[family] ?? null; + result[family] = stored && active.includes(stored) ? stored : (active[0] ?? null); + } + return result; + }, [modelsByFamily, profile]); + const moveModel = (route: string, family: Family) => { if (!profile || profile.assignments[route]?.family === family) return; setProfile(current => { @@ -281,6 +291,9 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) { {t(modelsByFamily[family].length === 1 ? "claudeDesktop.modelCountOne" : "claudeDesktop.modelCountMany", { count: modelsByFamily[family].length })} {modelsByFamily[family].length > 0 && profile.defaults[family] === null && {t("claudeDesktop.chooseDefault")}} + {effectiveDefaults[family] && effectiveDefaults[family] !== profile.defaults[family] && ( + {t("claudeDesktop.temporaryDefault")} + )}
@@ -307,6 +320,9 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) {
{context && {context}} + {effectiveDefaults[family] === model.route && profile.defaults[family] !== model.route && ( + {t("claudeDesktop.temporaryDefault")} + )}
{t("claudeDesktop.alias")} diff --git a/gui/src/styles.css b/gui/src/styles.css index 0e070f3b9..4acafe789 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1067,6 +1067,7 @@ button.prov-account-row.active { cursor: default; } .claude-lane-head > div > span { display: block; margin-top: 1px; color: var(--muted); font-size: 11.5px; } .claude-default-radio { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 11.5px; cursor: pointer; } .claude-default-needed { color: var(--amber); font-size: 11.5px; font-weight: 550; } +.claude-effective-default { display: inline-block; margin-top: 6px; color: var(--amber); font-size: 11px; font-weight: 550; } .claude-lane-models { display: flex; flex-direction: column; gap: 9px; min-height: 104px; padding: 10px; } .claude-lane-empty { display: grid; place-items: center; min-height: 82px; padding: 12px; border: 1px dashed var(--border); diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 9588e676e..c315c361f 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -131,11 +131,6 @@ function collectDesktop3pModels( for (const model of rendered) { aliasesByRoute.set(model.route, model.name); if (!model.route.startsWith("anthropic/claude-")) registry.set(model.name, model.route); - const providerEnd = model.route.indexOf("/"); - const provider = model.route.slice(0, providerEnd); - const id = model.route.slice(providerEnd + 1); - const legacy = legacyDesktop3pAlias(provider, id); - if (!registry.has(legacy)) registry.set(legacy, model.route); models.push({ name: model.name, labelOverride: model.label, @@ -144,6 +139,21 @@ function collectDesktop3pModels( ...(model.supports1m ? { supports1m: true } : {}), }); } + // Legacy hashes are compatibility-only and can collide. Bind them in stable route order so + // changing a family default or rendered ordering can never silently rebind an old Desktop id. + for (const model of [...rendered].sort((a, b) => a.route.localeCompare(b.route))) { + if (model.route.startsWith("anthropic/claude-")) continue; + const providerEnd = model.route.indexOf("/"); + const provider = model.route.slice(0, providerEnd); + const id = model.route.slice(providerEnd + 1); + const legacy = legacyDesktop3pAlias(provider, id); + const existing = registry.get(legacy); + if (existing && existing !== model.route) { + console.warn(`[opencodex] Claude Desktop legacy alias collision: ${legacy} stays bound to ${existing}; ignoring ${model.route}`); + continue; + } + registry.set(legacy, model.route); + } desktop3pAliasesByRoute = aliasesByRoute; return { models, registry }; } diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 197c9b8d3..080ba4b16 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -1194,7 +1194,9 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon // Claude Code inbound settings (GUI "Claude ON" toggle + Claude page). if (url.pathname === "/api/claude-desktop" && req.method === "GET") { try { - return jsonResponse(await buildClaudeDesktopState(config)); + const state = await buildClaudeDesktopState(config); + const runtimePort = Number(url.port) || config.port; + return jsonResponse({ ...state, port: runtimePort }); } catch (error) { return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); } @@ -1223,7 +1225,9 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon const state = await buildClaudeDesktopState(config, parsed); config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: reconcileDesktopProfile(state.profile, state.models) }; saveConfig(config); - return jsonResponse({ ok: true, ...await buildClaudeDesktopState(config) }); + const saved = await buildClaudeDesktopState(config); + const runtimePort = Number(url.port) || config.port; + return jsonResponse({ ok: true, ...saved, port: runtimePort }); } catch (error) { return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); } @@ -1242,7 +1246,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon return { provider: model.route.slice(0, slash), id: model.route.slice(slash + 1), contextWindow: model.contextWindow }; }); const result = writeDesktop3pConfig( - config.port, + Number(url.port) || config.port, [...visibleNativeSlugs(config)], routed, config.apiKeys?.[0]?.key, @@ -1850,6 +1854,7 @@ function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProviderConfi if (!matchesRegistryStaticHeaders) return provider; const { headers: _headers, ...rest } = provider; return rest; +} /** Shared Desktop profile DTO builder for the management API and CLI. */ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxClaudeDesktopProfile) { diff --git a/tests/claude-desktop-cli.test.ts b/tests/claude-desktop-cli.test.ts index 2bc390434..22e1eee97 100644 --- a/tests/claude-desktop-cli.test.ts +++ b/tests/claude-desktop-cli.test.ts @@ -20,7 +20,7 @@ beforeEach(() => { port: 10100, defaultProvider: "mock", providers: { - mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", models: ["test-model"] }, + mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1:1/v1", apiKey: "k", allowPrivateNetwork: true, models: ["test-model"] }, }, } as OcxConfig); }); diff --git a/tests/claude-management-api.test.ts b/tests/claude-management-api.test.ts index fe361451e..4262d4884 100644 --- a/tests/claude-management-api.test.ts +++ b/tests/claude-management-api.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; -import { mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { loadConfig, saveConfig } from "../src/config"; @@ -455,6 +455,8 @@ test("Claude Desktop profile GET, PUT and apply round-trip four-family assignmen const result = await apply.json() as { path: string; applied: boolean }; expect(result.applied).toBe(true); expect(result.path.startsWith(process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR!)).toBe(true); + const appliedConfig = JSON.parse(readFileSync(result.path, "utf8")) as { inferenceGatewayBaseUrl: string }; + expect(appliedConfig.inferenceGatewayBaseUrl).toBe(new URL(server.url).origin); } finally { server.stop(true); } diff --git a/tests/desktop-3p.test.ts b/tests/desktop-3p.test.ts index 89cc5949b..b3bc2bd56 100644 --- a/tests/desktop-3p.test.ts +++ b/tests/desktop-3p.test.ts @@ -13,7 +13,7 @@ import { parseDesktop3pModeArgs, resolveDesktop3pAlias, } from "../src/claude/desktop-3p"; -import { moveDesktopRoute, reconcileDesktopProfile } from "../src/claude/desktop-profile"; +import { moveDesktopRoute, reconcileDesktopProfile, setDesktopFamilyDefault } from "../src/claude/desktop-profile"; import { resolveInboundModel } from "../src/claude/inbound"; describe("Claude Desktop 3P models", () => { @@ -218,4 +218,25 @@ describe("Claude Desktop 3P models", () => { rmSync(dir, { recursive: true, force: true }); } }); + + test("legacy hash collisions stay bound to the same route when default ordering changes", () => { + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const routed = [ + { provider: "test", id: "model-123" }, + { provider: "test", id: "model-155" }, + ]; + let profile = reconcileDesktopProfile(undefined, routed.map(model => ({ + route: `${model.provider}/${model.id}`, + label: model.id, + }))); + profile = setDesktopFamilyDefault(profile, "opus", "test/model-155"); + generateDesktop3pModels([], routed, profile); + expect(legacyDesktop3pAlias("test", "model-123")).toBe(legacyDesktop3pAlias("test", "model-155")); + expect(resolveDesktop3pAlias(legacyDesktop3pAlias("test", "model-123"))).toBe("test/model-123"); + expect(warning.mock.calls.flat().join(" ")).toContain("stays bound to test/model-123"); + } finally { + warning.mockRestore(); + } + }); }); From 31a9dbd41fe00ab9ba6c00337d029b5023479db5 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 22 Jul 2026 07:57:35 +0900 Subject: [PATCH 14/57] feat(claude): desktop applied-state fingerprint + transactional write + status endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: OcxClaudeDesktopProfile에 appliedFingerprint/appliedAt 추가 - desktop-3p.ts: writeDesktop3pConfig에 SHA-256 fingerprint 반환 + metadata write 실패 시 .bak rollback - management-api.ts: /api/claude-desktop/apply에 fingerprint 저장 + /api/claude-desktop/status 엔드포인트 (saved-vs-on-disk 비교) --- src/claude/desktop-3p.ts | 15 ++++++++--- src/server/management-api.ts | 48 +++++++++++++++++++++++++++++++++++- src/types.ts | 4 +++ 3 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index c315c361f..60c91ef89 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -280,7 +280,7 @@ export function writeDesktop3pConfig( apiKey?: string, mode: Desktop3pConfigMode = "static", profile?: OcxClaudeDesktopProfile, -): { written: boolean; path: string; reason?: string } { +): { written: boolean; path: string; reason?: string; fingerprint?: string } { const libraryPath = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR?.trim() || join(homedir(), "Library", "Application Support", "Claude-3p", "configLibrary"); const metadataPath = join(libraryPath, "_meta.json"); @@ -298,9 +298,16 @@ export function writeDesktop3pConfig( : [...metadata.entries, entry]; const configJson = JSON.stringify(generateDesktop3pConfig(port, nativeSlugs, routedModels, apiKey, mode, profile), null, 2) + "\n"; - atomicReplaceDesktopConfig(configPath, configJson); - atomicWriteFile(metadataPath, JSON.stringify({ ...metadata, appliedId: id, entries }, null, 2) + "\n"); - return { written: true, path: configPath }; + const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16); + const { backupPath } = atomicReplaceDesktopConfig(configPath, configJson); + try { + atomicWriteFile(metadataPath, JSON.stringify({ ...metadata, appliedId: id, entries }, null, 2) + "\n"); + } catch (metaError) { + // Rollback: restore the backed-up config if metadata write fails. + if (backupPath && existsSync(backupPath)) copyFileSync(backupPath, configPath); + throw metaError; + } + return { written: true, path: configPath, fingerprint }; } catch (error) { const reason = error instanceof Error ? error.message : String(error); return { written: false, path: configPath, reason }; diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 080ba4b16..0478e09d6 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -1254,7 +1254,53 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon state.profile, ); if (!result.written) return jsonResponse({ error: result.reason ?? "Claude Desktop apply failed", saved: true, path: result.path }, 500); - return jsonResponse({ ok: true, saved: true, applied: true, path: result.path }); + // Persist applied fingerprint + timestamp so GUI can show saved-vs-applied state. + if (result.fingerprint) { + config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: { ...state.profile, appliedFingerprint: result.fingerprint, appliedAt: new Date().toISOString() } }; + saveConfig(config); + } + return jsonResponse({ ok: true, saved: true, applied: true, path: result.path, fingerprint: result.fingerprint }); + } catch (error) { + return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); + } + } + + // Desktop applied-state + health status. + if (url.pathname === "/api/claude-desktop/status" && req.method === "GET") { + try { + const { readFileSync, existsSync } = await import("node:fs"); + const { createHash } = await import("node:crypto"); + const { join } = await import("node:path"); + const { homedir } = await import("node:os"); + const libraryPath = process.env.OPENCODEX_CLAUDE_DESKTOP_CONFIG_DIR?.trim() + || join(homedir(), "Library", "Application Support", "Claude-3p", "configLibrary"); + const metaPath = join(libraryPath, "_meta.json"); + let onDiskFingerprint: string | null = null; + let configPath: string | null = null; + if (existsSync(metaPath)) { + try { + const meta = JSON.parse(readFileSync(metaPath, "utf8")); + const entry = Array.isArray(meta.entries) ? meta.entries.find((e: { name?: string }) => e?.name === "opencodex") : undefined; + if (entry?.id) { + configPath = join(libraryPath, `${entry.id}.json`); + if (existsSync(configPath)) { + const onDisk = readFileSync(configPath, "utf8"); + onDiskFingerprint = createHash("sha256").update(onDisk).digest("hex").slice(0, 16); + } + } + } catch { /* unreadable metadata */ } + } + const savedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; + const appliedAt = config.claudeCode?.desktopProfile?.appliedAt ?? null; + const stale = savedFingerprint !== null && onDiskFingerprint !== null && savedFingerprint !== onDiskFingerprint; + return jsonResponse({ + applied: savedFingerprint !== null, + appliedAt, + savedFingerprint, + onDiskFingerprint, + configPath, + stale, + }); } catch (error) { return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); } diff --git a/src/types.ts b/src/types.ts index a32efa530..c812c5a68 100644 --- a/src/types.ts +++ b/src/types.ts @@ -360,6 +360,10 @@ export interface OcxClaudeDesktopProfile { version: 1; assignments: Record; defaults: Record; + /** SHA-256 fingerprint of the last successfully applied 3P config content. */ + appliedFingerprint?: string; + /** ISO timestamp of the last successful apply. */ + appliedAt?: string; } /** 사용자가 대시보드에서 직접 추가한 커스텀 모델 정의. */ From 69f0c33b171f58ec01ad64cac3c391c19e0809c7 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 22 Jul 2026 07:59:32 +0900 Subject: [PATCH 15/57] feat(claude): desktop client surface discrimination in request logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - claude-messages.ts: Desktop 3P alias 감지 시 logCtx.surface = 'claude-desktop' - request-log.ts: surface 타입 확장 ('claude' | 'claude-desktop') - usage/log.ts, usage/summary.ts: surface 필터 확장 --- src/server/claude-messages.ts | 6 ++++++ src/server/request-log.ts | 8 ++++---- src/usage/log.ts | 4 ++-- src/usage/summary.ts | 2 +- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 25eb46fb3..f5fe566b1 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -10,6 +10,7 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard"; import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; +import { resolveDesktop3pAlias } from "../claude/desktop-3p"; import { stripOneMillionMarker } from "../claude/context-windows"; import { captureClaudeInbound } from "../claude/inbound-debug"; import { isTransientUpstreamStatus } from "../lib/upstream-retry"; @@ -546,6 +547,11 @@ export async function handleClaudeMessages( : undefined, req.headers.get("anthropic-beta") ?? undefined, ); + // Client surface discrimination: Desktop 3P aliases resolve through the + // desktop registry; Code uses readable aliases or direct model names. + if (isRec(anthropicBody) && typeof anthropicBody.model === "string" && resolveDesktop3pAlias(anthropicBody.model)) { + logCtx.surface = "claude-desktop"; + } if (isRec(anthropicBody) && wantsNativePassthrough(req, config, anthropicBody.model)) { return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages"); } diff --git a/src/server/request-log.ts b/src/server/request-log.ts index f76d3002c..ba099d045 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -33,7 +33,7 @@ export interface RequestLogContext { provider: string; /** TTFT: ms from request start to the first non-empty model output delta (WP4, devlog 040). */ firstOutputMs?: number; - surface?: "claude"; + surface?: "claude" | "claude-desktop"; requestedModel?: string; requestedEffort?: string; requestedServiceTier?: string; @@ -74,7 +74,7 @@ export interface RequestLogEntry { provider: string; /** TTFT: ms from request start to the first non-empty model output delta; unset for non-streaming/tool-only. */ firstOutputMs?: number; - surface?: "claude"; + surface?: "claude" | "claude-desktop"; requestedModel?: string; requestedEffort?: string; requestedServiceTier?: string; @@ -137,7 +137,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R model: entry.model, provider: entry.provider, ...(entry.firstOutputMs !== undefined ? { firstOutputMs: entry.firstOutputMs } : {}), - ...(entry.surface === "claude" ? { surface: entry.surface } : {}), + ...(entry.surface === "claude" || entry.surface === "claude-desktop" ? { surface: entry.surface } : {}), ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}), ...(entry.requestedEffort ? { requestedEffort: entry.requestedEffort } : {}), ...(entry.requestedServiceTier ? { requestedServiceTier: entry.requestedServiceTier } : {}), @@ -213,7 +213,7 @@ export function addRequestLog(entry: RequestLogEntry) { timestamp: entry.timestamp, provider: entry.provider, model: entry.model, - ...(entry.surface === "claude" ? { surface: entry.surface } : {}), + ...(entry.surface === "claude" || entry.surface === "claude-desktop" ? { surface: entry.surface } : {}), ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}), ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}), ...(entry.requestedEffort ? { requestedEffort: entry.requestedEffort } : {}), diff --git a/src/usage/log.ts b/src/usage/log.ts index 9a769aaf4..478b8cc94 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -36,7 +36,7 @@ export interface PersistedUsageEntry { timestamp: number; provider: string; model: string; - surface?: "claude"; + surface?: "claude" | "claude-desktop"; resolvedModel?: string; requestedModel?: string; /** Reasoning effort / service-tier metadata for GUI Logs after restart. */ @@ -217,7 +217,7 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { timestamp: entry.timestamp, provider: entry.provider, model: entry.model, - ...(entry.surface === "claude" ? { surface: entry.surface } : {}), + ...(entry.surface === "claude" || entry.surface === "claude-desktop" ? { surface: entry.surface } : {}), ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}), ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}), ...(typeof entry.requestedEffort === "string" && entry.requestedEffort diff --git a/src/usage/summary.ts b/src/usage/summary.ts index e92d40097..57c19c5df 100644 --- a/src/usage/summary.ts +++ b/src/usage/summary.ts @@ -453,7 +453,7 @@ export function summarizeUsage( const { since } = rangeWindow(range, now); const filteredEntries = entries.filter(entry => { if (since !== null && entry.timestamp < since) return false; - if (surface === "claude") return entry.surface === "claude"; + if (surface === "claude") return entry.surface === "claude" || entry.surface === "claude-desktop"; if (surface === "codex") return entry.surface !== "claude"; return true; }); From 4fac15a2f885f29dead8ee9440e9e8dadf02d317 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 22 Jul 2026 08:02:01 +0900 Subject: [PATCH 16/57] feat(claude): desktop auto-apply + health monitoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.ts: desktopAutoApply 옵션 추가 - desktop-health.ts: in-memory 요청/에러 트래커 (NEW) - claude-messages.ts: Desktop 요청 시 recordDesktopRequest() 호출 - management-api.ts: /api/claude-desktop/status에 health 포함 + autoApplyDesktopBestEffort (provider 변경 시 자동 3P config 갱신) --- src/claude/desktop-health.ts | 26 ++++++++++++++++++++++++++ src/server/claude-messages.ts | 2 ++ src/server/management-api.ts | 28 ++++++++++++++++++++++++++++ src/types.ts | 2 ++ 4 files changed, 58 insertions(+) create mode 100644 src/claude/desktop-health.ts diff --git a/src/claude/desktop-health.ts b/src/claude/desktop-health.ts new file mode 100644 index 000000000..863d4aeae --- /dev/null +++ b/src/claude/desktop-health.ts @@ -0,0 +1,26 @@ +/** In-memory Desktop health tracker (resets on server restart). */ + +interface DesktopHealthState { + lastRequestAt: number | null; + requestCount: number; + errorCount: number; +} + +const state: DesktopHealthState = { lastRequestAt: null, requestCount: 0, errorCount: 0 }; + +export function recordDesktopRequest(): void { + state.lastRequestAt = Date.now(); + state.requestCount++; +} + +export function recordDesktopError(): void { + state.errorCount++; +} + +export function getDesktopHealth(): { lastRequestAt: string | null; requestCount: number; errorCount: number } { + return { + lastRequestAt: state.lastRequestAt !== null ? new Date(state.lastRequestAt).toISOString() : null, + requestCount: state.requestCount, + errorCount: state.errorCount, + }; +} diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index f5fe566b1..2a258a4d5 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -11,6 +11,7 @@ import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard"; import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize"; import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; import { resolveDesktop3pAlias } from "../claude/desktop-3p"; +import { recordDesktopRequest } from "../claude/desktop-health"; import { stripOneMillionMarker } from "../claude/context-windows"; import { captureClaudeInbound } from "../claude/inbound-debug"; import { isTransientUpstreamStatus } from "../lib/upstream-retry"; @@ -551,6 +552,7 @@ export async function handleClaudeMessages( // desktop registry; Code uses readable aliases or direct model names. if (isRec(anthropicBody) && typeof anthropicBody.model === "string" && resolveDesktop3pAlias(anthropicBody.model)) { logCtx.surface = "claude-desktop"; + recordDesktopRequest(); } if (isRec(anthropicBody) && wantsNativePassthrough(req, config, anthropicBody.model)) { return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages"); diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 0478e09d6..edf482e4e 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -222,6 +222,30 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon } catch { /* best-effort */ } } + /** Best-effort Desktop 3P config auto-reconcile when providers change. */ + async function autoApplyDesktopBestEffort(): Promise { + try { + if (config.claudeCode?.desktopAutoApply === false) return; + if (!config.claudeCode?.desktopProfile) return; + const { writeDesktop3pConfig } = await import("../claude/desktop-3p"); + const { visibleNativeSlugs, filterCatalogVisibleModels } = await import("../codex/catalog"); + const allModels = await fetchAllModels(config); + const routed = filterCatalogVisibleModels(allModels, config).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })); + const result = writeDesktop3pConfig( + config.port ?? 10100, + [...visibleNativeSlugs(config)], + routed, + config.apiKeys?.[0]?.key, + "static", + config.claudeCode.desktopProfile, + ); + if (result.written && result.fingerprint) { + config.claudeCode = { ...config.claudeCode, desktopProfile: { ...config.claudeCode.desktopProfile, appliedFingerprint: result.fingerprint, appliedAt: new Date().toISOString() } }; + saveConfig(config); + } + } catch { /* best-effort */ } + } + if (url.pathname === "/api/config" && req.method === "GET") { return jsonResponse(safeConfigDTO(config)); } @@ -1188,6 +1212,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon save(config); await refreshCodexCatalogBestEffort(); await syncClaudeAgentDefsBestEffort(); + await autoApplyDesktopBestEffort(); return jsonResponse({ ok: true, applied: chosen }); } @@ -1293,6 +1318,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon const savedFingerprint = config.claudeCode?.desktopProfile?.appliedFingerprint ?? null; const appliedAt = config.claudeCode?.desktopProfile?.appliedAt ?? null; const stale = savedFingerprint !== null && onDiskFingerprint !== null && savedFingerprint !== onDiskFingerprint; + const { getDesktopHealth } = await import("../claude/desktop-health"); + const health = getDesktopHealth(); return jsonResponse({ applied: savedFingerprint !== null, appliedAt, @@ -1300,6 +1327,7 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon onDiskFingerprint, configPath, stale, + health, }); } catch (error) { return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 400); diff --git a/src/types.ts b/src/types.ts index c812c5a68..15fb434c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -347,6 +347,8 @@ export interface OcxClaudeCodeConfig { visionSidecar?: { backend?: "openai" | "anthropic"; model?: string }; /** Persisted Claude Desktop four-family routing profile. */ desktopProfile?: OcxClaudeDesktopProfile; + /** Auto-reconcile Desktop 3P config when provider catalog changes. Default: enabled. */ + desktopAutoApply?: boolean; } export type OcxClaudeDesktopFamily = "opus" | "fable" | "sonnet" | "haiku"; From 68b7d95f436ee4d9aa9462e4d6534f342cf7dda8 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 22 Jul 2026 08:04:44 +0900 Subject: [PATCH 17/57] feat(gui): desktop status bar + effort transparency + health display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - management-api.ts: buildClaudeDesktopState에 effortSupported 추가 - ClaudeDesktop.tsx: status bar (applied/stale/not-applied) + health (lastRequest, req/err) + effort badge - i18n 4개 언어: status/health/effort 키 추가 - styles.css: .claude-status-bar, .claude-effort-badge 스타일 --- gui/src/i18n/de.ts | 7 +++++++ gui/src/i18n/en.ts | 7 +++++++ gui/src/i18n/ko.ts | 7 +++++++ gui/src/i18n/zh.ts | 7 +++++++ gui/src/pages/ClaudeDesktop.tsx | 30 ++++++++++++++++++++++++++++++ gui/src/styles.css | 22 ++++++++++++++++++++++ src/server/management-api.ts | 10 ++++++++++ 7 files changed, 90 insertions(+) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 309face63..84d6fa591 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1055,6 +1055,13 @@ export const de = { "claudeDesktop.useAsDefault": "Als {family}-Standard verwenden", "claudeDesktop.moveTo": "Verschieben nach", "claudeDesktop.move": "Verschieben", + "claudeDesktop.status.applied": "Auf Desktop angewendet", + "claudeDesktop.status.stale": "Konfiguration veraltet — erneut anwenden", + "claudeDesktop.status.notApplied": "Nicht angewendet", + "claudeDesktop.health.lastRequest": "Letzte Anfrage", + "claudeDesktop.health.stats": "{count} Anf. / {errors} Fehl.", + "claudeDesktop.effort.supported": "effort", + "claudeDesktop.effort.displayOnly": "effort (nur Anzeige)", } as const; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 78877dfcf..49ef28b0a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1076,6 +1076,13 @@ export const en = { "claudeDesktop.useAsDefault": "Use as {family} default", "claudeDesktop.moveTo": "Move to", "claudeDesktop.move": "Move", + "claudeDesktop.status.applied": "Applied to Desktop", + "claudeDesktop.status.stale": "Config stale — re-apply", + "claudeDesktop.status.notApplied": "Not applied", + "claudeDesktop.health.lastRequest": "Last request", + "claudeDesktop.health.stats": "{count} req / {errors} err", + "claudeDesktop.effort.supported": "effort", + "claudeDesktop.effort.displayOnly": "effort (display only)", } as const; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 846a603ef..8190185b8 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1075,5 +1075,12 @@ export const ko: Record = { "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/zh.ts b/gui/src/i18n/zh.ts index b18a0e1e2..5a9d1de94 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1075,5 +1075,12 @@ export const zh: Record = { "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/pages/ClaudeDesktop.tsx b/gui/src/pages/ClaudeDesktop.tsx index d5a077c9f..164adaeea 100644 --- a/gui/src/pages/ClaudeDesktop.tsx +++ b/gui/src/pages/ClaudeDesktop.tsx @@ -21,9 +21,17 @@ interface DesktopModel { label: string; available: boolean; contextWindow?: number; + effortSupported?: boolean; assignment: Assignment; } +interface DesktopStatus { + applied: boolean; + appliedAt: string | null; + stale: boolean; + health: { lastRequestAt: string | null; requestCount: number; errorCount: number }; +} + interface DesktopResponse { profile: DesktopProfile; models: DesktopModel[]; @@ -85,6 +93,7 @@ function formatContextWindow(value: number | undefined, t: TFn): string | null { export default function ClaudeDesktop({ apiBase }: { apiBase: string }) { const t = useT(); + const [status, setStatus] = useState(null); const [data, setData] = useState(null); const [profile, setProfile] = useState(null); const [savedProfile, setSavedProfile] = useState(null); @@ -144,6 +153,15 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) { return result; }, [modelsByFamily, profile]); + // Poll Desktop status every 5s for applied-state + health. + useEffect(() => { + let cancelled = false; + const poll = () => fetch(`${apiBase}/api/claude-desktop/status`).then(r => r.json()).then(d => { if (!cancelled) setStatus(d as DesktopStatus); }).catch(() => {}); + poll(); + const timer = setInterval(poll, 5000); + return () => { cancelled = true; clearInterval(timer); }; + }, [apiBase]); + const moveModel = (route: string, family: Family) => { if (!profile || profile.assignments[route]?.family === family) return; setProfile(current => { @@ -200,6 +218,7 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) { const exportProfile = () => { if (!profile) return; + // eslint-disable-next-line local-i18n/no-hardcoded-ui-strings -- file content newline, not UI text const url = URL.createObjectURL(new Blob([`${JSON.stringify(profile, null, 2)}\n`], { type: "application/json" })); const anchor = document.createElement("a"); anchor.href = url; @@ -257,6 +276,15 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) {
+ {status && ( +
+ + {status.stale ? t("claudeDesktop.status.stale") : status.applied ? t("claudeDesktop.status.applied") : t("claudeDesktop.status.notApplied")} + {status.health.lastRequestAt && {t("claudeDesktop.health.lastRequest")}: {new Date(status.health.lastRequestAt).toLocaleTimeString()}} + {status.health.requestCount > 0 && {t("claudeDesktop.health.stats", { count: status.health.requestCount, errors: status.health.errorCount })}} +
+ )} +
{announcement}
{message && {message.text}} @@ -320,6 +348,8 @@ export default function ClaudeDesktop({ apiBase }: { apiBase: string }) { {context && {context}} + {model.effortSupported === false && {t("claudeDesktop.effort.displayOnly")}} + {model.effortSupported === true && {t("claudeDesktop.effort.supported")}} {effectiveDefaults[family] === model.route && profile.defaults[family] !== model.route && ( {t("claudeDesktop.temporaryDefault")} )} diff --git a/gui/src/styles.css b/gui/src/styles.css index 4acafe789..8d068a7a2 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1116,3 +1116,25 @@ button.prov-account-row.active { cursor: default; } .claude-lane-head { align-items: flex-start; } .claude-move-row .input, .claude-move-row .btn { min-height: 44px; } } + +/* ── Desktop status bar + effort badges (hardening WP3) ── */ +.claude-status-bar { + display: flex; align-items: center; gap: 10px; margin-bottom: 14px; padding: 8px 14px; + border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); + font-size: 12.5px; color: var(--muted); +} +.claude-status-bar.applied { border-color: var(--green); } +.claude-status-bar.stale { border-color: var(--amber); } +.claude-status-bar.not-applied { border-color: var(--border); } +.claude-status-dot { + width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex-shrink: 0; +} +.claude-status-bar.applied .claude-status-dot { background: var(--green); } +.claude-status-bar.stale .claude-status-dot { background: var(--amber); } +.claude-status-health { margin-left: auto; font-size: 11.5px; } +.claude-effort-badge { + display: inline-block; margin-top: 4px; padding: 1px 6px; border-radius: var(--radius-xs); + font-size: 10px; font-weight: 600; letter-spacing: 0.02em; +} +.claude-effort-badge.on { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); } +.claude-effort-badge.off { background: color-mix(in srgb, var(--muted) 12%, transparent); color: var(--muted); } diff --git a/src/server/management-api.ts b/src/server/management-api.ts index edf482e4e..269915581 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -1946,11 +1946,21 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla const profile = reconcileDesktopProfile(stored ?? config.claudeCode?.desktopProfile, profileModels); const available = new Set(profileModels.map(model => model.route)); const modelByRoute = new Map(profileModels.map(model => [model.route, model])); + // Effort support: routed models with a non-empty reasoningEfforts ladder support effort; + // native models always support it (Anthropic native effort). + const effortByRoute = new Map(); + for (const m of routed) { + effortByRoute.set(`${m.provider}/${m.id}`, Array.isArray(m.reasoningEfforts) && m.reasoningEfforts.length > 0); + } + for (const id of visibleNativeSlugs(config)) { + effortByRoute.set(`native/${id}`, true); + } const models = Object.keys(profile.assignments).sort().map(route => ({ route, label: modelByRoute.get(route)?.label ?? route, available: available.has(route), ...(modelByRoute.get(route)?.contextWindow ? { contextWindow: modelByRoute.get(route)!.contextWindow } : {}), + effortSupported: effortByRoute.get(route) ?? false, assignment: profile.assignments[route]!, })); return { From 20c935b61ef8115596979dc50642f3a98423e24d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Wed, 22 Jul 2026 08:28:10 +0900 Subject: [PATCH 18/57] feat(claude): desktop prefer1m + Claude-shaped alias guard (Luna research 260722) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - desktop-3p.ts: prefer1m:true 설정 (supports1m 모델에, 공식 스키마 정렬) - desktop-3p.ts: isClaudeShapedId() 가드 추가 (Ollama '0 usable' 거부 방어) - Luna 5레인 연구 기반: 공식 inferenceModels 스키마, 커뮤니티 모델 소실 이슈 --- src/claude/desktop-3p.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 60c91ef89..81c4988d2 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -20,6 +20,8 @@ export interface Desktop3pModelEntry { * authoritative routed contextWindow >= 1M — never guessed (devlog 136 B5). */ supports1m?: true; + /** When true, Desktop selects the 1M variant by default (official schema, Luna research 260722). */ + prefer1m?: true; } /** @@ -136,7 +138,7 @@ function collectDesktop3pModels( labelOverride: model.label, anthropicFamilyTier: model.family, ...(model.isFamilyDefault ? { isFamilyDefault: true } : {}), - ...(model.supports1m ? { supports1m: true } : {}), + ...(model.supports1m ? { supports1m: true, prefer1m: true } : {}), }); } // Legacy hashes are compatibility-only and can collide. Bind them in stable route order so @@ -173,6 +175,7 @@ function collectDesktop3pModels( labelOverride: `${displayModelId(id)} (${provider})`, anthropicFamilyTier: "opus", ...supports1m, + ...(supports1m.supports1m ? { prefer1m: true as const } : {}), }); continue; } @@ -191,6 +194,7 @@ function collectDesktop3pModels( labelOverride: `${displayModelId(id)} (${provider})`, anthropicFamilyTier: "opus", ...supports1m, + ...(supports1m.supports1m ? { prefer1m: true as const } : {}), }); } From f55fcb52591b9874567b6ca2b4eabbce9da70e57 Mon Sep 17 00:00:00 2001 From: OnlineChef <280567955+OnlineChef@users.noreply.github.com> Date: Wed, 22 Jul 2026 19:54:58 +0200 Subject: [PATCH 19/57] feat(gui): Dutch localization + provider workspace + quota bars - Add nl.ts translations + Dutch pages (Instellingen, Modellen, Systeem, Verkeer) - Refactor App.tsx into provider-workspace shell - Add quota bars + use-provider-quotas hook - Extend de/en i18n with new keys - Add depas.css styling, provider-quota and provider-workspace-shell CSS - Update FLEET.md --- FLEET.md | 16 +- gui/bun.lock | 6 + gui/package.json | 2 + gui/src/App.tsx | 394 ++++--------- gui/src/components/QuotaBars.tsx | 2 + .../provider-workspace/ProviderDetails.tsx | 19 +- .../provider-workspace/ProviderOverview.tsx | 29 +- .../provider-workspace/ProviderUsage.tsx | 26 +- .../ProviderWorkspaceShell.tsx | 43 +- gui/src/formatUptime.ts | 1 + gui/src/i18n/de.ts | 20 + gui/src/i18n/en.ts | 21 + gui/src/i18n/ja.ts | 20 + gui/src/i18n/ko.ts | 20 + gui/src/i18n/nl.ts | 145 +++++ gui/src/i18n/ru.ts | 20 + gui/src/i18n/shared.ts | 14 +- gui/src/i18n/zh.ts | 20 + gui/src/icons.tsx | 1 + gui/src/oauth-tos-risk.ts | 5 +- gui/src/pages/Dashboard.tsx | 18 +- gui/src/pages/Instellingen.tsx | 88 +++ gui/src/pages/Modellen.tsx | 42 ++ gui/src/pages/Providers.tsx | 91 +-- gui/src/pages/Systeem.tsx | 275 +++++++++ gui/src/pages/Verkeer.tsx | 230 ++++++++ gui/src/styles.css | 2 + gui/src/styles/depas.css | 536 ++++++++++++++++++ gui/src/styles/provider-quota.css | 9 + gui/src/styles/provider-workspace-shell.css | 18 + gui/src/use-provider-quotas.ts | 110 ++++ src/providers/quota.ts | 23 + src/server/management-api.ts | 21 +- 33 files changed, 1894 insertions(+), 393 deletions(-) create mode 100644 gui/src/i18n/nl.ts create mode 100644 gui/src/pages/Instellingen.tsx create mode 100644 gui/src/pages/Modellen.tsx create mode 100644 gui/src/pages/Systeem.tsx create mode 100644 gui/src/pages/Verkeer.tsx create mode 100644 gui/src/styles/depas.css create mode 100644 gui/src/use-provider-quotas.ts diff --git a/FLEET.md b/FLEET.md index cf1ea9df2..078209f95 100644 --- a/FLEET.md +++ b/FLEET.md @@ -5,34 +5,34 @@ | Upstream | https://github.com/lidge-jun/opencodex | | Org fork | https://github.com/OnlineChefGroep/opencodex | | npm package | `@bitkyc08/opencodex` | -| **Pinned host version (joep)** | **2.7.31** | -| Pin tag | `v2.7.31` | -| Pin commit | `304a1eab28cc114c182399db39ba91fb5af19d9d` | +| **Pinned host version (joep)** | **2.7.33** | +| Pin tag | `v2.7.33` | +| Pin commit | `6d6bef8b98d762ff9679916546cb44e8e3effebc` | | Verified | 2026-07-22 on `joep` (Ubuntu) | ## Host install (joep) ```text -CLI: ~/.local/bin/ocx → @bitkyc08/opencodex@2.7.31 +CLI: ~/.local/bin/ocx → @bitkyc08/opencodex@2.7.33 Config: ~/.opencodex/config.json Proxy: http://127.0.0.1:10100 Usage: ~/.opencodex/usage.jsonl ``` -Do **not** auto-upgrade past the pin without an explicit fleet decision. Latest npm may be newer; this host stays on 2.7.31 until bumped here and in chefgroep-vault `PROVIDERS.md`. +Do **not** auto-upgrade past the pin without an explicit fleet decision. Latest npm may be newer; this host stays on 2.7.33 until bumped here and in chefgroep-vault `PROVIDERS.md`. ## Pin / reinstall ```bash -npm install -g @bitkyc08/opencodex@2.7.31 -ocx --version # expect: opencodex 2.7.31 +npm install -g @bitkyc08/opencodex@2.7.33 +ocx --version # expect: opencodex 2.7.33 ``` Checkout this tag in the fork: ```bash git fetch --tags -git checkout v2.7.31 +git checkout v2.7.33 ``` ## Role in the ChefGroep stack diff --git a/gui/bun.lock b/gui/bun.lock index 11c7b8460..885be6958 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -5,6 +5,8 @@ "": { "name": "gui", "dependencies": { + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "@tanstack/react-virtual": "^3.14.5", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -80,6 +82,10 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], + "@fontsource-variable/archivo": ["@fontsource-variable/archivo@5.3.0", "", {}, "sha512-HogK8FJelrD1o7TlZlkIVtHgc20bO5PZRWE7mUeUTdMN055alznQV6/00J00IBeu8FQAH4s3zW9UJNvKExXf+g=="], + + "@fontsource/ibm-plex-mono": ["@fontsource/ibm-plex-mono@5.3.0", "", {}, "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], diff --git a/gui/package.json b/gui/package.json index cb29bbfbd..9d46c8327 100644 --- a/gui/package.json +++ b/gui/package.json @@ -13,6 +13,8 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/archivo": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", "@tanstack/react-virtual": "^3.14.5", "react": "^19.2.7", "react-dom": "^19.2.7" diff --git a/gui/src/App.tsx b/gui/src/App.tsx index eb9d5b806..5e6795aa7 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,347 +1,149 @@ -import { useEffect, useRef, useState } from "react"; -import Dashboard from "./pages/Dashboard"; +import { useEffect, useState } from "react"; import Providers from "./pages/Providers"; -import Models from "./pages/Models"; -import Combos from "./pages/Combos"; -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 ClaudeCode from "./pages/ClaudeCode"; -import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconActivity, IconHardDrive, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; -import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n"; -import { Select } from "./ui"; +import Modellen from "./pages/Modellen"; +import Verkeer from "./pages/Verkeer"; +import Systeem from "./pages/Systeem"; +import InstellingenSheet from "./pages/Instellingen"; +import { IconSettings } from "./icons"; import { installApiAuthFetch } from "./api"; installApiAuthFetch(); -type Page = "dashboard" | "providers" | "models" | "combos" | "subagents" | "logs" | "usage" | "storage" | "codex-auth" | "api" | "claude"; -type Theme = "light" | "dark" | "system"; - -const VALID_PAGES = new Set(["dashboard", "providers", "models", "combos", "subagents", "logs", "usage", "storage", "codex-auth", "api", "claude"]); +type Page = "leveranciers" | "modellen" | "verkeer" | "systeem"; + +const VALID_PAGES = new Set(["leveranciers", "modellen", "verkeer", "systeem"]); + +/** Legacy deep links from the old 11-page shell land on the view that absorbed them. */ +const LEGACY_ROUTES: Record = { + dashboard: "systeem", + providers: "leveranciers", + models: "modellen", + combos: "modellen", + subagents: "modellen", + logs: "verkeer", + debug: "verkeer", + usage: "verkeer", + storage: "systeem", + "codex-auth": "systeem", + api: "systeem", + claude: "systeem", +}; function readPageFromHash(): Page { const raw = location.hash.replace(/^#\/?/, ""); - // Sub-views use a "/" suffix (e.g. #providers/workspace); the first segment is the page id. - const pageId = raw.split("/")[0] as Page; - // Legacy: Debug used to be a standalone page; it now lives as a tab on Logs. - if (pageId === ("debug" as Page)) return "logs"; - return VALID_PAGES.has(pageId) ? pageId : "dashboard"; -} - -function hashBelongsToPage(rawHash: string, page: Page): boolean { - return rawHash === page - || (page === "providers" && rawHash === "providers/workspace") - || (page === "logs" && rawHash === "logs/debug"); + const head = raw.split("/")[0]; + if (VALID_PAGES.has(head as Page)) return head as Page; + return LEGACY_ROUTES[head] ?? "leveranciers"; } const API_BASE = import.meta.env.VITE_API_BASE || ""; -const THEME_KEY = "ocx-theme"; -const PROVIDERS_VIEW_KEY = "ocx-providers-view"; - -function readProvidersViewPreference(): "classic" | "workspace" { - try { - return localStorage.getItem(PROVIDERS_VIEW_KEY) === "workspace" ? "workspace" : "classic"; - } catch { - return "classic"; - } -} - -function writeProvidersViewPreference(view: "classic" | "workspace"): void { - try { - localStorage.setItem(PROVIDERS_VIEW_KEY, view); - } catch { - /* ignore quota / private-mode failures */ - } -} - -function providersHashForPage(): string { - return readProvidersViewPreference() === "workspace" ? "providers/workspace" : "providers"; -} -const NAV: { id: Page; tkey: TKey; Icon: typeof IconGrid }[] = [ - { id: "dashboard", tkey: "nav.dashboard", Icon: IconGrid }, - { id: "providers", tkey: "nav.providers", Icon: IconServer }, - { id: "models", tkey: "nav.models", Icon: IconBoxes }, - { id: "subagents", tkey: "nav.subagents", Icon: IconBot }, - { id: "logs", tkey: "nav.logs", Icon: IconList }, - { id: "usage", tkey: "nav.usage", Icon: IconActivity }, - { id: "storage", tkey: "nav.storage", Icon: IconHardDrive }, - { id: "codex-auth", tkey: "nav.codexAuth", Icon: IconKey }, - { id: "api", tkey: "nav.api", Icon: IconGlobe }, - { id: "claude", tkey: "nav.claude", Icon: IconSparkle }, +const NAV: { id: Page; label: string }[] = [ + { id: "leveranciers", label: "Leveranciers" }, + { id: "modellen", label: "Modellen" }, + { id: "verkeer", label: "Verkeer" }, + { id: "systeem", label: "Systeem" }, ]; -const THEME_ICON = { light: IconSun, dark: IconMoon, system: IconMonitor } as const; -const THEME_TKEY: Record = { light: "theme.light", dark: "theme.dark", system: "theme.system" }; - -function readRuntimeVersion(data: unknown): string | null { - if (!data || typeof data !== "object" || !("version" in data)) return null; - const version = (data as { version?: unknown }).version; - return typeof version === "string" && version.length > 0 ? version : null; -} - -function readStoredTheme(): Theme { - const t = localStorage.getItem(THEME_KEY); - return t === "light" || t === "dark" ? t : "system"; -} +interface HealthData { status: string; version: string; uptime: number } export default function App() { const [page, setPageState] = useState(readPageFromHash); - const [theme, setTheme] = useState(readStoredTheme); - const [runtimeVersion, setRuntimeVersion] = useState(null); - const { locale, setLocale } = useI18n(); - const t = useT(); + const [health, setHealth] = useState(null); + const [healthFailed, setHealthFailed] = useState(false); + const [sheetOpen, setSheetOpen] = useState(false); - // Narrow screens: the sidebar becomes an off-canvas drawer behind a hamburger toggle. - const [navOpen, setNavOpen] = useState(false); - const menuBtnRef = useRef(null); - const sidebarRef = useRef(null); - const navWasOpen = useRef(false); + // One identity: the old dark theme attribute no longer applies. + useEffect(() => { + document.documentElement.removeAttribute("data-theme"); + }, []); useEffect(() => { - // External navigation (hash edit, back/forward) also dismisses the mobile drawer. const onHash = () => { - const nextPage = readPageFromHash(); - const rawHash = window.location.hash.replace(/^#\/?/, ""); - setNavOpen(false); - // Legacy #debug deep links → the Debug tab on Logs. - if (rawHash === "debug" || rawHash.startsWith("debug/")) { - window.location.hash = "logs/debug"; + const next = readPageFromHash(); + const raw = location.hash.replace(/^#\/?/, ""); + if (raw.split("/")[0] !== next) { + location.hash = next; return; } - if (!hashBelongsToPage(rawHash, nextPage)) { - window.location.hash = nextPage === "providers" ? providersHashForPage() : nextPage; - return; - } - // Preference is source of truth for Classic/Workspace. Bare #providers must not - // wipe a saved workspace choice (that regressed when leaving Providers and returning). - if (nextPage === "providers") { - const preferred = readProvidersViewPreference(); - if (rawHash === "providers/workspace") { - writeProvidersViewPreference("workspace"); - } else if (rawHash === "providers" && preferred === "workspace") { - window.location.hash = "providers/workspace"; - return; - } else if (rawHash === "providers") { - writeProvidersViewPreference("classic"); - } - } - setPageState(nextPage); + setPageState(next); }; + onHash(); window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); - useEffect(() => { - const rawHash = window.location.hash.replace(/^#\/?/, ""); - // Legacy #debug deep links must resolve before generic normalization - // (otherwise the hash collapses to bare #logs and the tab choice is lost). - if (rawHash === "debug" || rawHash.startsWith("debug/")) { - window.location.hash = "logs/debug"; - return; - } - if (page === "providers") { - // Honor an explicit workspace deep link on first load before normalizing - // to the saved preference (bookmarks/shared links must not open Classic). - if (rawHash === "providers/workspace") { - writeProvidersViewPreference("workspace"); - return; - } - const wanted = providersHashForPage(); - if (rawHash !== wanted) window.location.hash = wanted; - return; - } - if (!hashBelongsToPage(rawHash, page)) { - window.location.hash = page; - } - }, [page]); - - useEffect(() => { - const el = document.documentElement; - if (theme === "system") { el.removeAttribute("data-theme"); localStorage.removeItem(THEME_KEY); } - else { el.setAttribute("data-theme", theme); localStorage.setItem(THEME_KEY, theme); } - }, [theme]); - useEffect(() => { let cancelled = false; - const fetchRuntimeVersion = async () => { + const poll = async () => { try { const res = await fetch(`${API_BASE}/healthz`); - if (!res.ok) return; - const version = readRuntimeVersion(await res.json()); - if (!cancelled && version) setRuntimeVersion(version); + if (!res.ok) throw new Error(String(res.status)); + const data = await res.json() as HealthData; + if (!cancelled) { setHealth(data); setHealthFailed(false); } } catch { - // Keep the build-time fallback when the proxy is unavailable. + if (!cancelled) setHealthFailed(true); } }; - fetchRuntimeVersion(); - const interval = setInterval(fetchRuntimeVersion, 30000); - return () => { cancelled = true; clearInterval(interval); }; + void poll(); + const iv = setInterval(() => void poll(), 5000); + return () => { cancelled = true; clearInterval(iv); }; }, []); - const cycleTheme = () => setTheme(t => (t === "light" ? "dark" : t === "dark" ? "system" : "light")); - const ThemeIcon = THEME_ICON[theme]; - const displayedVersion = runtimeVersion ?? __APP_VERSION__; - - const [stopping, setStopping] = useState(false); - // Sidebar "Claude ON" toggle — literal label in every locale (product name). - const [claudeEnabled, setClaudeEnabled] = useState(null); - - useEffect(() => { - if (!navOpen) return; - const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setNavOpen(false); }; - window.addEventListener("keydown", onKey); - const prevOverflow = document.body.style.overflow; - document.body.style.overflow = "hidden"; // no background scroll behind the drawer - return () => { window.removeEventListener("keydown", onKey); document.body.style.overflow = prevOverflow; }; - }, [navOpen]); - - // Move focus into the drawer on open; hand it back to the toggle on close. - useEffect(() => { - if (navOpen) { - navWasOpen.current = true; - // after the 180ms slide-in: while visibility is transitioning, focus() no-ops - const timer = setTimeout(() => sidebarRef.current?.focus(), 200); - return () => clearTimeout(timer); - } - if (navWasOpen.current) { navWasOpen.current = false; menuBtnRef.current?.focus(); } - }, [navOpen]); - - // Growing the window past the breakpoint dismisses the drawer state. - useEffect(() => { - const mq = window.matchMedia("(min-width: 761px)"); - const onChange = () => { if (mq.matches) setNavOpen(false); }; - mq.addEventListener("change", onChange); - return () => mq.removeEventListener("change", onChange); - }, []); - - useEffect(() => { - let cancelled = false; - fetch(`${API_BASE}/api/claude-code`) - .then(res => res.json()) - .then(d => { if (!cancelled && typeof d.enabled === "boolean") setClaudeEnabled(d.enabled); }) - .catch(() => { /* toggle stays hidden until the API answers */ }); - return () => { cancelled = true; }; - }, []); - - const toggleClaude = async () => { - if (claudeEnabled === null) return; - const next = !claudeEnabled; - setClaudeEnabled(next); // optimistic - try { - const res = await fetch(`${API_BASE}/api/claude-code`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled: next }), - }); - if (!res.ok) setClaudeEnabled(!next); - } catch { - setClaudeEnabled(!next); - } - }; - const handleStop = async () => { - if (!confirm(t("dash.stopConfirm"))) return; - setStopping(true); - try { await fetch(`${API_BASE}/api/stop`, { method: "POST" }); } catch { /* connection drops */ } - }; - - const brand = ( -
- - opencodex - v{displayedVersion} -
- ); + const online = !healthFailed && health?.status === "ok"; return ( -
- {/* inert while the drawer is open: keeps focus and assistive tech inside the drawer */} -
- - {brand} - -
- {navOpen &&
setNavOpen(false)} aria-hidden="true" />} -
{quota && } + {canRefreshQuota && ( +
+ {quotaReport?.updatedAt !== undefined && ( + {formatRelativeTime(quotaReport.updatedAt, timeLabels)} + )} + {quotaFailed && ( + {t("prov.quotaRefreshFailed")} + )} + +
+ )} {showAccounts && ( <> + )} + + + {open && children !== undefined && ( +
{children}
+ )} + + ); +} + +/** Systeem: proxy-status, versie/update, opslag, API-info, Codex Auth en de gevarenzone. */ +export default function Systeem({ apiBase, health, healthFailed }: { + apiBase: string; + health: HealthData | null; + healthFailed: boolean; +}) { + const { locale } = 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 ?? "update check mislukt"); + setUpdateCheck(data); + if (!data.updateAvailable) setUpdateMsg("Je draait de nieuwste versie."); + } 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 ?? "update starten mislukt"); + setUpdateMsg("Update draait. De proxy herstart zichzelf zo."); + } 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 ?? "sync mislukt"); + setSyncMsg(`Klaar. ${data.added ?? 0} modellen toegevoegd.`); + } 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 ( + <> +
+

Systeem

+
+

De proxy zelf: status, versie, opslag en beheer.

+ +
+
+
Proxy
+
+ + {online ? "Online" : "Offline"} + +
+ {health && ( +
+ {formatUptime(health.uptime, locale)} in dienst +
+ )} +
+
+
Versie
+
v{health?.version ?? "—"}
+
+ {updateCheck?.canUpdate && updateCheck.updateAvailable ? ( + + ) : ( + + )} +
+
+ {updateMsg && ( +
+ {updateMsg} +
+ )} +
+
Modellencatalogus
+
{syncMsg ?? "Sync haalt de nieuwste modellen op bij elke leverancier."}
+
+ +
+
+
+ + + + + + + {copied ? "Gekopieerd" : "Kopieer"} + + } + > + + + + + + + + + + + +
+
Gevarenzone
+
+ Stopt de proxy volledig. Codex en Cursor verliezen hun verbinding. + +
+
+ + { event.preventDefault(); setStopOpen(false); }} + > +
+
+

Proxy stoppen?

+
+
Codex en Cursor verliezen hun verbinding.
+
+ + +
+
+
+ + ); +} diff --git a/gui/src/pages/Verkeer.tsx b/gui/src/pages/Verkeer.tsx new file mode 100644 index 000000000..7d8071d13 --- /dev/null +++ b/gui/src/pages/Verkeer.tsx @@ -0,0 +1,230 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import Usage from "./Usage"; +import { useI18n } 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): { label: string; cls: string } { + if (entry.status >= 200 && entry.status < 300) return { label: "Klaar", cls: "stempel--klaar" }; + if (entry.status === 0) return { label: "Fout", cls: "stempel--fout" }; + if (entry.status >= 400) return { label: "Fout", cls: "stempel--fout" }; + return { label: "Bezig", 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 }: { apiBase: string }) { + const { locale } = 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(false); + const pausedRef = useRef(paused); + pausedRef.current = paused; + + 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 ( + <> +
+

Verkeer

+
+

Wat er door de proxy gaat: elke request is een bon.

+ +
+
+ {formatTokens(tokens30d, locale)} + tokens (30d) +
+
+ {requestsVandaag.toLocaleString(locale)} + requests vandaag +
+
+ {requests30d.toLocaleString(locale)} + requests (30d) +
+
+ +
+
+ + {providers.map(p => ( + + ))} +
+ +
+ + {logsFailed && ( +

+ Verkeer laden lukt niet. Laatste bekende bonnen blijven staan. +

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

+ Nog geen verkeer vandaag. +

+ ) : 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 && ( +
+
status {entry.status}{statusInfo ? ` · ${statusInfo.label}` : ""}
+ {entry.errorCode &&
fout: {entry.errorCode}
} + {entry.upstreamError &&
upstream: {entry.upstreamError}
} + {entry.usage && ( +
in {entry.usage.inputTokens} · uit {entry.usage.outputTokens}
+ )} + {entry.requestId &&
id {entry.requestId}
} +
+ )} +
+ ); + })} +
+ +
+ + {analyseOpen && ( +
+ +
+ )} +
+ + ); +} diff --git a/gui/src/styles.css b/gui/src/styles.css index 2469f43d3..94d7f6b62 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -13,6 +13,8 @@ @import "./styles/provider-workspace-settings.css"; @import "./styles/provider-overview-dashboard.css"; @import "./styles-combos-workspace.css"; +/* De Pas re-skin: loaded last so its token overrides win over the legacy layer. */ +@import "./styles/depas.css"; :root { /* default: follow the OS; [data-theme] below pins color-scheme so light-dark() obeys it */ 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-shell.css b/gui/src/styles/provider-workspace-shell.css index 71fa3cfa6..26425e40f 100644 --- a/gui/src/styles/provider-workspace-shell.css +++ b/gui/src/styles/provider-workspace-shell.css @@ -893,6 +893,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/use-provider-quotas.ts b/gui/src/use-provider-quotas.ts new file mode 100644 index 000000000..24575e4e8 --- /dev/null +++ b/gui/src/use-provider-quotas.ts @@ -0,0 +1,110 @@ +/** + * 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 scope join the in-flight request. + */ +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 => { + const joinable = inflightRef.current.get(scope); + 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(scope); + if (isCurrent() && scope !== ALL) setRefreshing(prev => ({ ...prev, [scope]: false })); + } + })(); + + inflightRef.current.set(scope, 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/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/server/management-api.ts b/src/server/management-api.ts index 9a842246c..b7e935308 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -26,7 +26,7 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "../oauth/key-p import { deriveProviderPresets } from "../providers/derive"; import { providerCodexAccountMode } from "../providers/registry"; import { routedSlug, slugEquals } from "../providers/slug-codec"; -import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../providers/quota"; +import { clearProviderQuotaCache, fetchProviderQuotaReports, fetchSingleProviderQuotaReport } from "../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { clearThreadAccountMap } from "../codex/routing"; import { primeCodexPoolQuotas } from "../codex/auth-api"; @@ -517,6 +517,14 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon if (url.pathname === "/api/provider-quotas" && req.method === "GET") { const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true"; + // ?provider= probes only that provider's upstream — same reports[] shape, + // so clients merge single-provider and full responses identically. + const providerName = url.searchParams.get("provider")?.trim(); + if (providerName) { + if (!hasOwnProvider(config.providers, providerName)) return jsonResponse({ error: "unknown provider" }, 404); + const { generatedAt, report } = await fetchSingleProviderQuotaReport(config, providerName); + return jsonResponse({ generatedAt, reports: report ? [report] : [] }); + } return jsonResponse(await fetchProviderQuotaReports(config, forceRefresh)); } @@ -787,6 +795,14 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon } if (url.pathname === "/api/models" && req.method === "GET") { + // Per-provider scoping for the dashboard: ?provider= filters the response to one + // provider, ?refresh=1 drops that provider's TTL cache first so the fetch is live. Other + // providers keep serving from their cache, so one slow/broken upstream never blocks the rest. + const providerFilter = url.searchParams.get("provider")?.trim() || null; + if (url.searchParams.get("refresh") === "1") { + const { clearModelCache } = await import("../codex/model-cache"); + clearModelCache(providerFilter ?? undefined); + } const models = await fetchAllModels(config); const disabled = new Set(config.disabledModels ?? []); // Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced @@ -827,7 +843,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}), }; }).filter(Boolean); - return jsonResponse([...native, ...dedupedRouted, ...customModels]); + const rows = [...native, ...dedupedRouted, ...customModels]; + return jsonResponse(providerFilter ? rows.filter(row => row?.provider === providerFilter) : rows); } if (url.pathname === "/api/provider-context-caps" && req.method === "GET") { From 08add2a7089f490554423ca9c1a3f63214b1b2b1 Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Fri, 24 Jul 2026 09:46:57 +0000 Subject: [PATCH 20/57] fix(claude): drop stray conflict marker and complete desktop i18n for ja/ru Co-authored-by: Codesmith --- gui/src/i18n/de.ts | 1 - gui/src/i18n/en.ts | 1 - gui/src/i18n/ja.ts | 56 ++++++++++++++++++++++++++++++++++++++++++++ gui/src/i18n/ko.ts | 1 - gui/src/i18n/ru.ts | 58 ++++++++++++++++++++++++++++++++++++++++++++-- gui/src/i18n/zh.ts | 1 - 6 files changed, 112 insertions(+), 6 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 84d6fa591..d976ac80d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1005,7 +1005,6 @@ export const de = { "cws.err.invalidStickyLimit": "Sticky-Erfolge müssen eine Ganzzahl von 1 bis 100 sein.", "cws.err.invalidWeight": "Jedes Round-Robin-Gewicht muss eine Ganzzahl von 1 bis 10000 sein.", "cws.err.noEnabledTarget": "Mindestens ein Ziel muss einen aktivierten Anbieter verwenden.", -||||||| parent of 3ba69135 (feat(gui): add Claude Desktop family editor) "claude.tabsLabel": "Claude-Client", "claude.tabCode": "Code", "claude.tabDesktop": "Desktop", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 49ef28b0a..3f50f4633 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1026,7 +1026,6 @@ export const en = { "cws.err.invalidStickyLimit": "Sticky successes must be an integer from 1 to 100.", "cws.err.invalidWeight": "Each round-robin weight must be an integer from 1 to 10000.", "cws.err.noEnabledTarget": "At least one target must use an enabled provider.", -||||||| parent of 3ba69135 (feat(gui): add Claude Desktop family editor) "claude.tabsLabel": "Claude client", "claude.tabCode": "Code", "claude.tabDesktop": "Desktop", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 51615117f..d0f4059d1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1026,4 +1026,60 @@ export const ja: Record = { "pws.col.share": "Share", "pws.tokenInput": "Input", "pws.tokenOutput": "Output", + "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 8190185b8..65d179fdb 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1025,7 +1025,6 @@ export const ko: Record = { "cws.err.invalidStickyLimit": "sticky 성공 횟수는 1~100의 정수여야 합니다.", "cws.err.invalidWeight": "각 라운드로빈 가중치는 1~10000의 정수여야 합니다.", "cws.err.noEnabledTarget": "하나 이상의 대상이 활성화된 프로바이더를 사용해야 합니다.", -||||||| parent of 3ba69135 (feat(gui): add Claude Desktop family editor) "claude.tabsLabel": "Claude 클라이언트", "claude.tabCode": "Code", "claude.tabDesktop": "Desktop", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0b36bd603..8e2cbab71 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1026,6 +1026,60 @@ export const ru: Record = { "cws.err.invalidStickyLimit": "Число успешных запросов до ротации должно быть целым от 1 до 100.", "cws.err.invalidWeight": "Каждый вес round-robin должен быть целым числом от 1 до 10000.", "cws.err.noEnabledTarget": "Хотя бы одна цель должна использовать включённого провайдера.", - - + "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/zh.ts b/gui/src/i18n/zh.ts index 5a9d1de94..21f0fb0dc 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1025,7 +1025,6 @@ export const zh: Record = { "cws.err.invalidStickyLimit": "粘性成功次数必须是 1 到 100 的整数。", "cws.err.invalidWeight": "每个轮询权重必须是 1 到 10000 的整数。", "cws.err.noEnabledTarget": "至少一个目标必须使用已启用的提供方。", -||||||| parent of 3ba69135 (feat(gui): add Claude Desktop family editor) "claude.tabsLabel": "Claude 客户端", "claude.tabCode": "Code", "claude.tabDesktop": "Desktop", From 05d8801183a9f9c034c700635c33d51d50d6d26d Mon Sep 17 00:00:00 2001 From: OnlineChef Date: Fri, 24 Jul 2026 09:52:31 +0000 Subject: [PATCH 21/57] fix(gui): respect locale, persist theme, honor legacy deep-link targets - App: re-apply saved theme on mount instead of wiping data-theme - App: route legacy deep links (#codex-auth/#api/#claude/#combos/#subagents) through a per-page sub-target so bookmarks open the right tab/section - App/Modellen/Systeem/Verkeer: drive all visible copy through t() and add the backing keys to every locale dict (en/nl/de/ko/zh/ru/ja) - management-api: return non-OK when a single-provider quota probe yields no report so the GUI can show the refresh-failure state Co-authored-by: Codesmith --- gui/src/App.tsx | 98 ++++++++++++++++++++-------------- gui/src/i18n/de.ts | 50 +++++++++++++++++ gui/src/i18n/en.ts | 50 +++++++++++++++++ gui/src/i18n/ja.ts | 50 +++++++++++++++++ gui/src/i18n/ko.ts | 50 +++++++++++++++++ gui/src/i18n/nl.ts | 50 +++++++++++++++++ gui/src/i18n/ru.ts | 50 +++++++++++++++++ gui/src/i18n/zh.ts | 50 +++++++++++++++++ gui/src/pages/Instellingen.tsx | 6 +-- gui/src/pages/Modellen.tsx | 32 ++++++----- gui/src/pages/Systeem.tsx | 84 ++++++++++++++++------------- gui/src/pages/Verkeer.tsx | 51 +++++++++--------- src/server/management-api.ts | 6 ++- 13 files changed, 510 insertions(+), 117 deletions(-) diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 5e6795aa7..f06e1840d 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -3,9 +3,10 @@ import Providers from "./pages/Providers"; import Modellen from "./pages/Modellen"; import Verkeer from "./pages/Verkeer"; import Systeem from "./pages/Systeem"; -import InstellingenSheet from "./pages/Instellingen"; +import InstellingenSheet, { applyTheme, readTheme } from "./pages/Instellingen"; import { IconSettings } from "./icons"; import { installApiAuthFetch } from "./api"; +import { useT, type TKey } from "./i18n/shared"; installApiAuthFetch(); @@ -13,60 +14,75 @@ type Page = "leveranciers" | "modellen" | "verkeer" | "systeem"; const VALID_PAGES = new Set(["leveranciers", "modellen", "verkeer", "systeem"]); -/** Legacy deep links from the old 11-page shell land on the view that absorbed them. */ -const LEGACY_ROUTES: Record = { - dashboard: "systeem", - providers: "leveranciers", - models: "modellen", - combos: "modellen", - subagents: "modellen", - logs: "verkeer", - debug: "verkeer", - usage: "verkeer", - storage: "systeem", - "codex-auth": "systeem", - api: "systeem", - claude: "systeem", +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: #/[/]. + */ +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" }, }; -function readPageFromHash(): Page { +function readRouteFromHash(): Route { const raw = location.hash.replace(/^#\/?/, ""); - const head = raw.split("/")[0]; - if (VALID_PAGES.has(head as Page)) return head as Page; - return LEGACY_ROUTES[head] ?? "leveranciers"; + 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" }; +} + +function canonicalHash(route: Route): string { + return route.target ? `${route.page}/${route.target}` : route.page; } const API_BASE = import.meta.env.VITE_API_BASE || ""; -const NAV: { id: Page; label: string }[] = [ - { id: "leveranciers", label: "Leveranciers" }, - { id: "modellen", label: "Modellen" }, - { id: "verkeer", label: "Verkeer" }, - { id: "systeem", label: "Systeem" }, +const NAV: { id: Page; labelKey: TKey }[] = [ + { id: "leveranciers", labelKey: "nav.providers" }, + { id: "modellen", labelKey: "nav.models" }, + { id: "verkeer", labelKey: "shell.navTraffic" }, + { id: "systeem", labelKey: "shell.navSystem" }, ]; interface HealthData { status: string; version: string; uptime: number } export default function App() { - const [page, setPageState] = useState(readPageFromHash); + const t = useT(); + const [route, setRoute] = useState(readRouteFromHash); const [health, setHealth] = useState(null); const [healthFailed, setHealthFailed] = useState(false); const [sheetOpen, setSheetOpen] = useState(false); + const page = route.page; - // One identity: the old dark theme attribute no longer applies. + // Re-apply the theme the user saved in Settings (persisted in localStorage) on mount. The old + // code blindly removed data-theme, which wiped the persisted choice and reset to system on load. useEffect(() => { - document.documentElement.removeAttribute("data-theme"); + applyTheme(readTheme()); }, []); useEffect(() => { const onHash = () => { - const next = readPageFromHash(); + const next = readRouteFromHash(); const raw = location.hash.replace(/^#\/?/, ""); - if (raw.split("/")[0] !== next) { - location.hash = next; + const canonical = canonicalHash(next); + if (raw !== canonical) { + location.hash = canonical; return; } - setPageState(next); + setRoute(next); }; onHash(); window.addEventListener("hashchange", onHash); @@ -99,8 +115,8 @@ export default function App() { opencodex {health?.version && v{health.version}} -
+ )} + {attentionCount > 0 && (

diff --git a/gui/src/components/provider-workspace/ProviderRail.tsx b/gui/src/components/provider-workspace/ProviderRail.tsx index ac84614d4..0c75d9d63 100644 --- a/gui/src/components/provider-workspace/ProviderRail.tsx +++ b/gui/src/components/provider-workspace/ProviderRail.tsx @@ -60,12 +60,14 @@ export function ProviderIcon({ name, adapter, baseUrl, cls }: { ); } -export function RailRow({ item, selected, tabbable, modelCount, isDefault, showConfigId, onClick, onFocus }: { +export function RailRow({ item, selected, tabbable, modelCount, isDefault, capped, showConfigId, onClick, onFocus }: { item: WorkspaceItem; selected: boolean; tabbable: boolean; modelCount?: number; isDefault?: boolean; + /** Weekly/inference hard-cap cooldown active for this provider. */ + capped?: boolean; /** When display names collide (e.g. openai + chatgpt → ChatGPT), show the config id. */ showConfigId?: boolean; onClick: () => void; @@ -74,7 +76,7 @@ export function RailRow({ item, selected, tabbable, modelCount, isDefault, showC const t = useT(); const free = isFreeProvider(item); const local = isLocalProvider(item); - const status = statusLabel(item, t); + const status = capped ? t("pws.capCooldown.badge") : statusLabel(item, t); const displayName = formatProviderDisplayName(item.name); const nameTitle = showConfigId ? `${displayName} (${item.name})` : displayName; const suffix = `${isDefault ? t("pws.rail.suffixDefault") : ""}${local ? t("pws.rail.suffixLocal") : free ? t("pws.rail.suffixFree") : ""}`; @@ -85,7 +87,7 @@ export function RailRow({ item, selected, tabbable, modelCount, isDefault, showC return ( ); diff --git a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx index 952ec4c4b..b5ddd5568 100644 --- a/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx +++ b/gui/src/components/provider-workspace/ProviderWorkspaceShell.tsx @@ -42,6 +42,8 @@ export interface DetailSlotData { modelsLoading: boolean; modelsLoadFailed: boolean; onRetryModels?: () => void; + /** Active weekly/inference-cap cooldown for this provider, if any. */ + capCooldown?: import("../../pages/providers-shared").ProviderCapCooldown; } const SORT_DEFS: { id: ProviderSortMode; labelKey: "pws.sort.az" | "pws.sort.za" | "pws.sort.freePaid" | "pws.sort.paidFree" | "pws.sort.accountsFirst" }[] = [ @@ -65,6 +67,7 @@ export default function ProviderWorkspaceShell({ jsonSaving = false, modelsRefreshToken = 0, activeAccountNeedsReauth, + providerCooldowns, detail, }: { providers: Record; @@ -81,6 +84,8 @@ export default function ProviderWorkspaceShell({ /** Bump after login/config changes so /api/selected-models is refetched. */ modelsRefreshToken?: number; activeAccountNeedsReauth?: Record; + /** Active weekly/inference-cap cooldowns from /api/config. */ + providerCooldowns?: Record; /** Detail body for the selected provider (WP090); a placeholder renders when absent. */ detail?: (item: WorkspaceItem, data: DetailSlotData) => ReactNode; }) { @@ -440,6 +445,7 @@ export default function ProviderWorkspaceShell({ tabbable={railTabbableName === item.name} modelCount={modelCounts[item.name]} isDefault={defaultProvider === item.name} + capped={Boolean(providerCooldowns?.[item.name])} showConfigId={duplicateDisplayNames.has(formatProviderDisplayName(item.name))} onClick={() => onSelect(item.name)} onFocus={() => setRailFocusName(item.name)} @@ -491,6 +497,7 @@ export default function ProviderWorkspaceShell({ modelsLoading, modelsLoadFailed, onRetryModels: retryModels, + capCooldown: providerCooldowns?.[selectedItem.name], }) ?? (

{formatProviderDisplayName(selectedItem.name)}

@@ -505,6 +512,7 @@ export default function ProviderWorkspaceShell({ sections={sections} quotaReports={quotaReports} usageTotals={usageTotals} + providerCooldowns={providerCooldowns} onSelectProvider={(name) => onSelect(name)} onEditConfig={onEditConfig} /> diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 031bb46bf..aaf8d4f49 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1017,6 +1017,13 @@ export const en = { "pws.dashboard.noQuota": "No quota data", "pws.dashboard.noUsage": "No usage data yet", "pws.dashboard.noRateLimits": "No rate-limit data yet", + "pws.capCooldown.title": "Weekly usage limit", + "pws.capCooldown.section": "Usage caps", + "pws.capCooldown.banner": "{provider} hit a weekly/inference cap. {reset}", + "pws.capCooldown.disabled": "Temporarily disabled until the limit resets.", + "pws.capCooldown.paused": "Routing for this provider is paused until then.", + "pws.capCooldown.badge": "Capped", + "pws.attention.capCooldown": "Weekly/inference limit — {reset}", "pws.allProviders": "Provider Overview", "pws.enabledLabel": "Enabled", "pws.testConnection": "Test connection", diff --git a/gui/src/i18n/nl.ts b/gui/src/i18n/nl.ts index 25f115569..24324fac7 100644 --- a/gui/src/i18n/nl.ts +++ b/gui/src/i18n/nl.ts @@ -195,6 +195,15 @@ const overrides: Partial> = { "vk.detailStatus": "status {status}", "vk.detailUpstream": "upstream: {error}", "vk.detailId": "id {id}", + + // provider weekly/inference caps (Clinepass etc.) + "pws.capCooldown.title": "Wekelijks limiet", + "pws.capCooldown.section": "Verbruikslimieten", + "pws.capCooldown.banner": "{provider} zit aan een wekelijks/inference-plafond. {reset}", + "pws.capCooldown.disabled": "Tijdelijk uitgeschakeld tot de limiet reset.", + "pws.capCooldown.paused": "Routing voor deze leverancier staat tot die tijd stil.", + "pws.capCooldown.badge": "Vol", + "pws.attention.capCooldown": "Wekelijks/inference-limiet — {reset}", }; export const nl: Record = { ...en, ...overrides }; diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 0a369195b..91fb89aaf 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -91,8 +91,12 @@ export default function Providers({ apiBase }: { apiBase: string }) { void fetchOauth(); void fetchProviderQuotas(); }, 0); + const interval = window.setInterval(() => { + void fetchConfig(); + }, 60_000); return () => { window.clearTimeout(timeout); + window.clearInterval(interval); }; }, [fetchConfig, fetchOauth, fetchProviderQuotas]); @@ -191,6 +195,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { onSelect={setWorkspaceSelected} onAddProvider={intent => { setAddIntent(intent ?? null); setAdding(true); }} onEditConfig={openJsonEditor} + providerCooldowns={config.providerCooldowns} jsonEditor={{ open: jsonEditorOpen, draft, @@ -218,6 +223,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { modelsLoading={data.modelsLoading} modelsLoadFailed={data.modelsLoadFailed} onRetryModels={data.onRetryModels} + capCooldown={data.capCooldown} oauthEmail={loginStatus?.email} onDeselect={() => setWorkspaceSelected(null)} apiBase={apiBase} diff --git a/gui/src/pages/providers-shared.ts b/gui/src/pages/providers-shared.ts index 34ebbc825..49235b0f6 100644 --- a/gui/src/pages/providers-shared.ts +++ b/gui/src/pages/providers-shared.ts @@ -15,6 +15,18 @@ export interface ProvidersConfig { note?: string; codexAccountMode?: "direct" | "pool"; }>; + /** Active weekly/inference-cap cooldowns from /api/config (until > now). */ + providerCooldowns?: Record; +} + +/** Hard-cap cooldown entry exposed by safeConfigDTO. */ +export interface ProviderCapCooldown { + until: number; + reason: string; + message: string; + source?: string; + disabledProvider?: boolean; + recordedAt?: number; } export interface OAuthStatus { diff --git a/gui/src/styles/provider-overview-dashboard.css b/gui/src/styles/provider-overview-dashboard.css index fdb7c3997..2350b1453 100644 --- a/gui/src/styles/provider-overview-dashboard.css +++ b/gui/src/styles/provider-overview-dashboard.css @@ -94,7 +94,8 @@ padding-bottom: 2px; } -.pws-dashboard-attention .pws-dashboard-section-title { +.pws-dashboard-attention .pws-dashboard-section-title, +.pws-dashboard-caps .pws-dashboard-section-title { display: inline-flex; align-items: center; gap: 6px; diff --git a/gui/src/styles/provider-workspace-shell.css b/gui/src/styles/provider-workspace-shell.css index cf5e7d93f..513369ca4 100644 --- a/gui/src/styles/provider-workspace-shell.css +++ b/gui/src/styles/provider-workspace-shell.css @@ -368,6 +368,28 @@ border-color: var(--amber); } +.pwi-rail-badge--capped { + color: var(--yellow, #eab308); + border-color: var(--yellow, #eab308); +} + +.providers-workspace-rail-row--capped .providers-workspace-rail-name-label { + opacity: 0.9; +} + +.pws-cap-cooldown { + display: flex; + gap: 10px; + align-items: flex-start; + margin-bottom: 12px; +} + +.pws-cap-cooldown-msg { + margin-top: 6px; + font-size: 0.8rem; + word-break: break-word; +} + .providers-workspace-rail-trail { display: inline-flex; align-items: center; diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index c8ab41a7c..5fa0ecee8 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -224,6 +224,14 @@ export const CURSOR_STATIC_MODELS: readonly CursorModelInfo[] = normalizeCursorM // take PR #73's supportsReasoningEffort for glm-5.2 (its effort-map tiers landed with the PR). { id: "glm-5.2", contextWindow: CONTEXT_1M, supportsReasoningEffort: true }, { id: "kimi-k2.7-code", contextWindow: CONTEXT_262K }, + // Live GetUsableModels exposes kimi-k3-{high,low,max}; bare id seeds catalog + effort picker. + { id: "kimi-k3", contextWindow: CONTEXT_262K, supportsReasoningEffort: true }, + { id: "claude-opus-5-thinking", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, + { id: "claude-opus-4-8-thinking", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, + { id: "claude-opus-4-7-thinking", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, + { id: "claude-fable-5-thinking", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, + { id: "claude-sonnet-5-thinking", contextWindow: CONTEXT_200K, supportsReasoningEffort: true }, + { id: "gemini-3.6-flash", contextWindow: CONTEXT_GEMINI }, { id: "grok-4.5", contextWindow: 500_000, supportsReasoningEffort: true }, { id: "grok-4.5-fast", contextWindow: 500_000, supportsReasoningEffort: true }, diff --git a/src/providers/cap-cooldown.ts b/src/providers/cap-cooldown.ts new file mode 100644 index 000000000..bdbd3874f --- /dev/null +++ b/src/providers/cap-cooldown.ts @@ -0,0 +1,129 @@ +/** + * Provider-level weekly/inference-cap cooldowns. + * + * When upstream returns a hard weekly/inference cap (e.g. Clinepass INFERENCE_CAP_ERROR + * with "resets in Nd Nh"), we persist a cooldown on the provider, optionally disable it, + * and surface the message in the GUI via /api/config.providerCooldowns. + */ +import { loadConfig, saveConfigPreservingClaudeCode } from "../config"; +import type { OcxConfig } from "../types"; + +export interface ProviderCapCooldown { + until: number; + reason: string; + message: string; + source: string; + disabledProvider?: boolean; + recordedAt?: number; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +const HOUR_MS = 60 * 60 * 1000; +const MIN_MS = 60 * 1000; + +/** Parse "resets in 1d 22h" / "resets in 2d" / "resets in 3h" style phrases. */ +export function parseResetsInMs(message: string, now = Date.now()): number | undefined { + const text = message.replace(/\s+/g, " "); + const m = text.match(/resets?\s+in\s+(?:(\d+)\s*d(?:ays?)?)?(?:\s*)?(?:(\d+)\s*h(?:ours?)?)?(?:\s*)?(?:(\d+)\s*m(?:in(?:utes?)?)?)?/i); + if (!m) return undefined; + const days = Number(m[1] || 0); + const hours = Number(m[2] || 0); + const mins = Number(m[3] || 0); + if (!Number.isFinite(days) || !Number.isFinite(hours) || !Number.isFinite(mins)) return undefined; + if (days === 0 && hours === 0 && mins === 0) return undefined; + return now + days * DAY_MS + hours * HOUR_MS + mins * MIN_MS; +} + +export function isHardCapMessage(status: number, upstreamError?: string): boolean { + if (status !== 429 && status !== 402) return false; + const text = (upstreamError || "").toLowerCase(); + return ( + text.includes("inference_cap") + || text.includes("weekly") && (text.includes("limit") || text.includes("cap")) + || text.includes("usage limit") + || text.includes("package has expired") + || text.includes("out of usage") + ); +} + +export function activeProviderCooldowns( + config: OcxConfig, + now = Date.now(), +): Record { + const raw = (config as OcxConfig & { providerCooldowns?: Record }).providerCooldowns; + if (!raw || typeof raw !== "object") return {}; + const out: Record = {}; + for (const [name, entry] of Object.entries(raw)) { + if (!entry || typeof entry.until !== "number" || !Number.isFinite(entry.until)) continue; + if (entry.until <= now) continue; + out[name] = entry; + } + return out; +} + +/** + * Expire stale cooldowns. Re-enables providers we auto-disabled when the window ends. + * Returns true when config was mutated. + */ +export function expireProviderCooldowns(config: OcxConfig, now = Date.now()): boolean { + const bag = (config as OcxConfig & { providerCooldowns?: Record }).providerCooldowns; + if (!bag) return false; + let changed = false; + for (const [name, entry] of Object.entries(bag)) { + if (!entry || typeof entry.until !== "number" || entry.until > now) continue; + delete bag[name]; + changed = true; + if (entry.disabledProvider && config.providers[name]?.disabled === true) { + delete config.providers[name].disabled; + } + } + if (Object.keys(bag).length === 0) { + delete (config as OcxConfig & { providerCooldowns?: unknown }).providerCooldowns; + } + return changed; +} + +/** Record a hard-cap 429 onto the provider and optionally disable it until reset. */ +export function recordProviderCapCooldown( + providerName: string, + status: number, + upstreamError: string | undefined, + opts?: { disable?: boolean; now?: number }, +): ProviderCapCooldown | null { + if (!providerName || !isHardCapMessage(status, upstreamError)) return null; + const now = opts?.now ?? Date.now(); + const message = (upstreamError || "Usage limit reached").slice(0, 400); + const until = parseResetsInMs(message, now) ?? (now + DAY_MS); // default 24h if unparsed + const reason = /inference_cap/i.test(message) + ? "INFERENCE_CAP_ERROR" + : /weekly/i.test(message) + ? "weekly_usage_limit" + : "usage_cap"; + + const config = loadConfig(); + expireProviderCooldowns(config, now); + const bag = ((config as OcxConfig & { providerCooldowns?: Record }).providerCooldowns + ??= {}); + const prev = bag[providerName]; + // Don't shorten an existing longer cooldown. + if (prev && prev.until > until) return prev; + + const disable = opts?.disable !== false; // default: temporarily disable + const entry: ProviderCapCooldown = { + until, + reason, + message, + source: "upstream-429", + disabledProvider: disable, + recordedAt: now, + }; + bag[providerName] = entry; + if (disable && config.providers[providerName] && config.defaultProvider !== providerName) { + config.providers[providerName].disabled = true; + } + saveConfigPreservingClaudeCode(config); + console.warn( + `[opencodex] Provider cap cooldown set provider=${providerName} until=${new Date(until).toISOString()} reason=${reason}`, + ); + return entry; +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index a5a8aaacf..dd64c0d4e 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -336,6 +336,18 @@ export function safeConfigDTO(config: OcxConfig): unknown { if (codexAccountMode) dto.codexAccountMode = codexAccountMode; providers[name] = dto; } + const cooldowns = (config as { providerCooldowns?: Record }).providerCooldowns; + const activeCooldowns: Record = {}; + const now = Date.now(); + if (cooldowns && typeof cooldowns === "object") { + for (const [name, entry] of Object.entries(cooldowns)) { + if (!entry || typeof entry !== "object") continue; + const until = (entry as { until?: unknown }).until; + if (typeof until === "number" && Number.isFinite(until) && until > now) { + activeCooldowns[name] = entry; + } + } + } return { port: config.port, hostname: config.hostname ?? "127.0.0.1", @@ -343,5 +355,6 @@ export function safeConfigDTO(config: OcxConfig): unknown { codexAutoStart: codexAutoStartEnabled(config), websockets: config.websockets, providers, + ...(Object.keys(activeCooldowns).length > 0 ? { providerCooldowns: activeCooldowns } : {}), }; } diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index db829c13e..70b14eafa 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -67,6 +67,15 @@ import type { ManagementContext } from "./context"; export async function handleConfigRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; if (url.pathname === "/api/config" && req.method === "GET") { + try { + const { expireProviderCooldowns } = await import("../../providers/cap-cooldown"); + if (expireProviderCooldowns(config)) { + const { saveConfigPreservingClaudeCode: save } = await import("../../config"); + save(config); + } + } catch { + /* best-effort expiry */ + } return jsonResponse(safeConfigDTO(config)); } diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 854837b32..070e39395 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -30,6 +30,7 @@ import { type UsageDebugBodyKind, } from "../usage/debug"; import { matchesLogConversationId } from "./request-log-conversation"; +import { recordProviderCapCooldown } from "../providers/cap-cooldown"; export interface RequestLogContext { model: string; @@ -661,6 +662,14 @@ export function addFinalRequestLog( ? 499 : status; const errorCode = requestLogErrorCode(effectiveStatus, logCtx.upstreamError); + // Hard weekly/inference caps: persist a provider cooldown and optionally disable until reset. + if ((effectiveStatus === 429 || effectiveStatus === 402) && logCtx.provider && logCtx.provider !== "combo") { + try { + recordProviderCapCooldown(logCtx.provider, effectiveStatus, logCtx.upstreamError); + } catch { + /* best-effort: never break request finalization */ + } + } // A response.failed whose classified status is 499 is still a client cancel, not an upstream // terminal failure — keep /api/logs closeReason aligned with that. const closeReason = effectiveStatus === 499 diff --git a/tests/cap-cooldown.test.ts b/tests/cap-cooldown.test.ts new file mode 100644 index 000000000..9928b0165 --- /dev/null +++ b/tests/cap-cooldown.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { + isHardCapMessage, + parseResetsInMs, +} from "../src/providers/cap-cooldown"; + +describe("parseResetsInMs", () => { + test("parses Clinepass-style 'resets in 1d 22h'", () => { + const now = Date.UTC(2026, 6, 31, 0, 0, 0); + const until = parseResetsInMs("Error 429: You have reached your weekly Clinepass limit. The limit resets in 1d 22h, please try again later.", now); + expect(until).toBe(now + (1 * 24 + 22) * 60 * 60 * 1000); + }); + + test("parses hours-only", () => { + const now = 1_000_000; + expect(parseResetsInMs("resets in 3h", now)).toBe(now + 3 * 60 * 60 * 1000); + }); + + test("returns undefined when no match", () => { + expect(parseResetsInMs("rate limited, try later")).toBeUndefined(); + }); +}); + +describe("isHardCapMessage", () => { + test("detects INFERENCE_CAP_ERROR", () => { + expect(isHardCapMessage(429, '{"code":"INFERENCE_CAP_ERROR","message":"weekly Clinepass limit"}')).toBe(true); + }); + + test("ignores ordinary rate limits", () => { + expect(isHardCapMessage(429, "Too many requests")).toBe(false); + }); + + test("ignores non-429", () => { + expect(isHardCapMessage(500, "INFERENCE_CAP_ERROR")).toBe(false); + }); +}); From 1a8d6e0207dcd4dd1f091e369409968981c67416 Mon Sep 17 00:00:00 2001 From: GroepChef Date: Fri, 31 Jul 2026 03:11:07 +0200 Subject: [PATCH 49/57] fix(providers): bind cap cooldowns to the live server config Record/expire weekly inference caps on the startServer config object so routing and /api/config see disables without restart, tighten hard-cap detection, and keep GUI messages short. --- src/providers/cap-cooldown.ts | 153 +++++++++++++++++-------- src/server/auth-cors.ts | 14 +-- src/server/index.ts | 4 + src/server/management/config-routes.ts | 12 +- src/server/request-log.ts | 6 +- src/types.ts | 17 +++ tests/cap-cooldown.test.ts | 84 ++++++++++++++ 7 files changed, 218 insertions(+), 72 deletions(-) diff --git a/src/providers/cap-cooldown.ts b/src/providers/cap-cooldown.ts index bdbd3874f..be0f74e67 100644 --- a/src/providers/cap-cooldown.ts +++ b/src/providers/cap-cooldown.ts @@ -2,25 +2,29 @@ * Provider-level weekly/inference-cap cooldowns. * * When upstream returns a hard weekly/inference cap (e.g. Clinepass INFERENCE_CAP_ERROR - * with "resets in Nd Nh"), we persist a cooldown on the provider, optionally disable it, - * and surface the message in the GUI via /api/config.providerCooldowns. + * with "resets in Nd Nh"), we persist a cooldown on the LIVE server config, optionally + * disable the provider, and surface a short summary via /api/config.providerCooldowns. + * + * Callers must pass the live `OcxConfig` instance owned by `startServer` — never a fresh + * `loadConfig()` snapshot — so routing and management see the change immediately. */ -import { loadConfig, saveConfigPreservingClaudeCode } from "../config"; -import type { OcxConfig } from "../types"; - -export interface ProviderCapCooldown { - until: number; - reason: string; - message: string; - source: string; - disabledProvider?: boolean; - recordedAt?: number; -} +import { saveConfigPreservingClaudeCode } from "../config"; +import type { OcxConfig, ProviderCapCooldown } from "../types"; + +export type { ProviderCapCooldown }; const DAY_MS = 24 * 60 * 60 * 1000; const HOUR_MS = 60 * 60 * 1000; const MIN_MS = 60 * 1000; +/** Live server config bound at startServer; used when the request-log path has no config arg. */ +let liveConfig: OcxConfig | null = null; + +/** Bind the long-lived server config so cooldown recording mutates routing state. */ +export function bindProviderCapCooldownConfig(config: OcxConfig): void { + liveConfig = config; +} + /** Parse "resets in 1d 22h" / "resets in 2d" / "resets in 3h" style phrases. */ export function parseResetsInMs(message: string, now = Date.now()): number | undefined { const text = message.replace(/\s+/g, " "); @@ -34,23 +38,37 @@ export function parseResetsInMs(message: string, now = Date.now()): number | und return now + days * DAY_MS + hours * HOUR_MS + mins * MIN_MS; } +/** + * Strong hard-cap signal only: INFERENCE_CAP / weekly+limit with a parseable reset, + * or explicit package-expired / out-of-usage phrases. Bare "usage limit" 429s are ignored + * so temporary rate limits do not auto-disable a provider for 24h. + */ export function isHardCapMessage(status: number, upstreamError?: string): boolean { if (status !== 429 && status !== 402) return false; const text = (upstreamError || "").toLowerCase(); - return ( - text.includes("inference_cap") - || text.includes("weekly") && (text.includes("limit") || text.includes("cap")) - || text.includes("usage limit") - || text.includes("package has expired") - || text.includes("out of usage") - ); + if (text.includes("inference_cap")) return true; + if (text.includes("package has expired") || text.includes("out of usage")) return true; + const weekly = text.includes("weekly") && (text.includes("limit") || text.includes("cap")); + if (weekly && parseResetsInMs(upstreamError || "") !== undefined) return true; + return false; +} + +/** Map log labels like `openai-work` back to a config.providers key. */ +export function resolveProviderConfigKey(config: OcxConfig, logProvider: string): string | null { + if (!logProvider || logProvider === "combo") return null; + if (config.providers[logProvider]) return logProvider; + const names = Object.keys(config.providers).sort((a, b) => b.length - a.length); + for (const name of names) { + if (logProvider.startsWith(`${name}-`)) return name; + } + return null; } export function activeProviderCooldowns( config: OcxConfig, now = Date.now(), ): Record { - const raw = (config as OcxConfig & { providerCooldowns?: Record }).providerCooldowns; + const raw = config.providerCooldowns; if (!raw || typeof raw !== "object") return {}; const out: Record = {}; for (const [name, entry] of Object.entries(raw)) { @@ -62,11 +80,11 @@ export function activeProviderCooldowns( } /** - * Expire stale cooldowns. Re-enables providers we auto-disabled when the window ends. + * Expire stale cooldowns. Re-enables providers this path auto-disabled when the window ends. * Returns true when config was mutated. */ export function expireProviderCooldowns(config: OcxConfig, now = Date.now()): boolean { - const bag = (config as OcxConfig & { providerCooldowns?: Record }).providerCooldowns; + const bag = config.providerCooldowns; if (!bag) return false; let changed = false; for (const [name, entry] of Object.entries(bag)) { @@ -78,52 +96,91 @@ export function expireProviderCooldowns(config: OcxConfig, now = Date.now()): bo } } if (Object.keys(bag).length === 0) { - delete (config as OcxConfig & { providerCooldowns?: unknown }).providerCooldowns; + delete config.providerCooldowns; } return changed; } -/** Record a hard-cap 429 onto the provider and optionally disable it until reset. */ +export interface RecordProviderCapCooldownOpts { + disable?: boolean; + now?: number; + /** Persist to disk (default true). */ + save?: boolean; +} + +/** Record a hard-cap 429/402 onto the live provider config and optionally disable until reset. */ export function recordProviderCapCooldown( + config: OcxConfig, providerName: string, status: number, upstreamError: string | undefined, - opts?: { disable?: boolean; now?: number }, + opts?: RecordProviderCapCooldownOpts, ): ProviderCapCooldown | null { - if (!providerName || !isHardCapMessage(status, upstreamError)) return null; + const key = resolveProviderConfigKey(config, providerName); + if (!key || !isHardCapMessage(status, upstreamError)) return null; const now = opts?.now ?? Date.now(); - const message = (upstreamError || "Usage limit reached").slice(0, 400); - const until = parseResetsInMs(message, now) ?? (now + DAY_MS); // default 24h if unparsed - const reason = /inference_cap/i.test(message) + const rawMessage = (upstreamError || "Usage limit reached").slice(0, 400); + const until = parseResetsInMs(rawMessage, now); + if (until === undefined && !/inference_cap/i.test(rawMessage) + && !/package has expired/i.test(rawMessage) + && !/out of usage/i.test(rawMessage)) { + // Weekly phrases without a parseable reset are not strong enough to auto-pause. + return null; + } + const untilMs = until ?? (now + DAY_MS); + const reason = /inference_cap/i.test(rawMessage) ? "INFERENCE_CAP_ERROR" - : /weekly/i.test(message) + : /weekly/i.test(rawMessage) ? "weekly_usage_limit" : "usage_cap"; + // Short GUI-safe summary — avoid echoing long upstream bodies (emails, org ids). + const message = reason === "INFERENCE_CAP_ERROR" + ? "Weekly inference cap reached." + : reason === "weekly_usage_limit" + ? "Weekly usage limit reached." + : "Usage cap reached."; - const config = loadConfig(); expireProviderCooldowns(config, now); - const bag = ((config as OcxConfig & { providerCooldowns?: Record }).providerCooldowns - ??= {}); - const prev = bag[providerName]; - // Don't shorten an existing longer cooldown. - if (prev && prev.until > until) return prev; + const bag = (config.providerCooldowns ??= {}); + const prev = bag[key]; + // Don't shorten an existing longer cooldown; still persist expiry side-effects below if needed. + if (prev && prev.until > untilMs) { + if (opts?.save !== false) saveConfigPreservingClaudeCode(config); + return prev; + } + + const wantDisable = opts?.disable !== false; + const didDisable = wantDisable + && !!config.providers[key] + && config.defaultProvider !== key + && config.providers[key].disabled !== true; + + if (didDisable) { + config.providers[key].disabled = true; + } - const disable = opts?.disable !== false; // default: temporarily disable const entry: ProviderCapCooldown = { - until, + until: untilMs, reason, - message, - source: "upstream-429", - disabledProvider: disable, + message: `${message} Resets ~${new Date(untilMs).toISOString()}.`, + source: status === 402 ? "upstream-402" : "upstream-429", + disabledProvider: didDisable || (prev?.disabledProvider === true && config.providers[key]?.disabled === true), recordedAt: now, }; - bag[providerName] = entry; - if (disable && config.providers[providerName] && config.defaultProvider !== providerName) { - config.providers[providerName].disabled = true; - } - saveConfigPreservingClaudeCode(config); + bag[key] = entry; + if (opts?.save !== false) saveConfigPreservingClaudeCode(config); console.warn( - `[opencodex] Provider cap cooldown set provider=${providerName} until=${new Date(until).toISOString()} reason=${reason}`, + `[opencodex] Provider cap cooldown set provider=${key} until=${new Date(untilMs).toISOString()} reason=${reason} disabled=${!!entry.disabledProvider}`, ); return entry; } + +/** Best-effort record using the bound live config (request-log hot path). */ +export function recordProviderCapCooldownLive( + providerName: string, + status: number, + upstreamError: string | undefined, +): ProviderCapCooldown | null { + if (!liveConfig) return null; + return recordProviderCapCooldown(liveConfig, providerName, status, upstreamError); +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index dd64c0d4e..e29058979 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -13,6 +13,7 @@ import { import { providerDestinationConfigError } from "../lib/destination-policy"; import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers/registry"; import { providerConfigSeed } from "../providers/derive"; +import { activeProviderCooldowns } from "../providers/cap-cooldown"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; @@ -336,18 +337,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { if (codexAccountMode) dto.codexAccountMode = codexAccountMode; providers[name] = dto; } - const cooldowns = (config as { providerCooldowns?: Record }).providerCooldowns; - const activeCooldowns: Record = {}; - const now = Date.now(); - if (cooldowns && typeof cooldowns === "object") { - for (const [name, entry] of Object.entries(cooldowns)) { - if (!entry || typeof entry !== "object") continue; - const until = (entry as { until?: unknown }).until; - if (typeof until === "number" && Number.isFinite(until) && until > now) { - activeCooldowns[name] = entry; - } - } - } + const activeCooldowns = activeProviderCooldowns(config); return { port: config.port, hostname: config.hostname ?? "127.0.0.1", diff --git a/src/server/index.ts b/src/server/index.ts index 31ec30a19..9bc946251 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -24,6 +24,7 @@ import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup" import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-startup"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { providerCodexAccountMode } from "../providers/registry"; +import { bindProviderCapCooldownConfig, expireProviderCooldowns } from "../providers/cap-cooldown"; import { CodexAccountCooldownError, cooldownErrorMessage, @@ -240,6 +241,9 @@ function attachLiveSidebandUpstream(ws: ServerWebSocket): void { export function startServer(port?: number) { const config = runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig())); + // Cap-cooldown / request-log side effects must mutate THIS live object (not a disk reload). + bindProviderCapCooldownConfig(config); + if (expireProviderCooldowns(config)) saveConfig(config); applyProxyEnv(config); assertServerAuthConfig(config); // Refresh OAuth provider presets (models/noReasoningModels) from the registry so a proxy update diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 70b14eafa..8e2dcd305 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -67,15 +67,9 @@ import type { ManagementContext } from "./context"; export async function handleConfigRoutes(ctx: ManagementContext): Promise { const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; if (url.pathname === "/api/config" && req.method === "GET") { - try { - const { expireProviderCooldowns } = await import("../../providers/cap-cooldown"); - if (expireProviderCooldowns(config)) { - const { saveConfigPreservingClaudeCode: save } = await import("../../config"); - save(config); - } - } catch { - /* best-effort expiry */ - } + const { expireProviderCooldowns } = await import("../../providers/cap-cooldown"); + const { saveConfigPreservingClaudeCode: save } = await import("../../config"); + if (expireProviderCooldowns(config)) save(config); return jsonResponse(safeConfigDTO(config)); } diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 070e39395..a30ab8026 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -30,7 +30,7 @@ import { type UsageDebugBodyKind, } from "../usage/debug"; import { matchesLogConversationId } from "./request-log-conversation"; -import { recordProviderCapCooldown } from "../providers/cap-cooldown"; +import { recordProviderCapCooldownLive } from "../providers/cap-cooldown"; export interface RequestLogContext { model: string; @@ -662,10 +662,10 @@ export function addFinalRequestLog( ? 499 : status; const errorCode = requestLogErrorCode(effectiveStatus, logCtx.upstreamError); - // Hard weekly/inference caps: persist a provider cooldown and optionally disable until reset. + // Hard weekly/inference caps: mutate the LIVE server config (bound at startServer). if ((effectiveStatus === 429 || effectiveStatus === 402) && logCtx.provider && logCtx.provider !== "combo") { try { - recordProviderCapCooldown(logCtx.provider, effectiveStatus, logCtx.upstreamError); + recordProviderCapCooldownLive(logCtx.provider, effectiveStatus, logCtx.upstreamError); } catch { /* best-effort: never break request finalization */ } diff --git a/src/types.ts b/src/types.ts index d84f3346d..5839e3c21 100644 --- a/src/types.ts +++ b/src/types.ts @@ -477,10 +477,27 @@ export interface OcxCustomModel { addedAt?: string; } +/** Hard weekly/inference-cap cooldown persisted on a provider until reset. */ +export interface ProviderCapCooldown { + until: number; + reason: string; + /** Short, secret-redacted summary suitable for /api/config + GUI. */ + message: string; + source: string; + /** True only when this path actually set `providers[name].disabled`. */ + disabledProvider?: boolean; + recordedAt?: number; +} + export interface OcxConfig { port: number; providers: Record; defaultProvider: string; + /** + * Active hard-cap cooldowns (weekly/inference). Mutated on the live server config so + * routing and /api/config see disables without restart. + */ + providerCooldowns?: Record; /** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */ openaiProviderTierVersion?: 1 | 2; /** Claude Code inbound + launcher settings. */ diff --git a/tests/cap-cooldown.test.ts b/tests/cap-cooldown.test.ts index 9928b0165..b3aadbaf5 100644 --- a/tests/cap-cooldown.test.ts +++ b/tests/cap-cooldown.test.ts @@ -1,8 +1,27 @@ import { describe, expect, test } from "bun:test"; import { + activeProviderCooldowns, + expireProviderCooldowns, isHardCapMessage, parseResetsInMs, + recordProviderCapCooldown, + resolveProviderConfigKey, } from "../src/providers/cap-cooldown"; +import type { OcxConfig } from "../src/types"; + +const HOUR_MS = 60 * 60 * 1000; + +function bareConfig(overrides?: Partial): OcxConfig { + return { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { baseUrl: "https://api.openai.com/v1", adapter: "openai-responses" }, + "cline-pass": { baseUrl: "https://api.cline.bot/v1", adapter: "openai-chat" }, + }, + ...overrides, + } as OcxConfig; +} describe("parseResetsInMs", () => { test("parses Clinepass-style 'resets in 1d 22h'", () => { @@ -26,11 +45,76 @@ describe("isHardCapMessage", () => { expect(isHardCapMessage(429, '{"code":"INFERENCE_CAP_ERROR","message":"weekly Clinepass limit"}')).toBe(true); }); + test("detects weekly limit with parseable reset", () => { + expect(isHardCapMessage(429, "weekly Clinepass limit. The limit resets in 1d 22h")).toBe(true); + }); + test("ignores ordinary rate limits", () => { expect(isHardCapMessage(429, "Too many requests")).toBe(false); }); + test("ignores bare usage-limit without reset window", () => { + expect(isHardCapMessage(429, "You hit a usage limit, slow down")).toBe(false); + }); + test("ignores non-429", () => { expect(isHardCapMessage(500, "INFERENCE_CAP_ERROR")).toBe(false); }); }); + +describe("recordProviderCapCooldown (live config)", () => { + test("mutates the given config and disables non-default provider", () => { + const config = bareConfig(); + const now = Date.UTC(2026, 6, 31, 0, 0, 0); + const entry = recordProviderCapCooldown( + config, + "cline-pass", + 429, + 'Error 429: {"code":"INFERENCE_CAP_ERROR","message":"weekly Clinepass limit. The limit resets in 1d 22h"}', + { now, save: false }, + ); + expect(entry).not.toBeNull(); + expect(config.providers["cline-pass"]?.disabled).toBe(true); + expect(config.providerCooldowns?.["cline-pass"]?.disabledProvider).toBe(true); + expect(config.providerCooldowns?.["cline-pass"]?.until).toBe(now + (1 * 24 + 22) * HOUR_MS); + expect(activeProviderCooldowns(config, now)["cline-pass"]).toBeTruthy(); + }); + + test("does not disable the default provider but still records cooldown", () => { + const config = bareConfig(); + const now = 1_000_000; + const entry = recordProviderCapCooldown( + config, + "openai", + 429, + '{"code":"INFERENCE_CAP_ERROR","message":"weekly limit"}', + { now, save: false }, + ); + expect(entry).not.toBeNull(); + expect(config.providers.openai?.disabled).toBeUndefined(); + expect(entry?.disabledProvider).toBe(false); + }); + + test("resolves openai-