diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index bb42e438a7f..2a8a26227e1 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -253,9 +253,9 @@ Support/Claude/configLibrary` sur macOS, `%APPDATA%\Claude\configLibrary` sur Wi `CLAUDE_USER_DATA_DIR` pour utiliser une autre racine de données Claude Desktop. L'ancien répertoire `Claude-3p` n'est ni lu ni supprimé automatiquement. -Les routes non Anthropic reçoivent des alias stables comme `claude-opus-4-8-YYYYMMDD`, dont l'année va de 2026 à 2035. La partie qui ressemble à une date -est un emplacement synthétique de route, et non la date de publication du modèle. Les emplacements de 2026 sont attribués en premier, de sorte que les alias -existants conservent leur identifiant ; les années suivantes ne sont utilisées qu'une fois 2026 saturée. Les véritables routes Anthropic Claude conservent +Les routes non Anthropic reçoivent des alias stables comme `claude-opus-4-8-p01q`, avec un code de quatre caractères préfixé par `p`. OpenCodex conserve +un emplacement synthétique daté en interne pour stabiliser les affectations du profil, mais n'expose pas cette date comme identifiant Desktop : les versions +actuelles de Desktop retirent les dates finales lors de la comparaison des modèles d'une session active, ce qui peut empêcher un changement. Les véritables routes Anthropic Claude conservent leur identité. Les nouvelles routes appartiennent par défaut à la famille Opus, mais déplacer une route ne change ni le fournisseur ni le modèle qu'elle appelle. Les anciens indicateurs `--static`, `--hybrid` et `--discovery-only` restent disponibles pour les scripts existants. @@ -375,10 +375,10 @@ chaque ligne du CLI Claude Code (`Routed by OpenCodex to /` ; l | Surface | Format | Exemple | | --- | --- | --- | | Claude Code CLI | `ocx-claude---` (simple) ou `ocx-claude2-…` (échappé) | `ocx-claude-native--gpt-5.6-sol` | -| Claude Desktop 3P | `claude-opus-4-8-` (hachage base36 de 3 caractères) | `claude-opus-4-8-ncb` | +| Claude Desktop 3P | `claude-opus-4-8-p` (emplacement base36 de 3 caractères) | `claude-opus-4-8-p01q` | Le proxy choisit la famille pour chaque requête : `?ids=cli` ou `?ids=desktop` est prioritaire ; à défaut, l'agent utilisateur -`claude-code/*` reçoit la forme lisible de la CLI et les autres clients reçoivent la forme hachée de Claude Desktop. +`claude-code/*` reçoit la forme lisible de la CLI et les autres clients reçoivent le code Claude Desktop. Les deux familles restent toujours décodables : un modèle enregistré sous l'une ou l'autre forme dans `settings.json` continue de fonctionner. Chaque entrée porte un nom d'affichage explicite, comme `gemini-3-pro (gemini)`, ainsi que toutes les capacités du modèle (échelle d'effort de raisonnement et types de réflexion) dans la structure officielle ModelInfo. Le mode passerelle tierce de Claude diff --git a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md index b58247b64a2..7a1b6cfedbb 100644 --- a/docs-site/src/content/docs/fr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/fr/reference/cli/lifecycle.md @@ -345,6 +345,8 @@ Ouvre le [tableau de bord Web](/fr/guides/web-dashboard/) à l’adresse `http:/ Lorsque OpenCodex est installé avec mise, cette commande échoue avant d'arrêter le proxy ou de modifier les fichiers du paquet et affiche `mise upgrade ` avec l'alias mise local vérifié. La vérification des mises à jour reste disponible et signale une gestion externe. Des métadonnées de propriété mise illisibles ou incohérentes bloquent aussi toute modification sans deviner le nom de l'outil, et `--tag preview` ne change jamais la sélection configurée dans mise. +Sous Linux, un service en arrière-plan dont le lanceur enregistré est le lanceur de paquet de mise (`/latest/node_modules/.bin/ocx`, et non un shim mise) suit `mise upgrade` tout seul : une dizaine de secondes après la stabilisation de la nouvelle version, il draine les requêtes actives et redémarre sur celle-ci, et il se rétablit de la même façon si mise supprime plus tard la version qu'il exécutait. Sous macOS, pour un service installé via un shim mise et pour un proxy au premier plan, redémarrez-le vous-même après la mise à jour (sous macOS, d'abord `ocx service repair`). + Met à jour opencodex depuis npm. Les installations stables utilisent `@latest` ; les préversions restent sur `@preview`, sauf si vous indiquez `--tag latest|preview`. La commande détecte un dépôt de sources et vous invite alors à exécuter `git pull && bun install`. Elle ne fait rien si la version la plus récente correspondant à cette balise est déjà installée. Avant tout arrêt, les installations npm effectuent sous Unix un contrôle borné de la propriété et de l’accès au cache. Les liens symboliques imbriqués sont examinés avec `lstat`, sans être suivis ; Windows ignore explicitement ce contrôle propre à Unix. En cas d’échec, l’opération s’interrompt tandis que l’icône et le proxy fonctionnent encore. Le proxy actif est ensuite arrêté avant le remplacement des fichiers. Un service installé est reconstruit et redémarré automatiquement ; pour une installation au premier plan, la commande indique `ocx start` comme étape suivante. Avant leur conservation, les enregistrements de mise à jour du tableau de bord masquent les chemins de profil et de cache ainsi que les valeurs UID/GID. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index b36f37b2d5b..fd4056f329c 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -315,10 +315,10 @@ Support/Claude/configLibrary` on macOS, `%APPDATA%\Claude\configLibrary` on Wind `CLAUDE_USER_DATA_DIR` for an alternate Desktop user-data root. The legacy `Claude-3p` directory is not read or deleted automatically. -Non-Anthropic routes receive stable aliases such as `claude-opus-4-8-YYYYMMDD`, where the year runs -from 2026 to 2035. The date-looking -part is a synthetic route slot, not the model's release date. 2026 slots are allocated first, so -existing aliases keep their ids; the later years are reached only once 2026 fills. +Non-Anthropic routes receive stable aliases such as `claude-opus-4-8-p01q`, with a `p`-prefixed +four-character code. OpenCodex keeps a separate synthetic date slot internally so existing profile +assignments stay stable, but it does not expose that date as the Desktop model id: current Desktop +builds strip terminal dates when comparing active-session models, which can suppress model changes. 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` @@ -443,22 +443,22 @@ for every Claude Code CLI row (`Routed by OpenCodex to /`; nati | Surface | Format | Example | | --- | --- | --- | | Claude Code CLI | `ocx-claude---` (plain) or `ocx-claude2-…` (escaped) | `ocx-claude-native--gpt-5.6-sol` | -| Claude Desktop 3P | `claude-opus-4-8-` (3-char base36 hash) | `claude-opus-4-8-ncb` | +| Claude Desktop 3P | `claude-opus-4-8-p` (3-char base36 profile slot) | `claude-opus-4-8-p01q` | 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. +`claude-code/*` user-agent gets the readable CLI form and other clients get the Desktop code. 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---` / `claude-ocx2---` ids from older configs still resolve. A legacy id configured in an OpenCodex model slot (`claudeCode.model`, `tierModels`, `smallFastModel`) is sent to Claude Code in its current spelling automatically. A legacy id saved by Claude Code's own picker still routes, but Claude Code keeps its 200k accounting for that id. Pick `ocx-claude-` for a saved `claude-ocx-` id, and `ocx-claude2-` for a saved escaped `claude-ocx2-` id, so the real context window and compact both apply. -If Claude Desktop's footer picker does not change the model for an already-running 3P -conversation, you can try `/model `, but this workaround may also fail on affected Desktop -builds. [Issue #3782](https://github.com/lidge-jun/opencodex/issues/3782) reports that on Windows -with Claude Desktop 1.46388.4, the conversation continues using its initial model after both -footer-picker and `/model` changes. The report does not establish which client or routing -component causes the behavior. +Older managed profiles exposed their internal `YYYYMMDD` slots directly. Affected Desktop builds strip +that terminal date when comparing active-session models, so two distinct routes can look identical +and no `set_model` control request is sent ([#3782](https://github.com/lidge-jun/opencodex/issues/3782)). +Current profiles expose non-date codes instead. After upgrading, reapply the Desktop profile and fully +quit and reopen Desktop; an already-running worker can still retain its old date-shaped id. The proxy +continues decoding those old ids for migration, but it cannot make Desktop emit a suppressed switch. You can also try selecting the intended default model in the OpenCodex Claude Desktop profile, reapplying the profile, and starting a new conversation. This is a troubleshooting step, not a diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index ecfc90c111f..712d5d9036c 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -15,6 +15,11 @@ file, removes OpenCodex's generated routing artifacts, and leaves the proxy runn clients. Re-enabling rebuilds the catalog from the models available at that time, so it does not restore the Codex files byte for byte. +For bare native Codex models and OpenCodex-generated account-selector rows, the catalog carries +authenticated access program metadata. Bare models use the main Codex account; account-qualified +models use their selected account. Refreshing the integration updates these rows when upstream +changes the account's access programs. + The proxy exposes one bare `openai` Codex-login route with Pool(default) and Direct account modes, plus `openai-apikey/` for the configured API key. Pool includes main plus added accounts; Direct uses only the caller/main bearer. The routes do not fall back to one another. Shipped v1 diff --git a/docs-site/src/content/docs/guides/grok-build.md b/docs-site/src/content/docs/guides/grok-build.md index eb60ed3f4cd..eb9281a86f9 100644 --- a/docs-site/src/content/docs/guides/grok-build.md +++ b/docs-site/src/content/docs/guides/grok-build.md @@ -174,3 +174,17 @@ the id `grok-4.5`. Generated aliases avoid dots entirely for this reason. document on every reload. - **Catalog updates:** the fenced block reflects the catalog at injection time. After adding providers or models, run `ocx ensure` (or restart the proxy) to refresh it. + +## Devin rate limits + +If Devin rejects a direct request with a rate limit before producing output or performing +an action, OpenCodex returns HTTP 429 with a JSON error. Grok can then recognize the limit +instead of displaying a generic 500 error from a failed HTTP 200 stream. If the response +includes `Retry-After`, follow that delay before retrying. Exhausted quotas without a reset +hint do not receive a default delay. Existing OAuth account failover still applies. This +HTTP 429 handling also applies to non-streaming Responses requests. + +For streaming requests, OpenCodex waits up to the configured stall timeout for this early +refusal. If that wait +expires, it opens the SSE response and lets the existing stream watchdog handle stalls. +After HTTP headers are sent, a later failure stays in the stream as `response.failed`. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 1ce2a0b374b..54372cce2f9 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -498,6 +498,7 @@ region-pinned EU routes, is at [opper.ai/models](https://opper.ai/models). Opper | Umans AI · Neuralwatt | `https://api.code.umans.ai` · `https://api.neuralwatt.com/v1` | | Mistral | `https://api.mistral.ai/v1` | | MiniMax · MiniMax (CN) | `https://api.minimax.io/v1` · `https://api.minimaxi.com/v1` | + | DeepSeek | `https://api.deepseek.com` | | Cerebras | `https://api.cerebras.ai/v1` | | Chutes | `https://llm.chutes.ai/v1` | @@ -538,6 +539,10 @@ region-pinned EU routes, is at [opper.ai/models](https://opper.ai/models). Opper | Cloudflare AI Gateway | `https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic` | | …and more | opencode zen, Vercel AI Gateway, Venice, NanoGPT, Synthetic, Qianfan, Alibaba, Parallel, ZenMux, LiteLLM | +The MiniMax and MiniMax (CN) provider cards can also show Coding Plan quota when the configured +key has an active plan. The dashboard reads the plan's 5-hour window and, when present, weekly +window; these are display observations and do not change model routing. + **OpenCode Go** requires a stable session identifier for routing. OpenCodex derives its Go session header from Codex thread/session headers, or from a client's `x-opencode-session` header when Codex headers are absent. This applies to direct diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 5a5337e230e..fe7752fd132 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -243,10 +243,10 @@ Claude Code 2.1.129 以降は `GET /v1/models?limit=1000` でゲートウェイ | 画面 | 形式 | 例 | | --- | --- | --- | | Claude Code CLI | `ocx-claude---` (plain) または `ocx-claude2-…` (escaped) | `ocx-claude-native--gpt-5.6-sol` | -| Claude Desktop 3P | `claude-opus-4-8-` (3 桁の base36 ハッシュ) | `claude-opus-4-8-ncb` | +| Claude Desktop 3P | `claude-opus-4-8-p` (3 桁の base36 プロファイルスロット) | `claude-opus-4-8-p01q` | プロキシはリクエストごとに系列を選びます。`?ids=cli` または `?ids=desktop` が優先し、指定しないと -`claude-code/*` user-agent には読みやすい CLI 形式を、他のクライアントには Desktop ハッシュを +`claude-code/*` user-agent には読みやすい CLI 形式を、他のクライアントには Desktop コードを 提供します。両系列は継続してデコードできるため、どちらの形式でも `settings.json` に保存したモデルは 引き続き動作します。古い設定の `claude-ocx---` / `claude-ocx2---` も 引き続き解決されますが、保存済みの旧 ID はルーティングされても Claude Code 側では 200k として計算されます。 diff --git a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md index 1132c8e380d..b6c5fe4a7db 100644 --- a/docs-site/src/content/docs/ja/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ja/reference/cli/lifecycle.md @@ -295,6 +295,8 @@ Windows ステータス トレイ アイコンをインストールして制御 OpenCodex が mise 経由でインストールされている場合、このコマンドはプロキシの停止やパッケージファイルの変更前に失敗終了し、検証済みのローカル mise エイリアスを使った `mise upgrade ` を表示します。更新確認は引き続き利用でき、外部管理として報告されます。mise の所有権メタデータを読み取れない場合や整合しない場合もツール名を推測せずに変更を拒否し、`--tag preview` は mise の設定済み選択を変更しません。 +Linux では、記録されたランチャーが mise のパッケージランチャー(mise の shim ではなく `/latest/node_modules/.bin/ocx`)であるバックグラウンドサービスは、`mise upgrade` に自動で追従します。新しいバージョンが安定してから約 10 秒以内に進行中のリクエストをドレインしてそのバージョンで再起動し、実行中のバージョンが後で mise によって削除された場合も同じ方法で復旧します。macOS、mise の shim 経由でインストールしたサービス、フォアグラウンドのプロキシでは、アップグレード後に手動で再起動してください(macOS では先に `ocx service repair`)。 + npm から opencodex を自己更新します。安定したインストールでは `@latest` を使用します。 `--tag latest|preview` を渡さない限り、プレビュー インストールは `@preview` に残ります。ソース チェックアウトを検出し、代わりに `git pull && bun install` を使用するように指示しますが、そのタグの最新バージョンをすでに使用している場合は何もしません。npm インストールでは、何かを停止する前に Unix キャッシュの所有権とアクセスを上限付きで検査します。ネストされたシンボリックリンクは `lstat` で確認しますが追跡しません。Windows では、この Unix 専用検査を明示的にスキップします。検査に失敗した場合、トレイとプロキシを実行したまま更新を中止します。その後、実行中のプロキシはファイルが置き換えられる前に停止されます。インストールされたサービスは再構築されて自動的に開始されますが、フォアグラウンド インストールでは次のステップとして `ocx start` が出力されます。ダッシュボードの更新記録では、保存前にプロファイル/キャッシュのパスと UID/GID 値が秘匿されます。 ```bash 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 028a3d4c7e8..7803afa88a7 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -278,10 +278,10 @@ Claude Code 2.1.129 이상은 `GET /v1/models?limit=1000`에서 게이트웨이 | 화면 | 형식 | 예시 | | --- | --- | --- | | Claude Code CLI | `ocx-claude---` (plain) 또는 `ocx-claude2-…` (escaped) | `ocx-claude-native--gpt-5.6-sol` | -| Claude Desktop 3P | `claude-opus-4-8-` (3자리 base36 해시) | `claude-opus-4-8-ncb` | +| Claude Desktop 3P | `claude-opus-4-8-p` (3자리 base36 프로필 슬롯) | `claude-opus-4-8-p01q` | 프록시는 요청마다 계열을 골라요. `?ids=cli` 또는 `?ids=desktop`이 우선하고, 지정하지 않으면 -`claude-code/*` user-agent에는 읽기 쉬운 CLI 형식을, 다른 클라이언트에는 Desktop 해시를 +`claude-code/*` user-agent에는 읽기 쉬운 CLI 형식을, 다른 클라이언트에는 Desktop 코드를 제공해요. 두 계열은 계속 디코딩할 수 있으므로 어느 형식이든 `settings.json`에 저장한 모델이 계속 작동해요. diff --git a/docs-site/src/content/docs/ko/guides/grok-build.md b/docs-site/src/content/docs/ko/guides/grok-build.md index 10747b2e1b6..26ab7598158 100644 --- a/docs-site/src/content/docs/ko/guides/grok-build.md +++ b/docs-site/src/content/docs/ko/guides/grok-build.md @@ -119,3 +119,17 @@ model_provider = "opencodex" - **서비스 설치된 `ocx restart`:** 실행 중인 프록시는 재시작 권한 확인과 드레인 조정을 담당하고, 기존 프로세스가 종료된 뒤 설치된 서비스 관리자가 교체 프로세스를 시작합니다. 서비스 감독은 그대로 유지됩니다. 루프백 자동 등록을 사용하는 경우에만 관리 블록도 핸드오프 동안 유지되며, 비루프백 배포에서는 Grok 설정을 수동으로 관리합니다. 같은 포트에서 신원이 확인된 다른 프로세스가 정상 상태가 된 뒤에만 명령이 성공합니다. - **설정 읽기 시점:** 가장 예측 가능한 결과를 얻으려면 opencodex를 먼저 시작하고 그다음 `grok`를 실행합니다. Grok Build는 `~/.grok/config.toml`을 감시하다가 `[model]` 테이블이 실제로 바뀔 때 다시 불러옵니다(내용을 기준으로 비교하는 약 1초 디바운스). 그래서 새로 고친 블록은 재시작 없이 열린 세션에도 들어갑니다. Grok가 무엇을 파싱했는지 확인하려면 `grok inspect`를 실행합니다. 이 명령은 로드한 설정 원본을 나열하고 거부한 필드가 있으면 경고합니다. 해석된 모델 목록은 출력하지 않습니다. 현재 Grok Build는 잘못된 모델 필드를 경고와 함께 건너뛰고 나머지 모델 항목을 유지합니다. TOML 구문 오류가 있으면 파일을 불러올 수 없습니다. opencodex는 파일을 원자적으로 기록하므로 Grok는 다시 읽을 때마다 완전한 문서를 봅니다. - **카탈로그 업데이트:** 펜스 블록은 주입 시점의 카탈로그를 반영합니다. 공급자나 모델을 추가한 뒤에는 `ocx ensure`를 실행하거나 프록시를 재시작해 갱신합니다. + +## Devin 사용 제한 + +Devin에 직접 요청했을 때, 출력이나 작업 실행 전에 Devin이 사용 제한으로 요청을 +거절하면 JSON 오류를 포함한 HTTP 429를 반환합니다. Grok은 HTTP 200 +스트림의 실패를 일반 500 오류로 표시하는 대신 사용 제한으로 인식할 수 있습니다. +`Retry-After`가 있으면 다시 요청하기 전에 해당 시간만큼 기다리세요. 한도가 소진됐고 +초기화 시점도 알려지지 않은 경우에는 기본 대기 시간을 붙이지 않습니다. 기존 OAuth 계정 +전환 정책은 그대로 적용됩니다. 비스트리밍 Responses 요청에도 같은 HTTP 429 처리를 +적용합니다. + +스트리밍 요청에서는 이 초기 거절을 설정된 정체 제한 시간까지만 기다립니다. 시간이 지나면 +SSE 응답을 열고 기존 스트림 감시 로직으로 정체를 처리합니다. HTTP 헤더를 전송한 뒤의 +오류는 스트림 안의 `response.failed`로 전달됩니다. diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 337f284f4cc..b9ec56615fb 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -432,6 +432,8 @@ Windows 상태 트레이 아이콘을 설치하고 제어합니다. Windows 로 OpenCodex가 mise를 통해 설치된 경우 이 명령은 프록시를 중지하거나 패키지 파일을 변경하기 전에 실패하며 검증된 로컬 mise 별칭을 사용한 `mise upgrade `을 표시합니다. 업데이트 확인은 계속 사용할 수 있고 외부 관리 설치로 보고합니다. mise 소유권 메타데이터를 읽을 수 없거나 일관되지 않아도 도구 이름을 추측하지 않고 변경을 거부하며, `--tag preview`는 mise에 구성된 선택을 변경하지 않습니다. +Linux에서 기록된 런처가 mise 패키지 런처(mise shim이 아닌 `/latest/node_modules/.bin/ocx`)인 백그라운드 서비스는 `mise upgrade`를 스스로 따라갑니다. 새 버전이 안정된 뒤 약 10초 안에 진행 중인 요청을 드레인하고 새 버전으로 재시작하며, 실행 중이던 버전을 mise가 나중에 정리해도 같은 방식으로 복구합니다. macOS, mise shim으로 설치한 서비스, 포그라운드 프록시는 업그레이드 후 직접 재시작하세요(macOS에서는 먼저 `ocx service repair`). + npm에서 opencodex를 자체 업데이트합니다. 안정판 설치는 `@latest`를 사용하고, 미리보기 설치는 `--tag latest|preview`를 주지 않으면 `@preview`를 유지합니다. 소스 체크아웃을 감지하면 대신 `git pull && bun install`을 실행하라고 안내하고, 해당 태그에서 이미 최신 버전이면 아무 동작도 하지 diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index 1e77251a296..ba797a4a561 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -115,7 +115,8 @@ understands: The bridge also runs a **heartbeat keep-alive** (RC3): during upstream silence, it emits an SSE comment line (`: opencodex heartbeat`) every 2 seconds to re-arm Codex's idle timer. Comment lines are discarded by every eventsource parser without producing an event, so strict Responses decoders -never see an unknown variant. The default **stall deadline** is 300 seconds (`stallTimeoutSec`); +never see an unknown variant. The default **stall deadline** is 300 seconds for a public upstream +and disabled for a local one (`stallTimeoutSec`; `0` disables it everywhere); reaching it aborts the upstream and emits `response.incomplete` with reason `upstream_stall_timeout`, preventing a hung connection from blocking Codex indefinitely. @@ -239,7 +240,8 @@ keepalives do not reset the adapter-event stall watchdog. The default stall timeout stays 300 seconds; encrypted compaction content is preserved unchanged. Native compact response buffering also enforces a body-byte inactivity deadline -using `stallTimeoutSec` (300 seconds by default). Nonempty chunks reset that +using `stallTimeoutSec` (300 seconds for a public upstream, disabled for a local one, +and disabled everywhere when set to `0`). Nonempty chunks reset that deadline; a stalled body returns HTTP 504, client cancellation retains HTTP 499, and cleanup does not wait for a stuck upstream cancellation promise. The 32 MiB response ceiling and the original body bytes are preserved. diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index 9bfe95036dc..24a63d5c7ad 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -711,6 +711,8 @@ package registry or install an update. When OpenCodex is installed through mise, this command exits unsuccessfully before stopping the proxy or changing package files and shows `mise upgrade `, using the verified local mise alias. Update checks remain available and report the installation as externally managed. An unreadable or inconsistent mise ownership record fails closed without guessing a tool name, and `--tag preview` never changes mise's configured selection. +On Linux, a background service whose recorded launcher is mise's package launcher (`/latest/node_modules/.bin/ocx`, not a mise shim) follows `mise upgrade` by itself: within about ten seconds of the new version settling, it drains active requests and restarts onto it, and it recovers the same way if mise later prunes the version it was running. On macOS, for a service installed through a mise shim, and for a foreground proxy, restart it yourself after upgrading (on macOS, `ocx service repair` first). + Self-update opencodex from npm. Stable installs use `@latest`; preview installs stay on `@preview` unless you pass `--tag latest|preview`. It detects a source checkout and tells you to `git pull && bun install` instead, and is a no-op if you are already on the newest version for that diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index ec28e68dd27..da425c82e47 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -16,7 +16,7 @@ runs helper features around provider requests. | `noProxy?` | `string \| string[]` | — | Hosts that bypass `proxy`, merged with inherited `NO_PROXY` and loopback entries. A string may use comma-separated `NO_PROXY` syntax or `${ENV_VAR}`. | | `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a turn has no text or tool call, including a stream that ends before a terminal event. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. | | `dropCodexSafetyBuffering?` | `boolean` | `false` | Remove optional client-facing hints from canonical Codex Responses passthrough: the two `x-codex-safety-buffering-enabled` / `x-codex-safety-buffering-faster-model` response headers, `response.metadata` events whose metadata type is `safety_buffering`, and top-level `safety_buffering` fields. Other headers, response data, policy refusals and failures are preserved. This does not disable provider safety enforcement or upstream buffering. Native `codex.response.metadata.headers` WebSocket metadata and `/responses/compact` are outside this filter. | -| `stallTimeoutSec?` | `number` | `300` | Seconds without meaningful upstream progress (Responses and native Chat). Minimum 1. | +| `stallTimeoutSec?` | `number` | `300` (public) / disabled (local) | Seconds without meaningful upstream progress (Responses and native Chat) before the stream is cut. Unset, a **local** upstream (loopback, private, or a `.local`/`.lan` name) defaults to disabled and a public upstream to 300 s; a positive value applies to both (minimum 1 s); `0` disables the watchdog everywhere. Disabled leaves a silent-but-healthy local model connected (keep-alives still flow). | | `oauthOpenBrowser?` | `boolean` | `true` | Whether a login may open a browser on the machine running the proxy. Absent and `true` both open, so an existing install is unchanged; only an explicit `false` declines. Decline when you need the authorization link in a different browser profile, or when the dashboard is not on the proxy's machine — the login still starts and the URL is still returned and displayed. `POST /api/oauth/login` and `POST /api/codex-auth/login` accept a per-request `openBrowser` boolean that overrides this, and the dashboard exposes the same choice beside the login button. Device-code flows never open a browser either way. | | `connectTimeoutMs?` | `number` | `200000` | Per-attempt DNS/TCP/TLS/final-header deadline; it ends before body generation. | | `shutdownTimeoutMs?` | `number` | `5000` | Graceful drain deadline before active turns are aborted. | @@ -116,7 +116,7 @@ history; review the full-scope warning in the lifecycle reference before running ### Native Chat timeouts and completion -Native Chat also uses `stallTimeoutSec` while waiting for upstream output. Nonempty text, reasoning, refusal, tool updates, and finish frames renew the allowance; keepalive comments, role-only frames, and usage alone do not. Waiting for a slow client to read pauses the allowance. A stall produces `upstream_stall_timeout`: an error frame for streaming clients, or HTTP 502 for non-streaming clients. Cancellation before a terminal result returns a cancellation error instead of a successful partial answer. Buffered Chat results accept both LF and CRLF SSE framing, including multiline data. +Native Chat also uses `stallTimeoutSec` while waiting for upstream output, with the same local-vs-public default: an unset budget is disabled for a local upstream and 300 s for a public one, and `0` disables it everywhere. Nonempty text, reasoning, refusal, tool updates, and finish frames renew the allowance; keepalive comments, role-only frames, and usage alone do not. Waiting for a slow client to read pauses the allowance. A stall produces `upstream_stall_timeout`: an error frame for streaming clients, or HTTP 502 for non-streaming clients. Cancellation before a terminal result returns a cancellation error instead of a successful partial answer. Buffered Chat results accept both LF and CRLF SSE framing, including multiline data. ## Codex quota network diagnostics diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index b0678de40d6..9bf1bea46bb 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -256,10 +256,10 @@ Claude Code 2.1.129+ обнаруживает модели шлюза через | Интерфейс | Формат | Пример | | --- | --- | --- | | Claude Code CLI | `ocx-claude---` (plain) или `ocx-claude2-…` (escaped) | `ocx-claude-native--gpt-5.6-sol` | -| Claude Desktop 3P | `claude-opus-4-8-` (3-символьный base36-хеш) | `claude-opus-4-8-ncb` | +| Claude Desktop 3P | `claude-opus-4-8-p` (3-символьный слот профиля base36) | `claude-opus-4-8-p01q` | Прокси выбирает семейство для каждого запроса: приоритет у `?ids=cli` или `?ids=desktop`; иначе -user-agent `claude-code/*` получает читаемую CLI-форму, а остальные клиенты — Desktop-хеш. Оба +user-agent `claude-code/*` получает читаемую CLI-форму, а остальные клиенты — Desktop-код. Оба семейства декодируются бессрочно — модель, сохранённая в `settings.json` в любой из форм, продолжает работать. Устаревшие id `claude-ocx---` и `claude-ocx2---` из старых конфигураций тоже разрешаются, но Claude Code продолжает считать такой сохранённый id как 200k. diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 27711fd81ef..3b7caf84a68 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -436,6 +436,8 @@ one-click управление прокси. `start` и `stop` управляю Если OpenCodex установлен через mise, команда завершается с ошибкой до остановки прокси или изменения файлов пакета и показывает `mise upgrade ` с проверенным локальным псевдонимом mise. Проверка обновлений остаётся доступной и сообщает о внешнем управлении. Нечитаемые или противоречивые метаданные владельца mise также запрещают изменения без угадывания имени инструмента, а `--tag preview` никогда не меняет выбранную в mise версию. +В Linux фоновая служба, у которой записанный лаунчер — пакетный лаунчер mise (`/latest/node_modules/.bin/ocx`, а не shim mise), сама следует за `mise upgrade`: примерно через десять секунд после того, как новая версия стабилизируется, она завершает активные запросы и перезапускается на ней, и так же восстанавливается, если mise позже удалит версию, на которой она работала. В macOS, для службы, установленной через shim mise, и для прокси на переднем плане перезапустите её сами после обновления (в macOS сначала `ocx service repair`). + Самообновить opencodex из npm. Стабильные установки используют `@latest`; preview-установки остаются на `@preview`, если только вы не передадите `--tag latest|preview`. Команда распознаёт source checkout и предлагает вместо этого `git pull && bun install`, а если у вас уже новейшая diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index a314a995d5a..f70584da6fc 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -248,9 +248,11 @@ alternatif bir Desktop kullanıcı verisi kökü için `CLAUDE_USER_DATA_DIR` değerini ayarlayın. Eski `Claude-3p` dizini otomatik olarak okunmaz veya silinmez. -Anthropic harici rotalar, `claude-opus-4-8-YYYYMMDD` gibi kararlı takma adlar -alır; yıl 2026 ile 2035 arasındadır. Tarih benzeri kısım, modelin çıkış tarihi -değil, sentetik bir rota yuvasıdır. Önce 2026 yuvaları atanır, bu nedenle mevcut +Anthropic harici rotalar, `p` önekli dört karakterli bir kod kullanan +`claude-opus-4-8-p01q` gibi kararlı takma adlar alır. OpenCodex profil atamalarını +kararlı tutmak için dahili olarak sentetik tarih yuvaları saklar; ancak güncel +Desktop sürümleri etkin oturum modellerini karşılaştırırken sondaki tarihleri +kaldırdığı ve model değişimini engelleyebildiği için bu tarihi Desktop model kimliği olarak yayımlamaz. Mevcut takma adlar kimliklerini korur; sonraki yıllara ancak 2026 dolduktan sonra geçilir. Gerçek Anthropic Claude rotaları kendi gerçek kimliklerini korur. Yeni rotalar varsayılan olarak Opus ailesine gider, ancak bir rotayı taşımak @@ -417,11 +419,11 @@ satırı için bir tane gönderir (`Routed by OpenCodex to /`; | Yüzey | Format | Örnek | | --- | --- | --- | | Claude Code CLI | `ocx-claude---` (düz) veya `ocx-claude2-…` (kaçışlı) | `ocx-claude-openai--gpt-5.6-sol` | -| Claude Desktop 3P | `claude-opus-4-8-` (3 karakterli base36 karması) | `claude-opus-4-8-ncb` | +| Claude Desktop 3P | `claude-opus-4-8-p` (3 karakterli base36 profil yuvası) | `claude-opus-4-8-p01q` | Proxy, istek başına aileyi seçer: `?ids=cli` veya `?ids=desktop` kazanır; aksi takdirde `claude-code/*` kullanıcı aracısı okunabilir CLI biçimini alır ve diğer -istemciler Desktop karmasını alır. Her iki aile de süresiz olarak kodu çözer — +istemciler Desktop kodunu alır. Her iki aile de süresiz olarak kodu çözer — her iki biçimde `settings.json` içine kaydedilen bir model çalışmaya devam eder. Her girdi, `gemini-3-pro (gemini)` gibi dürüst bir görünen adın yanı sıra Claude Desktop'ın üçüncü taraf ağ geçidi modunun çaba seçicisini sunabilmesi için resmi diff --git a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md index e40049e5ecf..d2c4a437743 100644 --- a/docs-site/src/content/docs/tr/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/tr/reference/cli/lifecycle.md @@ -510,6 +510,8 @@ adresindeki [web kontrol panelini](/tr/guides/web-dashboard/) açın; hub'da yö OpenCodex mise üzerinden kurulduğunda bu komut proxy'yi durdurmadan veya paket dosyalarını değiştirmeden önce başarısız olur ve doğrulanmış yerel mise diğer adını kullanarak `mise upgrade ` komutunu gösterir. Güncelleme denetimi kullanılabilir kalır ve kurulumun harici olarak yönetildiğini bildirir. Okunamayan veya tutarsız mise sahiplik meta verileri de araç adını tahmin etmeden değişikliği reddeder; `--tag preview` mise içinde yapılandırılmış seçimi değiştirmez. +Linux'ta kayıtlı başlatıcısı mise paket başlatıcısı (mise shim'i değil, `/latest/node_modules/.bin/ocx`) olan bir arka plan hizmeti `mise upgrade` işlemini kendiliğinden izler: yeni sürüm oturduktan yaklaşık on saniye sonra etkin istekleri boşaltır ve yeni sürümle yeniden başlar; mise daha sonra çalıştığı sürümü temizlerse de aynı şekilde toparlanır. macOS'ta, mise shim'i üzerinden kurulan bir hizmette ve ön plandaki bir proxy'de yükseltmeden sonra kendiniz yeniden başlatın (macOS'ta önce `ocx service repair`). + opencodex'i npm'den kendi kendine güncelleyin. Kararlı kurulumlar `@latest` kullanır; önizleme kurulumları `--tag latest|preview` iletmediğiniz sürece `@preview` üzerinde kalır. Bir kaynak kod kopyasını algılar ve bunun yerine `git 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 9e30d16099f..31ff449eb0b 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 @@ -235,10 +235,10 @@ Claude Code 2.1.278 接受包含 `claude` 或 `anthropic` 的 ID。以 `claude-` | 界面 | 格式 | 示例 | | --- | --- | --- | | Claude Code CLI | `ocx-claude---`(plain)或 `ocx-claude2-…`(escaped) | `ocx-claude-native--gpt-5.6-sol` | -| Claude Desktop 3P | `claude-opus-4-8-`(3 字符 base36 哈希) | `claude-opus-4-8-ncb` | +| Claude Desktop 3P | `claude-opus-4-8-p`(3 字符 base36 配置槽) | `claude-opus-4-8-p01q` | 代理会按请求选择别名族:`?ids=cli` 或 `?ids=desktop` 优先;否则,`claude-code/*` -user-agent 会获得易读的 CLI 形式,其他客户端会获得 Desktop 哈希形式。两种别名族都会永久 +user-agent 会获得易读的 CLI 形式,其他客户端会获得 Desktop 代码形式。两种别名族都会永久 保持可解码——以任一形式保存在 `settings.json` 中的模型都能继续工作。 如果 Claude Desktop 底部的选择器没有切换正在进行的 3P 对话的模型,可以尝试 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 04551fd679d..5b8c902e5bf 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -211,6 +211,7 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 | Umans AI · Neuralwatt | `https://api.code.umans.ai` · `https://api.neuralwatt.com/v1` | | Mistral | `https://api.mistral.ai/v1` | | MiniMax · MiniMax (CN) | `https://api.minimax.io/v1` · `https://api.minimaxi.com/v1` | + | DeepSeek | `https://api.deepseek.com` | | Cerebras | `https://api.cerebras.ai/v1` | | Chutes | `https://llm.chutes.ai/v1` | @@ -246,6 +247,9 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 | Cloudflare AI Gateway | `https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic` | | ……以及更多 | opencode zen、Vercel AI Gateway、Venice、NanoGPT、Synthetic、Qianfan、Alibaba、Parallel、ZenMux、LiteLLM | +MiniMax 和 MiniMax (CN) 的提供商卡片也会在配置的密钥有有效 Coding Plan 时显示用量。 +仪表盘读取 5 小时窗口,以及套餐提供时的每周窗口;这些仅用于展示,不会改变模型路由。 + **OpenCode Zen**(`opencode-zen`)与免密钥的 **OpenCode Free** 预设共用 `https://opencode.ai/zen/v1`。该网关上的免费模型常会触发约每分钟 15–20 次请求的短窗口限流(社区观测;OpenCode 未公布 RPM)。Zen 可能返回不带 `Retry-After` / `X-RateLimit-*` 的通用 429。这与免密钥桌面配额(`opencode-free` 上约每 5 小时 200 次 Big Pickle/免费模型请求)是分开的。当这类 429 省略 `Retry-After` 时,opencodex 会在客户端错误中补充说明并附带合成的 `Retry-After`;若上游已提供 `Retry-After`,则仍以它为准。同密钥等待重试仍可通过 [`retryOn429`](/zh-cn/reference/configuration/) 选择开启。 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md index fe59f62903b..c5caee6abb1 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/lifecycle.md @@ -282,6 +282,8 @@ ocx codex-shim uninstall 当 OpenCodex 由 mise 安装时,此命令会在停止代理或修改软件包文件之前以失败状态退出,并使用经过验证的本地 mise 别名显示 `mise upgrade `。更新检查仍然可用,并会报告该安装由外部管理。无法读取或不一致的 mise 所有权元数据也会阻止修改,且不会猜测工具名称;`--tag preview` 绝不会更改 mise 中配置的选择。 +在 Linux 上,如果后台服务记录的启动器是 mise 的包启动器(`/latest/node_modules/.bin/ocx`,而不是 mise shim),服务会自动跟随 `mise upgrade`:新版本稳定后约十秒内,它会排空进行中的请求并在新版本上重启;如果 mise 之后清理了它正在运行的版本,也会以同样方式恢复。在 macOS 上、通过 mise shim 安装的服务以及前台代理,请在升级后自行重启(macOS 上先运行 `ocx service repair`)。 + 从 npm 自更新 opencodex。稳定版安装使用 `@latest`;预览版安装保持在 `@preview`,除非你传入 `--tag latest|preview`。它会检测源码检出,并提示你改为运行 `git pull && bun install`;如果你已经是该标签的最新版本,则不会执行任何操作。对于 npm 安装,它会在停止任何进程之前,对 Unix 缓存的所有权和访问权限执行有界检查。嵌套符号链接会通过 `lstat` 检查但不会跟随;Windows 会明确跳过这项仅适用于 Unix 的检查。检查失败时,更新会在托盘和代理仍运行的情况下中止。随后才会在替换文件之前停止正在运行的代理;已安装的服务会自动重建并启动,而前台安装则会打印 `ocx start` 作为下一步。持久化前,仪表板更新记录会隐去用户配置文件/缓存路径以及 UID/GID 值。 ```bash diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index 03ad0a09b20..3a2c82dc5dc 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -184,8 +184,9 @@ ocx claude desktop import [--apply] 檔案,因此無效檔案不會改動目前設定檔。加上 `--apply` 可在匯入有效設定檔後立即寫入 Desktop。 `none` 僅適用於空系列;每個非空系列都必須保留一個預設。 -非 Anthropic 路由會得到穩定別名,例如 `claude-opus-4-8-YYYYMMDD`,年份範圍為 2026 至 2035。看起來像日期的部分是合成的 -路由槽位,不是模型釋出日期。系統會先配置 2026 的槽位,因此既有別名的 id 不變;2026 用盡後才會用到後續年份。 +非 Anthropic 路由會得到穩定別名,例如 `claude-opus-4-8-p01q`,其後綴是以 `p` 開頭的四字元代碼。OpenCodex 仍會在內部保留合成日期槽位, +以維持既有設定檔配置的穩定性,但不會把日期當成 Desktop 模型 ID;目前的 Desktop 版本在比較作用中工作階段的模型時會移除尾端日期, +因而可能抑制模型切換。 真正的 Anthropic Claude 路由保留真實 id。新路由預設落在 Opus 系列,但移動路由不會改變它所呼叫的供應商或模型。舊版 apply 旗標 `--static`、`--hybrid` 與 `--discovery-only` 仍可供既有腳本使用。 @@ -312,10 +313,10 @@ Claude Code 2.1.278 接受包含 `claude` 或 `anthropic` 的 ID。以 `claude-` | 介面 | 格式 | 示例 | | --- | --- | --- | | Claude Code CLI | `ocx-claude---`(plain)或 `ocx-claude2-…`(escaped) | `ocx-claude-native--gpt-5.6-sol` | -| Claude Desktop 3P | `claude-opus-4-8-`(3 字元 base36 雜湊) | `claude-opus-4-8-ncb` | +| Claude Desktop 3P | `claude-opus-4-8-p`(3 字元 base36 設定檔槽位) | `claude-opus-4-8-p01q` | 代理會按請求選擇別名族:`?ids=cli` 或 `?ids=desktop` 優先;否則,`claude-code/*` -user-agent 會獲得易讀的 CLI 形式,其他用戶端會獲得 Desktop 雜湊形式。兩種別名族都會永久 +user-agent 會獲得易讀的 CLI 形式,其他用戶端會獲得 Desktop 代碼形式。兩種別名族都會永久 保持可解碼——以任一形式儲存在 `settings.json` 中的模型都能繼續工作。 每個條目帶有誠實的顯示名(如 `gemini-3-pro (gemini)`),並以官方 ModelInfo 形態附帶完整模型 能力(推理強度階梯、thinking 型別),使 Claude Desktop 的第三方閘道器模式能夠提供其推理強度 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md index 6f80d34e094..16899542c02 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/lifecycle.md @@ -267,6 +267,8 @@ ocx codex-shim uninstall 當 OpenCodex 由 mise 安裝時,此命令會在停止代理或修改套件檔案之前以失敗狀態結束,並使用經過驗證的本機 mise 別名顯示 `mise upgrade `。更新檢查仍可使用,並會回報該安裝由外部管理。無法讀取或不一致的 mise 擁有權中繼資料也會阻止修改,且不會猜測工具名稱;`--tag preview` 絕不會變更 mise 中設定的選擇。 +在 Linux 上,若背景服務記錄的啟動器是 mise 的套件啟動器(`/latest/node_modules/.bin/ocx`,而非 mise shim),服務會自動跟隨 `mise upgrade`:新版本穩定後約十秒內,它會排空進行中的請求並在新版本上重新啟動;若 mise 之後清除了它正在執行的版本,也會以相同方式復原。在 macOS 上、透過 mise shim 安裝的服務以及前景代理,請在升級後自行重新啟動(macOS 上先執行 `ocx service repair`)。 + 從 npm 自我更新 opencodex。穩定安裝使用 `@latest`;預覽安裝停留在 `@preview`,除非你傳入 `--tag latest|preview`。它偵測原始碼 checkout 並告訴你改用 `git pull && bun install`,且若你已是該 tag 的最新版本則為 no-op。執行中的代理會在檔案被替換前停止;已安裝的服務會自動重建並啟動,而前景安裝會印出 `ocx start` 作為下一步。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index a89fdd0caf5..2f3304b4c7b 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -194,6 +194,7 @@ "start-args.test.ts": "cli", "start-ownership-publication.test.ts": "cli", "responses-core-modules.test.ts": "responses", + "responses-grok-devin-preflight.test.ts": "responses", "responses-passthrough-transient-policy.test.ts": "responses", "responses-spend-ledger-wiring.test.ts": "responses", "responses-send-budget-errors.test.ts": "responses", @@ -341,6 +342,7 @@ "bridge-nonstreaming-terminal.test.ts": "adapters", "bridge-raw-reasoning-hidden.test.ts": "adapters", "bridge-reasoning-replay-batch.test.ts": "adapters", + "bridge-stall.test.ts": "adapters", "bridge-terminal-singleness.test.ts": "adapters", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", @@ -391,6 +393,8 @@ "protocol-guard.test.ts": "responses", "protocol-ingress-guard.test.ts": "responses", "protocol-direct-encoders-chat.test.ts": "responses", + "protocol-direct-encoders-stall-disabled.test.ts": "responses", + "protocol-direct-encoders-local-upstream.test.ts": "responses", "protocol-direct-encoders-messages.test.ts": "responses", "chat-native-combo.test.ts": "responses", "messages-native-eligibility.test.ts": "responses", @@ -612,6 +616,7 @@ "codex-cli-update-launcher-policy.test.ts": "codex-integration", "codex-cli-update-zero-effect.test.ts": "codex-integration", "codex-cli-windows-installation-files.test.ts": "codex-integration", + "codex-forward-access-programs-writer.test.ts": "codex-integration", "codex-composed-acceptance.test.ts": "codex-integration", "codex-config-generation.test.ts": "codex-integration", "codex-context-owner.test.ts": "codex-integration", @@ -667,6 +672,7 @@ "codex-model-availability-error.test.ts": "codex-integration", "codex-model-denial-evidence.test.ts": "codex-integration", "codex-model-entitlement-admission.test.ts": "codex-integration", + "codex-model-entitlements-program-shape.test.ts": "codex-integration", "codex-model-entitlements.test.ts": "codex-integration", "codex-models-cache-invalidate.test.ts": "codex-integration", "codex-native-residue.test.ts": "codex-integration", @@ -1139,6 +1145,7 @@ "local-management-direct-transport.test.ts": "server", "local-provider-reload-client.test.ts": "server", "local-token-detect.test.ts": "oauth", + "local-upstream.test.ts": "lib", "logs-model-tier-confirmation.test.ts": "gui", "logs-timezone.test.ts": "server", "loop-reasoning-replay.test.ts": "images", @@ -1192,6 +1199,7 @@ "mimo-token-plan-provider.test.ts": "providers", "mimo-v26-catalog.test.ts": "providers", "minimax-clients.test.ts": "providers", + "minimax-quota.test.ts": "providers", "minimax-reasoning-split.test.ts": "providers", "model-cache-generation-tombstone.test.ts": "codex-integration", "model-cache.test.ts": "codex-integration", @@ -1343,6 +1351,7 @@ "package-tree-fenced-restart.test.ts": "ci-workflows", "package-tree-integrity.test.ts": "ci-workflows", "package-tree-restart-ownership.test.ts": "ci-workflows", + "package-tree-retarget.test.ts": "ci-workflows", "parallel-tool-calls-optin.test.ts": "codex-integration", "parser-content-audio.test.ts": "responses", "passive-route-linker.test.ts": "server", @@ -1776,6 +1785,7 @@ "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", + "update-mise-launcher-target.test.ts": "update", "update-mise.test.ts": "update", "update-mise-node-runtime.test.ts": "update", "update-pnpm.test.ts": "update", diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index aac52aacb7c..147a96419b1 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -411,8 +411,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = rewritten.body; convertedRoutedNamespaceToolAliases = rewritten.aliases; // Preserve xAI's cached-only fail-closed semantics and image-search mapping before the - // generic capability fallback removes the private OpenAI fields. - outBody = normalizeXaiResponsesWebSearch(outBody, provider); + // generic capability fallback removes the private OpenAI fields. Grok relayed through + // OpenCode Go speaks the same dialect, which the final model id and URL identify. + outBody = normalizeXaiResponsesWebSearch(outBody, provider, { modelId: parsed.modelId, responseUrl: url }); outBody = injectXaiResponsesXSearch(outBody, provider, parsed._replayPrefixLen); // xAI and explicitly classified compatible gateways reject these OpenAI web_search // extensions. Keep them for OpenAI API-key traffic and unclassified gateways. diff --git a/src/adapters/opencode-go-additional-tools.ts b/src/adapters/opencode-go-additional-tools.ts index 7e3609c134e..ea41ec1aa17 100644 --- a/src/adapters/opencode-go-additional-tools.ts +++ b/src/adapters/opencode-go-additional-tools.ts @@ -2,22 +2,31 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +/** + * True only for the exact OpenCode Go Responses resource. Lookalike hosts, other Zen paths, + * credentials, queries, fragments and unparsable URLs are all excluded, so a custom provider + * that merely resembles Go never inherits a Go-specific body rewrite. + */ +export function isOpenCodeGoResponsesUrl(responseUrl: string): boolean { + let destination: URL; + try { + destination = new URL(responseUrl); + } catch { + return false; + } + return destination.origin === "https://opencode.ai" + && destination.pathname === "/zen/go/v1/responses" + && !destination.username && !destination.password + && !destination.href.includes("?") && !destination.href.includes("#"); +} + /** Console Go accepts public tools but rejects the private additional_tools input wrapper. */ export function normalizeOpenCodeGoAdditionalTools( body: unknown, responseUrl: string, replayPrefixLength = 0, ): unknown { - let destination: URL; - try { - destination = new URL(responseUrl); - } catch { - return body; - } - if (destination.origin !== "https://opencode.ai" - || destination.pathname !== "/zen/go/v1/responses" - || destination.username || destination.password - || destination.href.includes("?") || destination.href.includes("#")) return body; + if (!isOpenCodeGoResponsesUrl(responseUrl)) return body; if (!isRecord(body) || !Array.isArray(body.input)) return body; // Do not replace a malformed top-level catalog with a partial promoted one. if (body.tools !== undefined && !Array.isArray(body.tools)) return body; diff --git a/src/adapters/run-turn-queue.ts b/src/adapters/run-turn-queue.ts index dc43e6ea622..6c706aff55f 100644 --- a/src/adapters/run-turn-queue.ts +++ b/src/adapters/run-turn-queue.ts @@ -132,14 +132,21 @@ export interface AdapterEventPreflight { error?: Extract; empty: boolean; replayUnsafe: boolean; + timedOut?: boolean; } async function* replay( buffered: readonly AdapterEvent[], iterator: AsyncIterator, + pendingNext?: Promise>, ): AsyncGenerator { try { for (const event of buffered) yield event; + if (pendingNext) { + const next = await pendingNext; + if (next.done) return; + yield next.value; + } while (true) { const next = await iterator.next(); if (next.done) return; @@ -153,31 +160,58 @@ async function* replay( export async function preflightAdapterEvents( source: AsyncIterable, classifyFirstEvent?: (event: AdapterEvent) => Extract | undefined, + options?: { maxWaitMs?: number }, ): Promise { const iterator = source[Symbol.asyncIterator](); const buffered: AdapterEvent[] = []; let replayUnsafe = false; - while (true) { - const next = await iterator.next(); - if (next.done) return { stream: replay(buffered, iterator), empty: true, replayUnsafe }; - if (next.value.type === "heartbeat") { - replayUnsafe ||= next.value.replayUnsafe === true; + const maxWaitMs = options?.maxWaitMs; + if (maxWaitMs !== undefined && maxWaitMs <= 0) { + return { stream: replay(buffered, iterator), empty: false, replayUnsafe, timedOut: true }; + } + let timeout: ReturnType | undefined; + let timeoutPromise: Promise<"timeout"> | undefined; + if (maxWaitMs !== undefined) { + timeoutPromise = new Promise(resolve => { + timeout = setTimeout(() => resolve("timeout"), maxWaitMs); + }); + } + try { + while (true) { + const pendingNext = iterator.next(); + const raced = timeoutPromise ? await Promise.race([pendingNext, timeoutPromise]) : await pendingNext; + if (raced === "timeout") { + return { + stream: replay(buffered, iterator, pendingNext), + empty: false, + replayUnsafe, + timedOut: true, + }; + } + const next = raced; + if (next.done) return { stream: replay(buffered, iterator), empty: true, replayUnsafe }; + if (next.value.type === "heartbeat") { + replayUnsafe ||= next.value.replayUnsafe === true; + // Preserve the latch in replay even after the original unsafe heartbeat is evicted. + buffered.push(replayUnsafe ? { ...next.value, replayUnsafe: true } : next.value); + if (buffered.length > PREFLIGHT_HEARTBEAT_RETAIN_LIMIT) buffered.shift(); + continue; + } + const classifiedError = replayUnsafe ? undefined : classifyFirstEvent?.(next.value); + if (classifiedError) { + buffered.push(classifiedError); + await iterator.return?.(); + return { stream: replay(buffered, iterator), error: classifiedError, empty: false, replayUnsafe }; + } buffered.push(next.value); - if (buffered.length > PREFLIGHT_HEARTBEAT_RETAIN_LIMIT) buffered.shift(); - continue; - } - const classifiedError = replayUnsafe ? undefined : classifyFirstEvent?.(next.value); - if (classifiedError) { - buffered.push(classifiedError); - await iterator.return?.(); - return { stream: replay(buffered, iterator), error: classifiedError, empty: false, replayUnsafe }; - } - buffered.push(next.value); - if (next.value.type === "error") { - await iterator.return?.(); - return { stream: replay(buffered, iterator), error: next.value, empty: false, replayUnsafe }; + if (next.value.type === "error") { + await iterator.return?.(); + return { stream: replay(buffered, iterator), error: next.value, empty: false, replayUnsafe }; + } + return { stream: replay(buffered, iterator), empty: false, replayUnsafe }; } - return { stream: replay(buffered, iterator), empty: false, replayUnsafe }; + } finally { + if (timeout !== undefined) clearTimeout(timeout); } } diff --git a/src/adapters/xai-web-search.ts b/src/adapters/xai-web-search.ts index 4dbb77d7364..836eb0ca6df 100644 --- a/src/adapters/xai-web-search.ts +++ b/src/adapters/xai-web-search.ts @@ -1,6 +1,7 @@ import type { OcxProviderConfig } from "../types"; import { debugProviderDiagnostic } from "../lib/debug"; import { isXaiResponsesDestination } from "../providers/xai-transport"; +import { isOpenCodeGoResponsesUrl } from "./opencode-go-additional-tools"; const CODEX_WEB_SEARCH_TOOL = "web_search"; const CODEX_WEB_SEARCH_PREVIEW_TOOL = "web_search_preview"; @@ -146,6 +147,24 @@ function hasToolType(tools: unknown, type: string): boolean { return Array.isArray(tools) && tools.some(tool => isPlainObject(tool) && tool.type === type); } +/** + * OpenCode Go serves Grok on its Responses endpoint by relaying to xAI, and relays xAI's schema + * verbatim. Probed 2026-09-26 on `https://opencode.ai/zen/go/v1/responses` with grok-4.6 and + * grok-4.7, one field per request: `external_web_access` and `search_context_size` -> 400 + * "Argument not supported", `web_search_preview` -> 422 `unknown variant`, while a plain + * `web_search` with `search_content_types`, `enable_image_search`, `user_location` or + * `filters` -> 200 and performs live searches. That is the xAI dialect exactly, so the same + * normalization applies. Sibling Go models (Luna, Muse Spark) accept the OpenAI fields and are + * left alone. Membership is by family prefix: an exact-id match is what let grok-4.7 reach this + * endpoint unnormalized once it joined grok-4.6 on the Responses wire. + */ +function isOpenCodeGoGrokResponses(modelId: unknown, responseUrl: string | undefined): boolean { + return typeof modelId === "string" + && modelId.trim().toLowerCase().startsWith("grok-") + && typeof responseUrl === "string" + && isOpenCodeGoResponsesUrl(responseUrl); +} + /** * Make Codex's hosted web-search declaration acceptable to xAI Responses without changing other * providers or mutating the caller-owned request body. @@ -157,12 +176,23 @@ function hasToolType(tools: unknown, type: string): boolean { * `web_search_preview` -> 422 `unknown variant`, `external_web_access` -> 400 on every value, * `search_context_size` -> 400, while `user_location` and `search_content_types` -> 200. Identical * to the public API, which is what makes one shared gate correct. + * + * Grok models on the exact OpenCode Go Responses URL speak the same dialect and share this gate; + * see `isOpenCodeGoGrokResponses`. `target` carries the final model id and request URL for that + * check, and callers without it keep the xAI-host-only behavior. */ export function normalizeXaiResponsesWebSearch( body: unknown, provider: Pick, + target?: { modelId?: unknown; responseUrl?: string }, ): unknown { - if (!isXaiResponsesDestination(provider) || !isPlainObject(body)) return body; + if (!isPlainObject(body)) return body; + const xaiDestination = isXaiResponsesDestination(provider); + if (!xaiDestination && !isOpenCodeGoGrokResponses(target?.modelId, target?.responseUrl)) return body; + // On Go, a wrapper emptied here stays in place: Go promotion (opencode-go-additional-tools.ts) + // removes every wrapper before the wire and splits them into history and current turn by + // index against _replayPrefixLen, so deleting one would shift the current turn into history. + const keepEmptiedWrappers = !xaiDestination; let next: Record = body; if (Array.isArray(body.tools)) { @@ -188,7 +218,7 @@ export function normalizeXaiResponsesWebSearch( continue; } inputChanged = true; - if (rewritten.tools.length > 0) input.push({ ...item, tools: rewritten.tools }); + if (rewritten.tools.length > 0 || keepEmptiedWrappers) input.push({ ...item, tools: rewritten.tools }); } if (inputChanged) next = { ...next, input }; } diff --git a/src/bridge/sse.ts b/src/bridge/sse.ts index 5b2f5a3de76..c733c1e45d7 100644 --- a/src/bridge/sse.ts +++ b/src/bridge/sse.ts @@ -73,6 +73,12 @@ export function bridgeToResponsesSSE( options?: { responseId?: string; stallTimeoutSec?: number; + /** + * The upstream is local infrastructure (loopback / private / `.local` / `.lan`); an unset + * `stallTimeoutSec` then resolves to disabled so a slow local model is not cut mid-turn. + * Wire keep-alives still re-arm the client's idle clock either way. + */ + localUpstream?: boolean; hideThinkingSummary?: boolean; /** * Remote compaction v2 turn: accumulate all assistant text and, on done, emit ONE synthetic @@ -341,7 +347,9 @@ export function bridgeToResponsesSSE( ? encoder.encode(': opencodex heartbeat\n\n') : encoder.encode('event: response.heartbeat\ndata: {"type":"response.heartbeat"}\n\n'); let stallTicks = 0; - const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec); + const stallSec = resolveStallTimeoutSec(options?.stallTimeoutSec, { + localUpstream: options?.localUpstream, + }); const maxStallTicks = Math.ceil((stallSec * 1000) / heartbeatMs); let currentMsg: { @@ -1407,7 +1415,11 @@ export function bridgeToResponsesSSE( if (upstreamActivity) { upstreamActivity = false; stallTicks = 0; - } else if (++stallTicks >= maxStallTicks) { + } else if (stallSec > 0 && ++stallTicks >= maxStallTicks) { + // stallSec of 0 is an explicit disabled budget (local upstream, or the operator's + // stallTimeoutSec: 0). Never let `maxStallTicks === 0` arm a kill on the first beat: + // with it the `>= 0` comparison is true the moment a single tick lands, which would + // terminate a healthy silent local model after ~2s instead of leaving it alone. if (!attemptTerminationCleanup(() => { if (currentMsg) closeCurrentMessage(); if (currentReasoning) closeCurrentReasoning(); diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index b07b2379f98..26447987f0f 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -193,8 +193,26 @@ function collectDesktop3pModels( const rendered = renderDesktopProfile(reconciled, profileModels); const aliasesByRoute = new Map(); for (const model of rendered) { + const realAnthropic = model.route.startsWith("anthropic/claude-"); + if (!realAnthropic && realAnthropicIds.has(model.name)) { + console.warn(`[opencodex] Claude Desktop profile alias ${model.name} for ${model.route} conflicts with a real Anthropic model; skipping the routed model`); + continue; + } aliasesByRoute.set(model.route, model.name); - if (!model.route.startsWith("anthropic/claude-")) registry.set(model.name, model.route); + if (!realAnthropic) { + registry.set(model.name, model.route); + // Profiles persist their historical date slots, while Desktop receives + // a non-date wire id. Keep the date form resolvable for running/stale + // sessions during the migration. + const storedAlias = reconciled.assignments[model.route]?.alias; + if (storedAlias && storedAlias !== model.name) { + if (realAnthropicIds.has(storedAlias)) { + console.warn(`[opencodex] Claude Desktop stored alias ${storedAlias} for ${model.route} conflicts with a real Anthropic model; ignoring the alias`); + } else { + registry.set(storedAlias, model.route); + } + } + } models.push({ name: model.name, labelOverride: model.label, @@ -203,25 +221,33 @@ function collectDesktop3pModels( ...(model.supports1m ? { supports1m: true, prefer1m: 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. + // Hash aliases from both pre-profile generations 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; + for (const legacy of [desktop3pAlias(provider, id), legacyDesktop3pAlias(provider, id)]) { + if (realAnthropicIds.has(legacy)) { + console.warn(`[opencodex] Claude Desktop legacy alias ${legacy} for ${model.route} conflicts with a real Anthropic model; ignoring the alias`); + continue; + } + 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); } - registry.set(legacy, model.route); } desktop3pAliasesByRoute = aliasesByRoute; return { models, registry, realAnthropicIds }; } + const aliasesByRoute = new Map(); + const emittedNames = new Set(); for (const { provider, id, contextWindow } of candidates) { const route = `${provider}/${id}`; const alias = desktop3pAlias(provider, id); @@ -232,13 +258,20 @@ function collectDesktop3pModels( // Real Anthropic model: keep it OUT of the decode registry — registering it would // make resolveInboundModel() non-identity and kill the sk-ant native passthrough // (audit 133 #1). It still appears in the static Desktop model list below. + if (emittedNames.has(alias)) continue; models.push({ name: alias, labelOverride: `${displayModelId(id)} (${provider})`, anthropicFamilyTier: "opus", ...supports1m, - ...(supports1m.supports1m ? { prefer1m: true as const } : {}), + ...(supports1m.supports1m ? { prefer1m: true as const } : {}), }); + emittedNames.add(alias); + aliasesByRoute.set(route, alias); + continue; + } + if (realAnthropicIds.has(alias)) { + console.warn(`[opencodex] Claude Desktop 3P alias ${alias} for ${route} conflicts with a real Anthropic model; skipping the routed model`); continue; } const existingRoute = registry.get(alias); @@ -250,7 +283,11 @@ function collectDesktop3pModels( registry.set(alias, route); // Back-compat decode for Desktop configs written before the opus-4-8 rename. const legacy = legacyDesktop3pAlias(provider, id); - if (!registry.has(legacy)) registry.set(legacy, route); + if (realAnthropicIds.has(legacy)) { + console.warn(`[opencodex] Claude Desktop legacy alias ${legacy} for ${route} conflicts with a real Anthropic model; ignoring the alias`); + } else if (!registry.has(legacy)) { + registry.set(legacy, route); + } models.push({ name: alias, labelOverride: `${displayModelId(id)} (${provider})`, @@ -258,10 +295,11 @@ function collectDesktop3pModels( ...supports1m, ...(supports1m.supports1m ? { prefer1m: true as const } : {}), }); + aliasesByRoute.set(route, alias); } if (models[0]) models[0].isFamilyDefault = true; - desktop3pAliasesByRoute = new Map(candidates.map(({ provider, id }) => [`${provider}/${id}`, desktop3pAlias(provider, id)])); + desktop3pAliasesByRoute = aliasesByRoute; return { models, registry, realAnthropicIds }; } @@ -308,7 +346,9 @@ export function isUnresolvedDesktop3pAlias(id: string): boolean { // identity only; it neither enables a tier nor strips an exact full catalog ID. const base = id.endsWith("--fast") ? id.slice(0, -"--fast".length) : id; if (isKnownDesktop3pModelId(base)) return false; - return validDateAlias(base) || /^claude-opus-4-(?:8-)?[a-z][a-z0-9]{2}$/.test(base); + return validDateAlias(base) + || /^claude-opus-4-(?:8-)?[a-z][a-z0-9]{2}$/.test(base) + || /^claude-opus-4-8-p[0-9a-z]{3}$/.test(base); } /** Alias selected by the installed profile registry, falling back to the legacy hash shape. */ diff --git a/src/claude/desktop-profile.ts b/src/claude/desktop-profile.ts index c44e7ce0246..9f617f59181 100644 --- a/src/claude/desktop-profile.ts +++ b/src/claude/desktop-profile.ts @@ -28,9 +28,11 @@ export interface RenderedDesktopModel extends DesktopProfileModel { // design, so the set only grows). Years before 2026 stay rejected: dated // ids like `claude-opus-4-8-20250201` are real model snapshot ids, not // managed aliases, and the inbound decoder relies on that distinction. -// Every emitted suffix stays 8 digits so modelMap date-stripping keeps -// working. +// The persisted slot stays date-shaped for stable allocation and migration. +// renderDesktopProfile() converts it to a letter-first wire id because current +// Claude Desktop strips terminal dates before comparing model identities. const DATE_ALIAS = /^claude-opus-4-8-(202[6-9]\d{4}|203[0-5]\d{4})$/; +const PROFILE_WIRE_PREFIX = "claude-opus-4-8-"; const LEGACY_YEAR = 2026; const LEGACY_DAY_COUNT = 365; const ALIAS_FIRST_YEAR = 2026; @@ -123,6 +125,24 @@ export function validDateAlias(alias: string): boolean { return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; } +/** + * Turn a persisted date slot into a unique non-date Desktop wire id. + * + * The 2026-2035 namespace has fewer than 3,888 days, so a `p`-prefixed + * three-character base36 slot has ample room. The four-character suffix is + * disjoint from both terminal dates and the historical three-character hash + * namespace used by profiles written before this allocator existed. + */ +export function desktopProfileWireAlias(alias: string): string { + const match = DATE_ALIAS.exec(alias); + if (!match || !validDateAlias(alias)) return alias; + 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 slot = Math.round((Date.UTC(year, month - 1, day) - Date.UTC(ALIAS_FIRST_YEAR, 0, 1)) / 86_400_000); + return `${PROFILE_WIRE_PREFIX}p${slot.toString(36).padStart(3, "0")}`; +} + export function parseDesktopProfile(value: unknown): DesktopProfile { if (!isPlainObject(value)) throw new DesktopProfileError("must be an object"); // `appliedFingerprint`/`appliedAt` are written back by the apply route once a profile @@ -235,7 +255,24 @@ export function reconcileDesktopProfile( 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)); + const activeRealAliases = new Set(models + .filter(model => isRealAnthropicRoute(model.route)) + .map(model => routeModelId(model.route))); + const displacedRoutes = Object.entries(assignments) + .filter(([route, assignment]) => !isRealAnthropicRoute(route) && activeRealAliases.has(assignment.alias)) + .map(([route]) => route) + .sort(); + const displaced = new Set(displacedRoutes); + const used = new Set(Object.entries(assignments) + .filter(([route]) => !displaced.has(route)) + .map(([, assignment]) => assignment.alias)); + for (const alias of activeRealAliases) used.add(alias); + for (const route of displacedRoutes) { + const assignment = assignments[route]!; + const alias = allocateAlias(route, used); + used.add(alias); + assignments[route] = { ...assignment, 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); @@ -319,7 +356,7 @@ export function renderDesktopProfile( const assignment = parsed.assignments[route]!; return { ...model, - name: assignment.alias, + name: isRealAnthropicRoute(route) ? assignment.alias : desktopProfileWireAlias(assignment.alias), family: assignment.family, isFamilyDefault: effectiveDefaults[assignment.family] === route, supports1m: typeof model.contextWindow === "number" && model.contextWindow >= 1_000_000, diff --git a/src/codex/catalog/access-programs.ts b/src/codex/catalog/access-programs.ts new file mode 100644 index 00000000000..3838296725e --- /dev/null +++ b/src/codex/catalog/access-programs.ts @@ -0,0 +1,50 @@ +import { isMainCodexAccountTarget } from "../account-namespaces"; +import { COMBO_NAMESPACE } from "../../combos"; +import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import type { CodexModelEntitlementSnapshot } from "../model-entitlements"; +import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; +import { isNativeAliasCatalogEntry, SUPPORTED_NATIVE_OPENAI_SLUGS } from "./metadata"; +import type { RawEntry } from "./parsing"; + +/** Project authenticated account metadata onto native rows only. */ +export function applyNativeAccessPrograms( + entries: RawEntry[], + snapshot: CodexModelEntitlementSnapshot, + accountTargets: ReadonlyMap, +): void { + for (const entry of entries) { + // A combo may deliberately claim a bare native slug. It is still a routed combo row, + // so the matching native Codex roster must not project its access programs onto it. + if (isNativeAliasCatalogEntry(entry)) continue; + if (entry.owned_by === COMBO_NAMESPACE) { + delete entry.available_access_programs; + continue; + } + const accountBoundSlug = trustedAccountBoundNativeCatalogSlug(entry); + const bareSlug = typeof entry.slug === "string" && !entry.slug.includes("/") + && SUPPORTED_NATIVE_OPENAI_SLUGS.has(entry.slug) ? entry.slug : undefined; + const slug = accountBoundSlug ?? bareSlug; + if (!slug) continue; + const selector = accountBoundSlug && typeof entry.slug === "string" + ? entry.slug.slice(0, entry.slug.indexOf("/")) : undefined; + const target = selector === undefined ? MAIN_CODEX_ACCOUNT_ID : accountTargets.get(selector); + const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target; + if (!accountId) { + delete entry.available_access_programs; + continue; + } + // An old on-disk catalog can contain metadata from a previous credential. Until this + // account has a confirmed roster, that value is no longer evidence of a grant. + if (!snapshot.confirmedAccountIds.has(accountId) + || !snapshot.modelsByAccount.get(accountId)?.has(slug)) { + delete entry.available_access_programs; + continue; + } + const accountPrograms = snapshot.accessProgramsByAccount?.get(accountId); + if (accountPrograms?.has(slug)) { + entry.available_access_programs = accountPrograms.get(slug) ?? null; + } else { + delete entry.available_access_programs; + } + } +} diff --git a/src/codex/catalog/build-entries.ts b/src/codex/catalog/build-entries.ts index 81bfee3ada0..aef0f234054 100644 --- a/src/codex/catalog/build-entries.ts +++ b/src/codex/catalog/build-entries.ts @@ -865,9 +865,22 @@ export function mergeCatalogEntriesFromObservedState({ const normalized = reserveProjection ? m : normalizeServiceTiers(m); if (!reserveProjection && !isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized, openaiContextCap); const exactCombo = isExactComboCatalogEntry(m, exactComboSlugs); + // The builder copied this metadata from the pinned first-party row only for an exact + // ChatGPT/Codex forward custom alias. Freshness plus source equality prevents an old or + // unrelated routed row from claiming the native account's access programs at this second + // normalization boundary. + const nativeSourceSlug = typeof m.slug === "string" && m.slug.startsWith("openai/") + ? m.slug.slice("openai/".length) : undefined; + const nativeSource = nativeSourceSlug ? upstreamNativeEntry(nativeSourceSlug) : undefined; + const preserveNativeAccessPrograms = freshCustomEntries.has(m) + && m.owned_by !== COMBO_NAMESPACE + && nativeSource != null + && Object.hasOwn(nativeSource, "available_access_programs") + && JSON.stringify(m.available_access_programs) === JSON.stringify(nativeSource.available_access_programs); const e = reserveProjection ? normalized : ensureStrictCatalogFields(normalized, { preserveExactInputModalities: exactCombo, isRouted: finalRoutedEntrySet.has(m), + preserveNativeAccessPrograms, }); // Mock-max universality (260709): preserved routed entries from disk may predate // the max rung — ensure it here so subagent max spawns validate on every diff --git a/src/codex/catalog/derive-entry.ts b/src/codex/catalog/derive-entry.ts index 8058fc6850d..e1d85541704 100644 --- a/src/codex/catalog/derive-entry.ts +++ b/src/codex/catalog/derive-entry.ts @@ -195,6 +195,7 @@ export function deriveEntry( return ensureStrictCatalogFields(normalizeServiceTiers(e), { preserveExactInputModalities: preserveExact, isRouted, + preserveNativeAccessPrograms: codexForwardNativeCapabilityAlias !== null, }); } // Fallback when no template is available (best-effort; strict parser may need more). diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 9a55680038c..233b882be46 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -582,9 +582,10 @@ export function applyNativeOpenAiContextOverride(entry: RawEntry, limits?: Nativ } } +/** Normalize a row for Codex's catalog parser, stripping native eligibility from routed rows unless explicitly preserved. */ export function ensureStrictCatalogFields( entry: RawEntry, - options: { preserveExactInputModalities?: boolean; isRouted?: boolean } = {}, + options: { preserveExactInputModalities?: boolean; isRouted?: boolean; preserveNativeAccessPrograms?: boolean } = {}, ): RawEntry { if (entry.shell_type === "default" || entry.shell_type === "local" || entry.shell_type === "shell_command") { entry.shell_type = "unified_exec"; @@ -634,7 +635,7 @@ export function ensureStrictCatalogFields( if (typeof entry.effective_context_window_percent !== "number") entry.effective_context_window_percent = 95; if (typeof entry.comp_hash !== "string") entry.comp_hash = "opencodex"; // Routed rows must not carry NATIVE eligibility metadata. `deriveEntry` deep-clones a - // native template and deletes a fixed denylist, so these five survive onto rows backed + // native template and deletes a fixed denylist, so these eligibility fields survive onto rows backed // by unrelated provider credentials — advertising ChatGPT plan eligibility for a model // that never touches a ChatGPT account (#2813). // @@ -644,6 +645,9 @@ export function ensureStrictCatalogFields( // leave already-contaminated rows contaminated forever. if (options.isRouted === true) { entry.supported_in_api = true; + // Exact Codex-forward aliases still use a ChatGPT credential and may retain their native + // source metadata. Other routed rows cannot claim that account's access programs. + if (!options.preserveNativeAccessPrograms) delete entry.available_access_programs; delete entry.available_in_plans; delete entry.minimal_client_version; delete entry.availability_nux; diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index 03beecfedbb..9e2ab48c989 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -12,6 +12,7 @@ import { providerCodexAccountMode } from "../../providers/registry"; import { COMBO_NAMESPACE } from "../../combos"; import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces"; import { MAIN_CODEX_ACCOUNT_ID } from "../main-account"; +import { applyNativeAccessPrograms } from "./access-programs"; import { availableAccountGatedNativeModels, codexModelEntitlementStateForAccount, @@ -266,6 +267,7 @@ function catalogModelsForMergeWithNativeRecovery( ]); } +/** Merge retained rows, project confirmed account metadata, and publish only changed catalog bytes. */ function writeRetainedCatalogSync({ config, goModels, @@ -526,6 +528,7 @@ function writeRetainedCatalogSync({ warningPolicy: "emit", }, }); + applyNativeAccessPrograms(catalog.models, modelEntitlements, accountTargets); clampCatalogModelsToCodexSupport(catalog.models); finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge, config); // Last mutation before serialization; see `enforceCatalogSlugUniqueness` for why the ordering diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 212f4759593..5d6abb06b7e 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -74,6 +74,7 @@ import { suppressedSyntheticMaxCatalogSlugs } from "./catalog/model-hints"; import { codexRuntimeStatePath, peekCodexRuntimeProcessCache } from "./runtime"; import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "./account-namespaces"; import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; +import { applyNativeAccessPrograms } from "./catalog/access-programs"; import { availableAccountGatedNativeModels, codexModelEntitlementStateForAccount, @@ -384,6 +385,7 @@ function prepareCatalog( warningPolicy: "suppress", }, }); + applyNativeAccessPrograms(mergedModels, modelEntitlements, accountTargets); clampCatalogModelsToObservedCodexSupport( mergedModels, source.runtimeSupport.kind === "available" diff --git a/src/codex/model-entitlements.ts b/src/codex/model-entitlements.ts index 6322bccc8d3..ded4e9a1159 100644 --- a/src/codex/model-entitlements.ts +++ b/src/codex/model-entitlements.ts @@ -407,17 +407,21 @@ interface CachedAccountModels { readonly clientVersion: string; readonly expiresAt: number; readonly models: ReadonlySet; + readonly accessProgramsByModel?: ReadonlyMap; readonly confirmed: boolean; readonly provenance?: CodexModelEntitlementProvenance; } export interface CodexModelEntitlementSnapshot { readonly modelsByAccount: ReadonlyMap>; + readonly accessProgramsByAccount?: ReadonlyMap>; readonly clientVersionByAccount: ReadonlyMap; readonly confirmedAccountIds: ReadonlySet; readonly credentialIdentities: ReadonlyMap; } +export type CodexAvailableAccessPrograms = Readonly> | null; + export type CodexModelEntitlementState = "granted" | "denied" | "unknown"; export interface CodexModelEntitlementResolveOptions { @@ -633,17 +637,28 @@ async function accountCredentialSnapshot( } } -function parseAccountModels(text: string): ReadonlySet | null { +/** Keep valid roster slugs while dropping malformed program metadata; preserve explicit null separately from absence. */ +function parseAccountModels(text: string): { models: ReadonlySet; accessProgramsByModel: ReadonlyMap } | null { try { const payload = JSON.parse(text) as { models?: unknown }; if (!Array.isArray(payload.models)) return null; + const accessProgramsByModel = new Map(); const models = payload.models.flatMap(entry => { if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; - const row = entry as { slug?: unknown; supported_in_api?: unknown; visibility?: unknown }; + const row = entry as { slug?: unknown; supported_in_api?: unknown; visibility?: unknown; available_access_programs?: unknown }; if (typeof row.slug !== "string" || row.supported_in_api !== true || row.visibility === "hide") return []; + const programs = row.available_access_programs; + if (programs === null) accessProgramsByModel.set(row.slug, null); + else if (programs && typeof programs === "object" && !Array.isArray(programs)) { + const record = programs as Record; + if (Array.isArray(record.cyber) && record.cyber.every(item => typeof item === "string") + && Object.values(record).every(value => Array.isArray(value) && value.every(item => typeof item === "string"))) { + accessProgramsByModel.set(row.slug, record as Record); + } + } return [row.slug]; }); - return new Set(models); + return { models: new Set(models), accessProgramsByModel }; } catch { return null; } @@ -669,6 +684,7 @@ function isTimeoutError(error: unknown): boolean { return error instanceof Error && error.name === "TimeoutError"; } +/** Fetch one bounded, version-scoped roster; failures and empty results remain unconfirmed for a short retry. */ async function fetchAccountModels( credential: CodexModelEntitlementCredentialSnapshot, fetcher: typeof fetch, @@ -702,8 +718,8 @@ async function fetchAccountModels( if (!body.displaySafe || body.truncated) { return unconfirmedAccountModels(credential, clientVersion, now, { kind: "unparseable" }); } - const models = parseAccountModels(body.text); - if (models === null) { + const parsed = parseAccountModels(body.text); + if (parsed === null) { return unconfirmedAccountModels(credential, clientVersion, now, { kind: "unparseable" }); } // A roster is a confirmation only when it lists something usable. `models` is a Set, and an @@ -713,6 +729,7 @@ async function fetchAccountModels( // account asked under too old a client version answers with no gated rows, and treating // that as authoritative is exactly how 2.36.0 denied sol/terra/luna to accounts that own // them (#3022). No usable rows means unconfirmed, on the 15s failure TTL, asked again. + const { models, accessProgramsByModel } = parsed; const usable = models.size > 0; if (!usable) { return unconfirmedAccountModels(credential, clientVersion, now, { kind: "parsed-empty" }); @@ -735,6 +752,7 @@ async function fetchAccountModels( ? MODEL_ROSTER_TTL_MS : MODEL_ROSTER_FAILURE_TTL_MS), models, + accessProgramsByModel, confirmed: true, }; } catch (error) { @@ -1173,6 +1191,10 @@ export async function resolveCodexModelEntitlements( }))); return { modelsByAccount: new Map(results.map(({ credential, result }) => [credential.accountId, result.models])), + accessProgramsByAccount: new Map(results.flatMap(({ credential, result }) => ( + result.confirmed && result.accessProgramsByModel + ? [[credential.accountId, result.accessProgramsByModel] as const] : [] + ))), clientVersionByAccount: new Map(results.map(({ credential, result }) => ( [credential.accountId, result.clientVersion] ))), diff --git a/src/lib/local-upstream.ts b/src/lib/local-upstream.ts new file mode 100644 index 00000000000..5999a6449cd --- /dev/null +++ b/src/lib/local-upstream.ts @@ -0,0 +1,146 @@ +/** + * Classify an upstream provider destination as local or public. + * + * A *local* upstream is operator-trusted infrastructure on the same machine or LAN — LM Studio, + * Ollama, a self-hosted vLLM, a local gateway. Those models are frequently CPU-bound and their + * time-to-first-token or thinking phases can go silent for minutes, so a stall budget applied + * there would kill healthy turns. The stall resolver (`src/stall-timeout.ts`) therefore treats an + * unset budget on a local upstream as *disabled* while public upstreams keep the 300 s default. + * + * The destination is the provider's `baseUrl` (or the built request URL) — where the proxy + * actually dials upstream. This is a different question from + * `src/lib/local-destinations.ts`, which answers where local clients dial *this hub*; do not + * conflate them. + * + * Conservative on purpose: only clear local evidence (loopback, private, link-local, or a + * `.local`/`.lan` name) earns the disabled budget. Anything else — a public DNS name, an + * unreachable host, a bare domain — stays public and keeps its stall budget, because being local + * is what switches OFF a safety clock. + */ +import { isIP } from "node:net"; + +/** True when the provider destination is local infrastructure and its stall budget should default to disabled. */ +export function isLocalUpstream(destination: string | null | undefined): boolean { + if (!destination) return false; + // A bare IP literal must be classified before URL parsing: `new URL("fe80::1")` reads the first + // hextet as a scheme and yields an empty host, and `new URL("::1")` throws — either way the host + // would be lost. Zone ids (`fe80::1%eth0`) are stripped for the family check. + const trimmed = destination.trim(); + const literalFamily = isIP(trimmed.split("%")[0]); + if (literalFamily === 4) return isLocalIpv4(trimmed); + if (literalFamily === 6) return isLocalIpv6(trimmed.split("%")[0]); + + const host = hostnameOf(destination); + if (!host) return false; + const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + const normalized = bare.toLowerCase().split("%")[0].replace(/\.$/, ""); + if (normalized === "") return false; + if (normalized === "localhost") return true; + if (normalized.endsWith(".local") || normalized.endsWith(".lan")) return true; + const family = isIP(normalized); + if (family === 4) return isLocalIpv4(normalized); + if (family === 6) return isLocalIpv6(normalized); + return false; +} + +/** + * Extract a hostname from a full URL or a schemeless `host:port` / bare host. + * + * `new URL` handles the scheme-bearing forms (it keeps IPv6 brackets in `hostname`); the fallback + * recovers the host from a schemeless string by dropping a port or bracketed IPv6 suffix. + */ +function hostnameOf(destination: string): string | null { + const trimmed = destination.trim(); + if (!trimmed) return null; + // Only trust `new URL` when it produces a non-empty host. `new URL("localhost:1234")` succeeds + // but reads `localhost` as a scheme and leaves the host empty, so fall through to the schemeless + // parse in that case rather than returning null. + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) { + try { + const parsed = new URL(trimmed).hostname; + if (parsed) return parsed; + } catch { + // Not an absolute URL after all: fall through to the schemeless forms. + } + } + let host = trimmed; + if (host.startsWith("[")) { + const end = host.indexOf("]"); + host = end > 0 ? host.slice(1, end) : host.slice(1); + } else { + const colon = host.indexOf(":"); + if (colon >= 0) host = host.slice(0, colon); + } + return host || null; +} + +/** 0.0.0.0/8, 10/8, 127/8 (loopback), 169.254/16 (link-local), 172.16/12, 192.168/16. */ +function isLocalIpv4(host: string): boolean { + const parts = host.split("."); + if (parts.length !== 4) return false; + const octets: number[] = []; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return false; + const value = Number(part); + if (value > 255) return false; + octets.push(value); + } + const [a, b] = octets; + if (a === 0) return true; + if (a === 10) return true; + if (a === 127) return true; + if (a === 169 && b === 254) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + return false; +} + +/** ::1 (loopback), `::ffff:a.b.c.d` (mapped), fe80::/10 (link-local), fc00::/7 (unique-local). */ +function isLocalIpv6(host: string): boolean { + const lower = host.toLowerCase(); + const groups = ipv6Groups(lower); + if (!groups) return false; + // Loopback in any spelling: every group zero except a trailing one. + if (groups.every((group, index) => (index < 7 ? group === 0 : group === 1))) return true; + const mapped = lower.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/); + if (mapped) return isLocalIpv4(mapped[1]); + const first = groups[0]; + if ((first & 0xffc0) === 0xfe80) return true; + if ((first & 0xfe00) === 0xfc00) return true; + return false; +} + +/** Expand a (possibly `::`-compressed) IPv6 literal into eight 16-bit groups, or null if malformed. */ +function ipv6Groups(host: string): number[] | null { + const marker = host.indexOf("::"); + const compressed = marker >= 0; + const head = compressed ? host.slice(0, marker) : host; + const tail = compressed ? host.slice(marker + 2) : ""; + const headGroups = head === "" ? [] : head.split(":"); + let tailGroups = tail === "" ? [] : tail.split(":"); + // A trailing embedded IPv4 (e.g. `64:ff9b::1.2.3.4`) counts as two groups. + const last = tailGroups[tailGroups.length - 1]; + if (last !== undefined && last.includes(".")) { + const v4 = last.split("."); + if (v4.length === 4 && v4.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)) { + const [o0, o1, o2, o3] = v4.map(Number); + tailGroups = [...tailGroups.slice(0, -1), ((o0 << 8) | o1).toString(16), ((o2 << 8) | o3).toString(16)]; + } else return null; + } + let groups: string[]; + if (compressed) { + // `::` fills the gap *between* head and tail with zero groups, not at either end. + const fill = 8 - headGroups.length - tailGroups.length; + if (fill < 1) return null; + groups = [...headGroups, ...Array(fill).fill("0"), ...tailGroups]; + } else { + groups = [...headGroups, ...tailGroups]; + if (groups.length !== 8) return null; + } + const numbers = groups.map((group) => (isHextet(group) ? parseInt(group, 16) : Number.NaN)); + return numbers.some((group) => Number.isNaN(group)) ? null : numbers; +} + +function isHextet(token: string): boolean { + return /^[0-9a-f]{1,4}$/i.test(token); +} diff --git a/src/lib/package-tree-integrity.ts b/src/lib/package-tree-integrity.ts index 5be14e0eb51..2997e63e9ec 100644 --- a/src/lib/package-tree-integrity.ts +++ b/src/lib/package-tree-integrity.ts @@ -1,4 +1,6 @@ import { readFileSync, statSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { isStandaloneBinary } from "./standalone"; export interface PackageTreeObservation { @@ -63,9 +65,19 @@ const packageManifestUrl = new URL("../../package.json", import.meta.url); const INSTALLED_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; +/** Directory of the package this process was loaded from, as the module loader spells it. */ +export function runningPackageRoot(): string { + return dirname(fileURLToPath(packageManifestUrl)); +} + function readInstalledManifestVersion(): string | undefined { + return readPackageManifestVersionAt(fileURLToPath(packageManifestUrl)); +} + +/** The `version` of the manifest at `manifestPath`, or undefined when unreadable or malformed. */ +export function readPackageManifestVersionAt(manifestPath: string): string | undefined { try { - const version = (JSON.parse(readFileSync(packageManifestUrl, "utf8")) as { version?: unknown }).version; + const version = (JSON.parse(readFileSync(manifestPath, "utf8")) as { version?: unknown }).version; return typeof version === "string" && version.length <= 64 && INSTALLED_VERSION_PATTERN.test(version) ? version : undefined; @@ -75,8 +87,13 @@ function readInstalledManifestVersion(): string | undefined { } function observePackageManifest(): PackageTreeObservation | null { + return observePackageManifestAt(fileURLToPath(packageManifestUrl)); +} + +/** Identity of the manifest at `manifestPath`, or null when it cannot be stat'd. */ +export function observePackageManifestAt(manifestPath: string): PackageTreeObservation | null { try { - const stat = statSync(packageManifestUrl, { bigint: true }); + const stat = statSync(manifestPath, { bigint: true }); return { device: stat.dev, inode: stat.ino, @@ -99,7 +116,12 @@ function observePackageManifest(): PackageTreeObservation | null { } } -function sameObservation(left: PackageTreeObservation, right: PackageTreeObservation): boolean { +/** The manifest path inside a package root. */ +export function packageManifestPathIn(root: string): string { + return join(root, "package.json"); +} + +export function sameObservation(left: PackageTreeObservation, right: PackageTreeObservation): boolean { return left.device === right.device && left.inode === right.inode && left.contentTimeNs === right.contentTimeNs diff --git a/src/lib/package-tree-retarget.ts b/src/lib/package-tree-retarget.ts new file mode 100644 index 00000000000..75598cc5f42 --- /dev/null +++ b/src/lib/package-tree-retarget.ts @@ -0,0 +1,130 @@ +import { + packageManifestPathIn, + readPackageManifestVersionAt, + sameObservation, + type PackageTreeObservation, +} from "./package-tree-integrity"; + +/** + * The package a service launcher would start right now: its canonical root and the identity of + * the manifest inside it. Null when the launcher, its target or the manifest cannot be resolved. + */ +export interface LauncherTarget { + readonly root: string; + readonly manifest: PackageTreeObservation; +} + +export type ResolveLauncherTarget = () => LauncherTarget | null; + +export interface PackageTreeRetargetOptions { + /** How often the launcher is re-resolved. Detection must not depend on inbound requests. */ + pollIntervalMs?: number; + /** How long one complete target identity must hold before the restart is requested. */ + settleMs?: number; + now?: () => number; + /** Test seam; production uses an unref'd timer and returns its cancellation. */ + schedule?: (callback: () => void, delayMs: number) => () => void; + /** Test seam for the settled target's version; production reads its package manifest. */ + readVersion?: (root: string) => string | undefined; +} + +export interface PackageTreeRetargetWatch { + /** + * Version of the package the launcher now starts, once that target has held for the full + * settle interval and still resolves to the same identity. A fenced `/healthz` reports it + * when the running tree itself has gone, so `ocx restart` compares against what the + * supervisor will actually start. + */ + settledVersion(): string | undefined; + /** Stops polling and invalidates any pending restart request. */ + dispose(): void; +} + +const DEFAULT_POLL_INTERVAL_MS = 5_000; +const DEFAULT_SETTLE_MS = 5_000; + +function sameTarget(left: LauncherTarget, right: LauncherTarget): boolean { + return left.root === right.root && sameObservation(left.manifest, right.manifest); +} + +/** + * Watches a version-manager launcher whose target can move to a different package tree while the + * running tree stays intact. + * + * The package-tree integrity fence only sees its own manifest. A manager that installs every + * version into its own directory and then repoints a floating link (mise's `latest`) never + * touches that manifest, so the fence neither restarts onto the new version nor recovers when + * the old version is later pruned from under it. This watch compares what the launcher resolves + * to with the running root instead. + * + * `onRetargeted` runs once, after one complete target identity (canonical root plus manifest + * identity) has held for `settleMs`. Any change of either part, an unresolvable launcher, or a + * return to the running root restarts the wait. A throwing handler is retried after another full + * interval. After the supervisor restarts through the launcher, the new process's running root + * is the target, so it never requests a second restart. + */ +export function createPackageTreeRetargetWatch( + runningRoot: string, + resolveTarget: ResolveLauncherTarget, + onRetargeted: () => void, + options: PackageTreeRetargetOptions = {}, +): PackageTreeRetargetWatch { + const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const settleMs = options.settleMs ?? DEFAULT_SETTLE_MS; + const now = options.now ?? Date.now; + const readVersion = options.readVersion ?? (root => readPackageManifestVersionAt(packageManifestPathIn(root))); + const schedule = options.schedule ?? ((callback, delayMs) => { + const timer = setTimeout(callback, delayMs); + timer.unref?.(); + return () => clearTimeout(timer); + }); + + let stopped = false; + let cancelPoll: (() => void) | null = null; + let candidate: LauncherTarget | null = null; + let candidateSince = 0; + let settled: LauncherTarget | null = null; + + const poll = (): void => { + cancelPoll = null; + if (stopped) return; + const target = resolveTarget(); + if (target === null || target.root === runningRoot) { + candidate = null; + settled = null; + } else if (candidate === null || !sameTarget(candidate, target)) { + candidate = target; + candidateSince = now(); + settled = null; + } else if (now() - candidateSince >= settleMs) { + settled = target; + try { + onRetargeted(); + stopped = true; + return; + } catch { + // Restart admission refused (for example, the service home changed owner). Keep the + // settled identity and ask again only after another full interval. + candidateSince = now(); + } + } + cancelPoll = schedule(poll, pollIntervalMs); + }; + + cancelPoll = schedule(poll, pollIntervalMs); + + return { + settledVersion: () => { + if (settled === null) return undefined; + const current = resolveTarget(); + if (current === null || !sameTarget(settled, current)) return undefined; + return readVersion(settled.root); + }, + dispose: () => { + stopped = true; + const cancel = cancelPoll; + cancelPoll = null; + cancel?.(); + }, + }; +} diff --git a/src/protocols/encoders/adapter-events.ts b/src/protocols/encoders/adapter-events.ts index b0c169bbb5a..65c3cf86667 100644 --- a/src/protocols/encoders/adapter-events.ts +++ b/src/protocols/encoders/adapter-events.ts @@ -142,6 +142,11 @@ export interface AdapterEventEncodeOptions { freeformToolNames?: ReadonlySet; toolSearchToolNames?: ReadonlySet; stallTimeoutSec?: number; + /** + * Operator-trusted local upstream (loopback / private / `.local` / `.lan`): an unset budget + * resolves to disabled there instead of the 300 s default, matching the bridge (#5876). + */ + localUpstream?: boolean; /** Wire-silence heartbeat and stall tick; the bridge's 2 s default. */ heartbeatMs?: number; hooks?: ClientEncodeHooks; @@ -165,7 +170,9 @@ export function encodeAdapterEventStream( const heartbeatMs = options.heartbeatMs ?? 2_000; const setTimer = options.timers?.setInterval ?? ((handler: () => void, ms: number) => setInterval(handler, ms)); const clearTimer = options.timers?.clearInterval ?? ((id: unknown) => clearInterval(id as ReturnType)); - const maxStallTicks = Math.ceil((resolveStallTimeoutSec(options.stallTimeoutSec) * 1000) / heartbeatMs); + const maxStallTicks = Math.ceil( + (resolveStallTimeoutSec(options.stallTimeoutSec, { localUpstream: options.localUpstream }) * 1000) / heartbeatMs, + ); const textEncoder = new TextEncoder(); let controller!: ReadableStreamDefaultController; @@ -836,7 +843,7 @@ export function encodeAdapterEventStream( if (upstreamActivity) { upstreamActivity = false; stallTicks = 0; - } else if (++stallTicks >= maxStallTicks) { + } else if (maxStallTicks > 0 && ++stallTicks >= maxStallTicks) { stall(); return; } diff --git a/src/providers/quota/vendor-probes-key.ts b/src/providers/quota/vendor-probes-key.ts index 59347607f5e..dee902b89c2 100644 --- a/src/providers/quota/vendor-probes-key.ts +++ b/src/providers/quota/vendor-probes-key.ts @@ -40,7 +40,7 @@ const OLLAMA_CLOUD_BASE_URL = "https://ollama.com"; const OLLAMA_CLOUD_USAGE_URL = `${OLLAMA_CLOUD_BASE_URL}/api/usage`; const ZAI_BASE_URL = "https://api.z.ai"; const ZAI_CN_BASE_URL = "https://open.bigmodel.cn"; -const MINIMAX_REMAINS_URL = "https://www.minimax.io/v1/token_plan/remains"; +const MINIMAX_REMAINS_PATH = "/v1/api/openplatform/coding_plan/remains"; const MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"; const VENICE_BASE_URL = "https://api.venice.ai/api/v1"; const SYNTHETIC_BASE_URL = "https://api.synthetic.new/v2"; @@ -652,20 +652,17 @@ async function fetchZaiQuota(provider: string, config: OcxProviderConfig): Promi } /** - * MiniMax Token Plan `GET /v1/token_plan/remains` — the subscription's - * remaining quota as a countdown-time value (ms). The endpoint does not expose - * the plan's total duration, so no percentage is fabricated from a presumed - * window: the remaining time is reported as a duration-only window. When the - * API supplies a total (`total_time` / `plan_duration_ms`), a consumed share - * is derived from it. Region selects the host: `minimax` → www.minimax.io, - * `minimax-cn` → api.minimaxi.com. + * MiniMax Coding Plan `GET /v1/api/openplatform/coding_plan/remains` reports + * remaining percentages per model/window. Only the `general` model is the + * Coding Plan quota; video remains are unrelated. Region selects the host. */ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): Promise { if (!isCanonicalMinimaxBaseUrl(config.baseUrl)) return null; const apiKey = resolveProviderApiKey(config.apiKey)?.trim(); if (!apiKey) return null; const cnHost = normalizedBaseUrl(config.baseUrl)?.startsWith("https://api.minimaxi.com"); - const remainsUrl = cnHost ? "https://api.minimaxi.com/v1/token_plan/remains" : MINIMAX_REMAINS_URL; + const canonicalHost = cnHost ? "https://api.minimaxi.com" : "https://api.minimax.io"; + const remainsUrl = `${canonicalHost}${MINIMAX_REMAINS_PATH}`; const response = await quotaFetch(provider, config, remainsUrl, { headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` }, redirect: "error", @@ -677,24 +674,32 @@ async function fetchMinimaxQuota(provider: string, config: OcxProviderConfig): P : null; } const body = asRecord(await readQuotaJson(response)); - if (!body || body.success === false) return null; - const data = asRecord(body.data) ?? body; - const remainsMs = toFiniteNumber(data.remains_time ?? data.remainsTime); - if (remainsMs === undefined || remainsMs < 0) return null; - const hours = Math.floor(remainsMs / 3_600_000); - const label = `Token Plan remaining (${hours}h)`; - // Only derive a consumed share when the API actually reports the plan total; - // a presumed window (e.g. 30 days) would fabricate utilization. A valid - // response that omits the total after a prior refresh had it is a DELIBERATE - // contract change — the old row must be dropped (terminal), not preserved as - // a transient last-good. - const totalMs = toFiniteNumber(data.total_time ?? data.plan_duration_ms ?? data.total_duration_ms); - if (totalMs === undefined || totalMs <= 0) return TERMINAL_QUOTA_FAILURE; - const consumed = Math.max(0, totalMs - remainsMs); - const percent = normalizePercent((consumed / totalMs) * 100); - if (percent === undefined) return null; + if (!body || asRecord(body.base_resp)?.status_code !== 0) return null; + const rows = Array.isArray(body.model_remains) ? body.model_remains : []; + const general = rows.map(asRecord).find(row => row?.model_name === "general"); + if (!general) return null; + const customWindows: NonNullable = []; + const fiveHourRemaining = toFiniteNumber(general.current_interval_remaining_percent); + if (fiveHourRemaining !== undefined) { + const percent = normalizePercent(100 - fiveHourRemaining); + if (percent !== undefined) { + const resetAt = normalizeResetAt(general.end_time); + customWindows.push({ label: "Coding Plan 5-hour", percent, ...(resetAt ? { resetAt } : {}) }); + } + } + if (general.current_weekly_status === 1) { + const weeklyRemaining = toFiniteNumber(general.current_weekly_remaining_percent); + if (weeklyRemaining !== undefined) { + const percent = normalizePercent(100 - weeklyRemaining); + if (percent !== undefined) { + const resetAt = normalizeResetAt(general.weekly_end_time); + customWindows.push({ label: "Coding Plan weekly", percent, ...(resetAt ? { resetAt } : {}) }); + } + } + } + if (customWindows.length === 0) return null; return report(provider, "minimax:token-plan-remains", { - customWindows: [{ label, percent }], + customWindows, updatedAt: Date.now(), }); } diff --git a/src/responses/hosted-tool-policy.ts b/src/responses/hosted-tool-policy.ts index 696541b5fe9..9bd60fef720 100644 --- a/src/responses/hosted-tool-policy.ts +++ b/src/responses/hosted-tool-policy.ts @@ -1,14 +1,15 @@ -/** Hosted tools rejected by specific native model slugs or exact provider destinations. */ +/** + * Hosted tools rejected by specific native model slugs or exact provider destinations. + * + * Currently empty. Its only row removed hosted web search for grok-4.6 on OpenCode Go, but that + * destination rejects two OpenAI-private fields rather than the tool: the xAI web-search + * normalizer in `src/adapters/xai-web-search.ts` now removes those fields for every Grok model + * there, and search works. Add a row only for a destination that refuses the tool itself. + */ const UNSUPPORTED_HOSTED_TOOLS: ReadonlyArray<{ match: (model: string, baseUrl?: string) => boolean; tools: ReadonlySet; -}> = [ - { - match: (model, baseUrl) => model === "grok-4.6" - && baseUrl?.replace(/\/+$/, "") === "https://opencode.ai/zen/go/v1", - tools: new Set(["web_search", "web_search_preview"]), - }, -]; +}> = []; /** * Hosted-tool declaration names an operator may list in `unsupportedHostedTools`. diff --git a/src/server/chat-native-sse.ts b/src/server/chat-native-sse.ts index 63962a2bb40..2fc379a5bfd 100644 --- a/src/server/chat-native-sse.ts +++ b/src/server/chat-native-sse.ts @@ -129,6 +129,11 @@ interface NativeChatSseOptions { translatorBudget: TranslatorBudget; signal: AbortSignal; stallTimeoutSec?: number; + /** + * The upstream is local infrastructure (loopback / private / `.local` / `.lan`); an unset + * `stallTimeoutSec` then resolves to disabled so a slow local model is not cut mid-turn. + */ + localUpstream?: boolean; onFirstOutput?: () => void; onUsage: (usage: OcxUsage) => void; onTerminal?: (status: number, message?: string) => void; @@ -157,7 +162,12 @@ export function nativeChatSse( let settled = false; let cancelled = false; let cancelledBySignal = false; - const stallMs = resolveStallTimeoutSec(options.stallTimeoutSec) * 1_000; + const stallSec = resolveStallTimeoutSec(options.stallTimeoutSec, { localUpstream: options.localUpstream }); + // 0 is an explicit disabled budget (local upstream, or the operator's stallTimeoutSec: 0). Map + // it to an Infinity deadline so every `now >= pullDeadline` / `remaining <= 0` check stays + // false and the read race is skipped, instead of arming an immediate kill on the first pull. + const stallEnabled = stallSec > 0; + const stallMs = stallEnabled ? stallSec * 1_000 : Number.POSITIVE_INFINITY; // Count only active upstream consumption; downstream backpressure must not // consume the provider's inactivity allowance. Comments and empty deltas do. let remainingStallMs = stallMs; @@ -332,17 +342,19 @@ export function nativeChatSse( let timeout: ReturnType | undefined; let read: Awaited>; try { - read = await Promise.race([ - reader.read(), - new Promise((_, reject) => { - const check = () => { - const remaining = pullDeadline - performance.now(); - if (remaining <= 0) reject(stalled); - else timeout = setTimeout(check, Math.min(remaining, 2_147_483_647)); - }; - check(); - }), - ]); + read = stallEnabled + ? await Promise.race([ + reader.read(), + new Promise((_, reject) => { + const check = () => { + const remaining = pullDeadline - performance.now(); + if (remaining <= 0) reject(stalled); + else timeout = setTimeout(check, Math.min(remaining, 2_147_483_647)); + }; + check(); + }), + ]) + : await reader.read(); } finally { if (timeout !== undefined) clearTimeout(timeout); } diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index 36a9477450b..04e143a3b82 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -59,6 +59,7 @@ import type { OcxProviderTransport } from "../providers/xai-transport"; import type { RouteResult } from "../router"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers"; +import { isLocalUpstream } from "../lib/local-upstream"; import { linkAbortSignal } from "./responses"; import { noteProviderAttemptSend, @@ -634,6 +635,7 @@ export async function runNativeChatAttempt( translatorBudget, signal: upstream.signal, stallTimeoutSec: config.stallTimeoutSec, + localUpstream: isLocalUpstream(activeProvider.baseUrl), onFirstOutput, onUsage: usage => { if (!recordKeyWireAttemptUsage(logCtx, usage)) { diff --git a/src/server/index/package-tree-guard.ts b/src/server/index/package-tree-guard.ts index f358738c5ce..b520154936d 100644 --- a/src/server/index/package-tree-guard.ts +++ b/src/server/index/package-tree-guard.ts @@ -2,9 +2,11 @@ import { createRuntimePackageTreeIntegrityGuard, type PackageTreeIntegrityGuard, } from "../../lib/package-tree-integrity"; +import { createPackageTreeRetargetWatch } from "../../lib/package-tree-retarget"; import { acceptSystemRestart } from "../management/system-restart"; import { inspectNativeCodexOwnership } from "../../integrations/native/ownership-preflight"; import { detectInstall } from "../../update/index"; +import { planMiseLauncherTargetWatch } from "../../update/mise-launcher-target"; import type { StartServerDeps } from "./startup-warnings"; // Production guard wiring for the package-tree integrity fence. Tests inject @@ -21,31 +23,48 @@ export function createPackageTreeIntegrityGuardForServer( } const acceptPackageTreeRestart = deps.acceptSystemRestart ?? acceptSystemRestart; let vetoAcceptedRestart: (() => void) | undefined; + // An out-of-band install replaced or retargeted the package this live process runs. Let the + // standard drain-and-restart path bring the new tree up instead of refusing traffic until a + // manual restart. acceptSystemRestart is idempotent and supervisor-aware. Throwing tells the + // caller to retry after its own debounce. + const restartOntoNewPackageTree = () => { + const beforeScheduledDrain = () => !isServiceChild() || serviceHomeOwned(); + if (!beforeScheduledDrain()) throw new Error("service home ownership changed"); + let admitted = false; + const result = acceptPackageTreeRestart(undefined, { + onAccepted: veto => { admitted = true; vetoAcceptedRestart = veto; }, + beforeScheduledDrain, + }); + // An automatic restart that could not take the drain lease (another drain owns the lifecycle + // gate) reports alreadyDraining without admitting it. Throw so the caller retries instead of + // treating the restart as done and leaving the service on the old package. + if (result.alreadyDraining && !admitted) throw new Error("restart admission is busy"); + }; const guard = createRuntimePackageTreeIntegrityGuard( deps.packageTreeInstaller ?? detectInstall(), deps.observePackageTree, undefined, - { - ...deps.packageTreeIntegrityOptions, - onReplaced: () => { - // An out-of-band install replaced the package under this live process. Serve - // the 503 for the triggering request, then let the standard drain-and-restart - // path bring the new tree up instead of refusing traffic until a manual - // restart. acceptSystemRestart is idempotent and supervisor-aware. - const beforeScheduledDrain = () => !isServiceChild() || serviceHomeOwned(); - if (!beforeScheduledDrain()) throw new Error("service home ownership changed"); - acceptPackageTreeRestart(undefined, { - onAccepted: veto => { vetoAcceptedRestart = veto; }, - beforeScheduledDrain, - }); - }, - }, + { ...deps.packageTreeIntegrityOptions, onReplaced: restartOntoNewPackageTree }, ); + // mise installs each version beside the last and repoints a floating link, so the manifest + // above never changes on `mise upgrade`. The managed service follows its launcher instead. + const plan = deps.packageTreeLauncherTarget === undefined + ? planMiseLauncherTargetWatch() + : deps.packageTreeLauncherTarget; + const retarget = plan + ? createPackageTreeRetargetWatch( + plan.runningRoot, + plan.resolveTarget, + restartOntoNewPackageTree, + deps.packageTreeRetargetOptions, + ) + : null; return { status: () => guard.status(), - installedVersion: () => guard.installedVersion?.(), + installedVersion: () => guard.installedVersion?.() ?? retarget?.settledVersion(), dispose: () => { guard.dispose(); + retarget?.dispose(); vetoAcceptedRestart?.(); vetoAcceptedRestart = undefined; }, diff --git a/src/server/index/startup-warnings.ts b/src/server/index/startup-warnings.ts index 2e395a688ea..5ebbb04805e 100644 --- a/src/server/index/startup-warnings.ts +++ b/src/server/index/startup-warnings.ts @@ -11,6 +11,8 @@ import type { PackageTreeIntegrityOptions, PackageTreeRuntimeInstall, } from "../../lib/package-tree-integrity"; +import type { PackageTreeRetargetOptions } from "../../lib/package-tree-retarget"; +import type { MiseLauncherTargetWatchPlan } from "../../update/mise-launcher-target"; import { consumeForInspection, relaySseWithHeartbeat, @@ -158,6 +160,10 @@ export interface StartServerDeps { packageTreeServiceChild?: () => boolean; /** Test-only: whether this service child still owns its service home. */ packageTreeServiceHomeOwned?: () => boolean; + /** Test-only launcher plan; production plans from the mise owner and service state. Null disables. */ + packageTreeLauncherTarget?: MiseLauncherTargetWatchPlan | null; + /** Test-only retarget-watch timing and version seams. */ + packageTreeRetargetOptions?: PackageTreeRetargetOptions; /** Test-only seam for observing quota-worker registration ownership. */ registerCodexQuotaAutoRefreshWorker?: typeof registerCodexQuotaAutoRefreshWorker; } diff --git a/src/server/inference/client-encoder-delivery.ts b/src/server/inference/client-encoder-delivery.ts index 059d0a879ea..1dc2a649e71 100644 --- a/src/server/inference/client-encoder-delivery.ts +++ b/src/server/inference/client-encoder-delivery.ts @@ -94,6 +94,8 @@ export interface ClientEncodedDelivery { toolSearchToolNames?: Set; }; stallTimeoutSec?: number; + /** Operator-trusted local upstream: an unset stall budget stays disabled there (#5876). */ + localUpstream?: boolean; turnAdmissionLease?: AdmissionLease; onFirstOutput?: () => void; /** The bridge's `onCancel`: stop completion notification and abort the upstream. */ @@ -212,6 +214,7 @@ export async function deliverClientEncodedResponse(input: ClientEncodedDelivery) translatorBudget, ...input.fold, ...(input.stallTimeoutSec !== undefined ? { stallTimeoutSec: input.stallTimeoutSec } : {}), + ...(input.localUpstream !== undefined ? { localUpstream: input.localUpstream } : {}), hooks, }; const encoded = encoder.protocol === "chat" diff --git a/src/server/responses/adapter-continuation.ts b/src/server/responses/adapter-continuation.ts index fd76d867d7a..2ee13b076ee 100644 --- a/src/server/responses/adapter-continuation.ts +++ b/src/server/responses/adapter-continuation.ts @@ -53,7 +53,7 @@ import { readResponseStreamWithInactivity, ResponseBodyInactivityError, } from "../../lib/response-body-inactivity"; -import { resolveStallTimeoutSec } from "../../stall-timeout"; +import { resolveStallTimeoutMs } from "../../stall-timeout"; import { guardTerminalEventStream } from "./terminal-guard"; /** One responsibility of the Responses request pipeline; state owners are explicit. */ @@ -107,7 +107,8 @@ export function createAdapterContinuations( | "rateLimitPolicy" | "rateLimitRetries" | "stallTimeoutMs" - >, + | "localUpstream" + >, ) { const { options, logCtx, config } = requestContext; const { @@ -120,8 +121,8 @@ export function createAdapterContinuations( } = transportState; const { route, translatorBudget, inboundWire, parsed } = requestState; const { routedCompaction } = sidecarState; - const { upstream, connectMs, rateLimitPolicy, stallTimeoutMs } = adapterExchange; - const bodyInactivityMs = resolveStallTimeoutSec(config.stallTimeoutSec) * 1000; + const { upstream, connectMs, rateLimitPolicy, stallTimeoutMs, localUpstream } = adapterExchange; + const bodyInactivityMs = resolveStallTimeoutMs(config.stallTimeoutSec, { localUpstream }); const { adapterDispatchBudget, noteAdapterPhysicalSend, diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index 31d8b6ef618..c7df1cade79 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -19,7 +19,7 @@ import { readResponseStreamWithInactivity, ResponseBodyInactivityError, } from "../../lib/response-body-inactivity"; -import { resolveStallTimeoutSec } from "../../stall-timeout"; +import { resolveStallTimeoutMs } from "../../stall-timeout"; import { clientEncoderForDelivery, deliverClientEncodedResponse } from "../inference/client-encoder-delivery"; /** One responsibility of the Responses request pipeline; state owners are explicit. */ @@ -43,7 +43,7 @@ export async function deliverAdapterResponse( | "notifyResponseComplete" >, completionPolicy: Pick, - adapterExchange: Pick, + adapterExchange: Pick, continuationState: Pick, ): Promise { const { logCtx, options, config } = requestContext; @@ -54,7 +54,7 @@ export async function deliverAdapterResponse( rememberKiroDeliveredFinalAnswer, responseStateOptions, } = requestState; - const { upstreamResponse, upstream, cleanupUpstreamAbort } = adapterExchange; + const { upstreamResponse, upstream, cleanupUpstreamAbort, localUpstream } = adapterExchange; const { terminalGuardEnabled, fetchTerminalGuardContinuation, @@ -68,7 +68,7 @@ export async function deliverAdapterResponse( notifyResponseComplete, } = responseEffects; const { routedCompaction } = sidecarState; - const bodyInactivityMs = resolveStallTimeoutSec(config.stallTimeoutSec) * 1000; + const bodyInactivityMs = resolveStallTimeoutMs(config.stallTimeoutSec, { localUpstream }); if (parsed.stream) { @@ -148,6 +148,7 @@ export async function deliverAdapterResponse( toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames, }, stallTimeoutSec: config.stallTimeoutSec, + localUpstream, turnAdmissionLease: options.turnAdmissionLease, ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}), stopUpstream: () => { cancelResponseCompletion(); upstream.abort(); }, @@ -164,6 +165,7 @@ export async function deliverAdapterResponse( replayCacheScope: parsed._reasoningReplayScope, ...(options.forceEmptyResponseId ? { responseId: "" } : {}), stallTimeoutSec: config.stallTimeoutSec, + localUpstream, hideThinkingSummary: parsed.options.hideThinkingSummary, declaredToolNames, enforceDeclaredToolNames: options.inboundWire !== "chat" && options.inboundWire !== "anthropic", diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index e1192297a39..e6f0c742e85 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -1,4 +1,5 @@ import { isNonReplayableResponse } from "../../lib/upstream-retry"; +import { isLocalUpstream } from "../../lib/local-upstream"; import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options"; import type { PreparedResponsesRequest } from "./request-prepare"; import type { ResponsesTransport } from "./request-transport"; @@ -184,6 +185,10 @@ export async function prepareAdapterExchange( const stallTimeoutMs = typeof config.stallTimeoutSec === "number" && Number.isFinite(config.stallTimeoutSec) && config.stallTimeoutSec > 0 ? Math.floor(config.stallTimeoutSec * 1000) : 300_000; + // Where this exchange dials. Local upstreams (loopback / private / `.local` / `.lan`) are + // operator-trusted and often CPU-bound, so an unset stall budget resolves to disabled for them. + // Key/OAuth rotation re-resolves credentials, never the origin, so the routed baseUrl is stable. + const localUpstream = isLocalUpstream(route.provider.baseUrl); transportState.activeAdapter = transportState.adapter; // One immutable, body-safe outbound request per same-target sequence (URL, serialized body, @@ -1141,6 +1146,7 @@ export async function prepareAdapterExchange( cleanupUpstreamAbort, connectMs, stallTimeoutMs, + localUpstream, upstreamResponse, rateLimitPolicy, get rateLimitRetries(): typeof rateLimitRetries { diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 28ec06c02b9..c9a3cc5e495 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -154,7 +154,8 @@ import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregi import type { AdmissionLease } from "../../lib/admission"; import { redactSecretString } from "../../lib/redact"; import { readBoundedResponseBytes } from "../../lib/bounded-body"; -import { resolveStallTimeoutSec } from "../../stall-timeout"; +import { resolveStallTimeoutMs } from "../../stall-timeout"; +import { isLocalUpstream } from "../../lib/local-upstream"; import { isRateLimitOrQuotaFailureMessage } from "../../lib/errors"; import { supportedLadderFor } from "../effort-policy"; import { @@ -562,6 +563,7 @@ export async function bufferCompactResponse( upstream: Response, signal: AbortSignal, stallTimeoutSec?: number, + localUpstream?: boolean, ): Promise { const headers = compactResponseHeaders(upstream); try { @@ -581,7 +583,7 @@ export async function bufferCompactResponse( const result = await readBoundedResponseBytes(upstream, { signal, maxBytes: COMPACT_RESPONSE_MAX_BYTES, - inactivityTimeoutMs: resolveStallTimeoutSec(stallTimeoutSec) * 1_000, + inactivityTimeoutMs: resolveStallTimeoutMs(stallTimeoutSec, { localUpstream }), }); if (signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request"); if (result.oversized) return compactResponseTooLargeError(); @@ -1332,7 +1334,7 @@ export async function handleResponsesCompact( upstream.headers.get("x-codex-secondary-reset-at"), upstream.headers.get("x-codex-tertiary-reset-at"), ].filter(Boolean); - const buffered = await bufferCompactResponse(upstream, req.signal, config.stallTimeoutSec); + const buffered = await bufferCompactResponse(upstream, req.signal, config.stallTimeoutSec, isLocalUpstream(compactUrl)); const bufferedErrorText = buffered.ok ? "" : await buffered.clone().text().catch(() => ""); diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 91034cda824..366c6c00ddc 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -129,7 +129,7 @@ import { registerTurn, unregisterTurn, trackStreamLifetime } from "../lifecycle" import { relaySseEagerBounded } from "../relay-eager"; import { readBoundedResponseBody } from "../../lib/bounded-body"; import { idleDeadline } from "../../lib/abort"; -import { resolveStallTimeoutSec } from "../../stall-timeout"; +import { resolveStallTimeoutMs } from "../../stall-timeout"; import { formatErrorResponse } from "../../bridge"; import { inspectResponseLogJson } from "../request-log"; import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; @@ -316,6 +316,7 @@ export async function deliverPassthroughResponse( | "rememberPassthroughResponse" | "noteInspectedPayload" | "normalizeFunctionCompletionJson" + | "localUpstream" >, ): Promise { const { logCtx, config, options, req } = requestContext; @@ -354,7 +355,7 @@ export async function deliverPassthroughResponse( && !isCodexWsUpstreamResponse(upstreamResponse) && !(options.nativeControl && isNativeControlResponse(upstreamResponse))) { upstreamResponse = await classifyPlaintextV2SseResponse(upstreamResponse, { - timeoutMs: resolveStallTimeoutSec(config.stallTimeoutSec) * 1000, + timeoutMs: resolveStallTimeoutMs(config.stallTimeoutSec, { localUpstream: nativeExchange.localUpstream }), signal: options.abortSignal ?? req.signal, }); } diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index 2380d80d946..333d5fef893 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -13,6 +13,7 @@ import type { ResponsesEffects } from "./response-effects"; import type { ResponsesSendBudget } from "./request-send-budget"; import { transientSendCapFor } from "./request-send-budget"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; +import { isLocalUpstream } from "../../lib/local-upstream"; import { codexSafetyBufferingFilterOptions, terminalStatusFromParsed } from "../relay"; import { imageGenToolCallAliases } from "../responses-image-gen-repair"; import { rememberResponseState, isBodyNonPersistable } from "../../responses/state"; @@ -1782,6 +1783,12 @@ export async function preparePassthroughExchange( break; } + // Where this relay dials upstream. Local infrastructure (loopback / private / `.local` / `.lan`) + // is operator-trusted and its silent phases are normal, so an unset stall budget resolves to + // disabled for it. Provider rotation above keeps the same endpoint origin, so the routed + // provider's baseUrl is the stable classification source. + const localUpstream = isLocalUpstream(route.provider.baseUrl); + return { codexSafetyBufferingOptions, imageGenCallAliases, @@ -1819,6 +1826,7 @@ export async function preparePassthroughExchange( rememberPassthroughResponseChecked, upstream, connectMs, + localUpstream, upstreamResponse, }; } diff --git a/src/server/responses/passthrough-execution.ts b/src/server/responses/passthrough-execution.ts index 425f2c16e5a..4773509e3f3 100644 --- a/src/server/responses/passthrough-execution.ts +++ b/src/server/responses/passthrough-execution.ts @@ -11,7 +11,7 @@ import type { ResponsesSendBudget } from "./request-send-budget"; import { preparePassthroughExchange } from "./passthrough-dispatch"; import { deliverPassthroughResponse } from "./passthrough-delivery"; import { guardDirectPassthroughBodyInactivity } from "../../lib/response-body-inactivity"; -import { resolveStallTimeoutSec } from "../../stall-timeout"; +import { resolveStallTimeoutMs } from "../../stall-timeout"; import { releaseUpstreamHostAdmission } from "../../codex/upstream-host-health"; import { releaseCodexAuthContextProbeLease } from "../../codex/auth-context"; @@ -53,7 +53,7 @@ export async function executePassthroughResponse( return guardDirectPassthroughBodyInactivity( response, nativeExchange.upstream.signal, - resolveStallTimeoutSec(requestContext.config.stallTimeoutSec) * 1000, + resolveStallTimeoutMs(requestContext.config.stallTimeoutSec, { localUpstream: nativeExchange.localUpstream }), ); } finally { if (nativeHostState.lease) { diff --git a/src/server/responses/run-turn-execution.ts b/src/server/responses/run-turn-execution.ts index 3c51b5b2834..762dbe3eec9 100644 --- a/src/server/responses/run-turn-execution.ts +++ b/src/server/responses/run-turn-execution.ts @@ -6,7 +6,7 @@ import type { ResponsesEffects } from "./response-effects"; import type { ResponsesSendBudget } from "./request-send-budget"; import type { ResponsesCompletionPolicy } from "./completion-policy"; import { linkAbortSignal, runTurnAdapterSseResponses } from "./core-lifetime"; -import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue"; +import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventPreflight } from "../../adapters/run-turn-queue"; import { bindRouteReasoningReplayScope, adapterNeedsForcedContinuation, @@ -31,6 +31,9 @@ import { import { resolveWireProtocolOverride } from "../adapter-resolve"; import { formatErrorResponse, bridgeToResponsesSSE, buildResponseJSON } from "../../bridge"; import { redactSecretString } from "../../lib/redact"; +import { adapterFailureFromEvent } from "../../bridge/internal"; +import { resolveClientRetryAfter } from "../../lib/retry-after"; +import { DEFAULT_STALL_TIMEOUT_SEC, resolveStallTimeoutSec } from "../../stall-timeout"; import { jsonUtf8Bytes } from "../../lib/json-byte-size"; import { isTranslatorBudgetExceededError } from "../../lib/translator-budget"; import { @@ -438,11 +441,32 @@ export async function executeResponsesRunTurn( // request — the grown-history iteration for post-search legs, the // tool-injected first request elsewhere. replayParsed: PreparedResponsesRequest["parsed"] = wsFirstParsed, + deadlineAt?: number, ): Promise> => { let source = firstSource; + let latestRetryAttempt: Promise | undefined; + let deferPendingPermitCleanup = false; try { while (true) { - const preflight = await preflightAdapterEvents(source); + const preflight = await preflightAdapterEvents(source, undefined, deadlineAt === undefined + ? undefined + : { maxWaitMs: deadlineAt - Date.now() }); + if (preflight.timedOut) { + const pendingPermit = sendBudgetState.pendingHopPermit; + if (pendingPermit && latestRetryAttempt) { + // The timed-out replay may still be waiting for its first physical dispatch. + // Keep its reservation available until the adapter claims it; if the attempt ends + // before claiming, refund it then rather than charging a later send twice. + deferPendingPermitCleanup = true; + const releaseIfUnclaimed = () => { + if (sendBudgetState.pendingHopPermit !== pendingPermit) return; + sendBudgetState.pendingHopPermit = undefined; + pendingPermit.release(); + }; + void latestRetryAttempt.then(releaseIfUnclaimed, releaseIfUnclaimed); + } + return preflight.stream; + } if (preflight.replayUnsafe || !preflight.error || !(await rotateRunTurnAdapterOnPreflight429(preflight.error))) { @@ -451,15 +475,14 @@ export async function executeResponsesRunTurn( const retryQueue = createAdapterEventQueue({ onBacklogExceeded: () => runTurnAbort.abort(), }); - void runTurnAttempt(retryQueue, "oauth-account-429", false, replayParsed); + latestRetryAttempt = runTurnAttempt(retryQueue, "oauth-account-429", false, replayParsed); + void latestRetryAttempt; source = retryQueue.stream(); } } finally { - // A handed-down hop reservation belongs to the replay this loop dispatched, and the - // loop only leaves after that replay's first event has arrived -- so the adapter has - // already reserved if it was ever going to. Dropping the reference here keeps an - // adapter that reserves nothing from leaving a free send for an unrelated later leg. - sendBudgetState.pendingHopPermit = undefined; + // On ordinary exit the replay's first event proves its first-send reservation was reached. + // A timed-out replay may not have dispatched yet, so that path defers cleanup above. + if (!deferPendingPermitCleanup) sendBudgetState.pendingHopPermit = undefined; } }; // The empty-completion retry re-runs the turn against a fresh queue: the @@ -490,14 +513,44 @@ export async function executeResponsesRunTurn( message: undeclaredToolCallMessage(effectiveName), }; }; + const grokDevinPreflight = !options.comboAttempt && logCtx.surface === "grok" && inboundWire === "responses" + && transportState.runTurnAdapter.name === "devin"; + const grokRateLimitResponse = (preflight: AdapterEventPreflight): Response | undefined => { + if (preflight.replayUnsafe || preflight.error?.status !== 429 + || preflight.error.code === SEND_BUDGET_EXHAUSTED_CODE) return; + const { httpStatus, error } = adapterFailureFromEvent(preflight.error); + cancelResponseCompletion(); + runTurnAbort.abort(); + queue.close(); + cleanupRunTurnAbort(); + releaseSearchProbeLease(); + return formatErrorResponse(httpStatus, error.type, error.message, { + code: error.code, + retryAfter: resolveClientRetryAfter({ status: httpStatus, message: error.message }), + }); + }; if (parsed.stream) { try { void runTurn(); let eventSource: AsyncIterable = queue.stream(); + const stallTimeoutSec = wsPlan?.stallTimeoutSec ?? config.stallTimeoutSec; + // A disabled watchdog (0) is not a zero preflight: it would commit SSE before Devin's first + // event and deliver a pre-output 429 in-stream. Fall back to the default finite bound. + const preflightDeadlineAt = grokDevinPreflight + ? Date.now() + (resolveStallTimeoutSec(stallTimeoutSec) || DEFAULT_STALL_TIMEOUT_SEC) * 1_000 + : undefined; if (runTurnFailoverArmed()) { // Preflight holds only heartbeats and the first meaningful event. A first-event 429 can be // replayed transparently; after any output reaches the bridge, a later error stays terminal. - eventSource = await preflightRunTurnFailover(eventSource); + eventSource = await preflightRunTurnFailover(eventSource, wsFirstParsed, preflightDeadlineAt); + } + if (grokDevinPreflight) { + const preflight = await preflightAdapterEvents(eventSource, undefined, { + maxWaitMs: (preflightDeadlineAt ?? Date.now()) - Date.now(), + }); + eventSource = preflight.stream; + const refusal = grokRateLimitResponse(preflight); + if (refusal) return refusal; } if (options.comboAttempt) { const preflight = await preflightAdapterEvents(eventSource, classifyUndeclaredFirstTool); @@ -559,7 +612,7 @@ export async function executeResponsesRunTurn( translatorBudget, replayCacheScope: parsed._reasoningReplayScope, ...(options.forceEmptyResponseId ? { responseId: "" } : {}), - stallTimeoutSec: wsPlan?.stallTimeoutSec ?? config.stallTimeoutSec, + stallTimeoutSec, hideThinkingSummary: parsed.options.hideThinkingSummary, declaredToolNames, enforceDeclaredToolNames, @@ -618,6 +671,11 @@ export async function executeResponsesRunTurn( (async function* () { yield* firstAttemptEvents; })(), )) runTurnEvents.push(event); } + if (grokDevinPreflight) { + const preflight = await preflightAdapterEvents((async function* () { yield* runTurnEvents; })()); + const refusal = grokRateLimitResponse(preflight); + if (refusal) return refusal; + } let events: AdapterEvent[]; // LOCAL PATCH (runturn-websearch): same exclusion as the streaming branch — // a search-aware retry runs inside runTurnWebSearchLoop, not the raw guard. diff --git a/src/stall-timeout.ts b/src/stall-timeout.ts index 06a7ef3ca20..f7256c612bd 100644 --- a/src/stall-timeout.ts +++ b/src/stall-timeout.ts @@ -1,20 +1,45 @@ +export const DEFAULT_STALL_TIMEOUT_SEC = 300; + /** - * Bridge upstream stall budget: seconds of silence (no adapter events) before the - * Responses bridge emits `response.incomplete` / `upstream_stall_timeout`. + * How the configured stall budget should be resolved for a request. * - * Raised from 90s so long reasoning + large tool writes are not cut mid-turn. - * Hung streams still die; they just get a more realistic window. + * `localUpstream` marks a request whose upstream is operator-trusted local infrastructure + * (loopback / private / `.local` / `.lan` — see src/lib/local-upstream.ts). Such models are often + * CPU-bound, and their time-to-first-token or "thinking" phases can go silent for many minutes; + * a stall budget there would cut a healthy turn mid-thought. Callers compute the flag with + * `isLocalUpstream(destination)` and pass it in. */ -export const DEFAULT_STALL_TIMEOUT_SEC = 300; +export interface StallTimeoutOptions { + localUpstream?: boolean; +} /** - * Resolve the effective bridge stall deadline for a turn. - * - unset / non-finite config → {@link DEFAULT_STALL_TIMEOUT_SEC} - * - finite config → ceil, minimum 1 + * Resolve the per-request stall budget in seconds. + * + * An explicit value always wins: `0` (or any non-positive) disables the budget everywhere, and a + * positive value applies verbatim to local and public upstreams alike. Only when the operator + * left the budget unset does the destination matter: a local upstream resolves to disabled + * (`0`) while a public upstream keeps the {@link DEFAULT_STALL_TIMEOUT_SEC} safety clock. + * + * Disabling is expressed as `0`, never as a tiny budget: several consumers translate the + * seconds into a tick count or a pull deadline, where `0` would otherwise expire on the first + * beat and kill a healthy stream in ~2s. Consumers that kill on the budget must therefore gate + * on `resolveStallTimeoutSec(...) > 0` (see src/bridge/sse.ts, src/server/chat-native-sse.ts). */ -export function resolveStallTimeoutSec(configuredSec: number | undefined): number { +export function resolveStallTimeoutSec(configuredSec: number | undefined, options: StallTimeoutOptions = {}): number { + const { localUpstream = false } = options; if (typeof configuredSec === "number" && Number.isFinite(configuredSec)) { + if (configuredSec <= 0) return 0; return Math.max(1, Math.ceil(configuredSec)); } - return DEFAULT_STALL_TIMEOUT_SEC; + return localUpstream ? 0 : DEFAULT_STALL_TIMEOUT_SEC; +} + +/** + * The same resolution as {@link resolveStallTimeoutSec}, in milliseconds, for consumers that + * deadline against `performance.now()`. `0` is a stable "disabled" sentinel the inactivity + * guards honor (they arm no clock for a non-positive budget). + */ +export function resolveStallTimeoutMs(configuredSec: number | undefined, options: StallTimeoutOptions = {}): number { + return resolveStallTimeoutSec(configuredSec, options) * 1_000; } diff --git a/src/update/mise-launcher-target.ts b/src/update/mise-launcher-target.ts new file mode 100644 index 00000000000..ec2596d34a2 --- /dev/null +++ b/src/update/mise-launcher-target.ts @@ -0,0 +1,100 @@ +import { realpathSync } from "node:fs"; +import { dirname, isAbsolute, join } from "node:path"; +import { + observePackageManifestAt, + packageManifestPathIn, + runningPackageRoot, +} from "../lib/package-tree-integrity"; +import type { LauncherTarget, ResolveLauncherTarget } from "../lib/package-tree-retarget"; +import { readServiceInstallState, SERVICE_MANAGED_ENV } from "../service/state"; +import { detectInstallOwnershipFromPath, type InstallOwnership } from "./install-detection.mjs"; + +const PACKAGE_DIR = join("@bitkyc08", "opencodex"); + +/** + * The package launcher mise's npm backend creates in `//node_modules/.bin`, + * beside the package it starts. This is the layout `detectInstallOwnershipFromPath` verifies as + * mise-owned; a launcher in any other layout is not followed. + */ +const LAUNCHER_BIN = join("node_modules", ".bin"); +const LAUNCHER_PACKAGE_DIR = join("node_modules", PACKAGE_DIR); + +export interface MiseLauncherTargetDeps { + platform?: NodeJS.Platform; + env?: NodeJS.ProcessEnv; + ownership?: () => InstallOwnership; + launcherPath?: () => string | undefined; + runningRoot?: () => string; + realpath?: (path: string) => string; +} + +export interface MiseLauncherTargetWatchPlan { + runningRoot: string; + resolveTarget: ResolveLauncherTarget; +} + +function canonical(path: string, realpath: (path: string) => string): string | null { + try { + return realpath(path); + } catch { + return null; + } +} + +/** + * Map a recorded service launcher to the package directory it starts, when it is one of mise's + * own package launchers for this tool. A mise shim (`/shims/ocx`) resolves to the mise + * binary and names no package, and anything outside `//` is not a launcher + * this install owns, so both are unsupported and return null. + */ +function launcherPackageEntry(launcherPath: string, toolRoot: string, realpath: (path: string) => string): string | null { + if (!isAbsolute(launcherPath)) return null; + const binDir = dirname(launcherPath); + const canonicalToolRoot = canonical(toolRoot, realpath); + if (canonicalToolRoot === null) return null; + if (!binDir.endsWith(`/${LAUNCHER_BIN}`)) return null; + const selectorDir = binDir.slice(0, binDir.length - LAUNCHER_BIN.length - 1); + if (canonical(dirname(selectorDir), realpath) !== canonicalToolRoot) return null; + return join(selectorDir, LAUNCHER_PACKAGE_DIR); +} + +/** + * Decide whether this process should watch its service launcher for a mise upgrade, and build the + * resolver when it should. + * + * Eligible only when every one of these holds: + * - Linux. launchd services always run pinned package paths (see the stable-launcher contract), + * and Windows services have no launcher, so neither can follow a moved `latest` link. + * - The process is the managed service job itself (`OCX_SERVICE_MANAGED=1`). `OCX_SERVICE=1` + * alone is also set on proxies `ocx claude`/`ocx opencode` spawn, and a foreground proxy must + * never restart itself because a service record exists. + * - The running package has a verified mise owner, and the recorded launcher is one of that + * tool's package launchers. + * - At boot, the launcher resolves to exactly the running package. That proves the supervisor + * started this process through it, so a restart through it converges instead of looping. + */ +export function planMiseLauncherTargetWatch(deps: MiseLauncherTargetDeps = {}): MiseLauncherTargetWatchPlan | null { + if ((deps.platform ?? process.platform) !== "linux") return null; + if ((deps.env ?? process.env)[SERVICE_MANAGED_ENV] !== "1") return null; + const realpath = deps.realpath ?? realpathSync; + const runningRoot = canonical((deps.runningRoot ?? runningPackageRoot)(), realpath); + if (runningRoot === null) return null; + const ownership = (deps.ownership ?? (() => detectInstallOwnershipFromPath(runningRoot)))(); + if (ownership.installer !== "mise" || !ownership.owner) return null; + const owner = ownership.owner; + const launcherPath = (deps.launcherPath ?? (() => readServiceInstallState()?.launcherPath))(); + if (!launcherPath) return null; + const entry = launcherPackageEntry(launcherPath, owner.toolRoot, realpath); + const toolRoot = canonical(owner.toolRoot, realpath); + if (entry === null || toolRoot === null) return null; + + const resolveTarget = (): LauncherTarget | null => { + const root = canonical(entry, realpath); + // A floating link repointed outside this tool's installs is not a mise upgrade of it. + if (root === null || !root.startsWith(`${toolRoot}/`)) return null; + const manifest = observePackageManifestAt(packageManifestPathIn(root)); + return manifest === null ? null : { root, manifest }; + }; + if (resolveTarget()?.root !== runningRoot) return null; + return { runningRoot, resolveTarget }; +} diff --git a/structure/catalog.md b/structure/catalog.md index c3a95e1546d..309532c2270 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -158,12 +158,12 @@ before registration. Covered by `tests/codex-integration/configured-native-model Retirement is a catalog/evidence policy, not a universal request denylist. Manually supplied model ids still follow generic routing. User-selected config and historical usage remain stored. -Account-gated native ids are a stricter subset. Their authenticated ChatGPT `/models` roster is -cached per credential generation with a bounded timeout. A bare gated row is emitted only when at -least one confirmed eligible account reports it; a selector-qualified row is emitted only when the -mapped account reports it. A failed or malformed discovery is not positive evidence and therefore -hides the gated row until a later refresh. The same snapshot gates Pool selection, so the catalog -and runtime cannot disagree by advertising through one account and dispatching through another. +Account-gated native ids use authenticated ChatGPT `/models` rosters cached per credential generation with a bounded timeout. +A bare gated row requires a confirmed eligible account; a selector-qualified row requires its mapped account. Failed discovery +grants neither. The same snapshot gates Pool selection, so catalog and runtime use consistent account evidence. +The roster's per-model `available_access_programs` is projected separately: bare native rows use only confirmed main-account +metadata, and selector-qualified rows use only their mapped account. Explicit `null` stays null; omission stays omitted. +Failed discovery or credential replacement removes stale access-program metadata. It changes presentation, not routing grants. `client_version` arrives on the inbound request and is part of that cache identity, so `src/codex/model-entitlements.ts` bounds the work as well as the state: stored versions per account, concurrent diff --git a/structure/clients/claude-desktop.md b/structure/clients/claude-desktop.md index 8f25468017d..075731e0fda 100644 --- a/structure/clients/claude-desktop.md +++ b/structure/clients/claude-desktop.md @@ -218,16 +218,24 @@ writes the resulting local Desktop configuration. No admin token, hub-profile up alias regeneration is part of this flow. Unsupported old hubs, invalid snapshots and unavailable Desktop models fail apply without a local-catalog or loopback fallback. -Managed-namespace date aliases occupy `claude-opus-4-8-YYYYMMDD` slots across 2026-2035, not 2026 +Managed profile assignments persist `claude-opus-4-8-YYYYMMDD` slots across 2026-2035, not 2026 alone. The original 2026-only design held 365 slots and failed with "all 365 encoded date slots are occupied" once a catalog exceeded 365 routes, because stale assignments are retained by design and -the set only grows. 2026 is still allocated first, so existing assignments keep their ids, and +the set only grows. 2026 is still allocated first, so existing assignments keep their slots, and 2027-2035 are reached only after it fills. Years before 2026 stay rejected: dated ids such as `claude-opus-4-8-20250201` are real Anthropic snapshot ids and the inbound decoder relies on that -distinction. Every emitted suffix stays eight digits so `modelMap` date-stripping keeps working. -`src/claude/desktop-profile.ts` owns this range. - -Date-shaped Desktop IDs can overlap genuine native model IDs. When available discovery and +distinction. At render time each stored slot becomes a unique `p`-prefixed four-character wire +code, disjoint from the historical three-character hash namespace. Claude Desktop strips terminal +dates when comparing active-session model identity, so writing +the persisted date slots directly would collapse every managed route to `claude-opus-4-8` and +suppress `set_model` between them (#3782). The registry accepts both forms during migration. If an +active real Anthropic id claims a persisted date slot, reconciliation reallocates the non-Anthropic +route before rendering. If any synthetic or compatibility alias matches an active real Anthropic id, +the conflicting routed row or compatibility binding is omitted with a warning, and the real +Anthropic identity remains unclaimed; a profile alias can never overwrite native routing. +`src/claude/desktop-profile.ts` owns the slot range and wire conversion. + +Persisted date-shaped Desktop IDs can overlap genuine native model IDs. When available discovery and mapping evidence cannot resolve one, Messages and count-tokens return HTTP 503 with the fixed `desktop_model_mapping_unavailable` error rather than classifying it as invalid. Unknown legacy hash aliases remain HTTP 400; neither case reaches date-stripping or fallback routing. Known/registered IDs, diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index b5858dad9eb..ac414690dff 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -338,6 +338,22 @@ startup identity cancels the pending restart. Failed restart admission retries a bounded delay. Stopping the server before the accepted restart begins vetoes it, and a service child restarts only while it still owns the service home. Source checkouts and standalone binaries remain outside this fence. +mise installs every version in its own directory and repoints a floating link, so `mise upgrade` +never changes the manifest the fence watches: the proxy would keep serving the old version, and once +mise pruned it the fence would report the tree unreadable and refuse traffic without restarting. +`src/update/mise-launcher-target.ts` therefore plans a launcher watch, and +`src/lib/package-tree-retarget.ts` runs it, only for the managed Linux service +(`OCX_SERVICE_MANAGED=1`) of a verified mise owner whose recorded launcher is that tool's +`/node_modules/.bin/ocx`, and only when that launcher resolves to the running package at +boot. Shims, other layouts, launchd (which pins package paths) and foreground proxies are not +followed. The watch re-resolves the launcher on its own unref'd timer, so an idle service notices an +upgrade without a request. One complete target identity, canonical package root plus manifest +identity, must hold for the settle interval; a change to either, an unresolvable target, a target +outside the tool root, or a return to the running root restarts the wait. It then enters the same +restart handler as the fence without fencing requests, retries a refused admission after another +full interval, and reports the settled target's version as `installedVersion` when the fence has +none. The replacement boots from the target, so it never restarts again. + The fence withholds readiness, never identity (INV-FENCE-01). The fenced `/healthz` still answers a local attestation challenge and reports `restartCapability`, plus the `installedVersion` on disk once the replacement has held for the full stability interval (a readable manifest alone does not diff --git a/structure/ops/service-and-sidecars.md b/structure/ops/service-and-sidecars.md index 2c5b32d69b0..8b5986622fe 100644 --- a/structure/ops/service-and-sidecars.md +++ b/structure/ops/service-and-sidecars.md @@ -266,7 +266,7 @@ so an override can never shorten the budgets that prevent a duplicate proxy, and `src/service/orchestration.ts`) stays bounded. `tests/server/probe-timeout-env.test.ts` reads the constants in child processes. -`src/update/install-detection.mjs` examines both lexical and resolved package paths. An enclosing mise installation owns its nested npm/aube package only when the adjacent `.mise.backend.toml` identifies the containing tool alias and the canonical `npm:@bitkyc08/opencodex` backend. That verified outer owner takes precedence over the inner npm layout. Two verified owners whose tool roots differ only by a symlinked ancestor (macOS `/var` -> `/private/var`) are compared by canonical directory and count as one install. An unreadable or contradictory ownership boundary on either path takes precedence over a verified owner on the other path, refusing mutation without inventing a tool name or recovery command. One boundary is not OpenCodex's at all: on Windows, npm -g under a mise-managed Node puts the package directly in `/installs/node//node_modules`, whose adjacent record is Node's own (`short = "node"`, `full = "core:node"`). That exact record with the package directly in the runtime's global `node_modules` is an npm install and falls through to npm detection; any other backend, alias or deeper layout stays fail-closed (`tests/update/update-mise-node-runtime.test.ts`). `ocx update`, dashboard update checks, and update workers expose `installer: "mise"`; checks remain read-only, while mutation is refused with `mise upgrade ` before any proxy stop, package write, or worker creation. The package-tree integrity guard remains active for mise packages. +`src/update/install-detection.mjs` examines both lexical and resolved package paths. An enclosing mise installation owns its nested npm/aube package only when the adjacent `.mise.backend.toml` identifies the containing tool alias and the canonical `npm:@bitkyc08/opencodex` backend. That verified outer owner takes precedence over the inner npm layout. Two verified owners whose tool roots differ only by a symlinked ancestor (macOS `/var` -> `/private/var`) are compared by canonical directory and count as one install. An unreadable or contradictory ownership boundary on either path takes precedence over a verified owner on the other path, refusing mutation without inventing a tool name or recovery command. One boundary is not OpenCodex's at all: on Windows, npm -g under a mise-managed Node puts the package directly in `/installs/node//node_modules`, whose adjacent record is Node's own (`short = "node"`, `full = "core:node"`). That exact record with the package directly in the runtime's global `node_modules` is an npm install and falls through to npm detection; any other backend, alias or deeper layout stays fail-closed (`tests/update/update-mise-node-runtime.test.ts`). `ocx update`, dashboard update checks, and update workers expose `installer: "mise"`; checks remain read-only, while mutation is refused with `mise upgrade ` before any proxy stop, package write, or worker creation. The package-tree integrity guard remains active for mise packages, and the managed Linux service additionally follows its mise package launcher onto an upgraded version ([package-tree integrity fence](docs-and-release.md#package-tree-integrity-fence)). ## Package cache refresh diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 2058602ca09..d09433afca1 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -44,6 +44,11 @@ the [bounded ingestion contract](transports/inventory.md#bounded-response-ingest Anthropic model-scoped quota labels in `src/providers/quota/vendor-probes-oauth.ts` publish only canonical Fable, Opus, or Sonnet labels after removing terminal controls; unknown upstream display names are omitted. +MiniMax and MiniMax CN Coding Plan quota in `src/providers/quota/vendor-probes-key.ts` uses the +region-matched `/v1/api/openplatform/coding_plan/remains` endpoint. It publishes the `general` +model's consumed 5-hour percentage and, when active, weekly percentage with their reset times; +video quota rows are unrelated and omitted. + The routed identity sentence a catalog row carries is model-neutral on disk: `base_instructions`, and a native capability alias's `model_messages.instructions_template`, hold `NEUTRAL_IDENTITY_LINE` rather than a model id, because Codex stores a session's instruction block once and replays it @@ -94,6 +99,8 @@ and valid padded or unpadded payloads pass unchanged without a decoding allocati Adapter output must stay in internal `AdapterEvent` form until `src/bridge/sse.ts` converts it back to Responses SSE or WebSocket frames, or `src/bridge/response-json.ts` buffers it into a JSON response. `src/bridge.ts` is the compatibility facade that re-exports both. +`src/adapters/run-turn-queue.ts` preflight callers may supply an optional wait bound; timeout hands +the outstanding iterator read to replay once, while callers without a bound keep the existing wait. The image/video loop bounds each hidden iteration before replay or fulfillment; see [media iteration retention](transports/inventory.md#media-iteration-retention). @@ -381,3 +388,6 @@ normalization, and `tool_choice` alias resolution, so every adapter matches a de same way. `src/types/wire.ts` owns accepted wire enumerations such as the per-provider upstream HTTP-version pin, shared by the config load schema, the management write boundary, and the fetch runtime, so no boundary accepts a value another rejects. + +Preflight heartbeat retention keeps `replayUnsafe` sticky in the replayed tail, so a second +preflight cannot forget earlier side effects after the original marker is evicted. diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 71002ea5869..ac4e96a7007 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -279,9 +279,9 @@ but rejected hosted `web_search` with HTTP 400 `unsupported_request`, which mean prompt failed before the model answered, because Codex's hosted declaration travelled with it. A provider nobody has classified can now describe itself in config. -The declaration is additive to that table, not a replacement for it. The table still covers -destinations that reject a tool regardless of configuration, so an operator who never heard of the -field stays protected; a declaration can only deny more, never re-enable a known-broken pairing. +The declaration is additive to that table, not a replacement for it. The table is for destinations that reject a tool regardless of configuration, so an operator who never heard of the field stays protected; +a declaration can only deny more, never re-enable a known-broken pairing. The table is currently empty: its only row removed hosted search for grok-4.6 on OpenCode Go, +but that destination refuses two OpenAI-private fields rather than the tool, and `src/adapters/xai-web-search.ts` now normalizes those fields for every Grok model there. Two properties are deliberate. Spelling variants of one capability are aliased, so declaring `web_search` also denies `web_search_preview` — the rest of the proxy already folds that pair into diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 7d0030de0b7..1ec2d5b83a1 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -82,6 +82,11 @@ the global subscription classifier is unchanged. The rewrite itself is owned by remain in either the top-level catalog or `additional_tools`. Cached-only search removal follows the same rule. When an omitted `none` selector stated the turn's only client-call prohibition, the explicit empty `tools` catalog preserves that denial. Available forced function selectors remain intact. +The whole web-search normalization also runs for any `grok-*` model whose final request URL is +exactly `https://opencode.ai/zen/go/v1/responses`: OpenCode Go relays Grok to xAI and returns xAI's +schema errors verbatim (`external_web_access` and `search_context_size` -> 400, +`web_search_preview` -> 422), while sibling Go models accept the OpenAI fields and keep them. +Regression coverage: `tests/providers/opencode-go-grok46-responses.test.ts`. `src/adapters/openai-responses/request-strips.ts` preserves valid xAI custom-call item ids and repairs missing/invalid ids from a stable digest of the JSON-encoded `(call_id, name, input)` string tuple. Incomplete tuples remain unchanged, and call/result pairing uses the original call id. diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index b31f57d6f45..76c41755933 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -192,6 +192,20 @@ The management quota DTO keeps Combo editing aligned with scoped inference evide Lite and routing metadata use the same suffix-normalized model object as serialization, including configured bracket-suffix removal. +## Grok Devin pre-output rate limits + +For direct Grok Responses requests served by the Devin runTurn adapter, +`src/server/responses/run-turn-execution.ts` uses `preflightAdapterEvents` before creating the +streaming Response. A first-event 429 without a replay-unsafe heartbeat becomes an HTTP 429 JSON +error through the shared error formatter and client Retry-After resolver. The buffered first event +is replayed for every other outcome. The preflight is bounded by the configured stall timeout, +including any earlier OAuth failover preflight on this path. On expiry, its pending iterator read is +handed to SSE replay exactly once; timeout therefore starts a 200 SSE response, and any later 429 +is an SSE failure. Text, reasoning, and tool output commit the stream. This boundary neither retries +the turn nor changes combo failover policy. Buffered Responses turns apply the same refusal +formatter to their collected first event after OAuth failover. Other buffered results retain +the original event list, including output preceding a late error. + ## Optional client transport hints `dropCodexSafetyBuffering` defaults to false. Canonical OpenAI forward Responses can remove only diff --git a/structure/transports/streaming-health.md b/structure/transports/streaming-health.md index b559605c603..61411dd0149 100644 --- a/structure/transports/streaming-health.md +++ b/structure/transports/streaming-health.md @@ -29,10 +29,17 @@ The HTTP/SSE bridge emits an SSE comment-line keep-alive (`: opencodex heartbeat silence to re-arm Codex's idle timer (Codex's default `stream_idle_timeout` is 300 s and ANY SSE bytes re-arm it). A comment line is discarded by every eventsource parser without producing an event, so strict Responses decoders never see an unknown variant. Those bridge-enqueued keepalive frames do -NOT count as activity for the bridge's own watchdog: a bounded stall deadline (default 300 s, -configurable via `stallTimeoutSec`, checked on the 2 s heartbeat tick) closes the stream with +NOT count as activity for the bridge's own watchdog: a bounded stall deadline closes the stream with `response.incomplete` / `upstream_stall_timeout` and cancels the upstream request if no real -adapter events arrive. Adapter-yielded `{ type: "heartbeat" }` events DO reset the watchdog. +adapter events arrive. The deadline is checked on the 2 s heartbeat tick and its budget is resolved +by `src/stall-timeout.ts` (`resolveStallTimeoutSec`): a public upstream defaults to 300 s, a **local +upstream** (loopback, RFC 1918 private, link-local, or a `.local`/`.lan` name — classified by +`src/lib/local-upstream.ts`) defaults to **disabled**, and any explicit `stallTimeoutSec` overrides +both — including `stallTimeoutSec: 0`, the operator's off switch, which disables the watchdog for +public upstreams too. A disabled budget resolves to `0`, and the watchdog kill is gated on budget +`> 0`, so a `0` never mis-arms a kill on the first beat; keep-alives keep flowing regardless, so a +silent-but-healthy local model (CPU-bound thinking or a long time-to-first-token) stays connected. +Adapter-yielded `{ type: "heartbeat" }` events DO reset the watchdog. The Anthropic adapter maps both SSE comments and `ping` events to that heartbeat (#5707), so an upstream that only pings while a long thinking block is silent still counts as live. When the Responses-to-Chat converter receives that typed heartbeat, it emits the same bounded SSE @@ -78,11 +85,12 @@ is emitted as `response.failed` SSE. ### Pending response-body reads -`src/lib/response-body-inactivity.ts` bounds pending byte reads using the resolved -`stallTimeoutSec` (the 300-second default and configuration schema are unchanged). -Connect/stall budgets and this body-silence budget therefore read the same setting in -different phases: one bounds event stalls on the bridge, the other bounds pending raw -byte reads. There is no separate body-inactivity setting. +`src/lib/response-body-inactivity.ts` bounds pending byte reads using the same resolved +`stallTimeoutSec` budget (`resolveStallTimeoutMs`), so it inherits the same local-vs-public default +and the `0`-disables rule: a disabled budget passes `0`, and the guard treats a non-positive budget +as "arm no clock" rather than firing immediately. Connect/stall budgets and this body-silence budget +therefore read the same setting in different phases: one bounds event stalls on the bridge, the +other bounds pending raw byte reads. There is no separate body-inactivity setting. The guard has no read-ahead queue: it starts a monotonic deadline only when its consumer asks for bytes, pauses on a non-empty chunk, and does not reset on empty chunks. Discarded empty chunks yield to the macrotask queue periodically, so a large diff --git a/tests/adapters/bridge-stall.test.ts b/tests/adapters/bridge-stall.test.ts new file mode 100644 index 00000000000..85a063c3334 --- /dev/null +++ b/tests/adapters/bridge-stall.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, test } from "bun:test"; +import { bridgeToResponsesSSE } from "../../src/bridge"; +import type { AdapterEvent } from "../../src/types"; + +async function collectSse(stream: ReadableStream): Promise<{ event?: string; data: Record }[]> { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let text = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + } + return text.split("\n\n") + .map(frame => frame.trim()) + .filter(frame => frame.length > 0 && frame !== "data: [DONE]") + .map(frame => { + const lines = frame.split("\n"); + const event = lines.find(line => line.startsWith("event: "))?.slice(7); + const dataLine = lines.find(line => line.startsWith("data: ")); + return { event, data: JSON.parse(dataLine?.slice(6) ?? "{}") as Record }; + }); +} + +function stallReason(frame: { data: Record }): string | undefined { + const response = frame.data.response as Record | undefined; + const details = response?.incomplete_details as Record | undefined; + return details?.reason as string | undefined; +} + +// Manual beat-loop seam: the bridge arms its heartbeat/stall watchdog through options.timers, so +// the test drives ticks deterministically instead of waiting on wall-clock intervals. +function beatSeam() { + let beatTick: (() => void) | undefined; + const timers = { + setInterval(handler: () => void, _ms: number) { + beatTick = handler; + return 1; + }, + clearInterval(_id: unknown) { + beatTick = undefined; + }, + }; + return { timers, tick: () => beatTick?.() }; +} + +const flush = async () => { + for (let i = 0; i < 30; i++) await Promise.resolve(); +}; + +describe("bridge stall watchdog budget", () => { + test("a disabled budget (stallTimeoutSec: 0) does not kill a silent upstream on the first beat", async () => { + // Regression: resolveStallTimeoutSec(0) used to floor to 1s, and maxStallTicks then rounded to + // 0, so `++stallTicks >= 0` tripped the watchdog on the very first silent beat — killing a + // healthy but silent turn after ~one heartbeat interval (~2s in production). A disabled budget + // must leave the stream open; only wire keepalives flow until the upstream actually emits. + const heartbeatMs = 50; + const { timers, tick } = beatSeam(); + + let release: (() => void) | undefined; + const gate = () => new Promise(resolve => { release = resolve; }); + + async function* silentThenDone(): AsyncGenerator { + await gate(); // fully silent: no heartbeats, no data, until released + yield { type: "text_delta", text: "ok" }; + yield { type: "done" }; + } + + const framesPromise = collectSse(bridgeToResponsesSSE( + silentThenDone(), + "model", + undefined, + undefined, + undefined, + undefined, + heartbeatMs, + { stallTimeoutSec: 0, timers }, + )); + + await flush(); // let the bridge arm its beat loop and block on the silent upstream + // Far more silent beats than the single beat a zero budget would have tripped on. + for (let i = 0; i < 25; i++) tick(); + await flush(); + + // The stream must still be open — a resolved promise here would mean the watchdog killed it. + const raced = await Promise.race([ + framesPromise.then(() => "settled"), + new Promise(resolve => setTimeout(() => resolve("alive"), 25)), + ]); + expect(raced).toBe("alive"); + + release?.(); + const frames = await framesPromise; + expect(frames.some(f => stallReason(f) === "upstream_stall_timeout")).toBe(false); + expect(frames.some(f => f.event === "response.completed")).toBe(true); + }); + + test("an unset budget on a local upstream is disabled and survives a silent stretch", async () => { + // Mirrors the LM Studio path: config leaves stallTimeoutSec unset and the upstream is local, + // so the budget resolves to disabled. The bug re-armed a 300s wall here; the fix must not. + const heartbeatMs = 50; + const { timers, tick } = beatSeam(); + + let release: (() => void) | undefined; + const gate = () => new Promise(resolve => { release = resolve; }); + + async function* silentThenDone(): AsyncGenerator { + await gate(); + yield { type: "text_delta", text: "hi" }; + yield { type: "done" }; + } + + const framesPromise = collectSse(bridgeToResponsesSSE( + silentThenDone(), + "model", + undefined, + undefined, + undefined, + undefined, + heartbeatMs, + { localUpstream: true, timers }, // stallTimeoutSec unset + )); + + await flush(); + for (let i = 0; i < 25; i++) tick(); + await flush(); + + const raced = await Promise.race([ + framesPromise.then(() => "settled"), + new Promise(resolve => setTimeout(() => resolve("alive"), 25)), + ]); + expect(raced).toBe("alive"); + + release?.(); + const frames = await framesPromise; + expect(frames.some(f => stallReason(f) === "upstream_stall_timeout")).toBe(false); + expect(frames.some(f => f.event === "response.completed")).toBe(true); + }); + + test("an armed budget still kills a silent upstream (watchdog intact for public)", async () => { + // Guard against over-correction: a positive budget on a public upstream must still terminate a + // silent stream. stallTimeoutSec 1 → maxStallTicks = ceil(1000/50) = 20 silent beats. + const heartbeatMs = 50; + const stallTimeoutSec = 1; + const maxStallTicks = Math.ceil((stallTimeoutSec * 1000) / heartbeatMs); + const { timers, tick } = beatSeam(); + + async function* silent(): AsyncGenerator { + await new Promise(() => {}); // never emits + } + + const framesPromise = collectSse(bridgeToResponsesSSE( + silent(), + "model", + undefined, + undefined, + undefined, + undefined, + heartbeatMs, + { stallTimeoutSec, localUpstream: false, timers }, + )); + + await flush(); + for (let i = 0; i < maxStallTicks + 2; i++) tick(); + + const frames = await framesPromise; + expect(frames.some(f => stallReason(f) === "upstream_stall_timeout")).toBe(true); + }); +}); diff --git a/tests/adapters/run-turn-queue.test.ts b/tests/adapters/run-turn-queue.test.ts index b7515820937..50abe61aaab 100644 --- a/tests/adapters/run-turn-queue.test.ts +++ b/tests/adapters/run-turn-queue.test.ts @@ -294,8 +294,10 @@ describe("run-turn adapter event preflight", () => { ]; const preflight = await preflightAdapterEvents(events(values)); expect(preflight.replayUnsafe).toBe(true); - expect(await collect(preflight.stream)).toEqual([ - ...Array.from({ length: PREFLIGHT_HEARTBEAT_RETAIN_LIMIT }, () => heartbeat), + const repeated = await preflightAdapterEvents(preflight.stream); + expect(repeated.replayUnsafe).toBe(true); + expect(await collect(repeated.stream)).toEqual([ + ...Array.from({ length: PREFLIGHT_HEARTBEAT_RETAIN_LIMIT }, () => unsafeHeartbeat), error, ]); }); @@ -317,6 +319,64 @@ describe("run-turn adapter event preflight", () => { expect(await collect(preflight.stream)).toEqual(values); }); + test("timeout transfers the pending next read to replay exactly once", async () => { + const pending = Promise.withResolvers>(); + let nextCalls = 0; + const source: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next() { + nextCalls++; + if (nextCalls === 1) return Promise.resolve({ done: false, value: heartbeat }); + if (nextCalls === 2) return pending.promise; + return Promise.resolve({ done: true, value: undefined }); + }, + }; + }, + }; + const preflightPromise = preflightAdapterEvents(source, undefined, { maxWaitMs: 20 }); + let guard: ReturnType | undefined; + const preflight = await Promise.race([ + preflightPromise, + new Promise(resolve => { guard = setTimeout(() => resolve(null), 500); }), + ]); + if (guard !== undefined) clearTimeout(guard); + if (preflight === null) { + pending.resolve({ done: false, value: text("late") }); + await preflightPromise; + } + expect(preflight).not.toBeNull(); + if (preflight === null) return; + + expect(preflight.timedOut).toBe(true); + expect(nextCalls).toBe(2); + pending.resolve({ done: false, value: text("late") }); + expect(await collect(preflight.stream)).toEqual([heartbeat, text("late")]); + expect(nextCalls).toBe(3); + }); + + test("an expired preflight wait is timed out, not empty, without reading ahead", async () => { + let nextCalls = 0; + const source: AsyncIterable = { + [Symbol.asyncIterator]() { + return { + next() { + nextCalls++; + return Promise.resolve(nextCalls === 1 + ? { done: false, value: text("resume") } + : { done: true, value: undefined }); + }, + }; + }, + }; + const preflight = await preflightAdapterEvents(source, undefined, { maxWaitMs: 0 }); + expect(preflight.timedOut).toBe(true); + expect(preflight.empty).toBe(false); + expect(nextCalls).toBe(0); + expect(await collect(preflight.stream)).toEqual([text("resume")]); + expect(nextCalls).toBe(2); + }); + test("first-event classifier replaces only the first meaningful event", async () => { const values: AdapterEvent[] = [ heartbeat, diff --git a/tests/ci-workflows/package-tree-retarget.test.ts b/tests/ci-workflows/package-tree-retarget.test.ts new file mode 100644 index 00000000000..0b093e4321d --- /dev/null +++ b/tests/ci-workflows/package-tree-retarget.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "bun:test"; +import type { PackageTreeObservation } from "../../src/lib/package-tree-integrity"; +import { createPackageTreeRetargetWatch, type LauncherTarget } from "../../src/lib/package-tree-retarget"; +import { createPackageTreeIntegrityGuardForServer } from "../../src/server/index/package-tree-guard"; + +const RUNNING = "/mise/installs/opencodex/2.65.0/pkg"; +const NEXT = "/mise/installs/opencodex/2.66.0/pkg"; +const OTHER = "/mise/installs/opencodex/2.67.0/pkg"; +const manifest = (inode: bigint): PackageTreeObservation => ({ device: 1n, inode, contentTimeNs: 1n, size: 1n }); +const target = (root: string, inode = 1n): LauncherTarget => ({ root, manifest: manifest(inode) }); + +/** + * Manual clock and scheduler. Only `tick()` advances anything, so every case proves detection + * comes from the watch's own timer and never from a health or API request. + */ +function harness(initial: LauncherTarget | null = target(RUNNING), runningRoot = RUNNING) { + let clock = 0; + let pending: { callback: () => void; at: number } | null = null; + let current = initial; + let restarts = 0; + let refuse = 0; + const watch = createPackageTreeRetargetWatch(runningRoot, () => current, () => { + if (refuse > 0) { + refuse -= 1; + throw new Error("admission refused"); + } + restarts += 1; + }, { + pollIntervalMs: 1_000, + settleMs: 3_000, + now: () => clock, + schedule: (callback, delayMs) => { + const entry = { callback, at: clock + delayMs }; + pending = entry; + return () => { if (pending === entry) pending = null; }; + }, + readVersion: root => (root === NEXT ? "2.66.0" : root === OTHER ? "2.67.0" : undefined), + }); + return { + watch, + set: (next: LauncherTarget | null) => { current = next; }, + refuseNext: (count: number) => { refuse = count; }, + restarts: () => restarts, + polling: () => pending !== null, + tick: (times = 1) => { + for (let i = 0; i < times; i += 1) { + const entry = pending; + if (!entry) return; + pending = null; + clock = entry.at; + entry.callback(); + } + }, + }; +} + +describe("package-tree retarget watch", () => { + test("an idle service restarts once the launcher's new target has held for the settle interval", () => { + const h = harness(); + h.tick(2); + h.set(target(NEXT)); + h.tick(); // first sighting at t=3s + h.tick(2); // t=5s: held 2s + expect(h.restarts()).toBe(0); + h.tick(); // t=6s: held 3s + expect(h.restarts()).toBe(1); + expect(h.polling()).toBe(false); + }); + + test("a launcher that keeps resolving to the running package never restarts", () => { + const h = harness(); + h.tick(20); + expect(h.restarts()).toBe(0); + expect(h.polling()).toBe(true); + }); + + test("retargeting again during the wait restarts the full settle interval", () => { + const h = harness(); + h.set(target(NEXT)); + h.tick(3); + h.set(target(OTHER)); + h.tick(3); + expect(h.restarts()).toBe(0); + h.tick(); + expect(h.restarts()).toBe(1); + expect(h.watch.settledVersion()).toBe("2.67.0"); + }); + + test("a manifest change inside the same target root restarts the wait", () => { + const h = harness(); + h.set(target(NEXT, 1n)); + h.tick(3); + h.set(target(NEXT, 2n)); // the install rewrote package.json after the root appeared + h.tick(3); + expect(h.restarts()).toBe(0); + h.tick(); + expect(h.restarts()).toBe(1); + }); + + test("reverting to the running package during the wait cancels the restart", () => { + const h = harness(); + h.set(target(NEXT)); + h.tick(2); + h.set(target(RUNNING)); + h.tick(10); + expect(h.restarts()).toBe(0); + expect(h.watch.settledVersion()).toBeUndefined(); + }); + + test("a target that briefly cannot be resolved needs a fresh full interval after it recovers", () => { + const h = harness(); + h.set(target(NEXT)); + h.tick(3); + h.set(null); + h.tick(); + h.set(target(NEXT)); + h.tick(3); + expect(h.restarts()).toBe(0); + h.tick(); + expect(h.restarts()).toBe(1); + }); + + test("a refused restart is asked again only after another full interval", () => { + const h = harness(); + h.refuseNext(1); + h.set(target(NEXT)); + h.tick(4); // refused at t=4s + expect(h.restarts()).toBe(0); + h.tick(2); + expect(h.restarts()).toBe(0); + h.tick(); + expect(h.restarts()).toBe(1); + }); + + test("the replacement process booted from the new target does not restart again", () => { + const h = harness(target(NEXT), NEXT); + h.tick(20); + expect(h.restarts()).toBe(0); + }); + + test("settledVersion reports the settled target only while it still resolves identically", () => { + const h = harness(); + expect(h.watch.settledVersion()).toBeUndefined(); + h.set(target(NEXT)); + h.tick(3); + expect(h.watch.settledVersion()).toBeUndefined(); + h.tick(); + expect(h.watch.settledVersion()).toBe("2.66.0"); + h.set(target(NEXT, 9n)); + expect(h.watch.settledVersion()).toBeUndefined(); + }); + + test("dispose stops polling and invalidates a pending restart", () => { + const h = harness(); + h.set(target(NEXT)); + h.tick(3); + h.watch.dispose(); + expect(h.polling()).toBe(false); + h.tick(5); + expect(h.restarts()).toBe(0); + }); +}); + +describe("server package-tree guard with a launcher plan", () => { + function serverGuard(serviceChild: boolean, owned: boolean) { + let current: LauncherTarget | null = target(RUNNING); + let pending: (() => void) | null = null; + const accepted: string[] = []; + let busy = 0; + const guard = createPackageTreeIntegrityGuardForServer({ + packageTreeInstaller: "mise", + observePackageTree: () => manifest(1n), + packageTreeLauncherTarget: { runningRoot: RUNNING, resolveTarget: () => current }, + packageTreeRetargetOptions: { + pollIntervalMs: 1, + settleMs: 0, + schedule: callback => { pending = callback; return () => { pending = null; }; }, + readVersion: root => (root === NEXT ? "2.66.0" : undefined), + }, + acceptSystemRestart: (() => { + accepted.push("restart"); + if (busy > 0) { busy--; return { accepted: true, alreadyDraining: true, activeTurnCount: 0, drainTimeoutMs: 0 }; } + return { accepted: true, alreadyDraining: false, activeTurnCount: 0, drainTimeoutMs: 0 }; + }) as never, + }, () => owned, () => serviceChild); + const tick = () => { const run = pending; pending = null; run?.(); }; + return { + guard, accepted, tick, busyFor: (n: number) => { busy = n; }, + retarget: (next: LauncherTarget | null) => { current = next; }, + }; + } + + test("a mise upgrade restarts through the shared handler without fencing requests", () => { + const s = serverGuard(true, true); + s.retarget(target(NEXT)); + s.tick(); + s.tick(); + expect(s.accepted).toEqual(["restart"]); + expect(s.guard.status()).toEqual({ ok: true }); + expect(s.guard.installedVersion?.()).toBe("2.66.0"); + s.guard.dispose(); + }); + + test("a busy restart admission is asked again instead of stopping the watch", () => { + const s = serverGuard(true, true); + s.busyFor(1); + s.retarget(target(NEXT)); + for (let i = 0; i < 6; i++) s.tick(); + // The first admission was busy (another drain held the gate); the watch asks again. + expect(s.accepted).toEqual(["restart", "restart"]); + s.guard.dispose(); + }); + + test("a service child that no longer owns its home refuses and keeps waiting", () => { + const s = serverGuard(true, false); + s.retarget(target(NEXT)); + s.tick(); + s.tick(); + expect(s.accepted).toEqual([]); + s.guard.dispose(); + }); + + test("null plan keeps the fence-only behavior", () => { + const guard = createPackageTreeIntegrityGuardForServer({ + packageTreeInstaller: "mise", + observePackageTree: () => manifest(1n), + packageTreeLauncherTarget: null, + }, () => true, () => true); + expect(guard.status()).toEqual({ ok: true }); + expect(guard.installedVersion?.()).toBeUndefined(); + guard.dispose(); + }); +}); diff --git a/tests/claude-integration/claude-desktop-discovery.test.ts b/tests/claude-integration/claude-desktop-discovery.test.ts index 655146f5347..25a235b890d 100644 --- a/tests/claude-integration/claude-desktop-discovery.test.ts +++ b/tests/claude-integration/claude-desktop-discovery.test.ts @@ -174,21 +174,24 @@ describe("Desktop snapshot through authenticated model discovery", () => { expect(routed.find(row => row.slug === "test/model-123")?.priority).toBe(1001); }); - test("snapshot installs its exact aliases and retains ordinary discovery shapes", async () => { + test("snapshot installs Desktop-safe aliases, legacy decoders, and ordinary discovery shapes", async () => { launch(); + const legacyAlias = "claude-opus-4-8-20260304"; + const wireAlias = "claude-opus-4-8-p01q"; const snapshot = await request("?ids=desktop&format=desktop-config"); expect(snapshot.status).toBe(200); expect(snapshot.headers.get("cache-control")).toBe("no-store"); const body = await snapshot.json() as { version: number; models: Array<{ name: string; anthropicFamilyTier: string }> }; expect(body.version).toBe(1); - expect(body.models.find(model => model.name === "claude-opus-4-8-20260304")?.anthropicFamilyTier).toBe("fable"); - expect(resolveDesktop3pAlias("claude-opus-4-8-20260304")).toBe("test/model-155"); + expect(body.models.find(model => model.name === wireAlias)?.anthropicFamilyTier).toBe("fable"); + expect(resolveDesktop3pAlias(wireAlias)).toBe("test/model-155"); + expect(resolveDesktop3pAlias(legacyAlias)).toBe("test/model-155"); const anthropic = await request("?flavor=anthropic&ids=desktop"); expect(anthropic.status).toBe(200); const anthropicBody = await anthropic.json() as { data: Array<{ id: string }>; version?: number }; expect(anthropicBody.version).toBeUndefined(); - expect(anthropicBody.data.some(model => model.id === "claude-opus-4-8-20260304")).toBe(true); - expect(resolveDesktop3pAlias("claude-opus-4-8-20260304")).toBe("test/model-155"); + expect(anthropicBody.data.some(model => model.id === wireAlias)).toBe(true); + expect(resolveDesktop3pAlias(wireAlias)).toBe("test/model-155"); const cli = await request("?flavor=anthropic&ids=cli"); expect(cli.status).toBe(200); expect((await cli.json() as { data: Array<{ id: string }> }).data.some(model => model.id.startsWith("ocx-claude-test--"))).toBe(true); diff --git a/tests/claude-integration/claude-desktop-remote-hub.test.ts b/tests/claude-integration/claude-desktop-remote-hub.test.ts index e126d6cf2e4..1aa9531e405 100644 --- a/tests/claude-integration/claude-desktop-remote-hub.test.ts +++ b/tests/claude-integration/claude-desktop-remote-hub.test.ts @@ -230,7 +230,9 @@ for (const storedProfile of [true, false]) { expect(snapshot.models.some(model => model.labelOverride.includes("(native)"))).toBe(true); const chosenEntry = snapshot.models.find(model => model.labelOverride === "Model Target (chosen)"); expect(chosenEntry).toBeDefined(); - if (storedProfile) expect(chosenEntry!.name).toBe("claude-opus-4-8-20260211"); + // A stored date-shaped slot (20260211) is emitted as its p wire id (#5862), so + // Desktop's date stripping cannot collapse it onto a real dated Anthropic id. + if (storedProfile) expect(chosenEntry!.name).toBe("claude-opus-4-8-p015"); else expect(chosenEntry!.name).toMatch(/^claude-opus-4-8-[a-z][a-z0-9]{2}$/); const apply = spawnOwned(client, ["claude", "desktop", "apply", "--static"]); diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index ab920e92d0c..679b3a7e83e 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -1073,9 +1073,14 @@ 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 savedProfile = loadConfig().claudeCode!.desktopProfile!; + const wireAlias = desktopProfiles.renderDesktopProfile(savedProfile, [{ + route: "mock/test-model", + label: "test-model (mock)", + }])[0]!.name; + expect(wireAlias).not.toBe(savedProfile.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); + expect(discovery.data.some(model => model.id === wireAlias)).toBe(true); const apply = await fetch(new URL("/api/claude-desktop/apply", server.url), { method: "POST", diff --git a/tests/clients/desktop-3p.test.ts b/tests/clients/desktop-3p.test.ts index 411d44b8052..8e61aeaf48a 100644 --- a/tests/clients/desktop-3p.test.ts +++ b/tests/clients/desktop-3p.test.ts @@ -370,18 +370,113 @@ describe("Claude Desktop 3P models", () => { expect(resolveDesktop3pAlias("claude-opus-4-ncb")).toBe("native/gpt-5.6-sol"); }); - test("renders persisted family/date assignments and installs their decode registry", () => { + test("deduplicates repeated real Anthropic ids without a profile", () => { + const id = "claude-opus-4-6"; + const rendered = generateDesktop3pModels([], [ + { provider: "anthropic", id }, + { provider: "anthropic", id }, + ]); + expect(rendered.map(model => model.name)).toEqual([id]); + expect(resolveDesktop3pAlias(id)).toBeNull(); + expect(resolveInboundModel(id)).toBe(id); + }); + + test("keeps real Anthropic ids authoritative without a profile", () => { + const currentCollision = { provider: "test", id: "model-14753" }; + const currentAlias = desktop3pAlias(currentCollision.provider, currentCollision.id); + expect(currentAlias).toBe("claude-opus-4-8-a00"); + + const legacyCollision = { provider: "native", id: "gpt-5.6-sol" }; + const legacyAlias = legacyDesktop3pAlias(legacyCollision.provider, legacyCollision.id); + expect(legacyAlias).toBe("claude-opus-4-ncb"); + + const rendered = generateDesktop3pModels([], [ + currentCollision, + legacyCollision, + { provider: "anthropic", id: currentAlias }, + { provider: "anthropic", id: legacyAlias }, + ]); + const legacyCurrentAlias = desktop3pAlias(legacyCollision.provider, legacyCollision.id); + expect(rendered.map(model => model.name).sort()).toEqual([ + currentAlias, + legacyAlias, + legacyCurrentAlias, + ].sort()); + expect(resolveDesktop3pAlias(currentAlias)).toBeNull(); + expect(resolveDesktop3pAlias(legacyAlias)).toBeNull(); + expect(resolveInboundModel(currentAlias)).toBe(currentAlias); + expect(resolveInboundModel(legacyAlias)).toBe(legacyAlias); + }); + + test("profile wire ids do not rebind existing three-character aliases", () => { + const staleRoute = { provider: "test", id: "model-14753" }; + const staleAlias = desktop3pAlias(staleRoute.provider, staleRoute.id); + expect(staleAlias).toBe("claude-opus-4-8-a00"); + generateDesktop3pModels([], [staleRoute]); + expect(resolveDesktop3pAlias(staleAlias)).toBe("test/model-14753"); + + const profile = { + version: 1 as const, + assignments: { + "other/current": { family: "opus" as const, alias: "claude-opus-4-8-20260101" }, + }, + defaults: { opus: "other/current", fable: null, sonnet: null, haiku: null }, + }; + const rendered = generateDesktop3pModels([], [staleRoute, { provider: "other", id: "current" }], profile); + expect(rendered.find(model => model.labelOverride.includes("Current"))?.name).toBe("claude-opus-4-8-p000"); + expect(resolveDesktop3pAlias(staleAlias)).toBe("test/model-14753"); + }); + + test("fails closed when a managed wire id matches a real Anthropic id", () => { + const warning = spyOn(console, "warn").mockImplementation(() => {}); + const collision = "claude-opus-4-8-p000"; + const profile = { + version: 1 as const, + assignments: { + "test/routed": { family: "opus" as const, alias: "claude-opus-4-8-20260101" }, + [`anthropic/${collision}`]: { family: "opus" as const, alias: collision }, + }, + defaults: { opus: "test/routed", fable: null, sonnet: null, haiku: null }, + }; + try { + const rendered = generateDesktop3pModels([], [ + { provider: "test", id: "routed" }, + { provider: "anthropic", id: collision }, + ], profile); + expect(rendered.map(model => model.name)).toEqual([collision]); + expect(resolveDesktop3pAlias(collision)).toBeNull(); + expect(resolveInboundModel(collision)).toBe(collision); + expect(warning.mock.calls.flat().join(" ")).toContain("conflicts with a real Anthropic model"); + } finally { + warning.mockRestore(); + buildDesktop3pRegistry([], []); + } + }); + + test("renders persisted date slots as Desktop-distinct aliases and keeps legacy decoding", () => { 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")); + const storedAliases = Object.values(profile.assignments).map(assignment => assignment.alias); + expect(storedAliases.every(alias => /^claude-opus-4-8-20\d{6}$/.test(alias))).toBe(true); + + const rendered = generateDesktop3pModels(["gpt-5.6-sol"], routed, profile); + const luna = rendered.find(model => model.labelOverride.includes("Luna")); expect(luna).toMatchObject({ anthropicFamilyTier: "haiku", isFamilyDefault: true, supports1m: true }); - expect(luna?.name).toMatch(/^claude-opus-4-8-20\d{6}$/); + expect(rendered.map(model => model.name)).toEqual([ + expect.stringMatching(/^claude-opus-4-8-p[0-9a-z]{3}$/), + expect.stringMatching(/^claude-opus-4-8-p[0-9a-z]{3}$/), + ]); + const desktopIdentity = (id: string) => id.replace(/-(\d{8})$/, ""); + expect(new Set(rendered.map(model => desktopIdentity(model.name))).size).toBe(2); expect(resolveDesktop3pAlias(luna!.name)).toBe("cursor/gpt-5.6-luna"); + expect(resolveInboundModel(luna!.name)).toBe("cursor/gpt-5.6-luna"); + const legacyDate = profile.assignments["cursor/gpt-5.6-luna"]!.alias; + expect(resolveDesktop3pAlias(legacyDate)).toBe("cursor/gpt-5.6-luna"); + expect(resolveInboundModel(legacyDate)).toBe("cursor/gpt-5.6-luna"); }); test("backs up owned config and preserves old bytes when atomic replacement fails", () => { diff --git a/tests/clients/desktop-profile.test.ts b/tests/clients/desktop-profile.test.ts index 82725d65a70..d4af229ebbc 100644 --- a/tests/clients/desktop-profile.test.ts +++ b/tests/clients/desktop-profile.test.ts @@ -64,6 +64,41 @@ describe("Claude Desktop profile", () => { expect(() => setDesktopFamilyDefault(selected, "opus", null)).toThrow(DesktopProfileError); }); + test("reallocates a managed slot claimed by a newly active real Anthropic id", () => { + const datedId = "claude-opus-4-8-20260101"; + const profile = parseDesktopProfile({ + version: 1, + assignments: { + "test/routed": { family: "opus", alias: datedId }, + }, + defaults: { opus: "test/routed", fable: null, sonnet: null, haiku: null }, + }); + const reconciled = reconcileDesktopProfile(profile, [ + { route: "test/routed", label: "Routed" }, + { route: `anthropic/${datedId}`, label: "Real Anthropic" }, + ]); + expect(reconciled.assignments["test/routed"]?.alias).not.toBe(datedId); + expect(reconciled.assignments[`anthropic/${datedId}`]?.alias).toBe(datedId); + const rendered = renderDesktopProfile(reconciled, [ + { route: "test/routed", label: "Routed" }, + { route: `anthropic/${datedId}`, label: "Real Anthropic" }, + ]); + expect(new Set(rendered.map(model => model.name)).size).toBe(2); + expect(rendered.find(model => model.route === `anthropic/${datedId}`)?.name).toBe(datedId); + }); + + test("keeps a genuine dated Anthropic model id unchanged", () => { + const id = "claude-opus-4-8-20260101"; + const profile = { + version: 1 as const, + assignments: { + [`anthropic/${id}`]: { family: "opus" as const, alias: id }, + }, + defaults: { opus: `anthropic/${id}`, fable: null, sonnet: null, haiku: null }, + }; + expect(renderDesktopProfile(profile, [{ route: `anthropic/${id}`, label: "Real Anthropic" }])[0]!.name).toBe(id); + }); + 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"); @@ -103,6 +138,9 @@ describe("Claude Desktop profile", () => { label: `Model ${index}`, })); const full = reconcileDesktopProfile(emptyDesktopProfile(), encoded); + const rendered = renderDesktopProfile(full, encoded); + expect(new Set(rendered.map(model => model.name)).size).toBe(TOTAL_ALIAS_SLOTS); + expect(rendered.every(model => /^claude-opus-4-8-p[0-9a-z]{3}$/.test(model.name))).toBe(true); const snapshot = structuredClone(full); expect(Object.keys(full.assignments)).toHaveLength(TOTAL_ALIAS_SLOTS); expect(() => reconcileDesktopProfile(full, [...encoded, { route: "test/overflow", label: "Overflow" }])).toThrow("encoded date slots"); diff --git a/tests/codex-integration/codex-convergence-account-selectors.test.ts b/tests/codex-integration/codex-convergence-account-selectors.test.ts index edfa49eef83..7e5426e15f7 100644 --- a/tests/codex-integration/codex-convergence-account-selectors.test.ts +++ b/tests/codex-integration/codex-convergence-account-selectors.test.ts @@ -66,6 +66,7 @@ let previousOpencodexHome: string | undefined; let previousCodexCliPath: string | undefined; let previousFetch: typeof fetch; let modelRostersByChatgptAccount: Map; +let modelAccessProgramsByChatgptAccount: Map>; const GPT56_NATIVE_MODELS = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] as const; @@ -307,6 +308,7 @@ beforeEach(() => { process.env.OPENCODEX_HOME = opencodexHome; previousFetch = globalThis.fetch; modelRostersByChatgptAccount = new Map(); + modelAccessProgramsByChatgptAccount = new Map(); writeFileSync(join(codexHome, "auth.json"), JSON.stringify({ tokens: { access_token: "main-token", account_id: "main-chatgpt-account" }, })); @@ -325,6 +327,9 @@ beforeEach(() => { slug, supported_in_api: true, visibility: "list", + ...(modelAccessProgramsByChatgptAccount.get(accountId)?.has(slug) + ? { available_access_programs: modelAccessProgramsByChatgptAccount.get(accountId)!.get(slug) } + : {}), })), }); } @@ -335,6 +340,25 @@ beforeEach(() => { resetCodexModelEntitlementCacheForTests(); }); +test.each(["convergence", "retained"] as const)("%s writes account-specific access programs into the serialized catalog", async writer => { + primeCodexRuntimeFixture(); + writeCatalog([nativeEntry()]); + grantGpt56NativeModels("main-chatgpt-account", "side-chatgpt-account"); + const mainPrograms = { cyber: ["standard", "daybreak_blue"] }; + const sidePrograms = { cyber: ["standard"] }; + modelAccessProgramsByChatgptAccount.set("main-chatgpt-account", new Map([["gpt-5.6-sol", mainPrograms]])); + modelAccessProgramsByChatgptAccount.set("side-chatgpt-account", new Map([["gpt-5.6-sol", sidePrograms]])); + const next = config(true); + const catalog = writer === "convergence" + ? await convergeCatalog(next) + : (await syncCatalogModels(next), JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog); + const rows = catalog.models ?? []; + expect(rows.find(row => row.slug === "gpt-5.6-sol")?.available_access_programs).toEqual(mainPrograms); + expect(rows.find(row => row.slug === "desktop/gpt-5.6-sol")?.available_access_programs).toEqual(mainPrograms); + expect(rows.find(row => row.slug === "team/gpt-5.6-sol")?.available_access_programs).toEqual(sidePrograms); + expect(JSON.parse(readFileSync(catalogPath, "utf8")).models).toEqual(rows); +}); + afterEach(() => { globalThis.fetch = previousFetch; const identity = resolveEffectiveUserIdentity(); diff --git a/tests/codex-integration/codex-forward-access-programs-writer.test.ts b/tests/codex-integration/codex-forward-access-programs-writer.test.ts new file mode 100644 index 00000000000..672e2670bf0 --- /dev/null +++ b/tests/codex-integration/codex-forward-access-programs-writer.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { buildCatalogEntries, mergeCatalogEntriesForSync } from "../../src/codex/catalog/build-entries"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND } from "../../src/codex/catalog/parsing"; + +describe("serialized Codex forward catalog", () => { + test("keeps pinned programs on an exact forward alias through the writer merge only", () => { + const fresh = buildCatalogEntries(null, [], [{ + provider: "openai", + id: "gpt-6-sol", + catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND, + codexForwardNativeCapabilityAlias: true, + }]); + const forward = fresh.find(row => row.slug === "openai/gpt-6-sol"); + expect(forward?.available_access_programs).toEqual({ cyber: ["standard"] }); + + const foreign = { + ...structuredClone(forward), + slug: "other/gpt-6-sol", + opencodex_catalog_kind: "routed-provider", + available_access_programs: { cyber: ["daybreak_blue"] }, + }; + const merged = mergeCatalogEntriesForSync([], [...fresh, foreign], new Map(), [], false); + const serialized = JSON.parse(JSON.stringify({ models: merged })) as { + models: Array>; + }; + expect(serialized.models.find(row => row.slug === "openai/gpt-6-sol")?.available_access_programs) + .toEqual({ cyber: ["standard"] }); + expect(serialized.models.find(row => row.slug === "other/gpt-6-sol")) + .not.toHaveProperty("available_access_programs"); + }); +}); diff --git a/tests/codex-integration/codex-model-entitlements-program-shape.test.ts b/tests/codex-integration/codex-model-entitlements-program-shape.test.ts new file mode 100644 index 00000000000..a33dbc34fde --- /dev/null +++ b/tests/codex-integration/codex-model-entitlements-program-shape.test.ts @@ -0,0 +1,120 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { + MAIN_CODEX_ACCOUNT_ID, +} from "../../src/codex/main-account"; +import { + resolveCodexModelEntitlements, + resetCodexModelEntitlementCacheForTests, + type CodexModelEntitlementCredentialSnapshot, +} from "../../src/codex/model-entitlements"; +import { applyNativeAccessPrograms } from "../../src/codex/catalog/access-programs"; +import { deriveEntry } from "../../src/codex/catalog/sync"; +import type { RawEntry } from "../../src/codex/catalog/parsing"; + +const TEST_CLIENT_VERSION = "0.146.0"; +const SOL = "gpt-5.6-sol"; + +function credential(accountId: string): CodexModelEntitlementCredentialSnapshot { + return { + accountId, + accessToken: `token-${accountId}`, + chatgptAccountId: `chatgpt-${accountId}`, + credentialIdentity: `test:${accountId}`, + }; +} + +beforeEach(() => resetCodexModelEntitlementCacheForTests()); + +describe("Codex roster access program metadata", () => { + test("a routed row never inherits native access programs from its template", () => { + const template: RawEntry = { + slug: SOL, + available_access_programs: { cyber: ["standard", "daybreak_blue"] }, + }; + const routed = deriveEntry(template, "other/model", "Other provider", 5, { + provider: "other", id: "model", + }); + expect(routed).not.toHaveProperty("available_access_programs"); + expect(template.available_access_programs).toEqual({ cyber: ["standard", "daybreak_blue"] }); + const codexForward = deriveEntry(template, "openai/gpt-6-sol", "Codex forward", 5, { + provider: "openai", id: "gpt-6-sol", codexForwardNativeCapabilityAlias: true, + }); + expect(codexForward).toHaveProperty("available_access_programs", { cyber: ["standard"] }); + }); + test("keeps access programs from each authenticated roster separate", async () => { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential(MAIN_CODEX_ACCOUNT_ID), credential("secondary")], + fetcher: (async (_input, init) => Response.json({ models: [{ + slug: SOL, supported_in_api: true, visibility: "list", + available_access_programs: { cyber: new Headers(init?.headers).get("chatgpt-account-id") === `chatgpt-${MAIN_CODEX_ACCOUNT_ID}` + ? ["standard", "daybreak_blue"] : ["standard"] }, + }] })) as typeof fetch, + now: 1_000, + clientVersion: TEST_CLIENT_VERSION, + }); + expect(snapshot.accessProgramsByAccount?.get(MAIN_CODEX_ACCOUNT_ID)?.get(SOL)).toEqual({ cyber: ["standard", "daybreak_blue"] }); + expect(snapshot.accessProgramsByAccount?.get("secondary")?.get(SOL)).toEqual({ cyber: ["standard"] }); + + const rows: RawEntry[] = [ + { slug: SOL, available_access_programs: null }, + { slug: `main/${SOL}`, opencodex_catalog_kind: "account-selector-v1", available_access_programs: null }, + { slug: `secondary/${SOL}`, opencodex_catalog_kind: "account-selector-v1", available_access_programs: null }, + { slug: `other/${SOL}`, opencodex_catalog_kind: "account-selector-v1", available_access_programs: { cyber: ["daybreak_blue"] } }, + { slug: `other/${SOL}`, opencodex_catalog_kind: "routed-provider", available_access_programs: { cyber: ["daybreak_blue"] } }, + ]; + applyNativeAccessPrograms(rows, snapshot, new Map([ + ["main", MAIN_CODEX_ACCOUNT_ID], ["secondary", "secondary"], ["other", "missing"], + ])); + expect(rows[0]?.available_access_programs).toEqual({ cyber: ["standard", "daybreak_blue"] }); + expect(rows[1]?.available_access_programs).toEqual({ cyber: ["standard", "daybreak_blue"] }); + expect(rows[2]?.available_access_programs).toEqual({ cyber: ["standard"] }); + expect(rows[3]).not.toHaveProperty("available_access_programs"); + expect(rows[4]?.available_access_programs).toEqual({ cyber: ["daybreak_blue"] }); + }); + + test("preserves explicit null and removes stale metadata when a roster omits the field", async () => { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential(MAIN_CODEX_ACCOUNT_ID)], + fetcher: (async () => Response.json({ models: [ + { slug: SOL, supported_in_api: true, visibility: "list", available_access_programs: null }, + { slug: "gpt-6-sol", supported_in_api: true, visibility: "list" }, + ] })) as typeof fetch, + now: 1_000, + clientVersion: TEST_CLIENT_VERSION, + }); + expect(snapshot.accessProgramsByAccount?.get(MAIN_CODEX_ACCOUNT_ID)?.has(SOL)).toBe(true); + expect(snapshot.accessProgramsByAccount?.get(MAIN_CODEX_ACCOUNT_ID)?.get(SOL)).toBeNull(); + expect(snapshot.accessProgramsByAccount?.get(MAIN_CODEX_ACCOUNT_ID)?.has("gpt-6-sol")).toBe(false); + const rows: RawEntry[] = [ + { slug: SOL, available_access_programs: { cyber: ["daybreak_blue"] } }, + { slug: "gpt-6-sol", available_access_programs: { cyber: ["daybreak_blue"] } }, + ]; + applyNativeAccessPrograms(rows, snapshot, new Map()); + expect(rows[0]?.available_access_programs).toBeNull(); + expect(rows[1]).not.toHaveProperty("available_access_programs"); + }); + test("keeps model slugs but drops malformed access programs without a cyber array", async () => { + const snapshot = await resolveCodexModelEntitlements({ codexAccounts: [] }, { + credentials: [credential(MAIN_CODEX_ACCOUNT_ID)], + fetcher: (async () => Response.json({ models: [ + { slug: "model-empty-programs", supported_in_api: true, visibility: "list", available_access_programs: {} }, + { slug: "model-invalid-cyber", supported_in_api: true, visibility: "list", available_access_programs: { cyber: "standard" } }, + { slug: "model-valid-programs", supported_in_api: true, visibility: "list", available_access_programs: { cyber: ["standard"] } }, + { slug: "gpt-5.6-sol", supported_in_api: true, visibility: "list", available_access_programs: { cyber: ["standard"] } }, + ] })) as typeof fetch, + now: 1_000, + clientVersion: TEST_CLIENT_VERSION, + }); + + const models = snapshot.modelsByAccount.get(MAIN_CODEX_ACCOUNT_ID); + const programs = snapshot.accessProgramsByAccount?.get(MAIN_CODEX_ACCOUNT_ID); + expect(models).toEqual(new Set(["model-empty-programs", "model-invalid-cyber", "model-valid-programs", "gpt-5.6-sol"])); + expect(programs?.has("model-empty-programs")).toBe(false); + expect(programs?.has("model-invalid-cyber")).toBe(false); + expect(programs?.get("model-valid-programs")).toEqual({ cyber: ["standard"] }); + + const comboAlias = { slug: "gpt-5.6-sol", owned_by: "combo", available_access_programs: { cyber: ["daybreak_blue"] } }; + applyNativeAccessPrograms([comboAlias], snapshot, new Map()); + expect(comboAlias).not.toHaveProperty("available_access_programs"); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index c25660f076d..18fe4f35168 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -25,6 +25,7 @@ "start-args.test.ts": "cli", "start-ownership-publication.test.ts": "cli", "responses-core-modules.test.ts": "responses", + "responses-grok-devin-preflight.test.ts": "responses", "responses-passthrough-transient-policy.test.ts": "responses", "responses-spend-ledger-wiring.test.ts": "responses", "responses-send-budget-errors.test.ts": "responses", @@ -167,6 +168,7 @@ "bridge-nonstreaming-terminal.test.ts": "adapters", "bridge-raw-reasoning-hidden.test.ts": "adapters", "bridge-reasoning-replay-batch.test.ts": "adapters", + "bridge-stall.test.ts": "adapters", "bridge-terminal-singleness.test.ts": "adapters", "bridge.test.ts": "adapters", "buffered-response-shape-guards.test.ts": "adapters", @@ -217,6 +219,8 @@ "protocol-guard.test.ts": "responses", "protocol-ingress-guard.test.ts": "responses", "protocol-direct-encoders-chat.test.ts": "responses", + "protocol-direct-encoders-stall-disabled.test.ts": "responses", + "protocol-direct-encoders-local-upstream.test.ts": "responses", "protocol-direct-encoders-messages.test.ts": "responses", "chat-native-combo.test.ts": "responses", "messages-native-eligibility.test.ts": "responses", @@ -438,6 +442,7 @@ "codex-cli-update-launcher-policy.test.ts": "codex-integration", "codex-cli-update-zero-effect.test.ts": "codex-integration", "codex-cli-windows-installation-files.test.ts": "codex-integration", + "codex-forward-access-programs-writer.test.ts": "codex-integration", "codex-composed-acceptance.test.ts": "codex-integration", "codex-config-generation.test.ts": "codex-integration", "codex-context-owner.test.ts": "codex-integration", @@ -493,6 +498,7 @@ "codex-model-availability-error.test.ts": "codex-integration", "codex-model-denial-evidence.test.ts": "codex-integration", "codex-model-entitlement-admission.test.ts": "codex-integration", + "codex-model-entitlements-program-shape.test.ts": "codex-integration", "codex-model-entitlements.test.ts": "codex-integration", "codex-models-cache-invalidate.test.ts": "codex-integration", "codex-native-residue.test.ts": "codex-integration", @@ -965,6 +971,7 @@ "local-management-direct-transport.test.ts": "server", "local-provider-reload-client.test.ts": "server", "local-token-detect.test.ts": "oauth", + "local-upstream.test.ts": "lib", "logs-model-tier-confirmation.test.ts": "gui", "logs-timezone.test.ts": "server", "loop-reasoning-replay.test.ts": "images", @@ -1018,6 +1025,7 @@ "mimo-token-plan-provider.test.ts": "providers", "mimo-v26-catalog.test.ts": "providers", "minimax-clients.test.ts": "providers", + "minimax-quota.test.ts": "providers", "minimax-reasoning-split.test.ts": "providers", "model-cache-generation-tombstone.test.ts": "codex-integration", "model-cache.test.ts": "codex-integration", @@ -1169,6 +1177,7 @@ "package-tree-fenced-restart.test.ts": "ci-workflows", "package-tree-integrity.test.ts": "ci-workflows", "package-tree-restart-ownership.test.ts": "ci-workflows", + "package-tree-retarget.test.ts": "ci-workflows", "parallel-tool-calls-optin.test.ts": "codex-integration", "parser-content-audio.test.ts": "responses", "passive-route-linker.test.ts": "server", @@ -1602,6 +1611,7 @@ "update-notify.test.ts": "update", "update-npm-cache-preflight.test.ts": "update", "update-npm-invocation.test.ts": "update", + "update-mise-launcher-target.test.ts": "update", "update-mise.test.ts": "update", "update-mise-node-runtime.test.ts": "update", "update-pnpm.test.ts": "update", diff --git a/tests/lib/local-upstream.test.ts b/tests/lib/local-upstream.test.ts new file mode 100644 index 00000000000..d2110916b9c --- /dev/null +++ b/tests/lib/local-upstream.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { isLocalUpstream } from "../../src/lib/local-upstream"; + +describe("isLocalUpstream", () => { + describe("loopback", () => { + test("classifies localhost (any case, with or without a port)", () => { + expect(isLocalUpstream("localhost")).toBe(true); + expect(isLocalUpstream("LOCALHOST")).toBe(true); + expect(isLocalUpstream("http://localhost:1234/v1")).toBe(true); + expect(isLocalUpstream("localhost:1234")).toBe(true); + }); + + test("classifies the IPv4 loopback block", () => { + expect(isLocalUpstream("127.0.0.1")).toBe(true); + expect(isLocalUpstream("127.8.9.10")).toBe(true); + expect(isLocalUpstream("http://127.0.0.1:8080")).toBe(true); + }); + + test("classifies the IPv6 loopback", () => { + expect(isLocalUpstream("::1")).toBe(true); + expect(isLocalUpstream("http://[::1]:8080")).toBe(true); + // Full-form loopback. + expect(isLocalUpstream("0:0:0:0:0:0:0:1")).toBe(true); + }); + + test("classifies the unspecified address", () => { + expect(isLocalUpstream("0.0.0.0")).toBe(true); + }); + }); + + describe("private and link-local ranges", () => { + test("classifies RFC 1918 private ranges", () => { + expect(isLocalUpstream("10.1.2.3")).toBe(true); + expect(isLocalUpstream("172.16.0.1")).toBe(true); + expect(isLocalUpstream("172.31.255.255")).toBe(true); + expect(isLocalUpstream("192.168.1.50")).toBe(true); + }); + + test("classifies IPv4 link-local", () => { + expect(isLocalUpstream("169.254.10.10")).toBe(true); + }); + + test("classifies IPv6 link-local and unique-local", () => { + expect(isLocalUpstream("fe80::1")).toBe(true); + expect(isLocalUpstream("fd12:3456:789a::1")).toBe(true); + // A zone id does not defeat classification. + expect(isLocalUpstream("fe80::1%eth0")).toBe(true); + }); + + test("classifies IPv4-mapped IPv6 loopback", () => { + expect(isLocalUpstream("::ffff:127.0.0.1")).toBe(true); + }); + }); + + describe("local multicast-name suffixes", () => { + test("classifies .local and .lan names", () => { + expect(isLocalUpstream("lm-studio.local")).toBe(true); + expect(isLocalUpstream("ollama.lan")).toBe(true); + expect(isLocalUpstream("http://vllm.local:8000/v1")).toBe(true); + }); + }); + + describe("public destinations", () => { + test("does not classify public IP ranges as local", () => { + expect(isLocalUpstream("8.8.8.8")).toBe(false); + // Just outside 172.16/12. + expect(isLocalUpstream("172.32.0.1")).toBe(false); + expect(isLocalUpstream("172.15.0.1")).toBe(false); + // Carrier-grade NAT and documentation ranges are not trusted local. + expect(isLocalUpstream("100.64.0.1")).toBe(false); + expect(isLocalUpstream("203.0.113.10")).toBe(false); + }); + + test("does not classify public IPv6 as local", () => { + expect(isLocalUpstream("2606:4700::1111")).toBe(false); + }); + + test("does not classify public hostnames as local", () => { + expect(isLocalUpstream("api.openai.com")).toBe(false); + expect(isLocalUpstream("https://api.anthropic.com/v1")).toBe(false); + expect(isLocalUpstream("example.com")).toBe(false); + }); + + test("a public hostname that merely contains 'local' is not local", () => { + expect(isLocalUpstream("localization.example.com")).toBe(false); + expect(isLocalUpstream("notlocalhost.example.com")).toBe(false); + }); + }); + + describe("degenerate input", () => { + test("returns false for empty, null, and undefined", () => { + expect(isLocalUpstream("")).toBe(false); + expect(isLocalUpstream(" ")).toBe(false); + expect(isLocalUpstream(null)).toBe(false); + expect(isLocalUpstream(undefined)).toBe(false); + }); + + test("returns false for hosts it cannot parse", () => { + expect(isLocalUpstream("not a url at all")).toBe(false); + expect(isLocalUpstream("300.300.300.300")).toBe(false); + }); + }); +}); diff --git a/tests/lib/stall-timeout.test.ts b/tests/lib/stall-timeout.test.ts index f1b53700e8b..fe21bc099c2 100644 --- a/tests/lib/stall-timeout.test.ts +++ b/tests/lib/stall-timeout.test.ts @@ -1,22 +1,60 @@ import { describe, expect, test } from "bun:test"; -import { DEFAULT_STALL_TIMEOUT_SEC, resolveStallTimeoutSec } from "../../src/stall-timeout"; +import { DEFAULT_STALL_TIMEOUT_SEC, resolveStallTimeoutMs, resolveStallTimeoutSec } from "../../src/stall-timeout"; describe("resolveStallTimeoutSec", () => { - test("defaults to 300 seconds when unset", () => { + test("defaults to 300 seconds for a public upstream when unset", () => { expect(DEFAULT_STALL_TIMEOUT_SEC).toBe(300); expect(resolveStallTimeoutSec(undefined)).toBe(300); + expect(resolveStallTimeoutSec(undefined, { localUpstream: false })).toBe(300); }); - test("honors finite configured values with a minimum of 1", () => { + test("defaults to disabled for a local upstream when unset", () => { + // Local models (LM Studio, Ollama, a self-hosted vLLM) are often CPU-bound and go silent for + // minutes while thinking; an unset budget must not kill a healthy local turn. + expect(resolveStallTimeoutSec(undefined, { localUpstream: true })).toBe(0); + }); + + test("an explicit 0 disables the budget everywhere, local and public alike", () => { + // The operator's documented off-switch. Regression: 0 previously resolved to a 1s minimum, + // which re-armed the watchdog instead of disarming it. + expect(resolveStallTimeoutSec(0)).toBe(0); + expect(resolveStallTimeoutSec(0, { localUpstream: false })).toBe(0); + expect(resolveStallTimeoutSec(0, { localUpstream: true })).toBe(0); + }); + + test("a non-positive configured value disables the budget", () => { + expect(resolveStallTimeoutSec(-5)).toBe(0); + expect(resolveStallTimeoutSec(-5, { localUpstream: true })).toBe(0); + }); + + test("honors positive configured values with a 1s floor, local and public", () => { expect(resolveStallTimeoutSec(90)).toBe(90); + expect(resolveStallTimeoutSec(90, { localUpstream: true })).toBe(90); expect(resolveStallTimeoutSec(600.2)).toBe(601); - expect(resolveStallTimeoutSec(0)).toBe(1); - expect(resolveStallTimeoutSec(-5)).toBe(1); + // A sub-second positive budget rounds up to the 1s floor rather than being dropped. + expect(resolveStallTimeoutSec(0.4)).toBe(1); + expect(resolveStallTimeoutSec(0.4, { localUpstream: true })).toBe(1); }); - test("rejects non-finite values back to the default", () => { + test("rejects non-finite values back to the destination default", () => { expect(resolveStallTimeoutSec(Number.NaN)).toBe(300); expect(resolveStallTimeoutSec(Number.POSITIVE_INFINITY)).toBe(300); expect(resolveStallTimeoutSec(Number.NEGATIVE_INFINITY)).toBe(300); + expect(resolveStallTimeoutSec(Number.NaN, { localUpstream: true })).toBe(0); + }); +}); + +describe("resolveStallTimeoutMs", () => { + test("scales the seconds resolution to milliseconds", () => { + expect(resolveStallTimeoutMs(undefined)).toBe(300_000); + expect(resolveStallTimeoutMs(undefined, { localUpstream: true })).toBe(0); + expect(resolveStallTimeoutMs(0)).toBe(0); + expect(resolveStallTimeoutMs(90)).toBe(90_000); + }); + + test("0 is a stable disabled sentinel for inactivity guards", () => { + expect(resolveStallTimeoutMs(0, { localUpstream: true })).toBe(0); + // Guards treat a non-positive budget as "arm no clock"; the resolver must hand them exactly 0. + expect(resolveStallTimeoutMs(undefined, { localUpstream: true })).toBe(0); }); }); diff --git a/tests/providers/minimax-quota.test.ts b/tests/providers/minimax-quota.test.ts new file mode 100644 index 00000000000..7bb8c7b947a --- /dev/null +++ b/tests/providers/minimax-quota.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../src/providers/quota"; +import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; +import type { OcxConfig } from "../../src/types"; + +const originalFetch = globalThis.fetch; +const originalProxyEnv = Object.fromEntries(PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]).map(key => [key, process.env[key]])); + +function config(name: string, baseUrl: string): OcxConfig { + return { + defaultProvider: name, + providers: { [name]: { adapter: "openai-chat", authMode: "key", baseUrl, apiKey: "test-secret" } }, + } as OcxConfig; +} + +function response(modelRemains: unknown, statusCode = 0): Response { + return Response.json({ base_resp: { status_code: statusCode }, model_remains: modelRemains }); +} + +beforeEach(() => { + for (const key of Object.keys(originalProxyEnv)) delete process.env[key]; + clearProviderQuotaCache(); +}); +afterEach(() => { + globalThis.fetch = originalFetch; + for (const [key, value] of Object.entries(originalProxyEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + clearProviderQuotaCache(); +}); + +describe("MiniMax Coding Plan quota", () => { + test("CN uses current endpoint and reports general 5-hour and weekly consumed percentages with resets", async () => { + let request: { url?: string; authorization?: string; redirect?: RequestRedirect } = {}; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + request = { + url: String(input), + authorization: new Headers(init?.headers).get("authorization") ?? undefined, + redirect: init?.redirect, + }; + return response([ + { model_name: "general", current_interval_remaining_percent: 62.5, end_time: 1_800_000_000, current_weekly_status: 1, current_weekly_remaining_percent: 25, weekly_end_time: 1_810_000_000 }, + { model_name: "video", current_interval_remaining_percent: 0, end_time: 1_800_000_000, current_weekly_status: 1, current_weekly_remaining_percent: 0, weekly_end_time: 1_810_000_000 }, + ]); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config("minimax-cn", "https://api.minimaxi.com/v1"), true); + + expect(request).toMatchObject({ + url: "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains", + authorization: "Bearer test-secret", + redirect: "error", + }); + expect(result.reports[0]?.quota.customWindows).toEqual([ + { label: "Coding Plan 5-hour", percent: 37.5, resetAt: 1_800_000_000_000 }, + { label: "Coding Plan weekly", percent: 75, resetAt: 1_810_000_000_000 }, + ]); + }); + + test("international provider uses the minimax.io host", async () => { + let url = ""; + globalThis.fetch = (async input => { + url = String(input); + return response([{ model_name: "general", current_interval_remaining_percent: 80, end_time: 1_800_000_000 }]); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config("minimax", "https://api.minimax.io/v1"), true); + + expect(url).toBe("https://api.minimax.io/v1/api/openplatform/coding_plan/remains"); + expect(result.reports[0]?.quota.customWindows?.[0]?.percent).toBe(20); + }); + + test("no-week plans expose only the 5-hour window", async () => { + globalThis.fetch = (async () => response([ + { model_name: "general", current_interval_remaining_percent: 44, end_time: 1_800_000_000, current_weekly_status: 0, current_weekly_remaining_percent: 0 }, + ])) as typeof fetch; + + const result = await fetchProviderQuotaReports(config("minimax-cn", "https://api.minimaxi.com/v1"), true); + + expect(result.reports[0]?.quota.customWindows).toEqual([ + { label: "Coding Plan 5-hour", percent: 56, resetAt: 1_800_000_000_000 }, + ]); + }); + + test.each([ + ["nonzero status", [{ model_name: "general", current_interval_remaining_percent: 20 }], 7], + ["missing general model", [{ model_name: "video", current_interval_remaining_percent: 20 }], 0], + ["invalid percentage", [{ model_name: "general", current_interval_remaining_percent: "invalid" }], 0], + ])("does not publish an empty window for %s", async (_label, rows, status) => { + globalThis.fetch = (async () => response(rows, status)) as typeof fetch; + + const result = await fetchProviderQuotaReports(config("minimax-cn", "https://api.minimaxi.com/v1"), true); + + expect(result.reports).toEqual([]); + }); + + test("bounds numeric percentages and ignores malformed JSON", async () => { + globalThis.fetch = (async () => response([ + { model_name: "general", current_interval_remaining_percent: -5 }, + ])) as typeof fetch; + const bounded = await fetchProviderQuotaReports(config("minimax-cn", "https://api.minimaxi.com/v1"), true); + expect(bounded.reports[0]?.quota.customWindows?.[0]?.percent).toBe(100); + + clearProviderQuotaCache(); + globalThis.fetch = (async () => new Response("not json")) as typeof fetch; + const malformed = await fetchProviderQuotaReports(config("minimax-cn", "https://api.minimaxi.com/v1"), true); + expect(malformed.reports).toEqual([]); + }); +}); diff --git a/tests/providers/opencode-go-grok46-responses.test.ts b/tests/providers/opencode-go-grok46-responses.test.ts index c48fbf13079..9c4bc5fae70 100644 --- a/tests/providers/opencode-go-grok46-responses.test.ts +++ b/tests/providers/opencode-go-grok46-responses.test.ts @@ -69,50 +69,38 @@ describe("OpenCode Go Grok Responses compatibility", () => { .toEqual({ effort: "xhigh" }); }); - test("drops the hosted search tool that this exact destination rejects", () => { + test.each(["grok-4.6", "grok-4.7"])("keeps hosted search for %s and removes only the fields xAI refuses", model => { const functionTool = { type: "function", name: "lookup", parameters: { type: "object" } }; - const body = build("grok-4.6", { + const userLocation = { type: "approximate", country: "IT" }; + const body = build(model, { tools: [ - { type: "web_search", search_context_size: "medium" }, - { type: "web_search_preview" }, + { + type: "web_search", + external_web_access: true, + search_context_size: "medium", + user_location: userLocation, + search_content_types: ["text", "image"], + }, + { type: "web_search_preview", external_web_access: true }, functionTool, ], }); - expect(body.tools).toEqual([functionTool]); - }); - - test("promotes additional_tools-only declarations before dropping refused hosted search", () => { - const functionTool = { type: "function", name: "lookup", parameters: { type: "object" } }; - const body = build("grok-4.6", { - input: [{ - type: "additional_tools", - tools: [{ type: "web_search_preview" }, functionTool], - }], - }); - - expect(body.input).toEqual([]); - expect(body.tools).toEqual([functionTool]); + expect(body.tools).toEqual([ + { type: "web_search", user_location: userLocation, search_content_types: ["text", "image"], enable_image_search: true }, + { type: "web_search" }, + functionTool, + ]); }); - test("disables an explicit choice for a removed hosted tool", () => { - const body = build("grok-4.6", { - tools: [{ type: "web_search" }], - tool_choice: { type: "web_search" }, - }); - - expect(body.tools).toEqual([]); - expect(body.tool_choice).toBe("none"); - }); - - test("narrows allowed_tools to declarations that remain", () => { + test("omits a cached-only search declaration instead of widening it to live search", () => { const functionTool = { type: "function", name: "lookup", parameters: { type: "object" } }; - const body = build("grok-4.6", { - tools: [{ type: "web_search_preview" }, functionTool], + const body = build("grok-4.7", { + tools: [{ type: "web_search", external_web_access: false }, functionTool], tool_choice: { type: "allowed_tools", mode: "required", - tools: [{ type: "web_search_preview" }, { type: "function", name: "lookup" }], + tools: [{ type: "web_search" }, { type: "function", name: "lookup" }], }, }); @@ -124,23 +112,58 @@ describe("OpenCode Go Grok Responses compatibility", () => { }); }); - test("disables required mode when every declared tool is removed", () => { - const body = build("grok-4.6", { - tools: [{ type: "web_search" }], - tool_choice: "required", + test("normalizes hosted search promoted out of additional_tools", () => { + const functionTool = { type: "function", name: "lookup", parameters: { type: "object" } }; + const body = build("grok-4.7", { + input: [{ + type: "additional_tools", + tools: [{ type: "web_search_preview", external_web_access: true, search_context_size: "low" }, functionTool], + }], }); - expect(body.tools).toEqual([]); - expect(body.tool_choice).toBe("none"); + expect(body.input).toEqual([]); + expect(body.tools).toEqual([{ type: "web_search" }, functionTool]); + }); + + test("removing replayed cached-only search does not shift the current turn into history", () => { + // The replayed wrapper empties once its cached-only search is omitted. Dropping it would move + // the current-turn wrapper below _replayPrefixLen, and Go promotion would then discard it as + // history instead of promoting its live search. + const request = buildRequest("grok-4.7", { + input: [ + { type: "additional_tools", tools: [{ type: "web_search", external_web_access: false }] }, + { type: "message", role: "assistant", content: "history" }, + { type: "additional_tools", tools: [{ type: "web_search", external_web_access: true }] }, + { type: "message", role: "user", content: "search now" }, + ], + }, provider(), 2); + const body = JSON.parse(request.body) as Record; + + expect(body.input).toEqual([ + { type: "message", role: "assistant", content: "history" }, + { type: "message", role: "user", content: "search now" }, + ]); + expect(body.tools).toEqual([{ type: "web_search" }]); }); test("preserves hosted search for another model on OpenCode Go", () => { - const webSearch = { type: "web_search", search_context_size: "medium" }; + const webSearch = { type: "web_search", external_web_access: true, search_context_size: "medium" }; const body = build("gpt-5.6-luna", { tools: [webSearch] }); expect(body.tools).toEqual([webSearch]); }); + test.each([ + "https://opencode.ai/zen/v1", + "https://opencode.ai.evil.test/zen/go/v1", + "https://example.test/v1", + ])("leaves Grok web_search fields alone on the unrelated destination %s", baseUrl => { + const webSearch = { type: "web_search", external_web_access: true, search_context_size: "medium" }; + const body = build("grok-4.7", { tools: [webSearch] }, provider(baseUrl)); + + expect(body.tools).toEqual([webSearch]); + }); + test("preserves hosted search for Grok 4.6 on another destination", () => { const webSearch = { type: "web_search", search_context_size: "medium" }; const body = build("grok-4.6", { tools: [webSearch] }, provider("https://api.x.ai/v1")); @@ -195,19 +218,21 @@ describe("OpenCode Go additional_tools placement", () => { }); }); - test("keeps nameless hosted tools for Luna and prunes Go Grok selectors after promotion", () => { + test("keeps nameless hosted tools for Luna and keeps them for Go Grok after promotion", () => { const web = { type: "web_search" }; const raw = { input: [{ type: "additional_tools", tools: [web, lookup] }], tool_choice: { type: "allowed_tools", mode: "required", tools: [web, { type: "function", name: "lookup" }], } }; expect(build("gpt-5.6-luna", raw).tools).toEqual([web, lookup]); const grok = build("grok-4.6", raw); - expect(grok.tools).toEqual([lookup]); - expect(grok.tool_choice).toEqual({ - type: "allowed_tools", mode: "required", tools: [{ type: "function", name: "lookup" }], - }); - expect(build("grok-4.6", { input: [{ type: "additional_tools", tools: [web] }], tool_choice: "required" })) - .toMatchObject({ input: [], tools: [], tool_choice: "none" }); + expect(grok.tools).toEqual([web, lookup]); + expect(grok.tool_choice).toEqual(raw.tool_choice); + const cachedOnly = { type: "web_search", external_web_access: false }; + // xAI 400s a `none` selector with nothing to select, so the deny-all is restated as the + // explicit empty catalog, exactly as on the direct xAI destinations. + const denied = build("grok-4.6", { input: [{ type: "additional_tools", tools: [cachedOnly] }], tool_choice: "required" }); + expect(denied).toMatchObject({ input: [], tools: [] }); + expect(denied).not.toHaveProperty("tool_choice"); }); test("does not promote additional tools restored from continuation history", () => { diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 37da5d6af47..139daa50d04 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -785,11 +785,11 @@ describe("fetchProviderQuotaReports", () => { }); test("routing quota scope keeps a key-bound display-only report out of model selection", async () => { - // MiniMax publishes its Token Plan countdown through the display-only path. The provider + // MiniMax publishes its Token Plan windows through the display-only path. The provider // is single-key `key` auth, so ownership alone would resolve a routing binding; without // an inference projection the exhausted row must still not rank or veto the target. globalThis.fetch = (async () => Response.json({ - success: true, data: { remains_time: 0, total_time: 1_000_000_000 }, + base_resp: { status_code: 0 }, model_remains: [{ model_name: "general", current_interval_remaining_percent: 0 }], })) as typeof fetch; const config = quotaCombo(keyQuotaConfig("minimax", "https://api.minimax.io/v1")); const reports = await fetchProviderQuotaReports(config, true); @@ -1671,61 +1671,6 @@ describe("fetchProviderQuotaReports", () => { expect(result.reports[0]?.quota.monthlyPercent).toBeUndefined(); }); - test("MiniMax quota drops the row when the API omits the plan total after having it", async () => { - // A valid row (with total) exists; a later valid response omitting the - // total is a DELIBERATE contract change — the stale row must be dropped - // (terminal), not preserved as a transient last-good. - let withTotal = true; - const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const url = String(input); - const headers = init?.headers as Record | undefined; - seen.push({ url, authorization: headers?.Authorization, redirect: init?.redirect }); - return new Response(JSON.stringify(withTotal - ? { success: true, data: { remains_time: 750_000_000, total_time: 1_000_000_000 } } - : { success: true, data: { remains_time: 1_000_000_000 } }), { status: 200 }); - }) as typeof fetch; - const config = keyQuotaConfig("minimax", "https://api.minimax.io/v1"); - - const valid = await fetchProviderQuotaReports(config, true); - withTotal = false; - const noTotal = await fetchProviderQuotaReports(config, true); - - expect(valid.reports).toHaveLength(1); - expect(noTotal.reports).toEqual([]); - expect(seen[0]?.url).toBe("https://www.minimax.io/v1/token_plan/remains"); - expect(seen[0]?.authorization).toBe("Bearer minimax-secret"); - expect(seen[0]?.redirect).toBe("error"); - }); - - test("MiniMax quota derives a consumed share when the API reports the plan total", async () => { - globalThis.fetch = (async () => new Response(JSON.stringify({ - success: true, - data: { remains_time: 750_000_000, total_time: 1_000_000_000 }, - }), { status: 200 })) as typeof fetch; - - const result = await fetchProviderQuotaReports(keyQuotaConfig("minimax", "https://api.minimax.io/v1"), true); - - expect(result.reports).toHaveLength(1); - expect(result.reports[0]?.quota.customWindows?.[0]?.percent).toBe(25); - }); - - test("MiniMax CN quota probes the minimaxi.com host", async () => { - const seen: string[] = []; - globalThis.fetch = (async (input: RequestInfo | URL) => { - seen.push(String(input)); - return new Response(JSON.stringify({ - success: true, - data: { remains_time: 750_000_000, total_time: 1_000_000_000 }, - }), { status: 200 }); - }) as typeof fetch; - - const result = await fetchProviderQuotaReports(keyQuotaConfig("minimax-cn", "https://api.minimaxi.com/v1"), true); - - expect(result.reports).toHaveLength(1); - expect(seen[0]).toBe("https://api.minimaxi.com/v1/token_plan/remains"); - }); - test("MiniMax quota never sends the key to a non-canonical base URL", async () => { const seen: string[] = []; globalThis.fetch = (async (input: RequestInfo | URL) => { diff --git a/tests/responses/chat-native-sse-stall.test.ts b/tests/responses/chat-native-sse-stall.test.ts new file mode 100644 index 00000000000..7a84de3586c --- /dev/null +++ b/tests/responses/chat-native-sse-stall.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, test } from "bun:test"; +import { nativeChatSse } from "../../src/server/chat-native-sse"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; + +// nativeChatSse arms its pull deadline off performance.now() rather than an injectable timer, so +// these tests use small real wall-clock gaps: an armed budget must fire inside its window, and a +// disabled budget must not fire across a gap that comfortably exceeds it. +describe("nativeChatSse stall budget", () => { + const encoder = new TextEncoder(); + const makeBudget = () => createTranslatorBudget({ maxResidentBytes: 1 << 20, maxLiveTransientBytes: 1 << 20 }); + const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + + function pushText(source: ReadableStreamDefaultController, text: string) { + source.enqueue(encoder.encode(`data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n`)); + } + + test("a disabled budget (stallTimeoutSec: 0) survives a silent gap and completes", async () => { + // Regression: a 0 budget used to resolve to a 1s floor and, for the pull deadline, expire on + // the first read — cutting a silent local model almost immediately. Disabled must mean the + // pull waits indefinitely (Infinity deadline) for the next byte. + const budget = makeBudget(); + const abort = new AbortController(); + let source!: ReadableStreamDefaultController; + const body = new ReadableStream({ start(c) { source = c; } }); + let terminalStatus = 0; + + const stream = nativeChatSse(body, { + requestedModel: "mock/test-model", + translatorBudget: budget, + signal: abort.signal, + stallTimeoutSec: 0, + onUsage() {}, + onTerminal(status) { terminalStatus = status; }, + }); + + const outputPromise = new Response(stream).text(); + // Stay silent well past any sub-second armed window, then deliver and close. + await delay(120); + pushText(source, "late but healthy"); + source.close(); + + const result = await outputPromise; + budget.dispose(); + + expect(result).toContain("late but healthy"); + expect(result).toContain("data: [DONE]"); + expect(result).not.toContain("upstream_stall"); + expect(terminalStatus).toBe(200); + }); + + test("an unset budget on a local upstream is disabled and survives a silent gap", async () => { + const budget = makeBudget(); + const abort = new AbortController(); + let source!: ReadableStreamDefaultController; + const body = new ReadableStream({ start(c) { source = c; } }); + + const stream = nativeChatSse(body, { + requestedModel: "mock/test-model", + translatorBudget: budget, + signal: abort.signal, + localUpstream: true, // stallTimeoutSec unset → disabled for local + onUsage() {}, + onTerminal() {}, + }); + + const outputPromise = new Response(stream).text(); + await delay(120); + pushText(source, "slow local token"); + source.close(); + + const result = await outputPromise; + budget.dispose(); + + expect(result).toContain("slow local token"); + expect(result).not.toContain("upstream_stall"); + }); +}); diff --git a/tests/responses/protocol-direct-encoders-local-upstream.test.ts b/tests/responses/protocol-direct-encoders-local-upstream.test.ts new file mode 100644 index 00000000000..6cb7a448f08 --- /dev/null +++ b/tests/responses/protocol-direct-encoders-local-upstream.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { encodeChatCompletionSse } from "../../src/protocols/encoders/chat"; +import type { AdapterEvent } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +// A local upstream resolves an unset stall budget to disabled (#5876): the bridge already +// passed localUpstream through, but the direct client encoders built their encode options +// without it and still applied the 300 s default. These tests pin the encoder-side resolution. +describe("direct client encoder with a local upstream", () => { + test("an unset budget stays disabled past the 300 s default horizon", async () => { + const ticks: (() => void)[] = []; + const timers = { + setInterval: (handler: () => void) => { ticks.push(handler); return ticks.length - 1; }, + clearInterval: () => {}, + }; + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + async function* quiet(): AsyncGenerator { + yield { type: "text_delta", text: "thinking" }; + await gate; + yield { type: "text_delta", text: " done" }; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } as AdapterEvent; + } + const stream = encodeChatCompletionSse(quiet(), { + model: "client-model", translatorBudget: createTestTranslatorBudget(), + heartbeatMs: 1_000, localUpstream: true, timers, + }); + const text = new Response(stream).text(); + await Bun.sleep(5); + // 400 beats at 1 s each is well past the 300 s default the flag must suppress. + for (let i = 0; i < 400; i++) for (const tick of ticks) tick(); + release?.(); + const body = await text; + expect(body).not.toContain("upstream_stall_timeout"); + expect(body).toContain(" done"); + expect(body).toContain("[DONE]"); + }); + + test("a public upstream with an explicit budget still stalls on silent ticks", async () => { + const ticks: (() => void)[] = []; + const timers = { + setInterval: (handler: () => void) => { ticks.push(handler); return ticks.length - 1; }, + clearInterval: () => {}, + }; + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + async function* quiet(): AsyncGenerator { + yield { type: "text_delta", text: "thinking" }; + await gate; + yield { type: "text_delta", text: " done" }; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 1 } } as AdapterEvent; + } + const stream = encodeChatCompletionSse(quiet(), { + model: "client-model", translatorBudget: createTestTranslatorBudget(), + heartbeatMs: 1_000, stallTimeoutSec: 2, timers, + }); + const text = new Response(stream).text(); + await Bun.sleep(5); + // 2 s budget at 1 s beats: a few silent ticks must trip the stall watchdog. + for (let i = 0; i < 5; i++) for (const tick of ticks) tick(); + release?.(); + const body = await text; + expect(body).toContain("upstream_stall_timeout"); + }); +}); diff --git a/tests/responses/protocol-direct-encoders-stall-disabled.test.ts b/tests/responses/protocol-direct-encoders-stall-disabled.test.ts new file mode 100644 index 00000000000..4639b0bc551 --- /dev/null +++ b/tests/responses/protocol-direct-encoders-stall-disabled.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { encodeChatCompletionSse } from "../../src/protocols/encoders/chat"; +import type { AdapterEvent } from "../../src/types"; +import { createTestTranslatorBudget } from "../helpers/translator-budget"; + +// An explicit stallTimeoutSec of 0 disables the stall guard (#5876). The direct client encoders +// run their own beat, so they must honour that too instead of stalling on the first silent tick. +describe("direct client encoder with a disabled stall budget", () => { + test("silent ticks never end the stream", async () => { + const ticks: (() => void)[] = []; + const timers = { + setInterval: (handler: () => void) => { ticks.push(handler); return ticks.length - 1; }, + clearInterval: () => {}, + }; + let release: (() => void) | undefined; + const gate = new Promise(resolve => { release = resolve; }); + async function* quiet(): AsyncGenerator { + yield { type: "text_delta", text: "thinking" }; + await gate; + yield { type: "text_delta", text: " done" }; + yield { type: "done", usage: { inputTokens: 1, outputTokens: 2 } } as AdapterEvent; + } + const stream = encodeChatCompletionSse(quiet(), { + model: "client-model", translatorBudget: createTestTranslatorBudget(), + heartbeatMs: 1_000, stallTimeoutSec: 0, timers, + }); + const text = new Response(stream).text(); + await Bun.sleep(5); + for (let i = 0; i < 5; i++) for (const tick of ticks) tick(); + release?.(); + const body = await text; + expect(body).not.toContain("upstream_stall_timeout"); + expect(body).toContain(" done"); + expect(body).toContain("[DONE]"); + }); +}); diff --git a/tests/responses/responses-grok-devin-preflight.test.ts b/tests/responses/responses-grok-devin-preflight.test.ts new file mode 100644 index 00000000000..9ba3704e42b --- /dev/null +++ b/tests/responses/responses-grok-devin-preflight.test.ts @@ -0,0 +1,283 @@ +import { afterEach, beforeEach, expect, mock, test } from "bun:test"; +import type { ProviderAdapter } from "../../src/adapters/base"; +import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "../../src/types"; +import { saveCredential } from "../../src/oauth/store"; +import { SEND_BUDGET_EXHAUSTED_CODE } from "../../src/lib/errors"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { createTempHome } from "../helpers/temp-home"; + +const resolver = await import("../../src/server/adapter-resolve"); +const originalResolve = resolver.resolveAdapter; +let events: AdapterEvent[] = []; +let calls = 0; +let blockedRun: ProviderAdapter["runTurn"]; +const limit: AdapterEvent = { + type: "error", status: 429, errorType: "rate_limit_error", code: "resource_exhausted", + retryable: true, message: "Cognition chat failed (resource_exhausted); retry after ~60s", +}; +mock.module("../../src/server/adapter-resolve", () => ({ ...resolver, + resolveAdapter(provider: OcxProviderConfig, cache?: "none" | "short" | "long") { + if (provider.adapter !== "devin") return originalResolve(provider, cache); + return { + name: "devin", + buildRequest: () => ({ url: provider.baseUrl, method: "POST", headers: {}, body: "" }), + async *parseStream() { yield { type: "done" } as AdapterEvent; }, + async runTurn(parsed, incoming, emit) { + calls++; + if (blockedRun) return blockedRun(parsed, incoming, emit); + for (const event of events) emit(event); + }, + } satisfies ProviderAdapter; + }, +})); +const { handleResponses } = await import("../../src/server/responses"); +let home: ReturnType; +let release: (() => void) | undefined; +beforeEach(async () => { + home = createTempHome("ocx-grok-devin-preflight-"); + release = acquireOwnedSpendHome(); + calls = 0; + events = [limit]; + blockedRun = undefined; + await saveCredential("devin", { + access: "synthetic-devin-preflight", refresh: "synthetic-refresh", + expires: Date.now() + 3_600_000, accountId: "fixture", + }); +}); +afterEach(() => { + try { + release?.(); + } finally { + home.remove(); + } +}); +function run({ + surface = "grok", comboAttempt = false, abortSignal, stallTimeoutSec, + oauthFailoverEnabled = false, stream = true, +}: { + surface?: "grok" | "codex"; comboAttempt?: boolean; abortSignal?: AbortSignal; + stallTimeoutSec?: number; oauthFailoverEnabled?: boolean; stream?: boolean; +} = {}) { + const config = { + port: 0, defaultProvider: "devin", oauthAccountFailover: { enabled: oauthFailoverEnabled }, + stallTimeoutSec, + providers: { devin: { adapter: "devin", authMode: "oauth", baseUrl: "https://server.codeium.com", models: ["swe-2"] } }, + } as OcxConfig; + return handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "devin/swe-2", input: "answer", stream }), + }), config, { model: "", provider: "", surface }, { comboAttempt, abortSignal }); +} + +async function waitForPreflightResponse(pending: Promise, started: Promise, resume: () => void) { + let guard: ReturnType | undefined; + let response: Response | null; + try { + response = await Promise.race([ + Promise.all([started, pending]).then(([, value]) => value), + new Promise(resolve => { guard = setTimeout(() => resolve(null), 2_500); }), + ]); + } finally { + if (guard !== undefined) clearTimeout(guard); + resume(); + } + if (response === null) { + await (await pending).body?.cancel(); + throw new Error("Grok did not receive a response before the next provider event"); + } + return response; +} + +test.each([ + { stream: true, heartbeat: false }, { stream: true, heartbeat: true }, + { stream: false, heartbeat: false }, { stream: false, heartbeat: true }, +])("pre-output 429 reaches Grok as HTTP 429 (stream=$stream heartbeat=$heartbeat)", async ({ stream, heartbeat }) => { + events = heartbeat ? [{ type: "heartbeat" }, limit] : [limit]; + const response = await run({ stream }); + expect(response.status).toBe(429); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(response.headers.get("retry-after")).toBe("60"); + expect(await response.json()).toEqual({ error: { + message: limit.message, type: "rate_limit_error", code: "rate_limit_exceeded", + } }); + expect(calls).toBe(1); +}); + +test.each([false, true])("first text is replayed once and later errors stay SSE (failure=%s)", async failure => { + events = [{ type: "heartbeat" }, { type: "text_delta", text: "answer" }, failure ? limit : { type: "done" }]; + const response = await run(); + expect(response.status).toBe(200); + const text = await response.text(); + const frames = text.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))); + expect(frames.filter(frame => frame.type === "response.output_text.delta").map(frame => frame.delta)).toEqual(["answer"]); + expect(frames.filter(frame => frame.type === (failure ? "response.failed" : "response.completed"))).toHaveLength(1); + expect(calls).toBe(1); +}); + +test("a disabled stall watchdog still gives Devin a finite preflight", async () => { + // stallTimeoutSec: 0 disables the stream watchdog (#5876); it must not turn into a zero-length + // preflight that commits SSE before the first event. + const response = await run({ stallTimeoutSec: 0 }); + expect(response.status).toBe(429); + expect(response.headers.get("retry-after")).toBe("60"); + expect(calls).toBe(1); +}); + +test("a replay-unsafe heartbeat leaves the error in SSE", async () => { + events = [{ type: "heartbeat", replayUnsafe: true }, limit]; + const response = await run(); + expect(response.status).toBe(200); + expect(await response.text()).toContain("response.failed"); + expect(calls).toBe(1); +}); + +test("other clients keep their existing SSE response", async () => { + const response = await run({ surface: "codex" }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("response.failed"); +}); + +test.each([false, true])("Grok starts SSE after bounded Devin preflight (heartbeat=%s)", async heartbeat => { + const started = Promise.withResolvers(); + const continueTurn = Promise.withResolvers(); + blockedRun = async (_parsed, _incoming, emit) => { + if (heartbeat) emit({ type: "heartbeat" }); + started.resolve(); + await continueTurn.promise; + emit({ type: "text_delta", text: "after preflight" }); + emit({ type: "done" }); + }; + + const response = await waitForPreflightResponse( + run({ stallTimeoutSec: 1 }), started.promise, () => continueTurn.resolve(), + ); + expect(response.status).toBe(200); + const frames = (await response.text()).split("\n") + .filter(line => line.startsWith("data: {")) + .map(line => JSON.parse(line.slice(6))); + expect(frames.filter(frame => frame.type === "response.output_text.delta").map(frame => frame.delta)) + .toEqual(["after preflight"]); + expect(frames.filter(frame => frame.type === "response.completed")).toHaveLength(1); +}); + +test("timed-out OAuth replay keeps its reserved dispatch permit until Devin sends", async () => { + await saveCredential("devin", { + access: "synthetic-devin-preflight-spare", refresh: "synthetic-refresh-spare", + expires: Date.now() + 3_600_000, accountId: "fixture-spare", + }); + const retryStarted = Promise.withResolvers(); + const dispatchRetry = Promise.withResolvers(); + let retryBudgetUsed: number | undefined; + let retryDispatchAllowed: boolean | undefined; + blockedRun = async (_parsed, incoming, emit) => { + if (calls === 1) { + const decision = incoming.sendBudget?.reserveDispatch({ sendClass: "initial", targetKey: "devin|first" }); + if (decision?.allowed) decision.permit.use(); + emit(limit); + return; + } + retryStarted.resolve(); + await dispatchRetry.promise; + const budget = incoming.sendBudget; + const decision = budget?.reserveDispatch({ sendClass: "auth-recovery", targetKey: "devin|retry" }); + retryDispatchAllowed = decision?.allowed; + if (decision?.allowed) decision.permit.use(); + retryBudgetUsed = budget?.used; + emit(limit); + }; + + const response = await waitForPreflightResponse( + run({ stallTimeoutSec: 1, oauthFailoverEnabled: true }), retryStarted.promise, () => dispatchRetry.resolve(), + ); + expect(response.status).toBe(200); + expect(await response.text()).toContain("response.failed"); + expect(retryDispatchAllowed).toBe(true); + expect(retryBudgetUsed).toBe(2); +}); + +test("combo children retain their existing preflight failure", async () => { + const response = await run({ comboAttempt: true }); + expect(response.status).toBe(502); + expect(response.headers.get("retry-after")).toBeNull(); + await response.text(); +}); + +test.each([ + { ...limit, status: 503, errorType: "upstream_error", code: "unavailable" }, + { ...limit, code: SEND_BUDGET_EXHAUSTED_CODE }, +])("unrelated errors keep their existing SSE response ($code)", async error => { + events = [error]; + const response = await run(); + expect(response.status).toBe(200); + expect(await response.text()).toContain("response.failed"); +}); + +test("cancellation before the first event aborts the producer", async () => { + const controller = new AbortController(); + const started = Promise.withResolvers(); + let aborted = false; + blockedRun = async (_parsed, incoming, emit) => { + const stopped = Promise.withResolvers(); + incoming.abortSignal!.addEventListener("abort", () => { + aborted = true; + emit({ type: "error", status: 499, message: "client closed request" }); + stopped.resolve(); + }, { once: true }); + started.resolve(); + await stopped.promise; + }; + const pending = run({ abortSignal: controller.signal }); + await started.promise; + controller.abort(); + const response = await pending; + await response.text(); + expect(aborted).toBe(true); + expect(calls).toBe(1); +}); + +const bufferedExclusions: { name: string; surface?: "grok" | "codex"; source: AdapterEvent[] }[] = [ + { name: "other client", surface: "codex", source: [limit] }, + { name: "replay-unsafe activity", source: [{ type: "heartbeat", replayUnsafe: true }, limit] }, + { name: "local send budget", source: [{ ...limit, code: SEND_BUDGET_EXHAUSTED_CODE }] }, + { name: "non-429 failure", source: [{ ...limit, status: 503, errorType: "upstream_error" }] }, +]; +test.each(bufferedExclusions)("buffered $name retains its existing JSON result", async ({ surface, source }) => { + events = source; + const response = await run({ stream: false, surface }); + expect(response.status).toBe(200); + expect(response.headers.get("retry-after")).toBeNull(); + expect(await response.json()).toMatchObject({ status: "failed", error: { message: limit.message } }); +}); + +test("buffered output before a 429 is retained exactly once", async () => { + events = [{ type: "text_delta", text: "answer" }, limit]; + const response = await run({ stream: false }); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + status: "failed", output: [{ content: [{ type: "output_text", text: "answer" }] }], + error: { message: limit.message }, + }); + expect(calls).toBe(1); +}); + +test.each([true, false])("OAuth replay preserves unsafe activity through heartbeat eviction (stream=%s)", async stream => { + await saveCredential("devin", { + access: "synthetic-devin-preflight-spare", refresh: "synthetic-refresh-spare", + expires: Date.now() + 3_600_000, accountId: "fixture-spare", + }); + blockedRun = async (_parsed, _incoming, emit) => { + if (calls > 1) { + emit({ type: "heartbeat", replayUnsafe: true }); + for (let i = 0; i < 32; i++) { + await Bun.sleep(1); + emit({ type: "heartbeat" }); + } + } + emit(limit); + }; + const response = await run({ stream, oauthFailoverEnabled: true }); + expect(response.status).toBe(200); + if (stream) expect(await response.text()).toContain("response.failed"); + else expect(await response.json()).toMatchObject({ status: "failed", error: { message: limit.message } }); + expect(calls).toBe(2); +}); diff --git a/tests/responses/responses-hosted-tool-declaration.test.ts b/tests/responses/responses-hosted-tool-declaration.test.ts index f44d0c15271..91b21e7633b 100644 --- a/tests/responses/responses-hosted-tool-declaration.test.ts +++ b/tests/responses/responses-hosted-tool-declaration.test.ts @@ -127,17 +127,16 @@ describe("provider-declared unsupported hosted tools", () => { }, ["web_search"]).input).toEqual([{ type: "additional_tools", tools: [functionTool] }]); }); - test("the declaration is additive to the built-in destination table, not a replacement", () => { - // An operator who declares only image_generation must still be protected from the - // known-broken grok-4.6 destination, and a declaration must not disable that table. + test("the declaration denies only what it names", () => { + // The built-in table is empty: OpenCode Go Grok, its former only row, accepts hosted + // web_search once the xAI-refused fields are normalized away (xai-web-search.ts), so + // neither an unrelated declaration nor the table may remove it there. const declaredImageOnly = declaredUnsupportedHostedTools({ unsupportedHostedTools: ["image_generation"] }); - expect(isHostedToolUnsupportedForModel( - "grok-4.6", - "web_search", - "https://opencode.ai/zen/go/v1", - declaredImageOnly, - )).toBe(true); + for (const model of ["grok-4.6", "grok-4.7"]) { + expect(isHostedToolUnsupportedForModel(model, "web_search", "https://opencode.ai/zen/go/v1", declaredImageOnly)) + .toBe(false); + } expect(isHostedToolUnsupportedForModel("grok-4.6", "web_search", GATEWAY_BASE_URL, declaredImageOnly)) .toBe(false); expect(isHostedToolUnsupportedForModel(GATEWAY_MODEL, "image_gen", GATEWAY_BASE_URL, declaredImageOnly)) diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 46da86d4234..5a64e42f6f1 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -47,6 +47,7 @@ import type { OcxConfig } from "../../src/types"; import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import * as destinationPolicy from "../../src/lib/destination-policy"; +import * as providerOutbound from "../../src/lib/provider-outbound"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; import { LOCAL_PROVIDER_RELOAD_NAME_HEADER, LOCAL_PROVIDER_RELOAD_PATH } from "../../src/lib/local-provider-reload-contract"; import { getAccountSet, saveCredential } from "../../src/oauth/store"; @@ -96,16 +97,14 @@ function redirectCanonicalCodexTo(baseUrl: string): void { }) as typeof fetch; } +let discoverySpy: ReturnType> | undefined; function stubModelDiscoveryFor(...origins: string[]): void { - const allowed = new Set(origins); - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; - const url = new URL(requestUrl); - if (allowed.has(url.origin) && url.pathname.endsWith("/models")) { - return Promise.resolve(Response.json({ data: [] })); - } - return originalGlobalFetch(input, init); - }) as typeof fetch; + const allowed = new Set(["https://api.example.test", ...origins]); + const original = providerOutbound.providerOutboundGet; + discoverySpy = spyOn(providerOutbound, "providerOutboundGet").mockImplementation((name, provider, url, ...args) => + allowed.has(new URL(url).origin) && new URL(url).pathname.endsWith("/models") + ? Promise.resolve(Response.json({ data: [] })) + : original(name, provider, url, ...args)); } beforeEach(() => { @@ -113,6 +112,8 @@ beforeEach(() => { }); afterEach(() => { + discoverySpy?.mockRestore(); + discoverySpy = undefined; globalThis.fetch = originalGlobalFetch; if (previousApiToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; else process.env.OPENCODEX_API_AUTH_TOKEN = previousApiToken; @@ -2653,8 +2654,8 @@ describe("provider management validation", () => { stubModelDiscoveryFor("https://api.example.com", "http://127.0.0.1:11434"); const server = startServer(0); + const resolvedDestination = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); try { - // Step 1: create a provider with a public URL const createRes = await fetch(new URL("/api/providers", server.url), { method: "POST", headers: { "content-type": "application/json" }, @@ -2665,7 +2666,6 @@ describe("provider management validation", () => { }); expect(createRes.status).toBe(200); - // Step 2: PATCH allowPrivateNetwork to true const patchRes = await fetch(new URL("/api/providers?name=patch-test", server.url), { method: "PATCH", headers: { "content-type": "application/json" }, @@ -2673,7 +2673,6 @@ describe("provider management validation", () => { }); expect(patchRes.status).toBe(200); - // Step 3: PATCH baseUrl to localhost — should succeed because flag is now true const urlRes = await fetch(new URL("/api/providers?name=patch-test", server.url), { method: "PATCH", headers: { "content-type": "application/json" }, @@ -2681,13 +2680,13 @@ describe("provider management validation", () => { }); expect(urlRes.status).toBe(200); - // Verify the persisted state const saved = await fetch(new URL("/api/config", server.url)).then(r => r.json()) as { providers: Record; }; expect(saved.providers["patch-test"].allowPrivateNetwork).toBe(true); expect(saved.providers["patch-test"].baseUrl).toContain("127.0.0.1"); } finally { + resolvedDestination.mockRestore(); await server.stop(true); } }); @@ -2731,8 +2730,10 @@ describe("provider management validation", () => { mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; saveConfig(config("127.0.0.1")); + stubModelDiscoveryFor("https://api.example.com"); const server = startServer(0); + const resolvedDestination = spyOn(destinationPolicy, "providerDestinationResolvedError").mockResolvedValue(null); try { const createRes = await fetch(new URL("/api/providers", server.url), { method: "POST", @@ -2780,6 +2781,7 @@ describe("provider management validation", () => { }; expect(saved.providers["discovery-toggle"].liveModels).toBe(false); } finally { + resolvedDestination.mockRestore(); await server.stop(true); } }); diff --git a/tests/service/service-claim.test.ts b/tests/service/service-claim.test.ts index d886631522d..2003b4f00e0 100644 --- a/tests/service/service-claim.test.ts +++ b/tests/service/service-claim.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { mkdirSync, statSync } from "node:fs"; +import * as os from "node:os"; import { parseClaimArgs, runServiceClaim, CLAIM_SCHEMA } from "../../src/service/claim"; import { ServiceOwnershipSubjectMismatchError, serviceStatePath, serviceStatePaths } from "../../src/service/state"; import type { ServiceOwnershipSubject } from "../../src/service/state"; @@ -144,8 +145,7 @@ describe("runServiceClaim", () => { test("an unreadable sandbox state refuses a real claim", async () => { const home = createTempHome("ocx-claim-refusal-"); - const previousUserProfile = process.env.USERPROFILE; - if (process.platform === "win32") process.env.USERPROFILE = home.root; + const homedir = spyOn(os, "homedir").mockReturnValue(home.root); try { expect(serviceStatePaths().every(path => path.startsWith(home.root))).toBe(true); mkdirSync(serviceStatePath()); @@ -159,11 +159,11 @@ describe("runServiceClaim", () => { }); expect(statSync(serviceStatePath()).isDirectory()).toBe(true); } finally { - if (process.platform === "win32") { - if (previousUserProfile === undefined) delete process.env.USERPROFILE; - else process.env.USERPROFILE = previousUserProfile; + try { + homedir.mockRestore(); + } finally { + home.remove(); } - home.remove(); } }); }); diff --git a/tests/service/service-wsl-home-ownership.test.ts b/tests/service/service-wsl-home-ownership.test.ts index f01f348b006..1d5ec1a2342 100644 --- a/tests/service/service-wsl-home-ownership.test.ts +++ b/tests/service/service-wsl-home-ownership.test.ts @@ -1,27 +1,31 @@ -import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { writeFileSync } from "node:fs"; +import * as os from "node:os"; import { join, posix } from "node:path"; import { assertServiceEnvironmentMatchesInstall } from "../../src/service/guards"; import { repairService } from "../../src/service/repair"; import { inspectNativeCodexOwnership } from "../../src/integrations/native/ownership-preflight"; -import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { serviceStatePaths } from "../../src/service/state"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; -const previousOpenCodexHome = process.env.OPENCODEX_HOME; -let fixtureHome: string | undefined; +let fixtureHome: TempHome | undefined; +let restoreHomedir: (() => void) | undefined; afterEach(() => { - if (previousOpenCodexHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousOpenCodexHome; - if (fixtureHome) removeTreeWithRetry(fixtureHome); + restoreHomedir?.(); + restoreHomedir = undefined; + fixtureHome?.remove(); fixtureHome = undefined; }); describe("WSL service ownership after Windows home discovery", () => { function fixture(recorded: "linux" | "windows") { - const root = mkdtempSync(join(tmpdir(), "ocx-wsl-service-home-")); - fixtureHome = root; - process.env.OPENCODEX_HOME = root; + const home = createTempHome("ocx-wsl-service-home-"); + fixtureHome = home; + const root = home.root; + const homedir = spyOn(os, "homedir").mockReturnValue(root); + restoreHomedir = () => homedir.mockRestore(); + expect(serviceStatePaths().every(path => path.startsWith(root))).toBe(true); const linuxHome = "/home/fixture/.codex"; const usersRoot = "/mnt/c/Users"; const windowsHome = posix.join(usersRoot, "profile", ".codex"); diff --git a/tests/update/update-mise-launcher-target.test.ts b/tests/update/update-mise-launcher-target.test.ts new file mode 100644 index 00000000000..0d120e35036 --- /dev/null +++ b/tests/update/update-mise-launcher-target.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { planMiseLauncherTargetWatch, type MiseLauncherTargetDeps } from "../../src/update/mise-launcher-target"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +let root = ""; +let toolRoot = ""; + +/** One mise npm-backend install as aube lays it out: `/node_modules/.mise/@/...`. */ +function installVersion(version: string, base = toolRoot): string { + const store = join(base, version, "node_modules", ".mise", `@bitkyc08+opencodex@${version}`, "node_modules", "@bitkyc08", "opencodex"); + mkdirSync(store, { recursive: true }); + writeFileSync(join(store, "package.json"), JSON.stringify({ name: "@bitkyc08/opencodex", version })); + const link = join(base, version, "node_modules", "@bitkyc08", "opencodex"); + mkdirSync(dirname(link), { recursive: true }); + symlinkSync(join("..", ".mise", `@bitkyc08+opencodex@${version}`, "node_modules", "@bitkyc08", "opencodex"), link); + mkdirSync(join(base, version, "node_modules", ".bin"), { recursive: true }); + writeFileSync(join(base, version, "node_modules", ".bin", "ocx"), "#!/bin/sh\n", { mode: 0o755 }); + return realpathSync(store); +} + +function pointLatestAt(version: string, base = toolRoot): void { + rmSync(join(base, "latest"), { force: true }); + symlinkSync(`./${version}`, join(base, "latest")); +} + +const launcher = (base = toolRoot) => join(base, "latest", "node_modules", ".bin", "ocx"); + +function plan(overrides: MiseLauncherTargetDeps = {}, running = join(toolRoot, "2.65.0", "node_modules", "@bitkyc08", "opencodex")) { + return planMiseLauncherTargetWatch({ + platform: "linux", + env: { OCX_SERVICE_MANAGED: "1" }, + runningRoot: () => running, + launcherPath: () => launcher(), + ...overrides, + }); +} + +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), "ocx-mise-launcher-"))); + toolRoot = join(root, "installs", "opencodex"); + mkdirSync(toolRoot, { recursive: true }); + writeFileSync(join(toolRoot, ".mise.backend.toml"), 'short = "opencodex"\nfull = "npm:@bitkyc08/opencodex"\n'); +}); + +afterEach(() => { + removeTreeWithRetry(root); +}); + +describe("mise launcher target plan", () => { + test("follows `latest` from the running version to the upgraded one", () => { + const running = installVersion("2.65.0"); + const next = installVersion("2.66.0"); + pointLatestAt("2.65.0"); + const watch = plan(); + expect(watch?.runningRoot).toBe(running); + expect(watch?.resolveTarget()?.root).toBe(running); + pointLatestAt("2.66.0"); + expect(watch?.resolveTarget()?.root).toBe(next); + }); + + test("still resolves the new target after the running version is pruned", () => { + installVersion("2.65.0"); + const next = installVersion("2.66.0"); + pointLatestAt("2.65.0"); + const watch = plan(); + pointLatestAt("2.66.0"); + removeTreeWithRetry(join(toolRoot, "2.65.0")); + expect(watch?.resolveTarget()?.root).toBe(next); + }); + + test("is disabled for npm's --prefix layout, which mise ownership detection does not verify", () => { + const pkg = join(toolRoot, "2.65.0", "lib", "node_modules", "@bitkyc08", "opencodex"); + mkdirSync(pkg, { recursive: true }); + writeFileSync(join(pkg, "package.json"), "{}"); + mkdirSync(join(toolRoot, "2.65.0", "bin"), { recursive: true }); + writeFileSync(join(toolRoot, "2.65.0", "bin", "ocx"), "", { mode: 0o755 }); + pointLatestAt("2.65.0"); + expect(plan({ launcherPath: () => join(toolRoot, "latest", "bin", "ocx") }, pkg)).toBeNull(); + }); + + test("a target repointed outside the tool's installs is unresolvable", () => { + installVersion("2.65.0"); + pointLatestAt("2.65.0"); + const watch = plan(); + const elsewhere = join(root, "elsewhere"); + installVersion("9.9.9", elsewhere); + rmSync(join(toolRoot, "latest")); + symlinkSync(join(elsewhere, "9.9.9"), join(toolRoot, "latest")); + expect(watch?.resolveTarget()).toBeNull(); + }); + + test.each([ + ["macOS, whose launchd services run pinned package paths", { platform: "darwin" as const }], + ["Windows, whose services have no launcher", { platform: "win32" as const }], + ["a proxy carrying only OCX_SERVICE=1", { env: { OCX_SERVICE: "1" } }], + ["a foreground proxy with a service record on disk", { env: {} }], + ["no recorded launcher", { launcherPath: () => undefined }], + ["a non-mise install", { ownership: () => ({ installer: "npm" as const }) }], + ["unverifiable mise ownership", { ownership: () => ({ installer: "mise" as const, owner: null, error: "metadata_inconsistent" as const }) }], + ])("is disabled for %s", (_label, overrides) => { + installVersion("2.65.0"); + pointLatestAt("2.65.0"); + expect(plan(overrides)).toBeNull(); + }); + + test("is disabled for a mise shim, which names no package", () => { + installVersion("2.65.0"); + pointLatestAt("2.65.0"); + const shims = join(root, "shims"); + mkdirSync(shims, { recursive: true }); + writeFileSync(join(root, "mise"), "", { mode: 0o755 }); + symlinkSync(join(root, "mise"), join(shims, "ocx")); + expect(plan({ launcherPath: () => join(shims, "ocx") })).toBeNull(); + }); + + test("is disabled for another tool's launcher", () => { + installVersion("2.65.0"); + pointLatestAt("2.65.0"); + const other = join(root, "installs", "other"); + installVersion("1.0.0", other); + pointLatestAt("1.0.0", other); + expect(plan({ launcherPath: () => launcher(other) })).toBeNull(); + }); + + test("is disabled when the launcher did not start this process", () => { + installVersion("2.65.0"); + installVersion("2.66.0"); + pointLatestAt("2.66.0"); + expect(plan()).toBeNull(); + }); +});