From 7d8523eed75a67f7a4a15b533744fcd0e6059aa8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:02:43 +0900 Subject: [PATCH 01/99] chore(release): open dev at 2.45.0 before releasing 2.44.0 (#3783) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9242c23306..34b7649856 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.44.0", + "version": "2.45.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From 3a991a33a08c79532cb03dd66fc57038136861df Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:43:52 +0900 Subject: [PATCH 02/99] fix(cli): report inert generic account thresholds truthfully --- .../fr/reference/cli/providers-accounts.md | 7 +-- .../ja/reference/cli/providers-accounts.md | 5 +- .../ko/reference/cli/providers-accounts.md | 5 +- .../docs/reference/cli/providers-accounts.md | 7 +-- .../ru/reference/cli/providers-accounts.md | 7 +-- .../tr/reference/cli/providers-accounts.md | 8 +-- .../zh-cn/reference/cli/providers-accounts.md | 7 +-- .../zh-tw/reference/cli/providers-accounts.md | 5 +- src/cli/account-extended.ts | 22 +++++++ tests/cli/cli-account-pool-verbs.test.ts | 62 ++++++++++++++++++- 10 files changed, 106 insertions(+), 29 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index 53c5b94b91..c7c4ffa7ca 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -203,12 +203,11 @@ renvoient 1 ; une sonde de quota en amont qui échoue ou expire produit plutôt ### `ocx account auto-switch > [--json]` -Contrôle uniquement le groupe de comptes Codex `openai`. `on` règle 80 %, `off` règle 0 %, `status` lit la -valeur actuelle et `threshold ` accepte un entier de 0 à 100. Les autres fournisseurs et les valeurs -invalides entraînent le code de sortie 1. `--json` renvoie : +Contrôle le seuil du pool Codex `openai`, ou enregistre celui d’un pool OAuth générique. `on` enregistre 80 %, `off` 0 % et `threshold ` accepte 0–100. Les seuils génériques sont actuellement inactifs : leur sauvegarde ne change ni le basculement par seuil, ni l’activation du fournisseur, ni la rotation réactive après une erreur 429. Les sorties utilisent la réponse confirmée du serveur. Pour un pool générique, `poolEnabled` est le réglage enregistré (`null` signifie non spécifié), pas l’état effectif hérité. `inert: true` indique que le seuil ne s’applique pas ; une capacité inconnue ne produit jamais `enabled: true`. Les fournisseurs à clé API, Anthropic et les valeurs invalides sont refusés. ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index f1b5ec4ff6..63596b369c 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -149,10 +149,11 @@ OAuth プロバイダーと API キー プロバイダーの場合、これに ### `ocx account auto-switch > [--json]` -`openai` Codex アカウント プールのみを制御します。 `on` は 80% を設定し、`off` は 0% を設定します。`status` は現在の値を読み取り、`threshold ` は 0 ~ 100 の整数を受け入れます。他のプロバイダーと無効な値は 1 を終了します。`--json` は次を返します。 +`openai` Codex プールのしきい値を制御するか、汎用 OAuth プールのしきい値を保存します。`on` は 80%、`off` は 0%、`threshold ` は 0–100 を保存します。汎用プールのしきい値は現在適用されません。保存しても、しきい値による切り替え、プロバイダーの有効化設定、429 エラー時のローテーションは変更されません。照会と変更の結果はサーバーの確認値を使用します。汎用プールの `poolEnabled` は保存された設定で、`null` は未指定です。継承後の実効状態ではありません。`inert: true` は未適用を示し、機能が不明な場合も `enabled: true` とは表示しません。API キープロバイダー、Anthropic、不正な値は拒否されます。 ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 31757f38d7..ff86f67e8d 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -209,10 +209,11 @@ OAuth 및 API 키 제공자에는 제공자의 할당량 보고 엔드포인트 ### `ocx account auto-switch > [--json]` -`openai` Codex 계정 풀만 제어합니다. `on`은 80%, `off`는 0%를 설정하고, `status`는 현재 값을 읽으며, `threshold `은 0부터 100까지의 정수를 받습니다. 다른 제공자와 잘못된 값은 종료 코드 1로 끝납니다. `--json`은 다음을 반환합니다: +`openai` Codex 풀의 임계값을 제어하거나 일반 OAuth 풀의 임계값을 저장합니다. `on`은 80%, `off`는 0%, `threshold `은 0–100을 저장합니다. 일반 풀의 임계값은 현재 동작에 적용되지 않습니다. 저장해도 임계값 기반 전환이나 제공자 활성화 설정이 바뀌지 않고, 429 오류에 따른 회전도 비활성화되지 않습니다. 조회와 변경 결과는 서버가 확인한 값을 사용합니다. 일반 풀의 `poolEnabled`는 저장된 제공자별 설정이며 `null`은 미지정입니다. 전역 설정을 상속한 실제 상태를 뜻하지 않습니다. `inert: true`이면 임계값이 적용되지 않으며, 기능 지원을 알 수 없을 때도 `enabled: true`로 표시하지 않습니다. API 키 제공자, Anthropic 및 잘못된 값은 거부합니다. ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index cc2471a527..7b9d9da16c 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -331,12 +331,11 @@ instead (exit 0), matching the dashboard's quota bars. ### `ocx account auto-switch > [--json]` -Controls only the `openai` Codex account pool. `on` sets 80%, `off` sets 0%, `status` reads the current -value, and `threshold ` accepts an integer from 0 through 100. Other providers and invalid values -exit 1. `--json` returns: +Controls the `openai` Codex pool threshold, or stores a threshold for a generic OAuth pool. `on` stores 80%, `off` stores 0%, and `threshold ` accepts 0–100. Generic pool thresholds are currently inert: saving one does not enable threshold-based switching, change the provider enablement override, or disable reactive 429 rotation. `status` and mutation output use the confirmed server response. For generic pools, `poolEnabled` is the stored provider override (`null` means unspecified), not inherited effective state; `inert: true` means the threshold is not applied, and unknown capability never reports `enabled: true`. API-key providers, Anthropic and invalid values are rejected. ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index 3ad8e527f7..1e011a0dd5 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -187,12 +187,11 @@ quota-bar'ов дашборда. ### `ocx account auto-switch > [--json]` -Управляет только пулом аккаунтов Codex `openai`. `on` ставит 80%, `off` — 0%, `status` читает -текущее значение, а `threshold ` принимает целое число от 0 до 100. Для других провайдеров и -некорректных значений команда завершается кодом 1. `--json` возвращает: +Управляет порогом пула Codex `openai` или сохраняет порог общего пула OAuth. `on` сохраняет 80 %, `off` — 0 %, а `threshold ` принимает 0–100. Пороги общих пулов пока не применяются: сохранение не включает переключение по порогу, не меняет настройку включения провайдера и не отключает ротацию после ошибки 429. Результат чтения и изменения берётся из подтверждённого ответа сервера. Для общего пула `poolEnabled` — сохранённая настройка провайдера (`null` означает отсутствие настройки), а не итоговое унаследованное состояние. `inert: true` означает, что порог не применяется; неизвестная возможность также не даёт `enabled: true`. Провайдеры с ключом API, Anthropic и неверные значения отклоняются. ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index 9c2e9378b7..92c0cbd2a8 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -229,13 +229,11 @@ eşleşen null veya eski bir rapora düşer (çıkış 0). ### `ocx account auto-switch > [--json]` -Yalnızca `openai` Codex hesap havuzunu denetler. `on` %80'i ayarlar, `off` %0'ı -ayarlar, `status` geçerli değeri okur ve `threshold ` 0 ile 100 arasında bir -tamsayı kabul eder. Diğer sağlayıcılar ve geçersiz değerler 1 ile çıkar. -`--json` şunu döndürür: +`openai` Codex havuzunun eşiğini yönetir veya genel OAuth havuzunun eşiğini kaydeder. `on` %80, `off` %0 kaydeder; `threshold ` 0–100 kabul eder. Genel havuz eşikleri şu anda uygulanmaz: kayıt işlemi eşik tabanlı geçişi, sağlayıcının etkinlik ayarını veya 429 hatasından sonraki otomatik hesap değişimini etkilemez. Çıktı sunucunun doğruladığı değerleri kullanır. Genel havuzlarda `poolEnabled`, kaydedilmiş sağlayıcı ayarıdır (`null` belirtilmemiş demektir); devralınmış etkin durumu göstermez. `inert: true`, eşiğin uygulanmadığını belirtir; yetenek bilinmiyorsa `enabled: true` bildirilmez. API anahtarlı sağlayıcılar, Anthropic ve geçersiz değerler reddedilir. ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index 9601b0766b..a05d6cd65b 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -169,12 +169,11 @@ token,也不是简单重读账号列表。`--json` 返回 ### `ocx account auto-switch > [--json]` -只控制 `openai` 的 Codex 账号池。`on` 会设为 80%,`off` 会设为 0%,`status` 会读取 -当前值,而 `threshold ` 接受 0 到 100 之间的整数。其他提供方和无效值都会以 1 -退出。`--json` 返回: +控制 `openai` Codex 账户池阈值,或保存通用 OAuth 账户池阈值。`on` 保存 80%,`off` 保存 0%,`threshold ` 接受 0–100。通用池的阈值目前不参与运行;保存阈值不会启用阈值切换、改变提供方启用设置或禁用 429 错误后的轮换。查询和修改结果使用服务器确认值。通用池的 `poolEnabled` 是已保存的提供方设置,`null` 表示未指定,并不代表继承后的实际状态。`inert: true` 表示阈值未应用;能力未知时也不会报告 `enabled: true`。API 密钥提供方、Anthropic 和无效值会被拒绝。 ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index d8d45133b6..2a1d55176c 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -129,10 +129,11 @@ Codex 池選擇套用於清除既有親和性後的下一個請求;進行中 ### `ocx account auto-switch > [--json]` -僅控制 `openai` Codex 帳號池。`on` 設為 80%,`off` 設為 0%,`status` 讀取目前值,而 `threshold ` 接受 0 到 100 的整數。其他供應商與無效值離開 1。`--json` 回傳: +控制 `openai` Codex 帳戶池閾值,或儲存通用 OAuth 帳戶池閾值。`on` 儲存 80%,`off` 儲存 0%,`threshold ` 接受 0–100。通用池的閾值目前不參與執行;儲存閾值不會啟用閾值切換、改變供應商啟用設定或停用 429 錯誤後的輪替。查詢與修改結果使用伺服器確認值。通用池的 `poolEnabled` 是已儲存的供應商設定,`null` 表示未指定,並不代表繼承後的實際狀態。`inert: true` 表示閾值未套用;能力未知時也不會回報 `enabled: true`。API 金鑰供應商、Anthropic 與無效值會被拒絕。 ```text -{ provider, autoSwitchThreshold: number, enabled: boolean } +openai: { provider, autoSwitchThreshold: number, enabled: boolean } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } ``` ### `ocx account login|reauth|code|cancel ...` diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index 82d6755e5b..d8ec552dca 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -367,6 +367,7 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise< if (threshold !== undefined && (!Number.isInteger(threshold) || threshold < 0 || threshold > 100)) { return usage("Error: threshold must be an integer 0-100"); } + let settings: Record = {}; const baseUrl = await resolveBaseUrl(deps); if (!baseUrl) return proxyUnreachable(); if (action === "status") { @@ -378,6 +379,7 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise< if (response.status !== 200 || (!genericPool && typeof response.json.autoSwitchThreshold !== "number")) { return apiError(response.json, "failed to read auto-switch status", response.status); } + settings = response.json; threshold = typeof response.json.autoSwitchThreshold === "number" ? response.json.autoSwitchThreshold : 0; } else { const response = genericPool @@ -385,6 +387,26 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise< : await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/auto-switch", { threshold }); if (response.status === 0) return proxyUnreachable(response.transportError); if (response.status !== 200) return apiError(response.json, "failed to update auto-switch", response.status); + settings = response.json; + } + if (genericPool) { + // Generic thresholds are stored independently of the enabled override. The + // latter may inherit global preference and never disables reactive rotation. + const stored = settings.autoSwitchThreshold; + const storedThreshold = typeof stored === "number" && Number.isInteger(stored) && stored >= 0 && stored <= 100 + ? stored : null; + const poolEnabled = typeof settings.enabled === "boolean" ? settings.enabled : null; + const inert = typeof settings.inert === "boolean" ? settings.inert : null; + const enabled = inert === false && poolEnabled === true && storedThreshold !== null && storedThreshold > 0; + if (wantsJson) { + console.log(JSON.stringify({ provider: name, autoSwitchThreshold: storedThreshold, enabled, poolEnabled, inert }, null, 2)); + } else if (inert !== false) { + const value = storedThreshold === null ? "unset" : `${storedThreshold}%`; + console.log(`auto-switch: ${inert === true ? "inactive" : "unavailable"} (stored threshold ${value}; ${inert === true ? "not applied by this pool" : "threshold support is unknown"})`); + } else { + console.log(enabled ? `auto-switch: on (threshold ${storedThreshold}%)` : "auto-switch: off"); + } + return 0; } const enabled = threshold! > 0; if (wantsJson) console.log(JSON.stringify({ provider: name, autoSwitchThreshold: threshold, enabled }, null, 2)); diff --git a/tests/cli/cli-account-pool-verbs.test.ts b/tests/cli/cli-account-pool-verbs.test.ts index 41c4c170fd..f46951cca5 100644 --- a/tests/cli/cli-account-pool-verbs.test.ts +++ b/tests/cli/cli-account-pool-verbs.test.ts @@ -349,10 +349,68 @@ describe("generic OAuth pool-settings contract (#695)", () => { const calls: Captured[] = []; const out = capture(); try { - expect(await cmdAutoSwitch(["google-antigravity", "threshold", "90"], genericDeps(() => ({ json: { ok: true, autoSwitchThreshold: 90 } }), calls))).toBe(0); + expect(await cmdAutoSwitch(["google-antigravity", "threshold", "90"], genericDeps(() => ({ json: { ok: true, autoSwitchThreshold: 90, enabled: true, inert: true } }), calls))).toBe(0); } finally { out.restore(); } expect(calls[0]).toMatchObject({ method: "PUT", path: "/api/oauth/accounts/pool", body: { provider: "google-antigravity", autoSwitchThreshold: 90 } }); - expect(out.lines.join("\n")).toContain("threshold 90%"); + expect(out.lines.join("\n")).toContain("stored threshold 90%"); + expect(out.lines.join("\n")).toContain("inactive"); + expect(out.lines.join("\n")).not.toContain("auto-switch: on"); + }); + + test("generic status preserves configured pool state without claiming an inert threshold is active", async () => { + for (const poolEnabled of [true, false, null]) { + const calls: Captured[] = []; + const out = capture(); + try { + expect(await cmdAutoSwitch(["google-antigravity", "status", "--json"], genericDeps(() => ({ + json: { kind: "generic", enabled: poolEnabled, autoSwitchThreshold: 90, inert: true }, + }), calls))).toBe(0); + } finally { out.restore(); } + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ method: "GET", path: "/api/oauth/accounts/pool?provider=google-antigravity" }); + expect(JSON.parse(out.lines.join("\n"))).toEqual({ + provider: "google-antigravity", autoSwitchThreshold: 90, enabled: false, poolEnabled, inert: true, + }); + } + }); + + test("generic writes report the confirmed DTO, not the requested threshold", async () => { + const calls: Captured[] = []; + const out = capture(); + try { + expect(await cmdAutoSwitch(["google-antigravity", "on", "--json"], genericDeps(() => ({ + json: { ok: true, enabled: null, autoSwitchThreshold: null, inert: true }, + }), calls))).toBe(0); + } finally { out.restore(); } + expect(calls[0]).toMatchObject({ method: "PUT", body: { provider: "google-antigravity", autoSwitchThreshold: 80 } }); + expect(JSON.parse(out.lines.join("\n"))).toEqual({ + provider: "google-antigravity", autoSwitchThreshold: null, enabled: false, poolEnabled: null, inert: true, + }); + }); + + test("generic missing or malformed capability stays unknown rather than enabled", async () => { + for (const json of [{}, { enabled: "true", autoSwitchThreshold: "90", inert: "false" }, + { enabled: true, autoSwitchThreshold: 90 }, { enabled: true, autoSwitchThreshold: 101, inert: false }]) { + const out = capture(); + try { + expect(await cmdAutoSwitch(["google-antigravity", "status", "--json"], genericDeps(() => ({ json }), []))).toBe(0); + } finally { out.restore(); } + const result = JSON.parse(out.lines.join("\n")); + expect(result.enabled).toBe(false); + expect(result.autoSwitchThreshold === null || result.autoSwitchThreshold === 90).toBe(true); + } + }); + + test("an inert zero threshold remains distinct from an unset threshold", async () => { + for (const autoSwitchThreshold of [0, null]) { + const out = capture(); + try { + expect(await cmdAutoSwitch(["google-antigravity", "status", "--json"], genericDeps(() => ({ + json: { enabled: true, autoSwitchThreshold, inert: true }, + }), []))).toBe(0); + } finally { out.restore(); } + expect(JSON.parse(out.lines.join("\n"))).toMatchObject({ autoSwitchThreshold, enabled: false, inert: true }); + } }); test("api-key providers are still refused before any request", async () => { From c18afc93478514a878460c610d48fa2f0d0b03c3 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:45:27 +0900 Subject: [PATCH 03/99] fix(cli): keep unknown generic pool capabilities inactive --- .../fr/reference/cli/providers-accounts.md | 4 ++-- .../ja/reference/cli/providers-accounts.md | 4 ++-- .../ko/reference/cli/providers-accounts.md | 4 ++-- .../docs/reference/cli/providers-accounts.md | 4 ++-- .../ru/reference/cli/providers-accounts.md | 4 ++-- .../tr/reference/cli/providers-accounts.md | 4 ++-- .../zh-cn/reference/cli/providers-accounts.md | 4 ++-- .../zh-tw/reference/cli/providers-accounts.md | 4 ++-- src/cli/account-extended.ts | 17 ++++++++------- tests/cli/cli-account-pool-verbs.test.ts | 21 ++++++++++++++++--- 10 files changed, 43 insertions(+), 27 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index c7c4ffa7ca..284c8c20c3 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -203,11 +203,11 @@ renvoient 1 ; une sonde de quota en amont qui échoue ou expire produit plutôt ### `ocx account auto-switch > [--json]` -Contrôle le seuil du pool Codex `openai`, ou enregistre celui d’un pool OAuth générique. `on` enregistre 80 %, `off` 0 % et `threshold ` accepte 0–100. Les seuils génériques sont actuellement inactifs : leur sauvegarde ne change ni le basculement par seuil, ni l’activation du fournisseur, ni la rotation réactive après une erreur 429. Les sorties utilisent la réponse confirmée du serveur. Pour un pool générique, `poolEnabled` est le réglage enregistré (`null` signifie non spécifié), pas l’état effectif hérité. `inert: true` indique que le seuil ne s’applique pas ; une capacité inconnue ne produit jamais `enabled: true`. Les fournisseurs à clé API, Anthropic et les valeurs invalides sont refusés. +Contrôle le seuil du pool Codex `openai`, ou enregistre celui d’un pool OAuth générique. `on` enregistre 80 %, `off` 0 % et `threshold ` accepte 0–100. Les seuils génériques sont actuellement inactifs : leur sauvegarde ne change ni le basculement par seuil, ni l’activation du fournisseur, ni la rotation réactive après une erreur 429. Pour les pools génériques, les sorties utilisent la réponse confirmée du serveur. Pour un pool générique, `poolEnabled` est le réglage enregistré (`null` signifie non spécifié), pas l’état effectif hérité. `inert: true` indique que le seuil ne s’applique pas ; une capacité inconnue ne produit jamais `enabled: true`. Les fournisseurs à clé API, Anthropic et les valeurs invalides sont refusés. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index 63596b369c..d20483a8a2 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -149,11 +149,11 @@ OAuth プロバイダーと API キー プロバイダーの場合、これに ### `ocx account auto-switch > [--json]` -`openai` Codex プールのしきい値を制御するか、汎用 OAuth プールのしきい値を保存します。`on` は 80%、`off` は 0%、`threshold ` は 0–100 を保存します。汎用プールのしきい値は現在適用されません。保存しても、しきい値による切り替え、プロバイダーの有効化設定、429 エラー時のローテーションは変更されません。照会と変更の結果はサーバーの確認値を使用します。汎用プールの `poolEnabled` は保存された設定で、`null` は未指定です。継承後の実効状態ではありません。`inert: true` は未適用を示し、機能が不明な場合も `enabled: true` とは表示しません。API キープロバイダー、Anthropic、不正な値は拒否されます。 +`openai` Codex プールのしきい値を制御するか、汎用 OAuth プールのしきい値を保存します。`on` は 80%、`off` は 0%、`threshold ` は 0–100 を保存します。汎用プールのしきい値は現在適用されません。保存しても、しきい値による切り替え、プロバイダーの有効化設定、429 エラー時のローテーションは変更されません。汎用プールの照会と変更の結果はサーバーの確認値を使用します。汎用プールの `poolEnabled` は保存された設定で、`null` は未指定です。継承後の実効状態ではありません。`inert: true` は未適用を示し、機能が不明な場合も `enabled: true` とは表示しません。API キープロバイダー、Anthropic、不正な値は拒否されます。 ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index ff86f67e8d..250b3d7b1b 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -209,11 +209,11 @@ OAuth 및 API 키 제공자에는 제공자의 할당량 보고 엔드포인트 ### `ocx account auto-switch > [--json]` -`openai` Codex 풀의 임계값을 제어하거나 일반 OAuth 풀의 임계값을 저장합니다. `on`은 80%, `off`는 0%, `threshold `은 0–100을 저장합니다. 일반 풀의 임계값은 현재 동작에 적용되지 않습니다. 저장해도 임계값 기반 전환이나 제공자 활성화 설정이 바뀌지 않고, 429 오류에 따른 회전도 비활성화되지 않습니다. 조회와 변경 결과는 서버가 확인한 값을 사용합니다. 일반 풀의 `poolEnabled`는 저장된 제공자별 설정이며 `null`은 미지정입니다. 전역 설정을 상속한 실제 상태를 뜻하지 않습니다. `inert: true`이면 임계값이 적용되지 않으며, 기능 지원을 알 수 없을 때도 `enabled: true`로 표시하지 않습니다. API 키 제공자, Anthropic 및 잘못된 값은 거부합니다. +`openai` Codex 풀의 임계값을 제어하거나 일반 OAuth 풀의 임계값을 저장합니다. `on`은 80%, `off`는 0%, `threshold `은 0–100을 저장합니다. 일반 풀의 임계값은 현재 동작에 적용되지 않습니다. 저장해도 임계값 기반 전환이나 제공자 활성화 설정이 바뀌지 않고, 429 오류에 따른 회전도 비활성화되지 않습니다. 일반 풀의 조회와 변경 결과는 서버가 확인한 값을 사용합니다. 일반 풀의 `poolEnabled`는 저장된 제공자별 설정이며 `null`은 미지정입니다. 전역 설정을 상속한 실제 상태를 뜻하지 않습니다. `inert: true`이면 임계값이 적용되지 않으며, 기능 지원을 알 수 없을 때도 `enabled: true`로 표시하지 않습니다. API 키 제공자, Anthropic 및 잘못된 값은 거부합니다. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index 7b9d9da16c..b31c0f3159 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -331,11 +331,11 @@ instead (exit 0), matching the dashboard's quota bars. ### `ocx account auto-switch > [--json]` -Controls the `openai` Codex pool threshold, or stores a threshold for a generic OAuth pool. `on` stores 80%, `off` stores 0%, and `threshold ` accepts 0–100. Generic pool thresholds are currently inert: saving one does not enable threshold-based switching, change the provider enablement override, or disable reactive 429 rotation. `status` and mutation output use the confirmed server response. For generic pools, `poolEnabled` is the stored provider override (`null` means unspecified), not inherited effective state; `inert: true` means the threshold is not applied, and unknown capability never reports `enabled: true`. API-key providers, Anthropic and invalid values are rejected. +Controls the `openai` Codex pool threshold, or stores a threshold for a generic OAuth pool. `on` stores 80%, `off` stores 0%, and `threshold ` accepts 0–100. Generic pool thresholds are currently inert: saving one does not enable threshold-based switching, change the provider enablement override, or disable reactive 429 rotation. `status` and mutation output for generic pools use the confirmed server response. For generic pools, `poolEnabled` is the stored provider override (`null` means unspecified), not inherited effective state; `inert: true` means the threshold is not applied, and unknown capability never reports `enabled: true`. API-key providers, Anthropic and invalid values are rejected. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index 1e011a0dd5..56bde92668 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -187,11 +187,11 @@ quota-bar'ов дашборда. ### `ocx account auto-switch > [--json]` -Управляет порогом пула Codex `openai` или сохраняет порог общего пула OAuth. `on` сохраняет 80 %, `off` — 0 %, а `threshold ` принимает 0–100. Пороги общих пулов пока не применяются: сохранение не включает переключение по порогу, не меняет настройку включения провайдера и не отключает ротацию после ошибки 429. Результат чтения и изменения берётся из подтверждённого ответа сервера. Для общего пула `poolEnabled` — сохранённая настройка провайдера (`null` означает отсутствие настройки), а не итоговое унаследованное состояние. `inert: true` означает, что порог не применяется; неизвестная возможность также не даёт `enabled: true`. Провайдеры с ключом API, Anthropic и неверные значения отклоняются. +Управляет порогом пула Codex `openai` или сохраняет порог общего пула OAuth. `on` сохраняет 80 %, `off` — 0 %, а `threshold ` принимает 0–100. Пороги общих пулов пока не применяются: сохранение не включает переключение по порогу, не меняет настройку включения провайдера и не отключает ротацию после ошибки 429. Для общего пула результат чтения и изменения берётся из подтверждённого ответа сервера. Для общего пула `poolEnabled` — сохранённая настройка провайдера (`null` означает отсутствие настройки), а не итоговое унаследованное состояние. `inert: true` означает, что порог не применяется; неизвестная возможность также не даёт `enabled: true`. Провайдеры с ключом API, Anthropic и неверные значения отклоняются. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index 92c0cbd2a8..f611d7be73 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -229,11 +229,11 @@ eşleşen null veya eski bir rapora düşer (çıkış 0). ### `ocx account auto-switch > [--json]` -`openai` Codex havuzunun eşiğini yönetir veya genel OAuth havuzunun eşiğini kaydeder. `on` %80, `off` %0 kaydeder; `threshold ` 0–100 kabul eder. Genel havuz eşikleri şu anda uygulanmaz: kayıt işlemi eşik tabanlı geçişi, sağlayıcının etkinlik ayarını veya 429 hatasından sonraki otomatik hesap değişimini etkilemez. Çıktı sunucunun doğruladığı değerleri kullanır. Genel havuzlarda `poolEnabled`, kaydedilmiş sağlayıcı ayarıdır (`null` belirtilmemiş demektir); devralınmış etkin durumu göstermez. `inert: true`, eşiğin uygulanmadığını belirtir; yetenek bilinmiyorsa `enabled: true` bildirilmez. API anahtarlı sağlayıcılar, Anthropic ve geçersiz değerler reddedilir. +`openai` Codex havuzunun eşiğini yönetir veya genel OAuth havuzunun eşiğini kaydeder. `on` %80, `off` %0 kaydeder; `threshold ` 0–100 kabul eder. Genel havuz eşikleri şu anda uygulanmaz: kayıt işlemi eşik tabanlı geçişi, sağlayıcının etkinlik ayarını veya 429 hatasından sonraki otomatik hesap değişimini etkilemez. Genel havuz çıktısı sunucunun doğruladığı değerleri kullanır. Genel havuzlarda `poolEnabled`, kaydedilmiş sağlayıcı ayarıdır (`null` belirtilmemiş demektir); devralınmış etkin durumu göstermez. `inert: true`, eşiğin uygulanmadığını belirtir; yetenek bilinmiyorsa `enabled: true` bildirilmez. API anahtarlı sağlayıcılar, Anthropic ve geçersiz değerler reddedilir. ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index a05d6cd65b..85633f54f5 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -169,11 +169,11 @@ token,也不是简单重读账号列表。`--json` 返回 ### `ocx account auto-switch > [--json]` -控制 `openai` Codex 账户池阈值,或保存通用 OAuth 账户池阈值。`on` 保存 80%,`off` 保存 0%,`threshold ` 接受 0–100。通用池的阈值目前不参与运行;保存阈值不会启用阈值切换、改变提供方启用设置或禁用 429 错误后的轮换。查询和修改结果使用服务器确认值。通用池的 `poolEnabled` 是已保存的提供方设置,`null` 表示未指定,并不代表继承后的实际状态。`inert: true` 表示阈值未应用;能力未知时也不会报告 `enabled: true`。API 密钥提供方、Anthropic 和无效值会被拒绝。 +控制 `openai` Codex 账户池阈值,或保存通用 OAuth 账户池阈值。`on` 保存 80%,`off` 保存 0%,`threshold ` 接受 0–100。通用池的阈值目前不参与运行;保存阈值不会启用阈值切换、改变提供方启用设置或禁用 429 错误后的轮换。通用池的查询和修改结果使用服务器确认值。通用池的 `poolEnabled` 是已保存的提供方设置,`null` 表示未指定,并不代表继承后的实际状态。`inert: true` 表示阈值未应用;能力未知时也不会报告 `enabled: true`。API 密钥提供方、Anthropic 和无效值会被拒绝。 ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account priority [<-100..100|first|earlier|normal|later|last|reset>] [--json]` diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index 2a1d55176c..c50a6d54ae 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -129,11 +129,11 @@ Codex 池選擇套用於清除既有親和性後的下一個請求;進行中 ### `ocx account auto-switch > [--json]` -控制 `openai` Codex 帳戶池閾值,或儲存通用 OAuth 帳戶池閾值。`on` 儲存 80%,`off` 儲存 0%,`threshold ` 接受 0–100。通用池的閾值目前不參與執行;儲存閾值不會啟用閾值切換、改變供應商啟用設定或停用 429 錯誤後的輪替。查詢與修改結果使用伺服器確認值。通用池的 `poolEnabled` 是已儲存的供應商設定,`null` 表示未指定,並不代表繼承後的實際狀態。`inert: true` 表示閾值未套用;能力未知時也不會回報 `enabled: true`。API 金鑰供應商、Anthropic 與無效值會被拒絕。 +控制 `openai` Codex 帳戶池閾值,或儲存通用 OAuth 帳戶池閾值。`on` 儲存 80%,`off` 儲存 0%,`threshold ` 接受 0–100。通用池的閾值目前不參與執行;儲存閾值不會啟用閾值切換、改變供應商啟用設定或停用 429 錯誤後的輪替。通用池的查詢與修改結果使用伺服器確認值。通用池的 `poolEnabled` 是已儲存的供應商設定,`null` 表示未指定,並不代表繼承後的實際狀態。`inert: true` 表示閾值未套用;能力未知時也不會回報 `enabled: true`。API 金鑰供應商、Anthropic 與無效值會被拒絕。 ```text openai: { provider, autoSwitchThreshold: number, enabled: boolean } -generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: boolean | null } +generic OAuth: { provider, autoSwitchThreshold: number | null, enabled: boolean, poolEnabled: boolean | null, inert: true | null } ``` ### `ocx account login|reauth|code|cancel ...` diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index d8ec552dca..38179aca42 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -379,15 +379,17 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise< if (response.status !== 200 || (!genericPool && typeof response.json.autoSwitchThreshold !== "number")) { return apiError(response.json, "failed to read auto-switch status", response.status); } - settings = response.json; - threshold = typeof response.json.autoSwitchThreshold === "number" ? response.json.autoSwitchThreshold : 0; + settings = genericPool && (!response.json || typeof response.json !== "object" || Array.isArray(response.json)) + ? {} : response.json; + threshold = typeof settings.autoSwitchThreshold === "number" ? settings.autoSwitchThreshold : 0; } else { const response = genericPool ? await apiJson(deps, baseUrl, "PUT", "/api/oauth/accounts/pool", { provider: name, autoSwitchThreshold: threshold }) : await apiJson(deps, baseUrl, "PUT", "/api/codex-auth/auto-switch", { threshold }); if (response.status === 0) return proxyUnreachable(response.transportError); if (response.status !== 200) return apiError(response.json, "failed to update auto-switch", response.status); - settings = response.json; + settings = genericPool && (!response.json || typeof response.json !== "object" || Array.isArray(response.json)) + ? {} : response.json; } if (genericPool) { // Generic thresholds are stored independently of the enabled override. The @@ -396,15 +398,14 @@ export async function cmdAutoSwitch(args: string[], deps: AccountDeps): Promise< const storedThreshold = typeof stored === "number" && Number.isInteger(stored) && stored >= 0 && stored <= 100 ? stored : null; const poolEnabled = typeof settings.enabled === "boolean" ? settings.enabled : null; - const inert = typeof settings.inert === "boolean" ? settings.inert : null; - const enabled = inert === false && poolEnabled === true && storedThreshold !== null && storedThreshold > 0; + const inert = settings.inert === true ? true : null; + // This CLI understands only the current inert generic threshold contract. + const enabled = false; if (wantsJson) { console.log(JSON.stringify({ provider: name, autoSwitchThreshold: storedThreshold, enabled, poolEnabled, inert }, null, 2)); - } else if (inert !== false) { + } else { const value = storedThreshold === null ? "unset" : `${storedThreshold}%`; console.log(`auto-switch: ${inert === true ? "inactive" : "unavailable"} (stored threshold ${value}; ${inert === true ? "not applied by this pool" : "threshold support is unknown"})`); - } else { - console.log(enabled ? `auto-switch: on (threshold ${storedThreshold}%)` : "auto-switch: off"); } return 0; } diff --git a/tests/cli/cli-account-pool-verbs.test.ts b/tests/cli/cli-account-pool-verbs.test.ts index f46951cca5..04be2e4fa6 100644 --- a/tests/cli/cli-account-pool-verbs.test.ts +++ b/tests/cli/cli-account-pool-verbs.test.ts @@ -382,15 +382,17 @@ describe("generic OAuth pool-settings contract (#695)", () => { json: { ok: true, enabled: null, autoSwitchThreshold: null, inert: true }, }), calls))).toBe(0); } finally { out.restore(); } - expect(calls[0]).toMatchObject({ method: "PUT", body: { provider: "google-antigravity", autoSwitchThreshold: 80 } }); + expect(calls).toHaveLength(1); + expect(calls[0]?.body).toEqual({ provider: "google-antigravity", autoSwitchThreshold: 80 }); expect(JSON.parse(out.lines.join("\n"))).toEqual({ provider: "google-antigravity", autoSwitchThreshold: null, enabled: false, poolEnabled: null, inert: true, }); }); test("generic missing or malformed capability stays unknown rather than enabled", async () => { - for (const json of [{}, { enabled: "true", autoSwitchThreshold: "90", inert: "false" }, - { enabled: true, autoSwitchThreshold: 90 }, { enabled: true, autoSwitchThreshold: 101, inert: false }]) { + for (const json of [null, [], {}, { enabled: "true", autoSwitchThreshold: "90", inert: "false" }, + { enabled: true, autoSwitchThreshold: 90 }, { enabled: true, autoSwitchThreshold: 101, inert: false }, + { enabled: true, autoSwitchThreshold: 90, inert: false }]) { const out = capture(); try { expect(await cmdAutoSwitch(["google-antigravity", "status", "--json"], genericDeps(() => ({ json }), []))).toBe(0); @@ -401,6 +403,19 @@ describe("generic OAuth pool-settings contract (#695)", () => { } }); + test("a successful generic write with a null body reports unknown settings", async () => { + const calls: Captured[] = []; + const out = capture(); + try { + expect(await cmdAutoSwitch(["google-antigravity", "off", "--json"], genericDeps(() => ({ json: null }), calls))).toBe(0); + } finally { out.restore(); } + expect(calls).toHaveLength(1); + expect(calls[0]?.body).toEqual({ provider: "google-antigravity", autoSwitchThreshold: 0 }); + expect(JSON.parse(out.lines.join("\n"))).toEqual({ + provider: "google-antigravity", autoSwitchThreshold: null, enabled: false, poolEnabled: null, inert: null, + }); + }); + test("an inert zero threshold remains distinct from an unset threshold", async () => { for (const autoSwitchThreshold of [0, null]) { const out = capture(); From 5faaff7d101709ee343f2067c5274fed236395fd Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:45:52 +0900 Subject: [PATCH 04/99] fix(storage): publish cleanup manifests atomically [skip ci] Preserve complete recovery records across handled publication failures and keep existing partial-purge restoration boundaries. Refs #3778. Local checks deferred to final stack CI by maintainer instruction. --- .../content/docs/reference/management-api.md | 4 + src/storage/cleanup.ts | 84 +++++++----- structure/02_config-and-codex-home.md | 6 + tests/storage/storage-cleanup.test.ts | 123 ++++++++++++++++++ 4 files changed, 182 insertions(+), 35 deletions(-) diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index a12303cfd7..2cd23eb4bd 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -231,6 +231,10 @@ Storage cleanup endpoints can move or permanently remove archived session data. first and submit the returned digest. Prefer quarantine when recovery may be needed. ::: +Cleanup recovery manifests are published atomically, preserving the previous complete record +if a replacement fails before publication. This does not reverse a permanent purge: restore +can still fail when a recorded session has no surviving rollout file. + ### Models and catalog | Method and path | Purpose | Notable errors | diff --git a/src/storage/cleanup.ts b/src/storage/cleanup.ts index c39bbeedf1..e44f7d7b5c 100644 --- a/src/storage/cleanup.ts +++ b/src/storage/cleanup.ts @@ -30,11 +30,11 @@ import { writeSync, chmodSync, } from "node:fs"; -import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { Database } from "bun:sqlite"; import { resolveCodexHomeDir } from "../codex/home"; import { readThreadFieldsFromRollout } from "../codex/history-provider"; -import { renameAtomicFile } from "../config"; +import { renameAtomicFile } from "../lib/windows-atomic-replace"; export const ARCHIVED_SESSIONS_DIR = "archived_sessions"; export const TRASH_DIR = ".trash"; @@ -115,9 +115,35 @@ function chmodPrivatePath(path: string, mode: number): void { try { chmodSync(path, mode); } catch { /* best-effort (e.g. Windows ACLs) */ } } -function writePrivateFile(path: string, content: string): void { - writeFileSync(path, content, "utf8"); - chmodPrivatePath(path, 0o600); +/** Publish complete stage metadata without truncating the last recovery record. */ +function writePrivateFile( + path: string, + content: string, + beforeRename?: (temporaryPath: string, targetPath: string) => void, +): void { + const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`; + let descriptor: number | undefined; + let created = false; + try { + descriptor = openSync(temporaryPath, "wx", 0o600); + created = true; + writeFileSync(descriptor, content, "utf8"); + fsyncSync(descriptor); + closeSync(descriptor); + descriptor = undefined; + chmodPrivatePath(temporaryPath, 0o600); + beforeRename?.(temporaryPath, path); + renameAtomicFile(temporaryPath, path, undefined, "storage-cleanup"); + chmodPrivatePath(path, 0o600); + fsyncDirectoryBestEffort(dirname(path)); + } finally { + if (descriptor !== undefined) { + try { closeSync(descriptor); } catch { /* preserve publication failure */ } + } + if (created) { + try { unlinkSync(temporaryPath); } catch { /* renamed or cleanup unavailable */ } + } + } } function chunkIds(ids: string[], chunkSize: number): string[][] { @@ -812,7 +838,6 @@ interface ReconcileTestHooks { const SATELLITE_BACKUP_FILE = "satellite-backup.json"; /** Marks an incomplete restore so retries can accept dest files and resume metadata. */ const RESTORE_PENDING_FILE = "restore-pending.json"; -let _satelliteBackupSeq = 0; type StagedFile = { from: string; to: string; relPath: string }; @@ -1070,34 +1095,11 @@ function writeSatelliteBackup( if (options?.failWrite) throw new Error("test_fail_satellite_backup_write"); const dest = join(stageDir, SATELLITE_BACKUP_FILE); const replacing = existsSync(dest); - const tmp = join(stageDir, `${SATELLITE_BACKUP_FILE}.${process.pid}.${++_satelliteBackupSeq}.tmp`); - const payload = Buffer.from(JSON.stringify(backup), "utf8"); - const fd = openSync(tmp, "w", 0o600); - try { - let offset = 0; - while (offset < payload.length) { - offset += writeSync(fd, payload, offset, payload.length - offset, null); + writePrivateFile(dest, JSON.stringify(backup), () => { + if (options?.failReplaceBeforeRename && replacing) { + throw new Error("test_fail_satellite_backup_replace"); } - fsyncSync(fd); - } catch (error) { - try { closeSync(fd); } catch { /* */ } - try { unlinkSync(tmp); } catch { /* */ } - throw error; - } - closeSync(fd); - chmodPrivatePath(tmp, 0o600); - if (options?.failReplaceBeforeRename && replacing) { - try { unlinkSync(tmp); } catch { /* */ } - throw new Error("test_fail_satellite_backup_replace"); - } - try { - renameAtomicFile(tmp, dest, undefined, "storage-cleanup"); - } catch (error) { - try { unlinkSync(tmp); } catch { /* */ } - throw error; - } - chmodPrivatePath(dest, 0o600); - fsyncDirectoryBestEffort(stageDir); + }); } function clearSatelliteBackup(stageDir: string): void { @@ -1734,6 +1736,12 @@ export interface ExecuteCleanupOptions { /** Test-only failure injection for atomicity regressions. */ _test?: { failManifestWrite?: boolean; + /** Observe the complete temp and prior destination before publication. Never serialized. */ + beforeManifestReplace?: ( + temporaryPath: string, + targetPath: string, + phase: "staging" | "pre-commit" | "purge-incomplete", + ) => void; failPurgeBasenames?: string[]; failRollbackBasenames?: string[]; blockStageDestBasenames?: string[]; @@ -1752,14 +1760,14 @@ export interface ExecuteCleanupOptions { /** Serializable cleanup test hooks allowed on the management API wire. */ export type CleanupWireTestHooks = Omit< NonNullable, - "afterSatelliteMutations" | "beforeReconcileLock" + "afterSatelliteMutations" | "beforeReconcileLock" | "beforeManifestReplace" >; function isStringArray(v: unknown): v is string[] { return Array.isArray(v) && v.every(e => typeof e === "string"); } -/** Pick only allowlisted serializable hooks; drops function hooks (afterSatelliteMutations, beforeReconcileLock) and unknown keys. */ +/** Pick only allowlisted serializable hooks; drops all function hooks and unknown keys. */ export function pickWireCleanupTestHooks(raw: unknown): CleanupWireTestHooks | undefined { if (!raw || typeof raw !== "object") return undefined; const o = raw as Record; @@ -1911,6 +1919,9 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR entries: manifestEntries, ...extra, }, null, 2), + (temporaryPath, targetPath) => options._test?.beforeManifestReplace?.( + temporaryPath, targetPath, extra.staging ? "staging" : "pre-commit", + ), ); }; @@ -1999,6 +2010,9 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR })) .filter(entry => entry.physicalRelPaths.length > 0), }, null, 2), + (temporaryPath, targetPath) => options._test?.beforeManifestReplace?.( + temporaryPath, targetPath, "purge-incomplete", + ), ); } catch { /* best-effort: the pre-commit manifest is still on disk */ } return { diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index bb8ec5630f..66a729b4fe 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -127,6 +127,12 @@ Worker cannot restore unrelated API keys or provider settings from a snapshot re If that metadata write is unavailable after cleanup has already completed, the job retains the cleanup outcome and exposes a bounded persistence error instead of relabeling the run as a Worker failure. +Cleanup manifests and satellite backups share the stage-local atomic publisher: an exclusive +private temporary file is fully written and file-synced before the existing Windows-tolerant +rename replaces the destination. Handled publication failures retain the previous record; +directory syncing remains best-effort. This does not make a partial permanent purge reversible: +restore still fails closed when a recorded logical entry has no surviving file. + Windows secret-file hardening resolves the effective token SID through an absolute, trusted PowerShell path before granting the owner and removing inherited broad ACL entries. The normal path obtains System32 from `GetSystemDirectoryW`. Windows ARM64 Bun builds that cannot execute diff --git a/tests/storage/storage-cleanup.test.ts b/tests/storage/storage-cleanup.test.ts index 421950368c..31cbcd6d21 100644 --- a/tests/storage/storage-cleanup.test.ts +++ b/tests/storage/storage-cleanup.test.ts @@ -8,6 +8,7 @@ import { readFileSync, renameSync, rmSync, + statSync, unlinkSync, utimesSync, writeFileSync, @@ -20,6 +21,7 @@ import { listArchivedCandidates, listTrashEntries, normalizeArchivedRolloutPath, + pickWireCleanupTestHooks, previewArchivedCleanup, previewExactArchivedCleanup, restoreTrashEntry, @@ -648,6 +650,127 @@ describe("executeArchivedCleanup", () => { expect(ids).toContain("told"); }); + test("initial manifest publication failure preserves originals and removes its private temp", () => { + home = buildHome(); + const observed: Array<{ priorExists: boolean; next: string; mode: number }> = []; + const result = runWithDigest(50, "quarantine", home, { + now: 881, + _test: { + beforeManifestReplace: (temporaryPath, targetPath, phase) => { + if (phase !== "staging") return; + observed.push({ + priorExists: existsSync(targetPath), + next: readFileSync(temporaryPath, "utf8"), + mode: statSync(temporaryPath).mode & 0o777, + }); + throw new Error("injected_manifest_publication_failure"); + }, + }, + }); + // Assert outside the production catch: an assertion inside the hook could be swallowed. + expect(observed).toHaveLength(1); + expect(observed[0]!.priorExists).toBe(false); + expect(JSON.parse(observed[0]!.next).staging).toBe(true); + if (process.platform !== "win32") expect(observed[0]!.mode).toBe(0o600); + expect(result.error).toBe("fs_failed"); + expect(existsSync(join(home, ".trash", "881"))).toBe(false); + expect(readFileSync(join(home, "archived_sessions", "rollout-old.jsonl"), "utf8")).toBe("OLD".repeat(10)); + const db = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(db.query("SELECT id FROM threads WHERE id = 'told'").get()).toBeTruthy(); + db.close(); + expect(pickWireCleanupTestHooks({ + beforeManifestReplace: () => {}, failManifestWrite: true, + })).toEqual({ failManifestWrite: true }); + }, STORE_BUDGET_MS); + + test("failed pre-delete replacement leaves the prior manifest intact during publication and restorable", () => { + home = buildHome(); + let stagingBytes = ""; + const observed: Array<{ prior: string; next: string }> = []; + const result = runWithDigest(50, "quarantine", home, { + now: 882, + _test: { + failRollbackBasenames: ["rollout-old.jsonl"], + beforeManifestReplace: (temporaryPath, targetPath, phase) => { + if (phase === "staging") stagingBytes = readFileSync(temporaryPath, "utf8"); + if (phase !== "pre-commit") return; + observed.push({ prior: readFileSync(targetPath, "utf8"), next: readFileSync(temporaryPath, "utf8") }); + throw new Error("injected_manifest_publication_failure"); + }, + }, + }); + expect(observed).toHaveLength(1); + expect(observed[0]!.prior).toBe(stagingBytes); + expect(JSON.parse(observed[0]!.prior).staging).toBe(true); + expect(JSON.parse(observed[0]!.next).staging).toBeUndefined(); + expect(result.error).toBe("fs_failed"); + expect(result.trashDir).toBe(".trash/882"); + const stage = join(home, ".trash", "882"); + expect(readFileSync(join(stage, "manifest.json"), "utf8")).toBe(stagingBytes); + expect(readFileSync(join(stage, "rollout-old.jsonl"), "utf8")).toBe("OLD".repeat(10)); + expect(readdirSync(stage).filter(name => name.endsWith(".tmp"))).toEqual([]); + const db = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(db.query("SELECT id FROM threads WHERE id = 'told'").get()).toBeTruthy(); + db.close(); + const restored = restoreTrashEntry(".trash/882", { codexHome: home }); + expect(restored.ok).toBe(true); + expect(restored.count).toBe(1); + expect(readFileSync(join(home, "archived_sessions", "rollout-old.jsonl"), "utf8")).toBe("OLD".repeat(10)); + }, STORE_BUDGET_MS); + + test.each([false, true])("failed post-purge manifest replacement preserves prior bytes (partial=%s)", partial => { + home = buildHome(); + let preCommitBytes = ""; + const observed: Array<{ prior: string; next: string }> = []; + const result = runWithDigest(100, "permanent", home, { + now: 883, + _test: { + failPurgeBasenames: partial + ? ["rollout-mid.jsonl"] + : ["rollout-old.jsonl", "rollout-mid.jsonl", "rollout-new.jsonl"], + beforeManifestReplace: (temporaryPath, targetPath, phase) => { + if (phase === "pre-commit") preCommitBytes = readFileSync(temporaryPath, "utf8"); + if (phase !== "purge-incomplete") return; + observed.push({ prior: readFileSync(targetPath, "utf8"), next: readFileSync(temporaryPath, "utf8") }); + throw new Error("injected_manifest_publication_failure"); + }, + }, + }); + expect(observed).toHaveLength(1); + expect(observed[0]!.prior).toBe(preCommitBytes); + expect(JSON.parse(observed[0]!.next).purgeIncomplete).toBe(true); + expect(JSON.parse(observed[0]!.next).entries).toHaveLength(partial ? 1 : 3); + expect(result.error).toBe("fs_failed"); + const stage = join(home, ".trash", "883"); + expect(readFileSync(join(stage, "manifest.json"), "utf8")).toBe(preCommitBytes); + expect(readdirSync(stage).filter(name => name.endsWith(".tmp"))).toEqual([]); + const dbBefore = new Database(join(home, "state_5.sqlite"), { readonly: true }); + const rowsBefore = dbBefore.query("SELECT id FROM threads ORDER BY id").all(); + dbBefore.close(); + expect(rowsBefore).toEqual([{ id: "active" }]); + const stageBefore = readdirSync(stage).sort(); + const restored = restoreTrashEntry(".trash/883", { codexHome: home }); + if (partial) { + // A wholly purged old entry still fails closed; valid JSON is not full recovery. + expect(restored.error).toBe("fs_failed"); + expect(restored.restoredPaths).toEqual([]); + expect(readdirSync(stage).sort()).toEqual(stageBefore); + expect(readFileSync(join(stage, "rollout-mid.jsonl"), "utf8")).toBe("MID".repeat(20)); + expect(existsSync(join(home, "archived_sessions", "rollout-old.jsonl"))).toBe(false); + const dbAfter = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(dbAfter.query("SELECT id FROM threads ORDER BY id").all()).toEqual(rowsBefore); + dbAfter.close(); + } else { + expect(restored.ok).toBe(true); + expect(restored.count).toBe(3); + const dbAfter = new Database(join(home, "state_5.sqlite"), { readonly: true }); + expect(dbAfter.query("SELECT id FROM threads ORDER BY id").all()).toEqual([ + { id: "active" }, { id: "tmid" }, { id: "tnew" }, { id: "told" }, + ]); + dbAfter.close(); + } + }, { timeout: STORE_BUDGET_MS }); + test("rename-back failure keeps staged file and reports relative trashDir", () => { home = buildHome(); const db = new Database(join(home, "state_5.sqlite")); From e23eb63b81343f5ff98f8adee0a35ce6c29ad391 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:48:26 +0900 Subject: [PATCH 05/99] fix(gui): render subscription credit quota bars consistently Carries #3728 with raw-label and partial-coverage preservation. Co-authored-by: Yumi Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> --- gui/src/components/QuotaBars.tsx | 58 ++++++++++-- tests/gui/quota-bars-rows.test.ts | 142 ++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 5 deletions(-) diff --git a/gui/src/components/QuotaBars.tsx b/gui/src/components/QuotaBars.tsx index 2715f48608..8b8fd9c4e6 100644 --- a/gui/src/components/QuotaBars.tsx +++ b/gui/src/components/QuotaBars.tsx @@ -20,15 +20,38 @@ export type QuotaBarRow = { /** * Window ordering is computed from RAW wire identities BEFORE localization * (ranking on translated labels breaks the moment a locale changes copy): - * shorter windows first — 5h, weekly, cursor first-party, cursor API, monthly. + * shorter windows first — 5h, weekly, cursor first-party, cursor API, monthly, + * then subscription credits before other custom windows. */ function rawCustomWindowRank(rawLabel: string): number { if (rawLabel === "5h") return 0; if (rawLabel === "First-party models") return 2; if (rawLabel === "API usage") return 3; + if (rawLabel === "Total subscription credits") return 4.5; return 5; } +const SUBSCRIPTION_CREDITS_LABEL = "Total subscription credits"; + +function canonicalCustomWindowLabel(rawLabel: string): string { + return rawLabel.trim().toLowerCase() === SUBSCRIPTION_CREDITS_LABEL.toLowerCase() + ? SUBSCRIPTION_CREDITS_LABEL + : rawLabel; +} + +/** Coverage metadata carries raw labels, while subscription rows use a canonical identity. */ +export function isCustomQuotaWindowIncomplete( + customLabel: string | undefined, + incompleteLabels?: ReadonlySet, +): boolean { + if (customLabel === undefined || !incompleteLabels) return false; + const canonical = canonicalCustomWindowLabel(customLabel); + for (const label of incompleteLabels) { + if (canonicalCustomWindowLabel(label) === canonical) return true; + } + return false; +} + function localizeCustomQuotaLabel(rawLabel: string, t: TFn): string { switch (rawLabel) { case "First-party models": @@ -84,11 +107,12 @@ export function buildQuotaRows(quota: AccountQuota | null, plan: string | null | }); } for (const w of displayQuota.customWindows ?? []) { - const localized = localizeCustomQuotaLabel(w.label, t); + const customLabel = canonicalCustomWindowLabel(w.label); + const localized = localizeCustomQuotaLabel(customLabel, t); ranked.push({ - rank: rawCustomWindowRank(w.label), + rank: rawCustomWindowRank(customLabel), row: { - customLabel: w.label, + customLabel, label: localized, limitLabel: localized, percent: w.percent, @@ -96,6 +120,24 @@ export function buildQuotaRows(quota: AccountQuota | null, plan: string | null | }, }); } + if (displayQuota.creditsUsd && typeof displayQuota.creditsUsd.percent === "number") { + const hasSubscriptionCreditsCustom = displayQuota.customWindows?.some( + w => canonicalCustomWindowLabel(w.label) === SUBSCRIPTION_CREDITS_LABEL, + ); + if (!hasSubscriptionCreditsCustom) { + const localized = localizeCustomQuotaLabel(SUBSCRIPTION_CREDITS_LABEL, t); + ranked.push({ + rank: rawCustomWindowRank(SUBSCRIPTION_CREDITS_LABEL), + row: { + customLabel: SUBSCRIPTION_CREDITS_LABEL, + label: localized, + limitLabel: localized, + percent: displayQuota.creditsUsd.percent, + resetAt: displayQuota.creditsUsd.expiresAt, + }, + }); + } + } return ranked.sort((a, b) => a.rank - b.rank).map(entry => entry.row); } @@ -107,6 +149,12 @@ export function maxQuotaUtilisation(quota: AccountQuota | null): number { for (const w of quota.customWindows ?? []) { if (typeof w.percent === "number") vals.push(w.percent); } + const hasSubscriptionCreditsCustom = quota.customWindows?.some( + w => canonicalCustomWindowLabel(w.label) === SUBSCRIPTION_CREDITS_LABEL, + ); + if (!hasSubscriptionCreditsCustom && typeof quota.creditsUsd?.percent === "number") { + vals.push(quota.creditsUsd.percent); + } return vals.length ? Math.max(...vals) : -1; } @@ -279,7 +327,7 @@ export default function QuotaBars({ locale={locale} incomplete={row.windowKey ? incompleteWindowKeys?.has(row.windowKey) === true - : row.customLabel !== undefined && incompleteCustomWindowLabels?.has(row.customLabel) === true} + : isCustomQuotaWindowIncomplete(row.customLabel, incompleteCustomWindowLabels)} /> ))} diff --git a/tests/gui/quota-bars-rows.test.ts b/tests/gui/quota-bars-rows.test.ts index 57ddd47cfd..8ca5a27579 100644 --- a/tests/gui/quota-bars-rows.test.ts +++ b/tests/gui/quota-bars-rows.test.ts @@ -3,6 +3,7 @@ import { barWidth, buildQuotaRows, formatResetFuture, + isCustomQuotaWindowIncomplete, isQuotaExhausted, isQuotaWarn, maxQuotaUtilisation, @@ -69,6 +70,102 @@ describe("buildQuotaRows (WP070)", () => { expect(rows.map(r => r.label)).toEqual(["quota.totalSubscriptionCredits"]); }); + test("direct creditsUsd renders Total subscription credits with resetAt", () => { + const reported = quota({ + creditsUsd: { used: 89.96, limit: 90, remaining: 0.04, percent: 99.96, expiresAt: 1790430938000 }, + }); + expect(buildQuotaRows(reported, null, t)).toEqual([{ + customLabel: "Total subscription credits", + label: "quota.totalSubscriptionCredits", + limitLabel: "quota.totalSubscriptionCredits", + percent: 99.96, + resetAt: 1790430938000, + }]); + expect(maxQuotaUtilisation(reported)).toBe(99.96); + }); + + test("zero direct credit usage remains a row without an invented expiry", () => { + const reported = quota({ + creditsUsd: { used: 0, limit: 100, remaining: 100, percent: 0 }, + }); + const rows = buildQuotaRows(reported, null, t); + expect(rows).toHaveLength(1); + expect(rows[0]?.customLabel).toBe("Total subscription credits"); + expect(rows[0]?.percent).toBe(0); + expect(rows[0]?.resetAt).toBeUndefined(); + expect(maxQuotaUtilisation(reported)).toBe(0); + }); + + test("subscription credits rank after monthly and before other custom windows", () => { + const rows = buildQuotaRows(quota({ + fiveHourPercent: 10, + weeklyPercent: 40, + monthlyPercent: 70, + customWindows: [ + { label: "Gem", percent: 1 }, + { label: "API usage", percent: 55 }, + { label: "First-party models", percent: 25 }, + ], + creditsUsd: { used: 80, limit: 100, remaining: 20, percent: 80 }, + }), null, t); + expect(rows.map(r => r.limitLabel)).toEqual([ + "quota.fiveHourLimit", + "quota.weeklyLimit", + "quota.cursorFirstParty", + "quota.cursorApiUsage", + "quota.monthlyLimit", + "quota.totalSubscriptionCredits", + "Gem", + ]); + }); + + test.each(["Total subscription credits", " TOTAL SUBSCRIPTION CREDITS "])( + "direct creditsUsd does not duplicate the canonical custom window: %s", + label => { + const customOnly = quota({ customWindows: [{ label, percent: 25, resetAt: 1790430938000 }] }); + const withDirect = quota({ + ...customOnly, + creditsUsd: { used: 99, limit: 100, remaining: 1, percent: 99, expiresAt: 1790517338000 }, + }); + for (const reported of [customOnly, withDirect]) { + expect(buildQuotaRows(reported, null, t)).toEqual([{ + customLabel: "Total subscription credits", + label: "quota.totalSubscriptionCredits", + limitLabel: "quota.totalSubscriptionCredits", + percent: 25, + resetAt: 1790430938000, + }]); + } + }, + ); + + test("unrelated credit windows keep their raw identity and do not suppress direct credits", () => { + const reported = quota({ + customWindows: [ + { label: "API credits", percent: 20 }, + { label: " Gem ", percent: 10 }, + ], + creditsUsd: { used: 50, limit: 100, remaining: 50, percent: 50 }, + }); + const rows = buildQuotaRows(reported, null, t); + expect(rows.map(r => r.label)).toEqual(["quota.totalSubscriptionCredits", "API credits", " Gem "]); + expect(rows.map(r => r.customLabel)).toEqual(["Total subscription credits", "API credits", " Gem "]); + expect(maxQuotaUtilisation(reported)).toBe(50); + }); + + test.each(["go", "free"])("30-day plan %s retains direct credits after normalization", plan => { + const rows = buildQuotaRows(quota({ + shortPercent: 10, + shortWindowSeconds: 5 * 60 * 60, + weeklyPercent: 30, + monthlyPercent: 60, + customWindows: [{ label: "Total subscription credits", percent: 99 }], + creditsUsd: { used: 80, limit: 100, remaining: 20, percent: 80 }, + }), plan, t); + expect(rows.map(r => r.limitLabel)).toEqual(["quota.monthlyLimit", "quota.totalSubscriptionCredits"]); + expect(rows.map(r => r.percent)).toEqual([60, 80]); + }); + test("null and empty quotas produce no rows; 30-day plans strip to monthly", () => { expect(buildQuotaRows(null, null, t)).toEqual([]); expect(buildQuotaRows(quota({}), null, t)).toEqual([]); @@ -93,6 +190,51 @@ describe("maxQuotaUtilisation", () => { fiveHourPercent: 10, customWindows: [{ label: "x", percent: 95 }], }))).toBe(95); + expect(maxQuotaUtilisation(quota({ + fiveHourPercent: 0, + weeklyPercent: 0, + creditsUsd: { used: 90, limit: 90, remaining: 0, percent: 100 }, + }))).toBe(100); + }); + + test.each(["Total subscription credits", " total subscription credits "])( + "subscription-credit urgency follows the visible custom window: %s", + label => { + expect(maxQuotaUtilisation(quota({ + customWindows: [{ label, percent: 25 }], + creditsUsd: { used: 99, limit: 100, remaining: 1, percent: 99 }, + }))).toBe(25); + }, + ); +}); + +describe("isCustomQuotaWindowIncomplete", () => { + test("canonical subscription rows retain raw-label coverage metadata", () => { + const rows = buildQuotaRows(quota({ + customWindows: [{ label: " TOTAL SUBSCRIPTION CREDITS ", percent: 25 }], + }), null, t); + expect(rows[0]?.customLabel).toBe("Total subscription credits"); + expect(isCustomQuotaWindowIncomplete( + rows[0]?.customLabel, + new Set(["API credits", " TOTAL SUBSCRIPTION CREDITS "]), + )).toBe(true); + expect(isCustomQuotaWindowIncomplete( + " total subscription credits ", + new Set(["Total subscription credits"]), + )).toBe(true); + }); + + test("absent and unrelated coverage does not mark a subscription row incomplete", () => { + expect(isCustomQuotaWindowIncomplete(undefined, new Set(["Total subscription credits"]))).toBe(false); + expect(isCustomQuotaWindowIncomplete("Total subscription credits")).toBe(false); + expect(isCustomQuotaWindowIncomplete("Total subscription credits", new Set())).toBe(false); + expect(isCustomQuotaWindowIncomplete("Total subscription credits", new Set(["API credits"]))).toBe(false); + }); + + test("unknown window coverage preserves exact raw-label matching", () => { + expect(isCustomQuotaWindowIncomplete(" Gem ", new Set([" Gem "]))).toBe(true); + expect(isCustomQuotaWindowIncomplete(" Gem ", new Set(["Gem"]))).toBe(false); + expect(isCustomQuotaWindowIncomplete("Gem", new Set(["gem"]))).toBe(false); }); }); From 24c761a052957883f9279c291b16194f91affcc3 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 7 Sep 2026 01:49:07 +0900 Subject: [PATCH 06/99] docs(credits): restore missing stacked-PR contributor attribution (#3787) [skip ci] Record verified carried-work credit omitted or unlinked at the final landing. Restore forward co-author credit for the carried contributors already documented in CREDITS.md as well as this follow-up. Preserve all historical commits and tags. Owner explicitly requested this documentation-only correction be merged into dev without running or waiting for CI. No tests, typecheck or build were run. Co-authored-by: agentHits <140916359+agentHits@users.noreply.github.com> Co-authored-by: benedictusrey <74437942+benedictusrey@users.noreply.github.com> Co-authored-by: bet4it <16643669+bet4it@users.noreply.github.com> Co-authored-by: Blushyes <75828899+Blushyes@users.noreply.github.com> Co-authored-by: chilung-cgu <215337896+chilung-cgu@users.noreply.github.com> Co-authored-by: darwintree <17946284+darwintree@users.noreply.github.com> Co-authored-by: dbc-hbin <282618027+dbc-hbin@users.noreply.github.com> Co-authored-by: drakonkat <17176640+drakonkat@users.noreply.github.com> Co-authored-by: Eleven-is-cool <54345049+Eleven-is-cool@users.noreply.github.com> Co-authored-by: Flowershangfromthebranches <152056395+Flowershangfromthebranches@users.noreply.github.com> Co-authored-by: gaoran1209 <15261543+gaoran1209@users.noreply.github.com> Co-authored-by: gulup <3413433+gulup@users.noreply.github.com> Co-authored-by: harryzhou2000 <67385896+harryzhou2000@users.noreply.github.com> Co-authored-by: Ingwannu <186453546+Ingwannu@users.noreply.github.com> Co-authored-by: jonathanli12 <47408717+jonathanli12@users.noreply.github.com> Co-authored-by: justin-mc-lai <13780920+justin-mc-lai@users.noreply.github.com> Co-authored-by: lilinxiong <24367027+lilinxiong@users.noreply.github.com> Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> Co-authored-by: luvyoun0224-beep <255666866+luvyoun0224-beep@users.noreply.github.com> Co-authored-by: MarcTCruz <58499846+MarcTCruz@users.noreply.github.com> Co-authored-by: mdwsk88 <11055210+mdwsk88@users.noreply.github.com> Co-authored-by: ntdatt812 <188129613+ntdatt812@users.noreply.github.com> Co-authored-by: olddonkey <22208754+olddonkey@users.noreply.github.com> Co-authored-by: rrmlima <137737127+rrmlima@users.noreply.github.com> Co-authored-by: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> Co-authored-by: Skyline-23 <62983047+Skyline-23@users.noreply.github.com> Co-authored-by: TooSpace <287153855+TooSpace@users.noreply.github.com> Co-authored-by: turin-dev <201305497+turin-dev@users.noreply.github.com> Co-authored-by: Veritas-7 <234569343+Veritas-7@users.noreply.github.com> Co-authored-by: waw4303 <59402644+waw4303@users.noreply.github.com> Co-authored-by: Yuxin-Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Co-authored-by: yxr1995-maker <257504378+yxr1995-maker@users.noreply.github.com> Co-authored-by: yzxcj797 <54314860+yzxcj797@users.noreply.github.com> --- CREDITS.md | 97 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 95 insertions(+), 2 deletions(-) diff --git a/CREDITS.md b/CREDITS.md index 14bd5c84d5..5d679a755d 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -23,8 +23,8 @@ invalidating every tag and clone — direction: authorship credit in git history is not rewritten. This file is the forward repair. -Every entry cites the maintainer's own words from the closing comment or the -landing commit. Nothing here is inferred from a diff. +Every entry cites the maintainer's own words from the closing comment, pull-request +description, or landing commit. Nothing here is inferred from a diff. This file is **not** a contributor list. Most contributions merged normally, with authorship intact, and need no entry. Absence from this page means the @@ -57,6 +57,71 @@ Code, design, or tests from these pull requests shipped. | [#3300](https://github.com/lidge-jun/opencodex/pull/3300) | [@S0RYUASUKA](https://github.com/S0RYUASUKA) | `15b43e51c` | the same two test files made hermetic | | [#3284](https://github.com/lidge-jun/opencodex/pull/3284) | [@mdwsk88](https://github.com/mdwsk88) | `3d3c4fe26` | "Core implementation is already on `dev` via #3286 (`3d3c4fe26`), including the suffix wire ladder, picker collapse, Google adapter coverage" | +### 2026-09-07 follow-up: missing or malformed trailers + +These additional landings are present in the audited 3,000-commit window. +The linked landing descriptions or commit messages identify what was taken. + +| Pull request | Author | Landed as | What landed | +| --- | --- | --- | --- | +| [#1748](https://github.com/lidge-jun/opencodex/pull/1748) | [@Blushyes](https://github.com/Blushyes) | [`e3bbf5321`](https://github.com/lidge-jun/opencodex/commit/e3bbf5321c6c0483e9662466e044545bb0e086ba) | [Scoped reimplementation of the outbound Fake-IP discovery fix; the source PR author is @Blushyes, correcting the name in the landing message.](https://github.com/lidge-jun/opencodex/commit/e3bbf5321c6c0483e9662466e044545bb0e086ba) | +| [#1842](https://github.com/lidge-jun/opencodex/pull/1842) | [@luvs01](https://github.com/luvs01) | [`e1e431332`](https://github.com/lidge-jun/opencodex/commit/e1e43133281cba5f952dfa3226a4d55d505365b5) | [Public OAuth error projection and preservation of typed authentication failures.](https://github.com/lidge-jun/opencodex/pull/2043) | +| [#1889](https://github.com/lidge-jun/opencodex/pull/1889) | [@dbc-hbin](https://github.com/dbc-hbin) | [`ea16f8613`](https://github.com/lidge-jun/opencodex/commit/ea16f86130291042486ba3c10640e73b63772d27) | [The remaining x-goog-api-client removal and the contributor's header assertions.](https://github.com/lidge-jun/opencodex/pull/2018) | +| [#1896](https://github.com/lidge-jun/opencodex/pull/1896) | [@luvyoun0224-beep](https://github.com/luvyoun0224-beep) | [`5f2b93979`](https://github.com/lidge-jun/opencodex/commit/5f2b93979e4eae78e1a8c66d1f4a324c4f394084) | [The functions-namespace parser flattening; the hardcoded-name guidance was not taken.](https://github.com/lidge-jun/opencodex/pull/2020) | +| [#1899](https://github.com/lidge-jun/opencodex/pull/1899) | [@ntdatt812](https://github.com/ntdatt812) | [`fb5ceee35`](https://github.com/lidge-jun/opencodex/commit/fb5ceee35f18925a118615b3eb69dc0093f27730) | [The catalog-writer temp-path binding, extended with ordered write/harden/publish assertions.](https://github.com/lidge-jun/opencodex/pull/1923) | +| [#1920](https://github.com/lidge-jun/opencodex/pull/1920) | [@Yuxin-Qiao](https://github.com/Yuxin-Qiao) | [`34b167367`](https://github.com/lidge-jun/opencodex/commit/34b167367c09a3a2445261b845ed10a3c0664693) | [Scoped native and replay Computer Use tool-result normalization.](https://github.com/lidge-jun/opencodex/pull/2038) | +| [#1932](https://github.com/lidge-jun/opencodex/pull/1932) | [@harryzhou2000](https://github.com/harryzhou2000) | [`f2b507f83`](https://github.com/lidge-jun/opencodex/commit/f2b507f831e8065d4fac2fd9fe44bf8106b3fa0e) | [The transient bare-401 concept and test scaffolding; the literal trailer "PR #1932" identifies no account.](https://github.com/lidge-jun/opencodex/pull/2021) | +| [#2027](https://github.com/lidge-jun/opencodex/pull/2027) | [@yzxcj797](https://github.com/yzxcj797) | [`5445ce3e6`](https://github.com/lidge-jun/opencodex/commit/5445ce3e626849855521e5ff54571a1c3babc7e2), [`293494cfa`](https://github.com/lidge-jun/opencodex/commit/293494cfa0c89542320a14bb3af6e4a3f31935a2) | ["Absorbed from #2027" — OpenCode Go identity follows its destination.](https://github.com/lidge-jun/opencodex/commit/5445ce3e626849855521e5ff54571a1c3babc7e2) | +| [#2040](https://github.com/lidge-jun/opencodex/pull/2040) | [@Ingwannu](https://github.com/Ingwannu) | [`16345ab8b`](https://github.com/lidge-jun/opencodex/commit/16345ab8b0a9e5d6fdc8a237786339b674fc374c) | ["implementation and tests, with two corrections".](https://github.com/lidge-jun/opencodex/commit/16345ab8b0a9e5d6fdc8a237786339b674fc374c) | +| [#2053](https://github.com/lidge-jun/opencodex/pull/2053) | [@Ingwannu](https://github.com/Ingwannu) | [`f4ad13922`](https://github.com/lidge-jun/opencodex/commit/f4ad1392271370e1c7c08b0b11ddea346d065138) | ["Carries @Ingwannu's #2053 unchanged."](https://github.com/lidge-jun/opencodex/commit/f4ad1392271370e1c7c08b0b11ddea346d065138) | +| [#2056](https://github.com/lidge-jun/opencodex/pull/2056) | [@Ingwannu](https://github.com/Ingwannu) | [`9b0c5a02d`](https://github.com/lidge-jun/opencodex/commit/9b0c5a02d95220161fc18c72aba5756fdcbf68e3) | [The shortPercent known-quota and scoring changes.](https://github.com/lidge-jun/opencodex/commit/9b0c5a02d95220161fc18c72aba5756fdcbf68e3) | +| [#2075](https://github.com/lidge-jun/opencodex/pull/2075) | [@olddonkey](https://github.com/olddonkey) | [`647b98eb8`](https://github.com/lidge-jun/opencodex/commit/647b98eb8ae4d4ca3c16e1d515dc17a97e5993e4) | [The native Chat fast-capability gate, reconciled with the exact-ID contract.](https://github.com/lidge-jun/opencodex/commit/647b98eb8ae4d4ca3c16e1d515dc17a97e5993e4) | +| [#2077](https://github.com/lidge-jun/opencodex/pull/2077) | [@ntdatt812](https://github.com/ntdatt812) | [`f9c224b70`](https://github.com/lidge-jun/opencodex/commit/f9c224b70abcb4237d166d93fd10677791719b51) | ["Both patches are @ntdatt812's work from #2100 and #2077, applied unchanged."](https://github.com/lidge-jun/opencodex/commit/f9c224b70abcb4237d166d93fd10677791719b51) | +| [#2082](https://github.com/lidge-jun/opencodex/pull/2082) | [@yzxcj797](https://github.com/yzxcj797) | [`06cdbc109`](https://github.com/lidge-jun/opencodex/commit/06cdbc109a631203618a2d1bcf2f395f121af8fa), [`057e8575c`](https://github.com/lidge-jun/opencodex/commit/057e8575c3138f4e90d31e23feccbad60511a2fe) | [AgentRouter opening-turn framing, with exact hostname and separate-block corrections.](https://github.com/lidge-jun/opencodex/commit/06cdbc109a631203618a2d1bcf2f395f121af8fa) | +| [#2099](https://github.com/lidge-jun/opencodex/pull/2099) | [@yzxcj797](https://github.com/yzxcj797) | [`81492fd10`](https://github.com/lidge-jun/opencodex/commit/81492fd10f428d7d638550de4ba2ebcdf13d7715) | [The repro-shaped prompt-cache-retention test fixture; the runtime contract came from #2102.](https://github.com/lidge-jun/opencodex/pull/2138) | +| [#2100](https://github.com/lidge-jun/opencodex/pull/2100) | [@ntdatt812](https://github.com/ntdatt812) | [`f9c224b70`](https://github.com/lidge-jun/opencodex/commit/f9c224b70abcb4237d166d93fd10677791719b51) | ["Both patches are @ntdatt812's work from #2100 and #2077, applied unchanged."](https://github.com/lidge-jun/opencodex/commit/f9c224b70abcb4237d166d93fd10677791719b51) | +| [#2101](https://github.com/lidge-jun/opencodex/pull/2101) | [@Ingwannu](https://github.com/Ingwannu) | [`0bce9516d`](https://github.com/lidge-jun/opencodex/commit/0bce9516d8d987d5b209d1c92cce24d278941b55) | [The entitlement module and full test suite, with maintainer corrections.](https://github.com/lidge-jun/opencodex/pull/2146) | +| [#2102](https://github.com/lidge-jun/opencodex/pull/2102) | [@lilinxiong](https://github.com/lilinxiong) | [`5904178c3`](https://github.com/lidge-jun/opencodex/commit/5904178c349c555704b5f461ef38a47a47324074) | [The prompt-cache-retention implementation, narrowed to the exact native model family.](https://github.com/lidge-jun/opencodex/pull/2138) | +| [#2104](https://github.com/lidge-jun/opencodex/pull/2104) | [@olddonkey](https://github.com/olddonkey) | [`7cd270dc2`](https://github.com/lidge-jun/opencodex/commit/7cd270dc2cd4f5a4cb07b2f23b4eb0dfe869b0da) | ["Carries @olddonkey's #2104 unchanged."](https://github.com/lidge-jun/opencodex/commit/7cd270dc2cd4f5a4cb07b2f23b4eb0dfe869b0da) | +| [#2105](https://github.com/lidge-jun/opencodex/pull/2105) | [@lilinxiong](https://github.com/lilinxiong) | [`a0635eaa2`](https://github.com/lidge-jun/opencodex/commit/a0635eaa2ec16344e25f77f41916637f9cddc4c3) | ["Carries @lilinxiong's #2105 implementation and tests."](https://github.com/lidge-jun/opencodex/commit/a0635eaa2ec16344e25f77f41916637f9cddc4c3) | +| [#2109](https://github.com/lidge-jun/opencodex/pull/2109) | [@drakonkat](https://github.com/drakonkat) | [`d2493a147`](https://github.com/lidge-jun/opencodex/commit/d2493a147d5286f54be34735a13f5d13f8f19597) | [The shared base-URL override implementation from #2109 and #2110.](https://github.com/lidge-jun/opencodex/pull/2148) | +| [#2110](https://github.com/lidge-jun/opencodex/pull/2110) | [@drakonkat](https://github.com/drakonkat) | [`d2493a147`](https://github.com/lidge-jun/opencodex/commit/d2493a147d5286f54be34735a13f5d13f8f19597) | [The shared base-URL override implementation from #2109 and #2110.](https://github.com/lidge-jun/opencodex/pull/2148) | +| [#2122](https://github.com/lidge-jun/opencodex/pull/2122) | [@chilung-cgu](https://github.com/chilung-cgu) | [`6a6efa928`](https://github.com/lidge-jun/opencodex/commit/6a6efa928a165726c0fd17d893d11c21176dc812) | [Configuration union, schema and migration design for retainModels; #2860 supplied the retention predicate.](https://github.com/lidge-jun/opencodex/pull/3206) | +| [#2127](https://github.com/lidge-jun/opencodex/pull/2127) | [@agentHits](https://github.com/agentHits) | [`1adcfde0c`](https://github.com/lidge-jun/opencodex/commit/1adcfde0cfff10589eefdaa33aae077ffe404bcc) | ["Carries @agentHits's #2127 unchanged."](https://github.com/lidge-jun/opencodex/commit/1adcfde0cfff10589eefdaa33aae077ffe404bcc) | +| [#2131](https://github.com/lidge-jun/opencodex/pull/2131) | [@bet4it](https://github.com/bet4it) | [`8ff77e11e`](https://github.com/lidge-jun/opencodex/commit/8ff77e11ebe7bc6472164d29c89c779986b9469a) | ["Carries @bet4it's #2131 implementation and tests."](https://github.com/lidge-jun/opencodex/commit/8ff77e11ebe7bc6472164d29c89c779986b9469a) | +| [#2155](https://github.com/lidge-jun/opencodex/pull/2155) | [@waw4303](https://github.com/waw4303) | [`64ba54edb`](https://github.com/lidge-jun/opencodex/commit/64ba54edbe5ac90b1127f51d5890e33da183c3f0), [`772d375fe`](https://github.com/lidge-jun/opencodex/commit/772d375fed252f838c41b9315cf88046f64d192a) | [Resolved tool-call padding handling, with per-field provenance and diagnostic corrections.](https://github.com/lidge-jun/opencodex/commit/64ba54edbe5ac90b1127f51d5890e33da183c3f0) | +| [#2227](https://github.com/lidge-jun/opencodex/pull/2227) | [@olddonkey](https://github.com/olddonkey) | [`63d387cae`](https://github.com/lidge-jun/opencodex/commit/63d387cae369e3e72d4d0a7702db9d98e607d785) | [The Grok OAuth registry flip, structure documentation and test conversions.](https://github.com/lidge-jun/opencodex/pull/2255) | +| [#2432](https://github.com/lidge-jun/opencodex/pull/2432) | [@mdwsk88](https://github.com/mdwsk88) | [`850afb2e9`](https://github.com/lidge-jun/opencodex/commit/850afb2e9f84979c87e914b248de482f44b34cd6) | [The omit-sentinel documentation and provider wire comments.](https://github.com/lidge-jun/opencodex/pull/3603) | +| [#2639](https://github.com/lidge-jun/opencodex/pull/2639) | [@bet4it](https://github.com/bet4it) | [`fefeb0501`](https://github.com/lidge-jun/opencodex/commit/fefeb05016d21dc9a3b8afe1b52427e4e1d8a0ed) | [The Responses item status backfill; queued/in_progress mapping was corrected separately.](https://github.com/lidge-jun/opencodex/pull/2721) | +| [#2647](https://github.com/lidge-jun/opencodex/pull/2647) | [@darwintree](https://github.com/darwintree) | [`e1e6ec04f`](https://github.com/lidge-jun/opencodex/commit/e1e6ec04f43a287b4cfb5149893d2c6c0a520588) | [Command Code profile metadata, reconciled with the intervening catalog update.](https://github.com/lidge-jun/opencodex/pull/2721) | +| [#2663](https://github.com/lidge-jun/opencodex/pull/2663) | [@Eleven-is-cool](https://github.com/Eleven-is-cool) | [`cb9bb9b76`](https://github.com/lidge-jun/opencodex/commit/cb9bb9b7634640f18568207322d386a059f6c9ac) | [Bare code-mode helper calls through exec: "the implementation is yours, unchanged".](https://github.com/lidge-jun/opencodex/pull/2724) | +| [#2938](https://github.com/lidge-jun/opencodex/pull/2938) | [@luvs01](https://github.com/luvs01) | [`8427efe6e`](https://github.com/lidge-jun/opencodex/commit/8427efe6e80a5ce9488eab7b80b2b1663ab20579) | [The failed-wrapper diagnosis and linear-scan design, with six behavioral divergences corrected.](https://github.com/lidge-jun/opencodex/pull/2945) | +| [#3069](https://github.com/lidge-jun/opencodex/pull/3069) | [@justin-mc-lai](https://github.com/justin-mc-lai) | [`a0d386b49`](https://github.com/lidge-jun/opencodex/commit/a0d386b49074ec81df5646fcc20a5e4979c67878) | [The two query/queries parity commits, followed by malformed-history boundary fixes.](https://github.com/lidge-jun/opencodex/pull/3089) | +| [#3329](https://github.com/lidge-jun/opencodex/pull/3329) | [@Veritas-7](https://github.com/Veritas-7) | [`3ac310782`](https://github.com/lidge-jun/opencodex/commit/3ac31078244ea04c9abce0e50275ffaccf25455a) | [Combo cooldown/wait design with corrected reset metadata, clocks and Retry-After handling.](https://github.com/lidge-jun/opencodex/pull/3606) | +| [#3407](https://github.com/lidge-jun/opencodex/pull/3407) | [@turin-dev](https://github.com/turin-dev) | [`3b3fe21d4`](https://github.com/lidge-jun/opencodex/commit/3b3fe21d45e57761e9769020da4b37de5cd95726) | [The desired-state Codex switch, observed-state badge and effective-home path.](https://github.com/lidge-jun/opencodex/pull/3617) | +| [#3421](https://github.com/lidge-jun/opencodex/pull/3421) | [@Skyline-23](https://github.com/Skyline-23) | [`89c0a64fe`](https://github.com/lidge-jun/opencodex/commit/89c0a64fe2c59af1814230b0c85d61cd08672bd5) | [The Compose/container foundation, with loopback binding and generated compatibility identity.](https://github.com/lidge-jun/opencodex/pull/3604) | +| [#3469](https://github.com/lidge-jun/opencodex/pull/3469) | [@agentHits](https://github.com/agentHits) | [`c44e187ee`](https://github.com/lidge-jun/opencodex/commit/c44e187ee901275f977f5a2be32c782f4e1f1794) | [Google location-error classification, carried through #3547 and corrected for error precedence.](https://github.com/lidge-jun/opencodex/pull/3608) | +| [#3487](https://github.com/lidge-jun/opencodex/pull/3487) | [@Ingwannu](https://github.com/Ingwannu) | [`f8ba644f3`](https://github.com/lidge-jun/opencodex/commit/f8ba644f3ad650b14af9cc420d4d42782939bfef) | [The bounded Kiro fallback-execution assertion at the migrated test path.](https://github.com/lidge-jun/opencodex/pull/3602) | +| [#3489](https://github.com/lidge-jun/opencodex/pull/3489) | [@Flowershangfromthebranches](https://github.com/Flowershangfromthebranches) | [`55395a9dc`](https://github.com/lidge-jun/opencodex/commit/55395a9dc8a252a01f606b7b65859579e4f2e53d) | [Canonical-final-URL discovery injection alongside independently proxy-bound IPv6 handling.](https://github.com/lidge-jun/opencodex/pull/3618) | +| [#3528](https://github.com/lidge-jun/opencodex/pull/3528) | [@benedictusrey](https://github.com/benedictusrey) | [`bef04efbc`](https://github.com/lidge-jun/opencodex/commit/bef04efbcf506ac26ebd3eeba8ac397a5d8a8d0d) | [The effort CLI command, exact selectors and distinct live/offline failures.](https://github.com/lidge-jun/opencodex/pull/3612) | +| [#3531](https://github.com/lidge-jun/opencodex/pull/3531) | [@benedictusrey](https://github.com/benedictusrey) | [`45045623b`](https://github.com/lidge-jun/opencodex/commit/45045623bfc9c1ec7f8c55e47493da343b98a968) | [The agy alias, with captured discovery authority retained.](https://github.com/lidge-jun/opencodex/pull/3601) | + +### 2026-09-07 follow-up: unlinked trailers + +These commits contain a contributor name, but GitHub's commit-author mapping +does not resolve that trailer to the source PR author. No personal addresses +are reproduced here. The forward correction uses account-linked noreply identities. + +| Pull request | Author | Landed as | What landed | +| --- | --- | --- | --- | +| [#2817](https://github.com/lidge-jun/opencodex/pull/2817) | [@gulup](https://github.com/gulup) | [`6fe46312c`](https://github.com/lidge-jun/opencodex/commit/6fe46312cd509bbef0e79025181e7ab6fc285681) | [The opt-in upstream Responses WebSocket transport and six carried commits.](https://github.com/lidge-jun/opencodex/pull/3216) | +| [#3148](https://github.com/lidge-jun/opencodex/pull/3148) | [@Veritas-7](https://github.com/Veritas-7) | [`865a36ef0`](https://github.com/lidge-jun/opencodex/commit/865a36ef04eb6395e617f94ed87aaa474a903444) | [The two subscription-launch admission-key fixes, plus connected-launch reconciliation.](https://github.com/lidge-jun/opencodex/pull/3182) | +| [#3293](https://github.com/lidge-jun/opencodex/pull/3293) | [@Veritas-7](https://github.com/Veritas-7) | [`3a9c4d297`](https://github.com/lidge-jun/opencodex/commit/3a9c4d297451bc40abb24cf13d5f50648450fc2e) | [The missing claude-fable-5-1 model metadata and accompanying usage-cost test.](https://github.com/lidge-jun/opencodex/pull/3478) | + +The correction commit records these contributors and the earlier **Carried work** +authors as co-authors. This is forward attribution: the old commit objects, +their original dates and release tags are unchanged. + ## Report and diagnosis These fixes exist because of the report. The branch's own approach was not the @@ -84,6 +149,19 @@ inaccuracy this file exists to correct. - [#2675](https://github.com/lidge-jun/opencodex/pull/2675) by [@Ingwannu](https://github.com/Ingwannu) — closed "Landed via #2677 at `8412fe156`". +Two further source PRs were closed with a landing reference but without an +explicit statement of what was carried. Their authors are acknowledged here; +they are not counted as carried code solely from that closure: + +- [#2360](https://github.com/lidge-jun/opencodex/pull/2360) by + [@chilung-cgu](https://github.com/chilung-cgu) — + [closed as landed via #2371](https://github.com/lidge-jun/opencodex/pull/2360#issuecomment-5379638729) + at `ae05672e3`. +- [#3621](https://github.com/lidge-jun/opencodex/pull/3621) by + [@yansigit](https://github.com/yansigit) — + [closed as landed via #3622](https://github.com/lidge-jun/opencodex/pull/3621#issuecomment-5549370301) + at `1505cb196`. + ## How this stays accurate This page is a repair, not a process. The process is @@ -127,3 +205,18 @@ The lesson generalizes: when carrying work, take the trailer address from the author's GitHub account (the numeric-id `users.noreply.github.com` form is always safe), not from the commit metadata on their branch. A contributor who commits under a work email is the normal case, not an edge case. + +### Verify the landing, not just the proposal + +The 2026-09-07 audit read exactly 3,000 commits reachable from +`7d8523eed75a67f7a4a15b533744fcd0e6059aa8`, ending at +`53130de4e540fbfcf2629079effb851af54e989e`, and followed source-PR descriptions, +closure comments and GitHub commit-author mappings. Normally merged work, +credited cherry-picks, independent fixes and report-only acknowledgements were +kept distinct from the carried-work tables. + +A PR description or an intermediate branch commit can contain the right trailer +and still lose it when a custom squash message replaces that text. Before +calling a carry credited, inspect the **actual landing commit**: its final +`Co-authored-by` trailer must remain present and resolve to the source author's +GitHub account. The existing presence gate alone does not establish either fact. From 7b1ac51eb831c779d23118ee31dc3272f9c1bfae Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:50:09 +0900 Subject: [PATCH 07/99] fix(container): persist Codex home separately [skip ci] Carry #3747 for #3746 with isolated serializer regressions and explicit volume lifecycle documentation. Runtime image recreation remains unverified; final stack CI is pending. Co-authored-by: Ingwannu --- Dockerfile | 6 +- compose.yaml | 5 + .../src/content/docs/fr/guides/remote-hub.md | 21 ++++ .../src/content/docs/guides/remote-hub.md | 49 +++++++- .../src/content/docs/ja/guides/remote-hub.md | 22 ++++ .../src/content/docs/ko/guides/remote-hub.md | 22 +++- .../src/content/docs/ru/guides/remote-hub.md | 24 ++++ .../src/content/docs/tr/guides/remote-hub.md | 24 ++++ .../content/docs/zh-cn/guides/remote-hub.md | 19 +++ structure/02_config-and-codex-home.md | 18 +++ tests/service/container-bootstrap.test.ts | 116 ++++++++++++++++++ 11 files changed, 318 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5f648192d4..1ed000743d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,9 +27,11 @@ WORKDIR /home/bun/app ENV NODE_ENV=production \ OPENCODEX_HOME=/home/bun/.opencodex \ + CODEX_HOME=/home/bun/.codex \ OCX_API_TOKEN_FILE=/home/bun/.opencodex/service-api-token -RUN install -d -m 0700 -o bun -g bun /home/bun/.opencodex +# These homes have incompatible auth.json formats; persist them without combining them. +RUN install -d -m 0700 -o bun -g bun /home/bun/.opencodex /home/bun/.codex COPY --chown=bun:bun --chmod=0600 docker/config.json /home/bun/.opencodex/config.json COPY --from=build --chown=bun:bun /home/bun/app/package.json ./package.json @@ -46,7 +48,7 @@ COPY --from=build --chown=bun:bun /home/bun/app/gui/dist ./gui/dist USER bun RUN ["bun", "docker/verify-compatibility.ts"] RUN ["bun", "-e", "import { readOpenCodexCompatibilityVersion } from './src/routing/compatibility/version.ts'; if (!/^[0-9a-f]{64}$/.test(readOpenCodexCompatibilityVersion() ?? '')) throw new Error('Missing or invalid generated compatibility manifest');"] -VOLUME ["/home/bun/.opencodex"] +VOLUME ["/home/bun/.opencodex", "/home/bun/.codex"] EXPOSE 10100 HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ diff --git a/compose.yaml b/compose.yaml index 8e25cf4cd0..b54795692a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -9,10 +9,14 @@ services: target: runtime init: true read_only: true + environment: + # A custom CODEX_HOME also requires a matching writable volume target below. + CODEX_HOME: /home/bun/.codex ports: - "${OPENCODEX_BIND_ADDRESS:-127.0.0.1}:${OPENCODEX_PORT:-10100}:10100" volumes: - ocx-state:/home/bun/.opencodex + - codex-state:/home/bun/.codex tmpfs: - /tmp:size=64m,mode=1777 security_opt: @@ -24,3 +28,4 @@ services: volumes: ocx-state: + codex-state: diff --git a/docs-site/src/content/docs/fr/guides/remote-hub.md b/docs-site/src/content/docs/fr/guides/remote-hub.md index 8ab577619f..15d5392c18 100644 --- a/docs-site/src/content/docs/fr/guides/remote-hub.md +++ b/docs-site/src/content/docs/fr/guides/remote-hub.md @@ -60,6 +60,27 @@ La rotation garde les deux clés valides sous le même `apiKeyId` pendant dix mi ## Docker, retour arrière et dépannage +Lors d'un retour arrière, conservez les deux volumes et leurs points de montage. Les droits des volumes existants ne sont pas corrigés automatiquement. Consultez le [guide canonique](/guides/remote-hub/#docker-compose) pour les montages nommés hors Compose et les chemins d'état personnalisés. + +Deux volumes distincts conservent l'état : `ocx-state` pour +`OPENCODEX_HOME=/home/bun/.opencodex` et `codex-state` pour +`CODEX_HOME=/home/bun/.codex`. Leurs fichiers `auth.json` ont des formats incompatibles : +ne fusionnez pas ces répertoires. Ils restent accessibles en écriture malgré la racine en lecture seule. + +Le catalogue n'est pas généré automatiquement. Avant de tester `/v1/catalog` avec authentification, +créez ou importez un fichier valide dans `/home/bun/.codex/opencodex-catalog.json`. +Un répertoire vide renvoie normalement 404 `catalog_not_found`. Une mise à jour conserve +`ocx-state` et ajoute `codex-state`, sans déplacer les fichiers. Sauvegardez tout catalogue +précédemment placé dans `.opencodex`, puis transférez seulement ce catalogue avec des permissions +réservées au propriétaire ; ne remplacez pas un `auth.json` par celui de l'autre produit. +Si vous redéfinissez `CODEX_HOME`, montez ce répertoire exact en écriture et placez le catalogue +par défaut dans `${CODEX_HOME}/opencodex-catalog.json`. Si `model_catalog_json` désigne un autre +fichier, son chemin résolu doit aussi être persistant. Conservez les variables et montages +personnalisés jusqu'à la fin d'une migration explicite. +`docker compose down` conserve les deux volumes ; `docker compose down --volumes` supprime +`ocx-state` et `codex-state`, avec les identifiants, l'historique d'utilisation, la clé de données, +l'état et le catalogue Codex. Ce n'est pas une commande de mise à jour ou de redémarrage. + Il n’existe pas d’image Docker officielle, mais le dépôt fournit un `Dockerfile` et un `compose.yaml` maintenus pour construire localement une image Bun épinglée par digest. Initialisez une seule fois la clé de données via stdin ; elle est enregistrée avec des permissions réservées au propriétaire dans le volume `ocx-state` et n’est jamais affichée. Installez Git et Bun sur l’hôte. Avant chaque construction, générez le manifeste canonique depuis les sources suivies par Git, sans modifier les sources entre la génération et la construction. Le JSON généré reste non suivi ; `.git` est exclu du contexte Docker. Le port hôte est lié à `127.0.0.1` par défaut. Pour un accès distant, utilisez explicitement `OPENCODEX_BIND_ADDRESS= docker compose up -d` ; `0.0.0.0` expose toutes les interfaces. Protégez cet accès par un pare-feu et un frontal TLS/tailnet authentifié. diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 800251ad4c..2334303be7 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -174,6 +174,45 @@ Before the first normal start, stream a freshly generated data-plane token into The helper accepts at most one 4096-byte line, never prints the token, refuses to replace an existing token, and persists it as the canonical owner-only `service-api-token` in the `ocx-state` volume. +The deployment persists two separate homes: `ocx-state` at `/home/bun/.opencodex` for +OpenCodex configuration, provider credentials and usage, and `codex-state` at +`/home/bun/.codex` for Codex state and `opencodex-catalog.json`. The image and Compose +explicitly set `CODEX_HOME=/home/bun/.codex`, so this catalog path remains writable +with `read_only: true` and survives container recreation. The image creates both +directories for the non-root `bun` user with mode `0700`; existing volume +ownership and permissions are not migrated automatically. + +Do not combine `CODEX_HOME` and `OPENCODEX_HOME`: both products use an `auth.json` +filename with different formats. This packaging change adds persistence, not a +catalog generator. Materialize or import a valid catalog into +`/home/bun/.codex/opencodex-catalog.json` before the catalog acceptance check below; +without one, `catalog_not_found` remains the expected response. + +Upgrading preserves the existing `ocx-state` volume and adds `codex-state`; no files +are migrated automatically. If a previous workaround placed a catalog directly +under `/home/bun/.opencodex`, back it up and deliberately copy only the catalog to +the new Codex home, preserving owner-only access. Do not copy either product's +`auth.json` over the other. Deployments with a custom `CODEX_HOME` should retain +their explicit environment and writable volume mapping until migration is complete. +When overriding `CODEX_HOME`, mount that exact directory writable and persist the +default catalog at `${CODEX_HOME}/opencodex-catalog.json`. If `model_catalog_json` +explicitly selects another file, that resolved path must also be persisted. + +Keep the Compose project name stable during upgrades so the same named volumes are reused. +Mounts with existing foreign ownership, read-only mounts, and mounts using `volume-nocopy` +are not repaired by the image's directory setup. Persist separately selected catalog or SQLite +paths separately; an OS credential store is not backed up by these two volumes. + +When running without Compose, explicitly supply both named mounts. Dockerfile `VOLUME` +declarations alone create anonymous volumes that a later `docker run` does not automatically +reuse. These mount options use standalone example names; to reuse Compose data, substitute +its actual project-prefixed volume names: + +```sh +--mount type=volume,src=ocx-state,dst=/home/bun/.opencodex \ +--mount type=volume,src=codex-state,dst=/home/bun/.codex +``` + Install Git and Bun on the host first. Before **every** image build, run the existing canonical generator from this Git checkout. It hashes Git-tracked working-tree sources (stage any newly added source files first), not an arbitrary directory scan. Do not change source files between @@ -227,7 +266,7 @@ docker compose restart hub ``` Do not put a token in `ARG`, `ENV`, `COPY`, Compose YAML, image history, or command arguments. Do not -mount the Docker socket, host home, Codex home, SSH agent, or provider-key files. A management +mount the Docker socket, the host's home or Codex home, SSH agent, or provider-key files. A management ingress bound to `127.0.0.1:10101` inside the container is reachable only by a TLS/tailnet frontend in the same network namespace; never publish `10101` as a shortcut. @@ -244,9 +283,9 @@ docker compose exec hub bun -e \ Then send one real authenticated routed response with a configured model. If the secret is absent or unreadable, a non-loopback hub must not be accepted as ready. Never treat liveness alone as proof. -`docker compose down` removes the container and network but retains the named volume. Treat +`docker compose down` removes the container and network but retains both named volumes. Treat `docker compose down --volumes` as destructive: it deletes configuration, OAuth credentials, usage -history, and the data-plane token together. +history, the data-plane token, and persisted Codex state together. ## Rollback @@ -260,7 +299,9 @@ ocx config set hub.managementIngress '{"enabled":false}' ocx service repair ``` -For a container rollback, remove or replace the container while retaining the named state volume. +For a container rollback, retain both named state volumes and their mappings. An older image +can still use `CODEX_HOME=/home/bun/.codex` when that directory remains mounted; do not revert +to an older Compose file that drops the Codex mount. Do not merge the homes or rerun token bootstrap. For a service rollback, stop the branch service and repair the prior release against the same `OPENCODEX_HOME`. Disabling management ingress or Serve does not require changing the data listener. diff --git a/docs-site/src/content/docs/ja/guides/remote-hub.md b/docs-site/src/content/docs/ja/guides/remote-hub.md index 022de50b12..cffc233143 100644 --- a/docs-site/src/content/docs/ja/guides/remote-hub.md +++ b/docs-site/src/content/docs/ja/guides/remote-hub.md @@ -60,6 +60,28 @@ OAuth は `POST /api/oauth/login` で開始し、コールバックできない ## Docker とトラブルシューティング +ロールバック時も両方のボリュームとマウント先を維持してください。既存ボリュームの所有者や権限は自動修復されません。Compose を使わない場合の名前付きマウントと独自の状態パスについては、[正本ガイド](/guides/remote-hub/#docker-compose)を参照してください。 + +状態は二つのボリュームに分けて永続化します。`ocx-state` は +`OPENCODEX_HOME=/home/bun/.opencodex`、`codex-state` は +`CODEX_HOME=/home/bun/.codex` に対応します。両製品の `auth.json` は形式が +異なるため、ホームを同じディレクトリにしないでください。読み取り専用の +ルートでも、この二つのホームは書き込み可能です。 + +カタログは自動生成されません。認証付き `/v1/catalog` の確認前に、有効な +`/home/bun/.codex/opencodex-catalog.json` を生成または取り込んでください。 +空のホームでは `catalog_not_found` の 404 が正常です。アップグレードは既存の +`ocx-state` を保持して `codex-state` を追加しますが、ファイルは自動移行しません。 +以前 `.opencodex` に置いたカタログはバックアップし、カタログだけを所有者限定の +権限で移してください。`auth.json` を相互に上書きしないでください。 +`CODEX_HOME` を変更する場合は、そのディレクトリ自体を書き込み可能なボリュームに +マウントし、既定のカタログを `${CODEX_HOME}/opencodex-catalog.json` に置きます。 +`model_catalog_json` で別のファイルを指定した場合は、その解決先も永続化します。 +カスタム構成は、明示的な移行が完了するまで環境変数とボリュームの対応を維持します。 +`docker compose down` は両ボリュームを保持しますが、`docker compose down --volumes` +は `ocx-state` と `codex-state` の両方を削除し、認証情報・使用履歴・データキー・ +Codex の状態とカタログも失われます。更新や再起動の代わりに使わないでください。 + 公式 Docker イメージはありませんが、リポジトリには digest 固定の Bun イメージをローカルビルドするための、管理された `Dockerfile` と `compose.yaml` があります。初回起動前にデータキーを stdin から一度だけ初期化します。キーは表示されず、`ocx-state` ボリューム内に所有者限定の権限で保存されます。 ホストに Git と Bun が必要です。イメージをビルドするたびに、Git 管理下のソースから正規のマニフェストを生成し、生成後はビルドまでソースを変更しないでください。生成 JSON は Git に追加せず、`.git` は Docker コンテキストから除外します。ホスト側は既定で `127.0.0.1` にバインドします。リモート公開は `OPENCODEX_BIND_ADDRESS= docker compose up -d` で明示的に指定し、`0.0.0.0` は全インターフェースを公開します。ファイアウォールと認証付き TLS/tailnet フロントエンドで保護してください。 diff --git a/docs-site/src/content/docs/ko/guides/remote-hub.md b/docs-site/src/content/docs/ko/guides/remote-hub.md index ffbb20f9c3..e924175672 100644 --- a/docs-site/src/content/docs/ko/guides/remote-hub.md +++ b/docs-site/src/content/docs/ko/guides/remote-hub.md @@ -86,6 +86,24 @@ ocx connect rotate --admin-token-stdin ## Docker +롤백할 때도 두 볼륨과 마운트 경로를 유지하세요. 기존 볼륨의 소유권과 권한은 자동으로 복구되지 않습니다. Compose 없이 실행할 때의 named volume 지정과 별도 상태 경로는 [영문 기준 가이드](/guides/remote-hub/#docker-compose)를 참고하세요. + +상태는 두 볼륨에 분리해 보관합니다. `ocx-state`는 +`OPENCODEX_HOME=/home/bun/.opencodex`, `codex-state`는 +`CODEX_HOME=/home/bun/.codex`에 연결됩니다. 두 제품의 `auth.json` 형식이 다르므로 +홈을 같은 폴더로 합치지 마세요. 루트 파일 시스템이 read-only여도 이 두 홈은 쓰기 가능합니다. + +카탈로그는 자동 생성되지 않습니다. 인증된 `/v1/catalog` 검사 전에 유효한 +`/home/bun/.codex/opencodex-catalog.json`을 생성하거나 가져와야 합니다. +빈 홈에서 `catalog_not_found` 404는 정상입니다. 업그레이드는 기존 `ocx-state`를 +유지하고 `codex-state`를 추가하지만 파일을 자동 이동하지 않습니다. 이전 우회 설정으로 +`.opencodex`에 둔 카탈로그는 백업한 뒤 카탈로그만 owner-only 권한으로 옮기세요. +두 제품의 `auth.json`을 서로 덮어쓰면 안 됩니다. 사용자 지정 `CODEX_HOME`은 그 정확한 +디렉터리를 쓰기 가능한 볼륨에 연결하고, 기본 카탈로그를 +`${CODEX_HOME}/opencodex-catalog.json`에 준비해야 합니다. `model_catalog_json`으로 +별도 파일을 지정했다면 그 경로도 영속 보관하세요. 명시적 이전이 완료되기 전까지는 +기존 사용자 지정 환경 변수와 볼륨 경로의 대응을 유지하세요. + opencodex는 공식 컨테이너 이미지를 배포하지 않지만, 저장소 루트의 `Dockerfile`과 `compose.yaml`로 digest가 고정된 소스 이미지를 직접 빌드할 수 있습니다. 최초 실행 전에 데이터 키를 stdin으로 초기화하세요. 키는 출력되지 않으며 `ocx-state` 볼륨의 owner-only `service-api-token`에 저장됩니다. 호스트에 Git과 Bun이 필요합니다. 이미지를 빌드할 때마다 Git이 추적하는 소스로 정식 매니페스트를 생성하고, 생성부터 빌드 사이에는 소스를 변경하지 마세요. 생성된 JSON은 Git에 추가하지 않으며 `.git`은 Docker 컨텍스트에서 제외됩니다. 호스트 포트는 기본적으로 `127.0.0.1`에 바인딩됩니다. 원격 공개는 `OPENCODEX_BIND_ADDRESS= docker compose up -d`로 명시적으로 선택하며, `0.0.0.0`은 모든 인터페이스에 공개합니다. 방화벽과 인증된 TLS/tailnet 프런트엔드로 보호하세요. @@ -101,11 +119,11 @@ openssl rand -hex 32 | docker compose run --rm -T hub bun run docker/bootstrap-t docker compose up -d ``` -이미지는 non-root `bun` 사용자로 실행되고 루트 파일 시스템은 read-only이며 공개 포트는 `10100` 하나뿐입니다. 토큰을 `ARG`, `ENV`, `COPY`, Compose YAML, 이미지 기록, 명령행에 넣지 마세요. Docker socket, 호스트 홈, Codex 홈, SSH agent, 프로바이더 키도 마운트하지 마세요. 컨테이너 안의 `127.0.0.1:10101` 관리 포트는 같은 네트워크 네임스페이스의 TLS/tailnet 프런트엔드로만 연결하고 직접 publish하지 마세요. +이미지는 non-root `bun` 사용자로 실행되고 루트 파일 시스템은 read-only이며 공개 포트는 `10100` 하나뿐입니다. 토큰을 `ARG`, `ENV`, `COPY`, Compose YAML, 이미지 기록, 명령행에 넣지 마세요. Docker socket, 호스트의 홈이나 Codex 홈, SSH agent, 프로바이더 키도 마운트하지 마세요. 컨테이너 안의 `127.0.0.1:10101` 관리 포트는 같은 네트워크 네임스페이스의 TLS/tailnet 프런트엔드로만 연결하고 직접 publish하지 마세요. 컨테이너 healthcheck의 `/healthz`가 통과한 뒤 `/readyz`, 인증된 `/v1/catalog`, 실제 모델 응답을 별도로 확인하세요. -`docker compose down`은 `ocx-state` 볼륨을 보존합니다. `docker compose down --volumes`는 설정, OAuth 인증 정보, 사용량 기록, 데이터 키를 함께 삭제하므로 파괴적 작업으로 취급하세요. +`docker compose down`은 `ocx-state`와 `codex-state`를 모두 보존합니다. `docker compose down --volumes`는 두 볼륨을 모두 삭제하여 설정, OAuth 인증 정보, 사용량 기록, 데이터 키, Codex 상태와 카탈로그를 지웁니다. 업그레이드나 재시작 대신 사용하지 마세요. ## 롤백과 문제 해결 diff --git a/docs-site/src/content/docs/ru/guides/remote-hub.md b/docs-site/src/content/docs/ru/guides/remote-hub.md index cc71e8efba..0887baf9a8 100644 --- a/docs-site/src/content/docs/ru/guides/remote-hub.md +++ b/docs-site/src/content/docs/ru/guides/remote-hub.md @@ -60,6 +60,30 @@ OAuth запускается через `POST /api/oauth/login`. Если callba ## Docker и устранение неполадок +При откате сохраняйте оба тома и их точки монтирования. Владельцы и права существующих томов не исправляются автоматически. Именованные тома вне Compose и отдельные пути состояния описаны в [основном руководстве](/guides/remote-hub/#docker-compose). + +Состояние хранится в двух отдельных томах: `ocx-state` для +`OPENCODEX_HOME=/home/bun/.opencodex` и `codex-state` для +`CODEX_HOME=/home/bun/.codex`. Форматы `auth.json` у двух продуктов несовместимы, +поэтому не объединяйте их домашние каталоги. Оба тома доступны для записи при +корневой файловой системе только для чтения. + +Каталог моделей автоматически не создаётся. Перед проверкой авторизованного +`/v1/catalog` создайте или импортируйте корректный файл +`/home/bun/.codex/opencodex-catalog.json`. Для пустого каталога состояния ответ +404 `catalog_not_found` ожидаем. Обновление сохраняет `ocx-state` и добавляет +`codex-state`, но не переносит файлы автоматически. Если обходное решение хранило +каталог моделей в `.opencodex`, сначала сделайте резервную копию, затем перенесите +только каталог моделей с доступом лишь для владельца. Не перезаписывайте один +`auth.json` другим. При переопределении `CODEX_HOME` монтируйте именно эту директорию +для записи и сохраняйте каталог по умолчанию в `${CODEX_HOME}/opencodex-catalog.json`. +Если `model_catalog_json` задаёт другой файл, его разрешённый путь также должен +храниться постоянно. До явного переноса сохраняйте прежнее соответствие переменных +окружения и томов. `docker compose down` сохраняет оба тома, а +`docker compose down --volumes` удаляет и `ocx-state`, и `codex-state`, включая +учётные данные, историю использования, ключ данных, состояние и каталог Codex. +Это разрушительная операция, а не способ обновления или перезапуска. + Официального Docker-образа нет, но репозиторий содержит поддерживаемые `Dockerfile` и `compose.yaml` для локальной сборки Bun-образа, закреплённого по digest. Перед первым запуском один раз передайте ключ данных через stdin; он не выводится и сохраняется с доступом только для владельца в volume `ocx-state`. На хосте нужны Git и Bun. Перед каждой сборкой создавайте канонический манифест из отслеживаемых Git исходников и не меняйте их до завершения сборки. Сгенерированный JSON не добавляйте в Git; `.git` исключён из контекста Docker. По умолчанию порт хоста привязан к `127.0.0.1`. Для удалённого доступа явно задайте `OPENCODEX_BIND_ADDRESS= docker compose up -d`; `0.0.0.0` открывает все интерфейсы. Защитите доступ брандмауэром и аутентифицированным TLS/tailnet-фронтендом. diff --git a/docs-site/src/content/docs/tr/guides/remote-hub.md b/docs-site/src/content/docs/tr/guides/remote-hub.md index 63f8e7e5c0..6499325908 100644 --- a/docs-site/src/content/docs/tr/guides/remote-hub.md +++ b/docs-site/src/content/docs/tr/guides/remote-hub.md @@ -60,6 +60,30 @@ Döndürme sırasında eski ve yeni anahtar aynı `apiKeyId` altında en fazla o ## Docker ve sorun giderme +Geri alırken iki volume'u ve bağlama yollarını koruyun. Mevcut volume sahipliği ve izinleri otomatik düzeltilmez. Compose dışındaki adlandırılmış bağlamalar ve özel durum yolları için [ana kılavuza](/guides/remote-hub/#docker-compose) bakın. + +Durum iki ayrı kalıcı volume'da tutulur: `ocx-state`, +`OPENCODEX_HOME=/home/bun/.opencodex` yoluna; `codex-state` ise +`CODEX_HOME=/home/bun/.codex` yoluna bağlanır. İki ürünün `auth.json` biçimleri +uyumsuzdur; bu dizinleri birleştirmeyin. Kök dosya sistemi salt okunur olsa da +bu iki volume yazılabilir durumda kalır. + +Katalog otomatik oluşturulmaz. Kimlik doğrulamalı `/v1/catalog` kontrolünden önce +`/home/bun/.codex/opencodex-catalog.json` konumunda geçerli bir katalog oluşturun +veya içe aktarın. Boş dizinde 404 `catalog_not_found` beklenen sonuçtur. Güncelleme +mevcut `ocx-state` volume'unu korur ve `codex-state` ekler; dosyaları otomatik taşımaz. +Önceden `.opencodex` içine konmuş kataloğu yedekleyin ve yalnızca katalog dosyasını, +sadece sahibine erişim veren izinlerle taşıyın. Bir ürünün `auth.json` dosyasını +diğerininkiyle değiştirmeyin. `CODEX_HOME` özelleştirilirse bu dizinin tam yolunu +yazılabilir bir volume'a bağlayın ve varsayılan kataloğu +`${CODEX_HOME}/opencodex-catalog.json` konumuna koyun. `model_catalog_json` başka +bir dosya seçiyorsa çözümlenen yol da kalıcı olmalıdır. Açık bir taşıma tamamlanana +kadar mevcut özel ortam ve volume eşlemesini koruyun. +`docker compose down` iki volume'u da korur; `docker compose down --volumes` hem +`ocx-state` hem `codex-state` ile birlikte kimlik bilgilerini, kullanım geçmişini, +veri anahtarını ve Codex durumunu/kataloğunu siler. Güncelleme veya yeniden başlatma +yerine kullanılmamalıdır. + Resmî Docker imajı yoktur; ancak depo, digest ile sabitlenmiş Bun imajını yerelde oluşturmak için bakımı yapılan bir `Dockerfile` ve `compose.yaml` sağlar. İlk başlatmadan önce veri anahtarını stdin üzerinden bir kez başlatın; anahtar yazdırılmaz ve `ocx-state` volume içinde yalnızca sahibinin okuyabileceği izinlerle saklanır. Host üzerinde Git ve Bun gereklidir. Her imaj derlemesinden önce Git tarafından izlenen kaynaklardan kanonik manifesti üretin ve derleme bitene kadar kaynakları değiştirmeyin. Üretilen JSON dosyasını Git'e eklemeyin; `.git` Docker bağlamının dışında kalır. Host portu varsayılan olarak `127.0.0.1` adresine bağlanır. Uzak erişim için açıkça `OPENCODEX_BIND_ADDRESS= docker compose up -d` kullanın; `0.0.0.0` tüm arayüzleri açar. Erişimi güvenlik duvarı ve kimlik doğrulamalı TLS/tailnet ön ucu ile koruyun. diff --git a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md index 81e91d5be1..6caa44fc23 100644 --- a/docs-site/src/content/docs/zh-cn/guides/remote-hub.md +++ b/docs-site/src/content/docs/zh-cn/guides/remote-hub.md @@ -60,6 +60,25 @@ ocx connect rotate --admin-token-stdin ## Docker、回滚与排障 +回滚时也要保留两个卷及其挂载路径。已有卷的所有权和权限不会自动修复。有关不使用 Compose 时的命名卷挂载及单独的状态路径,请参阅[英文基准指南](/guides/remote-hub/#docker-compose)。 + +部署使用两个独立持久卷:`ocx-state` 对应 +`OPENCODEX_HOME=/home/bun/.opencodex`,`codex-state` 对应 +`CODEX_HOME=/home/bun/.codex`。两个产品的 `auth.json` 格式不同,不能合并到同一个 +主目录。即使根文件系统只读,这两个目录也可通过各自的卷写入。 + +此设置不会自动生成模型目录。在检查认证后的 `/v1/catalog` 前,必须生成或导入有效的 +`/home/bun/.codex/opencodex-catalog.json`;空目录返回 `catalog_not_found` 404 属于正常行为。 +升级会保留现有 `ocx-state` 并新增 `codex-state`,但不会自动迁移文件。若之前的临时方案 +将模型目录放在 `.opencodex` 下,请先备份,再仅迁移模型目录文件,并保留仅所有者可访问的权限。 +不要用一个产品的 `auth.json` 覆盖另一个。自定义 `CODEX_HOME` 时,必须将该确切目录挂载为 +可写持久卷,并在 `${CODEX_HOME}/opencodex-catalog.json` 准备默认目录文件。 +若 `model_catalog_json` 指向其他文件,也必须持久保存其解析后的路径。 +在明确完成迁移前,请保留已有的环境变量与卷路径映射。 +`docker compose down` 保留两个卷;`docker compose down --volumes` 则会删除 +`ocx-state` 和 `codex-state`,包括配置、凭据、用量记录、数据密钥及 Codex 状态和模型目录。 +这是破坏性操作,不能当作升级或重启命令使用。 + opencodex 不发布官方 Docker 镜像,但仓库提供维护的 `Dockerfile` 和 `compose.yaml`,用于在本地构建按 digest 固定的 Bun 镜像。首次启动前,通过 stdin 初始化一次数据密钥;密钥不会输出,并以仅所有者可读的权限保存在 `ocx-state` 卷中。 宿主机需要安装 Git 和 Bun。每次构建镜像前,都应从 Git 跟踪的源码生成规范兼容性清单,生成后到构建完成前不要修改源码。生成的 JSON 不加入 Git;`.git` 不进入 Docker 构建上下文。宿主机端口默认绑定 `127.0.0.1`。远程访问须显式使用 `OPENCODEX_BIND_ADDRESS= docker compose up -d`;`0.0.0.0` 会公开所有接口。请使用防火墙和经过身份验证的 TLS/tailnet 前端保护访问。 diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 66a729b4fe..ca27674904 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -20,6 +20,24 @@ $CODEX_HOME/.opencodex-native-main-profiles/ Never assume macOS-only paths. Windows, service installs, and app-launched Codex can all depend on the resolved `CODEX_HOME`. +The source-built Docker image explicitly keeps `CODEX_HOME=/home/bun/.codex` separate +from `OPENCODEX_HOME=/home/bun/.opencodex`. Compose persists them in `codex-state` and +`ocx-state` respectively, retaining a read-only root. The image creates owner-only +writable homes for `bun`; existing volume ownership and permissions are not repaired. +The catalog resolver is unchanged; a writable empty home is not a materialized catalog. + +[Decision Log] +- 목적과 의도: Make the container's catalog location persistent and writable without changing native home semantics. +- 기존 구현 및 제약 조건: Compose persisted only the OCX home, leaving Codex state on a read-only root; both products use incompatible auth.json formats. +- 검토한 주요 대안: Merge the homes, nest Codex under an existing volume with a new startup initializer, or persist the existing separate Codex home. +- 선택한 방식: Add a separate codex-state volume and create both owner-only directories in the image. +- 다른 대안 대신 이 방식을 선택한 이유: It preserves existing paths, avoids credential-file collisions, and works when an older ocx-state volume hides the image's seeded directory tree. +- 장점, 단점 및 영향: Two volumes must be backed up, but no automatic credential migration or runtime resolver change is needed. Catalog import/materialization remains an explicit prerequisite. + +`docker compose down` retains both volumes. `docker compose down --volumes` deletes +both `ocx-state` and `codex-state`, including their credentials and catalog/state; +treat it as destructive, not as an upgrade or restart command. + Service install-state ownership uses this same resolver. In WSL, an unset `CODEX_HOME` may resolve to the single discoverable Windows Desktop home; recording Linux `~/.codex` instead would make a later repair or uninstall look foreign even though the service and runtime were started from the diff --git a/tests/service/container-bootstrap.test.ts b/tests/service/container-bootstrap.test.ts index 237a2754ab..ada807d1d0 100644 --- a/tests/service/container-bootstrap.test.ts +++ b/tests/service/container-bootstrap.test.ts @@ -1,11 +1,14 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; +import { pathToFileURL } from "node:url"; import { readBoundedToken } from "../../docker/bootstrap-token"; import { verifyCompatibilitySnapshot } from "../../docker/verify-compatibility"; import type { CompatibilityVersionManifest } from "../../scripts/generate-compatibility-version"; +import type { SerializedCatalog } from "../../src/server/catalog-download"; import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoPath } from "../helpers/repo-root"; @@ -39,6 +42,32 @@ describe("container token bootstrap", () => { }); describe("container deployment contract", () => { + test("persists separate OCX and Codex homes under the read-only root", () => { + const compose = Bun.YAML.parse(readFileSync(repoPath("compose.yaml"), "utf8")) as { + services: { hub: { + environment: Record; volumes: string[]; read_only: boolean; + security_opt: string[]; cap_drop: string[]; + } }; + volumes: Record; + }; + const hub = compose.services.hub; + expect(hub.environment?.CODEX_HOME).toBe("/home/bun/.codex"); + expect(hub.read_only).toBe(true); + expect(hub.volumes).toContain("ocx-state:/home/bun/.opencodex"); + expect(hub.volumes).toContain("codex-state:/home/bun/.codex"); + expect(Object.hasOwn(compose.volumes, "ocx-state")).toBe(true); + expect(Object.hasOwn(compose.volumes, "codex-state")).toBe(true); + expect(hub.security_opt).toContain("no-new-privileges:true"); + expect(hub.cap_drop).toContain("ALL"); + + const runtime = readFileSync(repoPath("Dockerfile"), "utf8").split(" AS runtime")[1]!; + expect(runtime).toContain("OPENCODEX_HOME=/home/bun/.opencodex"); + expect(runtime).toContain("CODEX_HOME=/home/bun/.codex"); + expect(runtime).toContain("install -d -m 0700 -o bun -g bun /home/bun/.opencodex /home/bun/.codex"); + expect(runtime).toContain('VOLUME ["/home/bun/.opencodex", "/home/bun/.codex"]'); + expect(runtime).toContain("USER bun"); + }); + test("publishes only the data port with loopback and explicit bind overrides", () => { const compose = Bun.YAML.parse(readFileSync(repoPath("compose.yaml"), "utf8")) as { services: { hub: { ports: string[] } }; @@ -82,6 +111,93 @@ afterEach(() => { for (const dir of snapshotDirs.splice(0)) removeTreeWithRetry(dir); }); +function catalogHomeFixture(codexDirectory = "codex-state") { + const root = mkdtempSync(join(tmpdir(), "ocx-container-catalog-")); + snapshotDirs.push(root); + const ocxHome = join(root, "ocx-state"); + const codexHome = join(root, codexDirectory); + mkdirSync(ocxHome, { mode: 0o700 }); + mkdirSync(codexHome, { mode: 0o700 }); + const ocxAuth = '{"fixture":"ocx-oauth-store"}'; + const codexAuth = '{"fixture":"native-codex-store"}'; + writeFileSync(join(ocxHome, "auth.json"), ocxAuth, { mode: 0o600 }); + writeFileSync(join(codexHome, "auth.json"), codexAuth, { mode: 0o600 }); + const moduleUrl = pathToFileURL(repoPath("src/server/catalog-download.ts")).href; + const script = ` + const { serializePersistedCatalog } = await import(${JSON.stringify(moduleUrl)}); + process.stdout.write(JSON.stringify(await serializePersistedCatalog())); + `; + const read = (): SerializedCatalog => { + // A fresh process keeps import-time home constants out of the parent test runner. + const result = spawnSync(process.execPath, ["--eval", script], { + cwd: repoPath(), + env: { ...process.env, HOME: root, USERPROFILE: root, + OPENCODEX_HOME: ocxHome, CODEX_HOME: codexHome }, + encoding: "utf8", + timeout: 15000, + }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + expect(readFileSync(join(ocxHome, "auth.json"), "utf8")).toBe(ocxAuth); + expect(readFileSync(join(codexHome, "auth.json"), "utf8")).toBe(codexAuth); + return JSON.parse(result.stdout); + }; + return { root, ocxHome, codexHome, read }; +} + +function fixtureCatalog(slug: string) { + return { models: [{ slug, display_name: "Fixture", description: "fixture", priority: 1, + visibility: "list", base_instructions: "Fixture", input_modalities: ["text"] }] }; +} + +describe("container catalog home selection", () => { + test("reads only the Codex-home catalog across fresh processes without changing auth stores", () => { + const fixture = catalogHomeFixture(); + const catalog = fixtureCatalog("fixture/codex-home"); + expect(fixture.read().body).toBeNull(); + writeFileSync(join(fixture.ocxHome, "opencodex-catalog.json"), JSON.stringify(fixtureCatalog("fixture/ocx-home")), { mode: 0o600 }); + expect(fixture.read().body).toBeNull(); + writeFileSync(join(fixture.codexHome, "opencodex-catalog.json"), JSON.stringify(catalog), { mode: 0o600 }); + const serialized = fixture.read(); + expect(JSON.parse(serialized.body!)).toEqual(catalog); + expect(serialized.bytes).toBe(Buffer.byteLength(JSON.stringify(catalog), "utf8")); + expect(serialized.etag).toMatch(/^"[0-9a-f]{64}"$/); + // This proves a disk reread, not Docker volume initialization or container recreation. + expect(fixture.read()).toEqual(serialized); + }, 60000); + + test("uses a custom Codex home containing spaces", () => { + const fixture = catalogHomeFixture("custom codex state"); + const catalog = fixtureCatalog("fixture/custom-home"); + writeFileSync(join(fixture.codexHome, "opencodex-catalog.json"), JSON.stringify(catalog), { mode: 0o600 }); + expect(JSON.parse(fixture.read().body!)).toEqual(catalog); + }, 60000); + + for (const selection of ["relative", "absolute"] as const) { + test(`honors a ${selection} catalog override without falling back when it is absent`, () => { + const fixture = catalogHomeFixture(); + const selectedPath = selection === "relative" + ? join(fixture.codexHome, "catalogs", "custom.json") + : join(fixture.root, "external catalog.json"); + mkdirSync(dirname(selectedPath), { recursive: true, mode: 0o700 }); + const configuredPath = selection === "relative" ? "catalogs/custom.json" : selectedPath; + writeFileSync(join(fixture.codexHome, "config.toml"), `model_catalog_json = ${JSON.stringify(configuredPath)}\n`, { mode: 0o600 }); + writeFileSync(join(fixture.codexHome, "opencodex-catalog.json"), JSON.stringify(fixtureCatalog("fixture/default")), { mode: 0o600 }); + const catalog = fixtureCatalog(`fixture/${selection}`); + writeFileSync(selectedPath, JSON.stringify(catalog), { mode: 0o600 }); + expect(JSON.parse(fixture.read().body!)).toEqual(catalog); + unlinkSync(selectedPath); + expect(fixture.read().body).toBeNull(); + }, 60000); + } + + test("returns no catalog for malformed selected JSON without modifying auth stores", () => { + const fixture = catalogHomeFixture(); + writeFileSync(join(fixture.codexHome, "opencodex-catalog.json"), "not JSON", { mode: 0o600 }); + expect(fixture.read().body).toBeNull(); + }, 60000); +}); + function compatibilitySnapshot() { const root = mkdtempSync(join(tmpdir(), "ocx-container-identity-")); snapshotDirs.push(root); From 1ddbc4e989b2240c4f1dc4adb407d4a5679e4248 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:50:54 +0900 Subject: [PATCH 08/99] docs(skill): keep plaintext keys out of agent recipes [skip ci] Carry #3324 from 2933cc5842 and 7734e758b7. Preserve prior exact revocation authority, benign commit/abort, and managed connect rotation. Add bounded literal recipe regression detector; this is guidance and static warning, not runtime enforcement. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> --- skills/ocx/SKILL.md | 25 ++++- skills/ocx/references/03_recipes.md | 55 +++++++++-- skills/ocx/references/05_remote_hub.md | 6 ++ tests/ci-workflows/skill-ocx.test.ts | 132 +++++++++++++++++++++++++ 4 files changed, 209 insertions(+), 9 deletions(-) diff --git a/skills/ocx/SKILL.md b/skills/ocx/SKILL.md index a9975e7745..a5f19d861f 100644 --- a/skills/ocx/SKILL.md +++ b/skills/ocx/SKILL.md @@ -1,12 +1,12 @@ --- name: ocx -description: Drive a running opencodex (`ocx`) proxy from the CLI — account pools, provider routing, model catalog, usage and cost attribution, request logs, access keys, storage cleanup, and the management API. Use when a task involves controlling or inspecting an opencodex proxy rather than editing the opencodex codebase. Triggers: ocx, opencodex, proxy control, account pool, pause account, pool strategy, provider routing, usage report, cost attribution, access key, request log, conversation trace, storage cleanup, management API. +description: "Drive a running opencodex (`ocx`) proxy from the CLI — account pools, provider routing, model catalog, usage and cost attribution, request logs, access keys, storage cleanup, and the management API. Use when a task involves controlling or inspecting an opencodex proxy rather than editing the opencodex codebase. Triggers: ocx, opencodex, proxy control, account pool, pause account, pool strategy, provider routing, usage report, cost attribution, access key, request log, conversation trace, storage cleanup, management API." --- # Operating `ocx` `ocx` controls a locally running opencodex proxy. The CLI covers the dashboard's operational -surface, with one consent exception (starring) recorded under Consent below. `ocx capabilities` +surface, subject to Consent and Secret-bearing commands below. `ocx capabilities` lists the *declared* index, not every verb. Be precise about the gap, because guessing costs you more than reading: the capability index below @@ -90,6 +90,27 @@ starring would be useful, say so and let the user decide. The same boundary covers the session-gated `/api/codex-prompt` writes: read them with `ocx inspect codex-prompt`, and leave the writes to the dashboard. +## Secret-bearing commands + +**Do not create an access key or start an access-key rotation from an agent session.** +This covers the create and rotation-start operations under `ocx access key`, +`ocx access keys`, and `ocx api-key`, their `opencodex` equivalents and executable +wrappers, and direct POST requests to `/api/keys` and `/api/keys/rotate`. +Both text and JSON responses contain a one-time plaintext data-plane credential, +which can enter the agent transcript. Ask the user to perform that step in a +human-operated terminal outside the agent session, configure and verify the +replacement, and report only confirmation plus non-secret key/rotation IDs. +Never ask for the plaintext key in chat or offer a pipe, redirection, or API +workaround to perform the secret-returning step inside the agent session. + +Configuration confirmation is not approval to revoke the existing credential. +Identify the existing key ID and obtain separate explicit revocation approval +before committing an in-place rotation or removing an old, separately replaced key. +An existing explicit approval for that exact revocation remains valid; setup +confirmation alone does not supply it. Commit and abort return no plaintext key, +but still require authority for their state changes. Follow +[recipe 5](references/03_recipes.md#5-prepare-an-access-key-rotation-without-exposing-the-new-key). + ## Destructive verbs `storage trash restore` and `storage policy run` refuse without `--yes` (exit 2, nothing sent). diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index 85b734a9ce..424ad34a53 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -94,20 +94,61 @@ Read `accounts[]`. Two things to respect: `providers[]` and `models[]` carry `estimatedCostUsd`. Costs are estimates; `estimateReasons` in the log rows tells you why (for example `usage_estimated`, `expected_price_overlay`). -## 5. Rotate an access key and confirm it went quiet +## 5. Prepare an access-key rotation without exposing the new key ```bash ocx access key list --json -ocx access key create rotated --json # the plaintext key is in THIS response only +``` + +Creating a key or starting a rotation returns a one-time plaintext credential in both text and +JSON output. **Do not perform either operation in an agent session**, including through the +aliases, executable wrappers, or management POST routes named in +[Secret-bearing commands](../SKILL.md#secret-bearing-commands). Ask the user to perform that step +in a terminal outside the agent session, configure and verify the replacement, and report only +configuration confirmation and the non-secret key/rotation IDs. Never ask for the key itself. + +Configuration confirmation is not revocation approval. Identify the existing key ID and obtain +separate explicit revocation approval before taking either path below. An existing explicit +approval for that exact revocation remains valid; do not ask again for the same action and ID. + +For an in-place rotation, commit the pending replacement on the same ID: + +```bash +ocx access key rotate commit --json +``` + +For a separately created replacement, remove only the old ID: + +```bash ocx access key remove --yes --json -ocx access key list --json # the old id is gone; check usage on the rest ``` -Note the argument style: `create ` and `remove ` are **positionals**, not `--label` and -`--id`. `remove` also refuses without `--yes`. +After the command succeeds, inspect the matching result: + +```bash +ocx access key list --json +``` + +For an in-place rotation, the same ID remains and `pendingRotation` disappears. For a separately +created replacement, the old ID disappears. The list alone does not prove the replacement accepts +traffic; use the user's successful connection verification as that evidence. `remove ` is +positional, not `--id`, and refuses without `--yes`. + +To cancel a pending rotation, with authority to discard the replacement: + +```bash +ocx access key rotate abort --json +``` + +Abort retains the old credential and removes the pending replacement. Re-list to inspect pending +state. On stale, mismatched, or expired rotation IDs, or an uncertain commit result, inspect +non-secret state and report the refusal or uncertainty. Do not start another rotation, delete the +entry, or retrieve a secret as automatic recovery. Missing pending state alone is not proof of a +successful commit: expiry and abort also clear it. -The list carries per-key usage, so a key whose count stops advancing is genuinely unused. The -plaintext key appears once, in the `create` response, and is never retrievable again. +The list carries per-key usage. A count that stops advancing shows no recorded new usage in that +observation window; it does not prove no client still needs the key. Creation and rotation-start +return the plaintext once; list does not return the full plaintext. An `ambiguous` footer on the list means two configured keys share an id, so per-key totals do not exist for them — do not attribute usage to either. diff --git a/skills/ocx/references/05_remote_hub.md b/skills/ocx/references/05_remote_hub.md index 46b846d443..0c0596be07 100644 --- a/skills/ocx/references/05_remote_hub.md +++ b/skills/ocx/references/05_remote_hub.md @@ -114,6 +114,12 @@ The ordering is not ceremony. If the old key died at issuance, a client that had received the new key would be disconnected — and a disconnected client cannot be given a new key. So the contract is: apply the new key, verify the connection, then commit. +Raw access-key creation and rotation-start return plaintext and belong outside the agent +session; follow [recipe 5](03_recipes.md#5-prepare-an-access-key-rotation-without-exposing-the-new-key) +for the human handoff and separate revocation approval. The managed `ocx connect rotate` +flow returns non-secret status and is a distinct command, not permission to invoke the raw +secret-returning endpoint from an agent tool. + The token backup (`.prev`) is not deleted while a rotation is in flight, and commits only once both sides are confirmed to have accepted. diff --git a/tests/ci-workflows/skill-ocx.test.ts b/tests/ci-workflows/skill-ocx.test.ts index 9293dd080a..dbd9693dcc 100644 --- a/tests/ci-workflows/skill-ocx.test.ts +++ b/tests/ci-workflows/skill-ocx.test.ts @@ -163,3 +163,135 @@ describe("the consent boundary is stated, not implied", () => { expect(recipes).toContain("get approval"); }); }); + +describe("access-key recipes keep plaintext outside agent sessions", () => { + // CLI oracle: access.ts removes one --json before checking exact commit/abort tokens. + // These canonical spellings are case-sensitive; commit-old-id is a start, not a commit. + const secretBearingAccessKeyCommand = + /\b(?:ocx|opencodex)(?:\.(?:exe|mjs|cmd|ps1))?["']?\s+(?:access\s+keys?|api-key)\s+(?:create\b|rotate\b(?!\s+(?:--json\s+)?(?:commit|abort)(?=\s|$)))/gm; + const secretBearingManagementRequest = + /(?:(?:\bPOST\b|(?:--request|-X|-Method)\s+["']?POST["']?|method\s*:\s*["']POST["'])[^\n]{0,240}\/api\/keys(?:\/rotate)?(?=$|[\s"'?#])|\/api\/keys(?:\/rotate)?(?=$|[\s"'?#])[^\n]{0,240}(?:\bPOST\b|(?:--request|-X|-Method)\s+["']?POST["']?|method\s*:\s*["']POST["']))/gim; + + /** + * Early warning for literal recipes in ordinary fences and single-backtick spans. + * Not a shell/JS parser: implicit POSTs, dynamic calls, alternate Markdown and + * arbitrary multiline requests remain outside this bounded detector. + */ + function secretBearingCommandsInCode(text: string): string[] { + const spans: string[] = []; + const prose = text.replace(/```[^\n]*\n([\s\S]*?)```/g, (_all: string, body: string) => { + spans.push(body); + return ""; + }); + for (const span of prose.matchAll(/`([^`\n]+)`/g)) spans.push(span[1]!); + const matches: string[] = []; + for (const span of spans) { + const executable = span.replace(/(?:\\|`|\^)\r?\n\s*/g, " "); + matches.push(...Array.from(executable.matchAll(secretBearingAccessKeyCommand), match => match[0])); + matches.push(...Array.from(executable.matchAll(secretBearingManagementRequest), match => match[0])); + } + return matches; + } + + test("all key aliases reject creation/start and preserve non-secret commit/abort", () => { + for (const binary of ["ocx", "opencodex"]) { + for (const group of ["access key", "access keys", "api-key"]) { + const prefix = `${binary} ${group}`; + for (const action of [ + "create rotated", "create rotated --json", + "rotate old-id", "rotate old-id --json", "rotate --json old-id", + ]) { + const command = `${prefix} ${action}`; + expect(secretBearingCommandsInCode("```bash\n" + command + "\n```"), command).toHaveLength(1); + } + for (const operation of ["commit", "abort"]) { + for (const args of [ + `${operation} old-id rotation-id`, + `${operation} old-id rotation-id --json`, + `--json ${operation} old-id rotation-id`, + ]) { + const command = `${prefix} rotate ${args}`; + expect(secretBearingCommandsInCode("```bash\n" + command + "\n```"), command).toEqual([]); + } + const start = `${prefix} rotate --json ${operation}-old-id`; + expect(secretBearingCommandsInCode("`" + start + "`"), start).toHaveLength(1); + } + } + } + }); + + test("wrappers, shell continuations and inline examples cannot hide literal commands", () => { + for (const command of [ + "& ocx access keys create rotated --json", + "command ocx access key create rotated", + "env ocx api-key rotate old-id", + "& 'C:\\Tools\\opencodex.exe' api-key rotate old-id", + "node /opt/bin/ocx.mjs access key create rotated", + "ocx.cmd access key create rotated", + "& './opencodex.ps1' access keys rotate old-id", + "ocx access key \\\n create rotated --json", + "ocx access key `\r\n create rotated --json", + "ocx access key ^\n rotate old-id", + "ocx access key rotate COMMIT", + ]) { + expect(secretBearingCommandsInCode("```bash\n" + command + "\n```"), command).toHaveLength(1); + } + expect(secretBearingCommandsInCode("Run `ocx api-key create rotated --json` next.")).toHaveLength(1); + expect(secretBearingCommandsInCode("Do not run `ocx api-key create rotated --json`.")).toHaveLength(1); + expect(secretBearingCommandsInCode("Creation under `ocx access key` returns plaintext.")).toEqual([]); + }); + + test("explicit management POST recipes are detected without banning commit or abort", () => { + for (const route of ["/api/keys", "/api/keys/rotate"]) { + for (const command of [ + `POST ${route}`, + `curl -X POST http://127.0.0.1:3000${route}`, + `curl 'http://127.0.0.1:3000${route}?source=recipe' --request POST`, + `curl --request POST \\\n 'http://127.0.0.1:3000${route}#example'`, + `Invoke-RestMethod http://127.0.0.1:3000${route} -Method Post`, + `Invoke-WebRequest -Method Post http://127.0.0.1:3000${route}`, + `fetch('${route}', { method: 'POST' })`, + ]) { + expect(secretBearingCommandsInCode("```text\n" + command + "\n```"), command).toHaveLength(1); + } + } + expect(secretBearingCommandsInCode("Run `POST /api/keys` next.")).toHaveLength(1); + for (const command of [ + "ocx access key list --json", + "ocx access key remove old-id --yes --json", + "ocx connect rotate --admin-token-stdin --json", + "curl -X POST http://127.0.0.1:3000/api/keys/rotate/commit", + "curl -X DELETE http://127.0.0.1:3000/api/keys/rotate", + "curl -X DELETE http://127.0.0.1:3000/api/keys", + "curl http://127.0.0.1:3000/api/keys\ncurl -X POST http://127.0.0.1:3000/api/keys/rotate/commit", + ]) { + expect(secretBearingCommandsInCode("```bash\n" + command + "\n```"), command).toEqual([]); + } + expect(secretBearingCommandsInCode("| POST | `/api/keys/rotate` |")).toEqual([]); + }); + + test("the original unsafe recipe is detected and every shipped page is scanned", () => { + const original = "```bash\nocx access key list --json\nocx access key create rotated --json\n" + + "ocx access key remove --yes --json\nocx access key list --json\n```"; + expect(secretBearingCommandsInCode(original)).toHaveLength(1); + for (const file of ["SKILL.md", ...REFERENCES.map(ref => join("references", ref))]) { + expect(secretBearingCommandsInCode(read(file)), file).toEqual([]); + } + }); + + test("guidance distinguishes configuration confirmation from revocation authority", () => { + // Documentation presence/order only: these assertions do not prove agent behavior. + const skill = readFileSync(SKILL, "utf8"); + const recipes = read("references/03_recipes.md"); + for (const text of [skill, recipes]) { + expect(text).toMatch(/outside the agent\s+session/); + expect(text).toMatch(/configuration confirmation is not (?:revocation )?approval/i); + expect(text).toMatch(/existing explicit\s+approval for that exact revocation remains valid/); + } + const approvalAt = recipes.indexOf("separate explicit revocation approval"); + expect(approvalAt).toBeGreaterThanOrEqual(0); + for (const command of ["ocx access key rotate commit", "ocx access key remove"]) { + expect(recipes.indexOf(command)).toBeGreaterThan(approvalAt); + } + }); +}); From 17c657c88784c6053d3185f26c01177a01f92f3c Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:53:16 +0900 Subject: [PATCH 09/99] fix(diagnostics): distinguish spill ACL timeout origins [skip ci] Add closed origin and cumulative terminal-publication counters without changing ACL decisions, retry limits, memo handling, cancellation or readiness. Refs #3522; runtime recovery remains unresolved. Final stack CI pending. Co-authored-by: Ingwannu <186453546+Ingwannu@users.noreply.github.com> --- .../content/docs/reference/management-api.md | 2 +- .../docs/troubleshooting/windows-memory.md | 17 ++- src/lib/windows-secret-acl.ts | 12 +- src/responses/state.ts | 51 +++++++- tests/responses/responses-state.test.ts | 115 ++++++++++++++++++ tests/server/memory-watchdog.test.ts | 11 +- tests/windows/windows-secret-acl.test.ts | 88 +++++++++++--- 7 files changed, 267 insertions(+), 29 deletions(-) diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 2cd23eb4bd..538f81e400 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -339,7 +339,7 @@ whether to star the repository. | Method and path | Purpose | Notable errors | | --- | --- | --- | -| `GET /api/system/memory` | Return scalar process, heap, stream, response-state, watchdog, and active-turn metrics. Response-state diagnostics include spill-write status, consecutive failures, fixed privacy-safe failure class, and last failure/success timestamps; raw errors and paths are never returned. | — | +| `GET /api/system/memory` | Return scalar process, heap, stream, response-state, watchdog, and active-turn metrics. Response-state diagnostics include spill-write status, consecutive failures, fixed privacy-safe failure class, and last failure/success timestamps. `spillLastWriteFailureOrigin` is `retry_returned_timeout`, `timeout_memo_refusal`, or null; cumulative `spillAclRetryReturnedTimeouts` and `spillAclTimeoutMemoRefusals` count terminal failed publications. See [Windows spill diagnostics](/troubleshooting/windows-memory/) for process-local semantics. Raw errors and paths are never returned. | — | | `POST /api/system/restart` | Begin a drain-aware process restart without removing client injection | Returns 202; repeated calls report the existing drain | | `POST /api/stop` | Stop the service, restore native Codex, remove managed Grok injection, and drain the proxy | 409 service ownership conflict; 409 `respawnable_service` when a Windows Task Scheduler wrapper could respawn the proxy and the caller is not `ocx stop` (nothing is changed); 409 when the installed manager refuses to stop; 409 `service_state_unknown` when the Task Scheduler state cannot be read (nothing is changed; repair the query and retry) | | `GET /api/system/codex-app-server` | Report whether running Codex app-servers predate the current model catalog | — | diff --git a/docs-site/src/content/docs/troubleshooting/windows-memory.md b/docs-site/src/content/docs/troubleshooting/windows-memory.md index cea5b3c233..10bc3799cc 100644 --- a/docs-site/src/content/docs/troubleshooting/windows-memory.md +++ b/docs-site/src/content/docs/troubleshooting/windows-memory.md @@ -57,7 +57,22 @@ runtime the leak itself remains an upstream problem: time show whether failures are accumulating or recovering in the same process. The last failure is a fixed privacy-safe class such as `EACCES`, `ENOSPC`, `ETIMEDOUT`, or `EACLRETRYEXHAUSTED`; raw error messages and filesystem paths - are never returned. These diagnostics stay on the authenticated management + are never returned. `spillLastWriteFailureOrigin` adds a fixed origin or null: + `retry_returned_timeout` means the existing second spill attempt returned a + timeout; `timeout_memo_refusal` means the ACL helper refused through its + remembered timeout state. Other failures use null. The cumulative + `spillAclRetryReturnedTimeouts` and `spillAclTimeoutMemoRefusals` count terminal + failed publications, not individual ACL commands or transient first attempts. + Success clears the failure streak but retains the last failure fields and + cumulative counts; a later unrelated failure sets the last origin to null. + These values are process-local, so compare snapshots from the same process. + Neither origin identifies an OS command: the attempt budget can expire before + a command starts, and an optional compliance inspection can run before a memo + refusal. A separate process succeeding does not prove that the live process's + memo recovered. These observations do not add retries, clear memos, weaken + required ACLs, or automatically restart the service. + + These diagnostics stay on the authenticated management endpoint and are intentionally absent from `/healthz`, which remains a liveness signal. The dashboard's **Memory observability** card renders the memory and continuation-size fields from this endpoint and offers a confirm-gated diff --git a/src/lib/windows-secret-acl.ts b/src/lib/windows-secret-acl.ts index dbf1f06b79..dc8bb5749f 100644 --- a/src/lib/windows-secret-acl.ts +++ b/src/lib/windows-secret-acl.ts @@ -715,18 +715,22 @@ function sanitizedAclError(diagnostics: string, cause: unknown): NodeJS.ErrnoExc return error; } -function previousTimeoutError(retryConsumed: boolean): NodeJS.ErrnoException { +type TimeoutMemoRefusalError = NodeJS.ErrnoException & { + aclFailureOrigin: "timeout_memo_refusal"; +}; + +function previousTimeoutError(retryConsumed: boolean): TimeoutMemoRefusalError { if (retryConsumed) { const error = new Error( "ACL hardening skipped — the previous timeout recovery was already consumed", ) as NodeJS.ErrnoException; error.code = "EACLRETRYEXHAUSTED"; - return error; + return Object.assign(error, { aclFailureOrigin: "timeout_memo_refusal" as const }); } - return sanitizedAclError( + return Object.assign(sanitizedAclError( "ACL hardening skipped — previous attempt timed out", Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }), - ); + ), { aclFailureOrigin: "timeout_memo_refusal" as const }); } /** Consume, but never reset, the single explicit recovery attempt for this key. */ diff --git a/src/responses/state.ts b/src/responses/state.ts index e581653725..f9195196a2 100644 --- a/src/responses/state.ts +++ b/src/responses/state.ts @@ -169,7 +169,10 @@ async function snapshotOnDiskMatches(path: string, payload: string, payloadBytes return false; } } -const spillCounters = { writes: 0, writeFailures: 0, readFailures: 0 }; +const spillCounters = { + writes: 0, writeFailures: 0, readFailures: 0, + aclRetryReturnedTimeouts: 0, aclTimeoutMemoRefusals: 0, +}; export type ResponseSpillWriteFailureCode = | "EACLRETRYEXHAUSTED" @@ -184,9 +187,14 @@ export type ResponseSpillWriteFailureCode = export type ResponseSpillWriteStatus = "initial" | "healthy" | "degraded"; +export type ResponseSpillWriteFailureOrigin = + | "retry_returned_timeout" + | "timeout_memo_refusal"; + interface ResponseSpillWriteHealth { consecutiveFailures: number; lastFailureCode: ResponseSpillWriteFailureCode | null; + lastFailureOrigin: ResponseSpillWriteFailureOrigin | null; lastFailureAt: number | null; lastSuccessAt: number | null; } @@ -194,6 +202,7 @@ interface ResponseSpillWriteHealth { const spillWriteHealth: ResponseSpillWriteHealth = { consecutiveFailures: 0, lastFailureCode: null, + lastFailureOrigin: null, lastFailureAt: null, lastSuccessAt: null, }; @@ -226,6 +235,20 @@ function classifySpillWriteFailure(error: unknown): ResponseSpillWriteFailureCod return "EUNKNOWN"; } +/** The spill writer preserves ACL errors in cause; only a fixed memo marker is diagnostic. */ +function spillAclMemoRefusalOrigin(error: unknown): "timeout_memo_refusal" | null { + let cursor = error; + for (let depth = 0; depth < 4 && cursor && typeof cursor === "object"; depth += 1) { + const record = cursor as { code?: unknown; aclFailureOrigin?: unknown; cause?: unknown }; + if ((record.code === "ETIMEDOUT" || record.code === "EACLRETRYEXHAUSTED") + && record.aclFailureOrigin === "timeout_memo_refusal") { + return "timeout_memo_refusal"; + } + cursor = record.cause; + } + return null; +} + function noteSpillWriteSuccess(): void { spillCounters.writes += 1; spillWriteHealth.consecutiveFailures = 0; @@ -235,11 +258,20 @@ function noteSpillWriteSuccess(): void { function noteSpillWriteFailure( error: unknown, override?: ResponseSpillWriteFailureCode, + retryOrigin: ResponseSpillWriteFailureOrigin | null = null, ): void { + const code = override ?? classifySpillWriteFailure(error); + const origin = code === "ETIMEDOUT" || code === "EACLRETRYEXHAUSTED" + ? spillAclMemoRefusalOrigin(error) ?? retryOrigin + : null; spillCounters.writeFailures += 1; spillWriteHealth.consecutiveFailures += 1; - spillWriteHealth.lastFailureCode = override ?? classifySpillWriteFailure(error); + spillWriteHealth.lastFailureCode = code; + spillWriteHealth.lastFailureOrigin = origin; spillWriteHealth.lastFailureAt = now(); + // Count terminal publications, not ACL calls or a transient first attempt. + if (origin === "retry_returned_timeout") spillCounters.aclRetryReturnedTimeouts += 1; + else if (origin === "timeout_memo_refusal") spillCounters.aclTimeoutMemoRefusals += 1; } /** * Admission-boundary observability (test-visible). directSpills: oversized @@ -418,6 +450,7 @@ async function runPendingResponseSpill(job: PendingResponseSpill): Promise const candidate = job.candidate; let ref: ResponseSpillRef | null = null; let exhaustedAclRetry = false; + let aclRetryFailureOrigin: ResponseSpillWriteFailureOrigin | null = null; try { const state = spillPayloadForResident(candidate); try { @@ -437,6 +470,9 @@ async function runPendingResponseSpill(job: PendingResponseSpill): Promise }); } catch (retryError) { exhaustedAclRetry = isAclTimeout(retryError); + // A returned timeout can also mean an exhausted budget before the next OS command. + aclRetryFailureOrigin = spillAclMemoRefusalOrigin(retryError) + ?? (exhaustedAclRetry ? "retry_returned_timeout" : null); throw retryError; } } @@ -460,7 +496,7 @@ async function runPendingResponseSpill(job: PendingResponseSpill): Promise } catch (error) { if (ref) deleteResponseSpill(ref); if (states.get(job.id) === candidate && !job.cancelled) { - noteSpillWriteFailure(error, exhaustedAclRetry ? "EACLRETRYEXHAUSTED" : undefined); + noteSpillWriteFailure(error, exhaustedAclRetry ? "EACLRETRYEXHAUSTED" : undefined, aclRetryFailureOrigin); replaceWithSpillFailure(job.id, candidate); deferSupersededSpill(job.supersededSpill); } @@ -2188,6 +2224,9 @@ export interface ResponseStateMetrics { spillWriteStatus: ResponseSpillWriteStatus; spillWriteConsecutiveFailures: number; spillLastWriteFailureCode: ResponseSpillWriteFailureCode | null; + spillLastWriteFailureOrigin: ResponseSpillWriteFailureOrigin | null; + spillAclRetryReturnedTimeouts: number; + spillAclTimeoutMemoRefusals: number; spillLastWriteFailureAt: number | null; spillLastWriteSuccessAt: number | null; spillReadFailures: number; @@ -2240,6 +2279,9 @@ export function responseStateMetrics(): ResponseStateMetrics { : "initial", spillWriteConsecutiveFailures: spillWriteHealth.consecutiveFailures, spillLastWriteFailureCode: spillWriteHealth.lastFailureCode, + spillLastWriteFailureOrigin: spillWriteHealth.lastFailureOrigin, + spillAclRetryReturnedTimeouts: spillCounters.aclRetryReturnedTimeouts, + spillAclTimeoutMemoRefusals: spillCounters.aclTimeoutMemoRefusals, spillLastWriteFailureAt: spillWriteHealth.lastFailureAt, spillLastWriteSuccessAt: spillWriteHealth.lastSuccessAt, spillReadFailures: spillCounters.readFailures, @@ -2360,8 +2402,11 @@ export function clearResponseStateMemoryForTests(): void { spillCounters.writes = 0; spillCounters.writeFailures = 0; spillCounters.readFailures = 0; + spillCounters.aclRetryReturnedTimeouts = 0; + spillCounters.aclTimeoutMemoRefusals = 0; spillWriteHealth.consecutiveFailures = 0; spillWriteHealth.lastFailureCode = null; + spillWriteHealth.lastFailureOrigin = null; spillWriteHealth.lastFailureAt = null; spillWriteHealth.lastSuccessAt = null; replayScopeMismatchDrops = 0; diff --git a/tests/responses/responses-state.test.ts b/tests/responses/responses-state.test.ts index 8ba5da86b2..464642cda7 100644 --- a/tests/responses/responses-state.test.ts +++ b/tests/responses/responses-state.test.ts @@ -1079,6 +1079,9 @@ describe("Responses previous_response_id state", () => { spillWriteFailures: 0, spillWriteStatus: "healthy", spillWriteConsecutiveFailures: 0, + spillLastWriteFailureOrigin: null, + spillAclRetryReturnedTimeouts: 0, + spillAclTimeoutMemoRefusals: 0, }); }); @@ -1112,6 +1115,9 @@ describe("Responses previous_response_id state", () => { spillWriteConsecutiveFailures: 1, spillLastWriteFailureCode: "EACLRETRYEXHAUSTED", spillLastWriteSuccessAt: null, + spillLastWriteFailureOrigin: "retry_returned_timeout", + spillAclRetryReturnedTimeouts: 1, + spillAclTimeoutMemoRefusals: 0, }); expect(metrics.spillLastWriteFailureAt).toBeGreaterThanOrEqual(0); @@ -1127,11 +1133,65 @@ describe("Responses previous_response_id state", () => { spillWriteStatus: "healthy", spillWriteConsecutiveFailures: 0, spillLastWriteFailureCode: "EACLRETRYEXHAUSTED", + spillLastWriteFailureOrigin: "retry_returned_timeout", + spillAclRetryReturnedTimeouts: 1, + spillAclTimeoutMemoRefusals: 0, }); expect(typeof recovered.spillLastWriteSuccessAt === "number" && recovered.spillLastWriteSuccessAt >= (recovered.spillLastWriteFailureAt ?? 0)).toBe(true); }); + test("Windows stable-directory memo refusals stay distinct after the runner becomes healthy", async () => { + forceWindowsAclLane(); + const previousVerify = process.env.OPENCODEX_ACL_VERIFY_EXISTING; + delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + let clock = 0; + let grantCalls = 0; + setNowForTests(() => clock); + setResponseSpillNowForTests(() => clock); + setResponseSpillAsyncAclAttemptBudgetForTests(100); + setResponseStateByteCapForTests(1_024); + const spillDir = responseSpillDirectory(); + let healthy = false; + setAsyncIcaclsRunnerForTests(async args => { + if (args[0] !== spillDir) return ICACLS_OK; + if (args.includes("/grant:r")) grantCalls += 1; + if (healthy) return ICACLS_OK; + clock += 100; + return { success: false, exitCode: null, timedOut: true, stdout: "private-acl-output" }; + }); + try { + rememberLarge("resp_stable_timeout", "x".repeat(8_000)); + await flushPendingResponseSpillsForTests(); + expect(responseStateMetrics()).toMatchObject({ + spillWrites: 0, spillWriteFailures: 1, + spillLastWriteFailureCode: "EACLRETRYEXHAUSTED", + spillLastWriteFailureOrigin: "retry_returned_timeout", + spillAclRetryReturnedTimeouts: 1, spillAclTimeoutMemoRefusals: 0, + }); + expect(grantCalls).toBe(2); + healthy = true; // Same stable directory and process; no memo reset between jobs. + for (let refusal = 1; refusal <= 2; refusal += 1) { + rememberLarge(`resp_stable_refusal_${refusal}`, "y".repeat(8_000)); + await flushPendingResponseSpillsForTests(); + expect(responseStateMetrics()).toMatchObject({ + spillWrites: 0, spillWriteFailures: 1 + refusal, + spillWriteStatus: "degraded", spillWriteConsecutiveFailures: 1 + refusal, + spillLastWriteFailureCode: "EACLRETRYEXHAUSTED", + spillLastWriteFailureOrigin: "timeout_memo_refusal", + spillAclRetryReturnedTimeouts: 1, spillAclTimeoutMemoRefusals: refusal, + spillLastWriteSuccessAt: null, spillStubCount: 0, + }); + expect(grantCalls).toBe(2); + expect(spillFileNames(home)).toHaveLength(0); + expect(spillTempNames(home)).toHaveLength(0); + } + } finally { + if (previousVerify === undefined) delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + else process.env.OPENCODEX_ACL_VERIFY_EXISTING = previousVerify; + } + }); + test("Windows async spill attempts share one bounded ACL budget across every harden", async () => { forceWindowsAclLane(); let clock = 0; @@ -2591,6 +2651,7 @@ describe("Responses previous_response_id state", () => { const { spillWriteStatus, spillLastWriteFailureCode, + spillLastWriteFailureOrigin, spillLastWriteFailureAt, spillLastWriteSuccessAt, ...numericMetrics @@ -2599,6 +2660,7 @@ describe("Responses previous_response_id state", () => { .every(value => typeof value === "number" && Number.isFinite(value))).toBe(true); expect(spillWriteStatus).toBe("healthy"); expect(spillLastWriteFailureCode).toBeNull(); + expect(spillLastWriteFailureOrigin).toBeNull(); expect(spillLastWriteFailureAt).toBeNull(); expect(typeof spillLastWriteSuccessAt === "number" && Number.isFinite(spillLastWriteSuccessAt)).toBe(true); const serialized = JSON.stringify(metrics); @@ -3359,6 +3421,9 @@ describe("Responses previous_response_id state", () => { spillWriteStatus: "initial", spillWriteConsecutiveFailures: 0, spillLastWriteFailureCode: null, + spillLastWriteFailureOrigin: null, + spillAclRetryReturnedTimeouts: 0, + spillAclTimeoutMemoRefusals: 0, spillLastWriteFailureAt: null, spillLastWriteSuccessAt: null, spillReadFailures: 0, @@ -3366,6 +3431,53 @@ describe("Responses previous_response_id state", () => { }); }); + test("spill failure origin decoding stays bounded, closed and paired with the effective code", () => { + setResponseStateByteCapForTests(1_024); + const memoError = Object.assign(new Error("private-path-and-payload"), { + code: "ETIMEDOUT", aclFailureOrigin: "timeout_memo_refusal", + }); + const cycle: { code: string; cause?: unknown; aclFailureOrigin: string } = { + code: "ETIMEDOUT", aclFailureOrigin: "private-origin", + }; + cycle.cause = cycle; + const cases = [ + { error: new Error("wrapper", { cause: memoError }), code: "ETIMEDOUT", origin: "timeout_memo_refusal" }, + { error: Object.assign(new Error("denied", { cause: memoError }), { code: "EACCES" }), code: "EACCES", origin: null }, + { error: { code: "EACLRETRYEXHAUSTED" }, code: "EACLRETRYEXHAUSTED", origin: null }, + { error: { code: "ETIMEDOUT", aclFailureOrigin: "private-origin" }, code: "ETIMEDOUT", origin: null }, + { error: { code: "ETIMEDOUT", aclFailureOrigin: ["timeout_memo_refusal"] }, code: "ETIMEDOUT", origin: null }, + { error: cycle, code: "ETIMEDOUT", origin: null }, + // Including the writer's wrapper, the marker is beyond the four-object scan. + { error: { code: "ETIMEDOUT", cause: { cause: { cause: memoError } } }, code: "ETIMEDOUT", origin: null }, + ]; + cases.forEach(({ error, code, origin }, index) => { + setSpillIoForTest({ write: () => { throw error; } }); + rememberLarge(`resp_private_origin_${index}`, "private-content".repeat(1_000)); + const metrics = responseStateMetrics(); + expect(metrics).toMatchObject({ + spillWriteFailures: index + 1, + spillLastWriteFailureCode: code, + spillLastWriteFailureOrigin: origin, + spillAclRetryReturnedTimeouts: 0, spillAclTimeoutMemoRefusals: 1, + }); + const serialized = JSON.stringify(metrics); + for (const privateValue of ["private-path-and-payload", "private-origin", "private-content", "resp_private_origin", home]) { + expect(serialized).not.toContain(privateValue); + } + }); + setSpillIoForTest(null); + rememberLarge("resp_after_origin_failures", "healthy".repeat(1_500)); + expect(responseStateMetrics()).toMatchObject({ + spillWriteStatus: "healthy", spillWriteConsecutiveFailures: 0, + spillLastWriteFailureCode: "ETIMEDOUT", spillLastWriteFailureOrigin: null, + spillAclRetryReturnedTimeouts: 0, spillAclTimeoutMemoRefusals: 1, + }); + clearResponseStateMemoryForTests(); + expect(responseStateMetrics()).toMatchObject({ + spillLastWriteFailureOrigin: null, spillAclRetryReturnedTimeouts: 0, spillAclTimeoutMemoRefusals: 0, + }); + }); + test("a successful spill clears a repeated failure streak without erasing the last failure", () => { const realNow = Date.now; let clock = 1_000; @@ -3471,6 +3583,9 @@ describe("Responses previous_response_id state", () => { spillWriteStatus: "initial", spillWriteConsecutiveFailures: 0, spillLastWriteFailureCode: null, + spillLastWriteFailureOrigin: null, + spillAclRetryReturnedTimeouts: 0, + spillAclTimeoutMemoRefusals: 0, spillLastWriteFailureAt: null, spillLastWriteSuccessAt: null, spillReadFailures: 0, diff --git a/tests/server/memory-watchdog.test.ts b/tests/server/memory-watchdog.test.ts index 80c38dd020..918503456c 100644 --- a/tests/server/memory-watchdog.test.ts +++ b/tests/server/memory-watchdog.test.ts @@ -196,6 +196,9 @@ describe("GET /api/system/memory", () => { spillWriteStatus: "initial" | "healthy" | "degraded"; spillWriteConsecutiveFailures: number; spillLastWriteFailureCode: string | null; + spillLastWriteFailureOrigin: string | null; + spillAclRetryReturnedTimeouts: number; + spillAclTimeoutMemoRefusals: number; spillLastWriteFailureAt: number | null; spillLastWriteSuccessAt: number | null; replayScopeMismatchDrops: number; @@ -221,11 +224,12 @@ describe("GET /api/system/memory", () => { // responseState is a scalar-only continuation-store attribution block: numbers plus fixed // enum/null fields (no paths, messages, tokens, or account identifiers). // The exact count is pinned on purpose: a new field must be reviewed for privacy safety - // before it reaches this surface. 17 after #3522 added spill-write health diagnostics. - expect(Object.keys(body.responseState)).toHaveLength(17); + // before it reaches this surface. 20 after #3522 added failure origins and counters. + expect(Object.keys(body.responseState)).toHaveLength(20); const { spillWriteStatus, spillLastWriteFailureCode, + spillLastWriteFailureOrigin, spillLastWriteFailureAt, spillLastWriteSuccessAt, ...numericResponseState @@ -233,6 +237,9 @@ describe("GET /api/system/memory", () => { expect(Object.values(numericResponseState) .every(value => typeof value === "number" && Number.isFinite(value))).toBe(true); expect(["initial", "healthy", "degraded"]).toContain(spillWriteStatus); + expect(spillLastWriteFailureOrigin === null || [ + "retry_returned_timeout", "timeout_memo_refusal", + ].includes(spillLastWriteFailureOrigin)).toBe(true); expect(spillLastWriteFailureCode === null || [ "EACLRETRYEXHAUSTED", "ETIMEDOUT", "EACCES", "ENOSPC", "EFBIG", "EIO", "ECAPACITY", "ELOOP", "EUNKNOWN", diff --git a/tests/windows/windows-secret-acl.test.ts b/tests/windows/windows-secret-acl.test.ts index dddbd38084..aa011bb516 100644 --- a/tests/windows/windows-secret-acl.test.ts +++ b/tests/windows/windows-secret-acl.test.ts @@ -376,6 +376,42 @@ describe("opt-in existing ACL proof", () => { expect(calls).toEqual([[target]]); }); + test("async compliance inspection can precede a memo refusal or an existing compliant success", async () => { + const target = join(testDir, "memo-compliance.json"); + writeFileSync(target, "secret"); + let clock = 0; + setNowForTests(() => clock); + setAsyncWindowsPrincipalRunnerForTests(async () => success(`${ownerSid}\n${ownerName}\n`)); + seedIdentity(); + delete process.env.OPENCODEX_ACL_VERIFY_EXISTING; + setAsyncIcaclsRunnerForTests(async () => { + clock += 100; + return { success: false, exitCode: null, timedOut: true, stdout: "" }; + }); + try { + await expect(hardenSecretPathAsync(target, { required: true, deadlineMs: 100 })) + .rejects.toMatchObject({ code: "ETIMEDOUT" }); + await expect(hardenSecretPathAsync(target, { required: true, deadlineMs: 100, retryTimedOutOnce: true })) + .rejects.toMatchObject({ code: "ETIMEDOUT" }); + process.env.OPENCODEX_ACL_VERIFY_EXISTING = "1"; + const calls: string[][] = []; + let compliant = false; + setAsyncIcaclsRunnerForTests(async args => { + calls.push(args); + return success(compliant ? `${target} ${ownerName}:(F)\r\n` : "unverified"); + }); + await expect(hardenSecretPathAsync(target, { required: true, deadlineMs: 100 })) + .rejects.toMatchObject({ code: "EACLRETRYEXHAUSTED", aclFailureOrigin: "timeout_memo_refusal" }); + expect(calls).toEqual([[target]]); // Inspection ran, but no grant was launched. + compliant = true; + await expect(hardenSecretPathAsync(target, { required: true, deadlineMs: 100 })).resolves.toEqual({ ok: true }); + expect(calls).toEqual([[target], [target]]); + expect(timedOutSecretPathCountForTests()).toBe(1); // Existing proof did not clear the memo. + } finally { + setNowForTests(null); + } + }); + test("an inherited owner ACE falls through to the mutation sequence", () => { const target = join(testDir, "inherited.json"); writeFileSync(target, "secret"); @@ -1014,7 +1050,7 @@ describe("async hardenSecretPath (issue #612)", () => { expect(timedOutSecretPathCountForTests()).toBe(0); }); - test("the explicit timeout recovery cannot be consumed more than once", async () => { + test.each(["sync", "async"] as const)("%s timeout origin distinguishes memo refusal without another recovery", async lane => { // Pinned: this asserts recovery CARDINALITY. At the 30s default the first call would // succeed on its internal retry and the cardinality claim would never be exercised. process.env.OPENCODEX_ACL_TIMEOUT_MS = "5000"; @@ -1022,27 +1058,43 @@ describe("async hardenSecretPath (issue #612)", () => { let now = 0; let grantCalls = 0; setNowForTests(() => now); - setAsyncIcaclsRunnerForTests(async args => { + const runner = (args: string[]): IcaclsResult => { if (args.includes("/grant:r")) grantCalls += 1; now += 5_000; return timeout; - }); + }; + setIcaclsRunnerForTests(runner); + setAsyncIcaclsRunnerForTests(async args => runner(args)); + const identity = { ...ok, stdout: "S-1-5-21-1-2-3-1001\nocx-test\n" }; + setWindowsPrincipalRunnerForTests(() => identity); + setAsyncWindowsPrincipalRunnerForTests(async () => identity); + const harden = async (retryTimedOutOnce = false) => lane === "sync" + ? hardenSecretPath(target, { required: true, retryTimedOutOnce }) + : hardenSecretPathAsync(target, { required: true, retryTimedOutOnce }); - await expect(hardenSecretPathAsync(target, { required: true })).rejects.toMatchObject({ - code: "ETIMEDOUT", - }); - await expect(hardenSecretPathAsync(target, { - required: true, - retryTimedOutOnce: true, - })).rejects.toMatchObject({ code: "ETIMEDOUT" }); - const callsAfterRecovery = grantCalls; - await expect(hardenSecretPathAsync(target, { - required: true, - retryTimedOutOnce: true, - })).rejects.toMatchObject({ code: "EACLRETRYEXHAUSTED" }); - expect(grantCalls).toBe(callsAfterRecovery); - expect(grantCalls).toBe(2); - expect(timedOutSecretPathCountForTests()).toBe(1); + try { + const first = await harden().catch(error => error); + expect(first).toMatchObject({ code: "ETIMEDOUT" }); + expect(first).not.toHaveProperty("aclFailureOrigin"); + await expect(harden()).rejects.toMatchObject({ + code: "ETIMEDOUT", aclFailureOrigin: "timeout_memo_refusal", + }); + expect(grantCalls).toBe(1); + const recovery = await harden(true).catch(error => error); + expect(recovery).toMatchObject({ code: "ETIMEDOUT" }); + expect(recovery).not.toHaveProperty("aclFailureOrigin"); + const callsAfterRecovery = grantCalls; + await expect(harden(true)).rejects.toMatchObject({ + code: "EACLRETRYEXHAUSTED", aclFailureOrigin: "timeout_memo_refusal", + }); + expect(grantCalls).toBe(callsAfterRecovery); + expect(grantCalls).toBe(2); + expect(timedOutSecretPathCountForTests()).toBe(1); + } finally { + setWindowsPrincipalRunnerForTests(null); + setAsyncWindowsPrincipalRunnerForTests(null); + resetWindowsPrincipalForTests(); + } }); test("optional timeout memo does not poison a later required harden of the same path", () => { From 20d84d30d233f503dfb7253ff5c20e579e698855 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:55:06 +0900 Subject: [PATCH 10/99] fix(gui): keep subscription quota rows readable in narrow cards --- .../pr-screenshots/t4-credit-bars-81a8.png | Bin 0 -> 194622 bytes gui/src/components/QuotaBars.tsx | 6 ++++-- gui/src/styles/provider-quota.css | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 docs-site/public/pr-screenshots/t4-credit-bars-81a8.png diff --git a/docs-site/public/pr-screenshots/t4-credit-bars-81a8.png b/docs-site/public/pr-screenshots/t4-credit-bars-81a8.png new file mode 100644 index 0000000000000000000000000000000000000000..3d63f634b79ed17b4ee531a1a63031dc640bedb3 GIT binary patch literal 194622 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%0DP}9yD{q3F9&!);_#|2!CwcX|gGY zek{#9+wX?61HJ;$t0Y@zVJJuY*Uz4@G%-ks%X7tJYk#u-Q6sf}PUIs={}9`LrsK$l z=-7;GPyF2oyy5F22tXER=X?63_-0q_QJY2S(a=<&s2IM#H;^^@R=@c277vD9(tws$ zAKI3g{j=PkX5+2STCN9Tt7+n*qM}mwzeI)f7D)=*jk48Tr=(oBFC#r(Bml+8xyY{3%xeQ2@==t<5bXIo(QIH+b6KK`Lf4c66Q~tt7xC6> zvNQ_jyu<`zY^KFPXUq5IT^(=jmY>1l5u;ta-SSxSx7_Y2xAFS=dYENqw>ZX4O-;?h zk_Ai=VBx^nqJA3oc?e$}%pura->Et|V%nV9Z?#9cc@2F!9w@PgKG_d^rJgLWq6e}X zE|daRbXR)3(7AZugP~uint~~O~3BkWk zoTJ5$3=osvf9|C>1mofdF8rc)O(hFPZS*CH%`BhovSm~xjOoWqKAG|Fn<2Up=d{#f zoKtDnm(qa|k?uExw%-U+Awi9F!!N!X{5RLo+$fLI>5 zba1T__!O8nel$LmubW|q1gLf4tN+Qcx_eSOy)zz(wjw($<^sfHFpLwU(?rDT$`##a%>h6aU|mZ1NuBVi=Sj zU`j#;HUE%{7yuJxNlDN1aBLT=LOsn{w&DhoRQmMJ$N&B&IP4?rk~$SWl^4J}Hx%06 z-^CxV{QC8ZJ5%9YFUQ$?hKbTr`rSi%BX*kaF!-dj3HGte0*FyPx(bMTcLf)=olkq#^ zm%j^d6W4WeD_zTU*a&Bdc7AMxprmx)xh_(9)`L^JJaG712Hem8``A}*B?}Hh8$7Yp z=KwJa2bzH=*B!mfgJ6|_5RK4u%F}Jj-(ITR-5Z5wp8SK?4)xc)>XH z{*HRGl8TXm0i(>wd~oX)vFGXJ>ZpBtw33|$Y&x|=r$qS~0Xyi|5F+5Fp<-vJ;;(|; z`@;}q7(XCazh(WTe5HD&)cwr04v$NDYWf}D9@Sres==wdX`t)@P-X}mHya_}Aq^mu zJXn#dE^I6(wp~njo!EA^t2Y>i^q*VX$ik*-vcI&?*IU852Q5Ajp4BZ~B)zI=5t z#3WQzTe;o(l^c`I?d=r@wSM((BgIXX1vbO7)peEA_Pjthdf{{b87t^=b8WxP^isQP zn+hiaZK)1l^Q;P;a<&`6Am3)4ZuXwXp81%mX?RUT!s=fv;JsAVG(EJ2oNRYWNQ#l4 zEq)Z1AohC(B(Qq94c}q?mreouAsCyiEH5u_9h(M0+7;~9wxB)3pi2%cx+eJi>pjR# z&F*Mz3T^({_>(aVi1k}piHawGvmjfK1H^My&|`z3SyLMs zRoULMn34);6#}CcpTX;t`B?lQ`-L+X6ikBobko8fKi2{M1o}`=Ow7>j)QoE4xOyA} z6b;h9vR%1+-90Hdc8-?YRO3uWesWJ{u)sXKl_AC~q+*T=pE>Geiu#3j+rB^IzFrKp z;oAb8XA^|;h-p1+`go!4@XtTV*|`JnnH0W#Hr~QkkSJsE-z|4+5Gz)DTdZ7Jwm07F zhK)c@9?S$VF*Bd6Z2O$ImvTFuoqwp;nYnArW4l7*_8SsZR+X}(qsj(nXc(M2);Qib z8BNI=B3OxdpY~gfK4z%Y8V14}56;CnZMDqDbD2P|=33T>Cimq$G6f!c~Gb!n$qYBwpWnL*Go!4U8GuyuhBGc7J?ck{0<15jS4T+2?*;*S(IOBgu zCd^lNe${aJ{f8HqFGtU?>;33knuWF&^wE0Nb`hHl4E+tjp>XMTX2i2R3|BLEo;*FS z7wHAbCUBQ#N1A;?xE9wa9s%#(VZZy4C;)nYY9Ds-FIPJCedfX^v(vzn-o02c1WX>V z=)bRMx;*n(_t{4|PhgnTUI{jUZ?WAK?d%L)QMg3>t|0pMc!GwWK|B|c!l60C zRL(7pW`M8{`~ih_2f$u&=$Yz;29X%aXs(>L4)tQQ*aP!f-&=|$3nlMd4XQCYbHsm= zNvJPOz})V0M+6*UqP%NEGf|wTjC-`5@sxLKfEi!Dj76b{rZ6Ib!(E4_lq>| z?$5RHGN19J8I-F%_&Z32ui=q#fo41-fh@xx5~YXp@Uyu{`OIL0>IQwl+GrD ztm>fGs)o8~n$LNp$k6&}JipRwyWt3Eyfm`y-ur!A^b}`rnN34~ZbhYh+gQL^0)4bN zJpp+8;rLo5Ej8b`+f1%ClU=EF{N^C=+h)S#e~&RUB7CM1QhX>_q^r;2H1%8$@eW{; zh*e~xG=$Uhba6hui+diiX|@ksQYCOIFqvzAyd1_~`LS00fgX|axuuKgC4S-hc^m`?-zHo&m&L>`q;g%mzJRWRNP@bGV!PFo3xZbbE~c}U zwarjp%7mn(S1yn5E6K`gi_QEu$SKYI{{=aGTr7NqMfc<{z$+--PJ)LTl+ovLb{4;c zhwq^Y1Rj}Su&hDQaqMZbw3+e*N_R z=+7UDm%*E(RjR)?XJX9J-TCZKFC#%KVrc#8=Dmyod7tL?D86#$18o&J!Bl*Mrn;-Z z$n+4Jfq$mRz5DQCAx-$^i_m1Lx+^jpy=`Z0X2V-Riyt_nDwg(rbxuH6)&i}5@gX61 zT1Tg#Z)V*34BhYvh}wf#%b5WFBZJLf=m1``cy?<&2FhV9{3+0kLqgpe6=w+)T%1Mi z=5#HP@GNe>qbQ1u6jUxBHIkXX6t(ziN?^#iV&oRR-ko=^CeLbCoQcQR*H^KBOmzlo zi@ms4a~PYdBGYJv(}X5xrChc=L{=03a}2`!276tQE#O1zhMR{XyHz@)V>Q0B{Hl<4 z0#mBRgO_CXoSMBA$J~;>mJTEz7#ff;gU$|ATo}6cYzim3bwA(;@3oQ9Qn!$&FJB8p zHpn#>V1)LM)EJ(NkR6=&tC08Fya(KxnUXHD53-=8RsAFihdOP&Mah<@n|fvEy%pbQN@ zy#ZCfoZHbRgw7K5oSIU$>eq5JbvwTme3-SGp(H(4W!Oh|{6W7fw!o#q>X_w)sk~%{ z4k@>o(GWeu5-EP$y~YH3(+dcMh{&5BglISq9@BFXH@8!k;;}m??4u3y!>hcRBP-SJ zfyUXTjf`D*{?7a35H>1#*T&metC_)X;M073IjnPo{^pym9Iea1$~TBCMa9`PQwdd& z)O)SpCD=ZK7LsuB$JOPjNl9;u(~wMmV8a?aMIP8uQGAVwVXnGVXwZ|WkO3NKHQ)7f zAXUjes>vl0*!0*(SqJVLFBQUUN03WV@qNbH8}j-KU0ZZ0M!2Elhea0tIoumm$Bl)(=Idbl! zc{=l3x7t9*vs8}GpQv*`-%Cx=ZTx)R2&tZ15_Ie(YS zq5mz$klH{z5ED430vVnS-OeW*cbLILAMM?OF3-heAe*T(=RwTRzCZ4qbHKpN3PTis z9sBI9TFyuzH$MXW1-Rj2N1hI}i>}>?&#|}+T^Jw1Ux*e(mk#!|+Yfe&H8UnoL4=ZljeOg<_Kx4xMe`M;q^o!qCeXDDB0VCK zD}KHgo_PItk^JigUxBwE6z;iTbcH#)I1s-I4aes^wC3sQ=%h}j{h)n!WPK}Jcsg57 zioaCoc#mv*3(^Xbb@l0I@DS~-PiUUdfjQsj^&Lt#VipY=x6Cb~AN|r`3FGEEDdNfb z7*ac&9qyVd6LFQ!sb`PJ(_>N?>7>8js5K5n2mhJaJ|!0Ty}s0+H*=ppb_`G-l>&XG z3RR@qx8$~PpZND!*fBOIJ{T-!uW4Pe`SLvj%_a`3kp8=MG7$|tHNOOH|?%4ZAA9%074@LcV?t4p*GGB4Q{e_UxIIW8O4nnigG z;?%e(Z`2E|PJXNpk_u+$xgW!)&*|wrK*i4Rv>woj z3VYV&ds{A0r<}(R`2Ia)#Gd~HkZU2*{G@DdE5(1HJ7?!%*BE%wLDyUdw0HhF&SeGT zn$;+G4QJ?s2Z_Bope4C{Ei=a=d7y@L1(8L<6Al)Z-AJ7P+}U7AouN6Kq5S4)xr3sj zFb@s56+kBjTouJbyUda@`!0~S`tGj`Ln#jU&|sDteO2R-mr^I_VciD4>O<@3nEM%1 zPRVqb%=ZfdQUBf6OP5c>)hV)&Nsj(JDbs}T2Mof;gf9T7Md?nWpPa0075cQ?uo`FW zsJl#ISx++Bhc6sT&0nA+TebIzjmZJ4o{uxqaN`J%y;$_;U^=|v_MEL?-4*zDj?;_u zSSVXK;`f(F_hapwX8&L#l_b8+R9zq9fPJ>p+Qycnl!JovlppP)t)Zdu4(InPdRp$; zemEXODZ{0_+4^8;wdHX)l^4QlBM1O~KedWLm)+eS#mm=Ryb^AC?b(KIrnOio!(;(b ze~ACjMQ9VrLT)2>8w2BWo$XISqojm3%`Yi&_`|nuTN#zChE&J>89_X^mZ$K$>Gcm)GFQJ?{K+IWvsLEx;@9(k3m)m!@at#W8c@kts~CUbS7hjuJ7q)D zJj(tXr{!M;MP2g}W(pXRc(iHpNjv59UB8c5{;|wqez8r8k50-a+_ohz2X0*~xw()| z`L+1@y!m&_qN|n(e7*OjhwPowQ?!hv8Xp-CtUxjZBTv6qC}AWx02(;f9pQm3lh=WT z^{8nv#xLk>-(4C=l_8EG%XN`(#=i;BD=$lgJOw+xVE|oo1@kjfZN!Z|)2dK*g9UF( z<$+6eon6h%gd|71tMqQ^0+YW7HO5PgBT6>*D;%rHu!tFkBTySF$HgfVbwo$3WVBwd z%KyobE;-f+lf1sYv3Cf1{g=xJznmVPgHa5Z(tSHA)2Dxkrjl#SbdSAp$q zwxB6SiVA`4clX0>uP54wUT@1GaNC683=cUPWjJLLBwsn9ZPq_wA4{QE&Ww(wrmhR_ z_VxPq_I(gY`Ic(|E^?^0o>PefVLuYn`^)Cni^dNR!%;6y?mZnPSQN)*#WPAXI7o<0 z|Mcr{TJjzBYds1QsxqoiGS-bChFBukpi2^Q1ACE#i^xz(u}tmnhlhvYOAvhvFQ91+ zEQ#Q#2wI1^?8fo#r=MEB&>x!}f+U%kMHMh2u61zEfZlg#t7mfqX?j2C_|!0(;g%%=aAsDiP;PFNR#G!qG10|N<5SHWK4PVBH!tsvmfz+5qOj`%R2lg2j2nm zF|DR`!a>-FX4n;o`PmOw*F&?4hRa&&W&!CEuUDjEV+d4g_guXT!OReAO3W`Y$jFs1gAG2M`so+=wh>C~=@XyR|pgw+V z(EaTngE~p@T7c1=W3J5f8_~99klt);lmm zSA-aau2-eJ!VTWL{VO~+j|3^c;B%>pr+Ot=3*D!!-{kJ&K;%UVLywB~SyGO>=QyNV zgPAI1t$u&9)krq>@=^C5O9wMBibp(8>9de;lsfT*j$dYG=AX6nakOz{tr|z~JP2ZW z9s6kq{L^*jdI?SS^pMQ9S3itRlMnu|R=WC_3%z#XscW;3xkRZ27pV_Wbesgxu>$E_W2T5Kte-g~Y|VO?QJd5xP>>=uHB34G%(L$B-a{a+;`BqM@nDFG3ZXBzNZB@IWS89l}u|1i-+X z_-0ovnmQ`!@#g!y@>xp~Uf*$hb$mYm;YoWLvlHv&Yi(msBT*V4mMVvvMc{Ru!qr(( zd|TljhfPQHBce02p`Sgy9_fy6Rms8T4+arw{ZPAI%OdoyPI{c@;xi{|h8OhO=@*ep z%1+eGmQ**>sVli&k-=yT*ju-7VQ+C>M|X;P3W58&p;?#3a|d#HI;m-j*ak5a$&{>_ zd=qFNVw3?ej3J2CJCE;o&Fh1>ODi53DSoPLt)oSS!XOOE=%-5JO8-%Q^#e>Wh`DvI z^1T(zO2|pN#stQ>=MtOiaHI_9T)Sk7Bx-vuZn_TRC5OIQNcw8yl6}u#dra-Cm%3s8 z%O?yeMjZoK%>1a>VUll(1sF0F<3Hbz(w6EP)UrUJf?|ZMv_Q1`{6ovQp=J{)M z1%~TdLWF`aJOnrmHOeF|nybO6r`&ca zn82Y(1MkByo%aNM%tAy|m@E{+>ep}|^AeLw29;SV2~F)hurXsu{@66bKF1In8I&*T zYgmwGqJz*MQuAlL;w43@u-*KVM~@Y+M=?+4abWr`rI24wxq`J6yx2h0K=lkdL z{?|l^%#zzyzleNuE|H8!XF!x=_w#?oSe?_Z9jg0$j_|69{P&ONL}K5VnfWhxHa-d0 zvKG-j#_L0EDND|7?z8^);#Gm`d;%~U5JGsdG=KFjMzvaw1YErz>jdw&F+ZC67Lau# z-by#}>y)=G5&i{)1zS;4ln%Doz1+XnEpX{FwekK~PcP=ld`4bC%sqT0Ac}ly-pFFN zl+sjD%&n;Cm?`q#&%2ZG|ND8@jBB;RRzE#96<`K}7^98HEMM5$H=fiflE}*zHMT=e zwaQ61|3jODvCxMPLHWqqTY?vn*F-2U)}Ql>y}60fo1A!%*V?MX*Zc3*zWmkE9S3){ z^sPRhyC1W>2Lo3WZSm1%d2ry~_ zGYJFp5#(pG6)H#_QGeng1i_1XCTGj`+Qz})9pGHYk(z#d9C2Px~EB8m=IA5 z8~c4O#p1tDHu8lhR%`IogED-O!2RP#`B#wo^gfF1UQ-r3ejzFBiWI8K_qSoL16?oK z<&V3fXX357_u#AEeFk$Oxj7Io2C3@a1D>|?55cu`k)h#O_<6&sPN{e8HIc+i3*LBC z{tZ4=u$%sQjn(UD_cINg;D7xZfgdV_ga17;U%tSq>%x;$V9@&Q0|17mPLlk^W zl*MgzFFHipbI{{}=m2oyG@_E5=A<&jN}rQ&DMVxQSVVsKX9s>QszVS#|f*JYEhl82K1&xwHJlc|6nG+TnW?W4V7O)a1@(i^!U9Itz#rD}qEat^CR+!tr+m%fY8%p<2S2BmR-2fcXn|&4P3ui}UMo zgsVD;lq|BP0m1c{#|uL#mPAPb(2aV(aVqwPE2oCGf!brqkrG|=P1K0{4<{eX4l=r| zae0%8BSp;$H7<#ff~EqkWep}P4p}0Oz8Nm_2{q*vcO`A@*G{S~R`866V$Q{W z97Bqh#5#{ZNd5o11)k!nT0VuT4X`$fur^Eqxk>$g<)@i+`qw&cC}cY@>aT5iydy9k zrdizEjzV53To6J0+;EURn$52b&}Mx^Ip9O_`0fXnrPZuG7Eg23h* zU1c+2wi9QCOgo>04kSH$GMD6I9P zg^rW_+620WD-DlPRqC?wmD#f`IJ%1N8=F`U&yI5xUCUFL}$mFDb6p<4;-aV_r__fW)18G7vud}wHb;lLZgGjEjBIjvWD0#+iJ+-XJjih z7ii2VC6d!j?<~|zDe6_KSE0 zy7{sc`jYZA&0S}fv!<86QS1itJ*YG-Ncw$z$CidpnLvF&yGx@BOJ{%=rRVkYy3s^p zCP{Pr)vF#)^o|m1LvBcW5$|S3a&XoCB8&eZ3yDSGI-h6{eb<7E!8a@~CRpfeASZ?a zQSx<8N&ig%`W>23^Qh*^waXUUL_lNej|| z1Zr%5<&^-+v5ga*J3C8}FYUn9|L=>CCoj-tZ7b29-t=rPa5bDcklt);e^ldkb!l$N zqXX(LzWhvyrUT8Q<6bh2_>&U!v<_L^Bjk29rTK`$tG#=M_cv1!uZepO4w@LNBORtc zY8LG;MOy7msc4?%^*QSZS`KIECO0{!GCX|z%l>WBZ4SVUj?unL9%*}SGE0);Ww{)KL{`^ytxl@lGp(9uf8%UIy zCU|I9jYca<5YTZh0^n018;3lyFpyBEEb%tGb7LP}ixBa$I+@~hNj%Y-Pq9{eZRHXU z&3k`ePb2H`S5UDb&h|zFm8M(n0wZt4RH~B?e(ZCNX!I4VYn}<+=HpHB{$MX4zs#NI zG!xfg>|0hOwlGQ#48WHUObuz);wBJBl{DO_S0CAc-&y3LgQVk5{36p zb&f&c{8^)EOkvVw9+N79>%q)NLDD^)OVfiAFLN=+Qh$AiqC=t^a{T)P>NhGz`LmX% z|6&2sR})jDb`<($tXSEp1BDt%f4Y>fjcNEGMqgZGP+w54Eu8Z?wc9@Uh4)t${`WQ@ z%HW;~XZTf}x~lC2AoYGrL)03r%CHe}6FbCHV9e?m?JJbD+V&I+R|s!3Kz++xZy1qu zPOt6^aM`?){MB&tb1xCsIx{bAD?H3{^Rtw^cPkaXa4!D9T~(#2x1#C7UbqwLBt@L}vjSPG+gZ-p(BWhx zp336p;*ZV4PsyWpVXrN0>Xy5VLcSC?T_Izk9eX8?7bqk5J%lU#hW^0TD)A=qw-6vs z7#%{BxISu$aOM7IrO#Qcj8PY~+8K+iz7g4eq5IXpgLiMOJV88}g}3bjaYw5)nJ&ql zZ~+JB$^M4$Rb95;Oc{^$a5W9p{k`JRkdSHnTcK4*w{5v~^BY|FYcgjmbp>>%MLJvU z>fxc^kM!RT1a`HI_LbUJo*$RR;-CA5?#t%;o_A2X6x#S0PhISXep^7uZ?Zj;5siE$ zIj5pq|6W6Y zqdn3uc9ddWt3!1Ck6ENfc<}~jWA2Z%Kb4g-dct&d&RR`tHCkS)hsnBuErebd{3?oX z5=ZcFYsI12VU-A8kC@aA@JUxi=(pH0l}s*RN87JdPPY_NKIvXd)FC@9P5XAFgF3KU z77;LGW0G;(3m)w>jfzv<6q)+_5UXX}62CHkhkf3rh%Un&@C+U@WYi zlo>3mP2(|=5GI4#>^fOFawgn7_WI;I#w4!Xz}6kl|2}j{!uRW@J{veZ#8U`UXnn~f zVPZ0_5b;WrAw13P}zQ#d9k{+(|00lU6eIFHXD z=!A8gCT-rXqS2p9B#HcYC8T`FJFD7ynk`I*RKtr6VyN;uu{dKpei?j&ys|sH60A2qHjR%(%#2+rk9jZo9MM ztyNsRnq(on%41fuDnsX%Cb`x!7rsS~ zy&!J8NHMHTJsakDOl!6?AAQxY4d$GNoz8!+2z9b# zLk(FOtRE>dw0f~p@n=h?hUyTsBj>%bBjWoVhGg)hZiljeQ!}!Xy%%s^#2g{6;M@f-P#*b!2mHR z0YMO@LlicW3W%h1hopkEbR!ayN=SorN{MtS4bmWpbTc_M#P~uE&t{6_T20HQ8|^s}5p+_vqNRYz8NKD6{ikJYc9M+D(8px! z#PD?}r(}LMLkH}A@u$?BqBS85oqh|))vlnz3pb4qo(NAYZPj!|VcouD%w5vpwBNmQ zP&?tkRP>G~`|Bf-!evr1zxj|wuIFE)%c50JC4!?IoPKyeb}FR8>R_r$Y_4E2huoR_ zQ1O0^n7DHjpT!yGy#sNG>})5IT5EZQxcxYZrMc@MN#K~Z7V7E(>htRB7&XU>#+|?u z32de05hI&V8rKJ-q*{r?VK5|=`I|L7*oI{Ld+V)H94jKF!IqOD_Io=mogTXTV{I7+ zj-?G7^{+MyS=`_4iH%_dq91&DdM(`I(b9I7eZJ0qnit(8v$D-i>*qffy(~#MnuHY^ zgzmDgEcMf{7pe7!X62V7H9iihF4>n@tZk1vMGsnc_(aJ`V6j|MtN1APLsv*${Kr-| zDaRBYZLh4v@;OKg=Gqx1>dR-Qh`+yg>gl45Rw^-b$iYQBQeh~2@VQg;VVaQXL-v-Y zkjPeA9-S|{t00+$?hwiv*d^xg;?4FPcoPuA3M%wH@v?QCvq=(?iPOQ<99L%Lseffz z)%t!kwP;+&RnmQ(zUH1HYgUHrG0vh<<1r)jTS{Ik-I`A-<47EI&{&1AYq;4Y(#&L1 zppNwl{@~u{ADwY=9Xo*qQQUV{N5`%*sLOLM7m&!=*lIy4Xlo(*z4+sJ1P?ktmC{|Y z;)(gBRe8L!bpf4q&8LnHEd!Kg90}H0>vR>_tj3SUF8}&+E23iW#nej&DEmUFPw$w~ zNBo@)6{VsA*OmzG99hV_zA8wOIpTeww;k@7OePK3it%Sve0$%$3M&ysDGMd1I4=Z7 z93-{t%s=lK&>1lRO*AU^u0>Tb-aZ+=h&i1(Dv?5LA#zC&Es@sde3MCNL0f9PzQ)hO zTUC;!JugV7?rDa*)~PEWw|4GJMf;)u|k< zJjI{Ej%_+P?guB|8C2GE*xJEPcaNo|Uh4`P#8eH;IJvyk=W7Q9Uk9PxMBYuLgx}G_ z4ax?B>u@g#dnQ;;Y7~_Zt}zxk9)ze1`sNCgihnQH5p3|hOk`mg*dHgUsbM-pdhTqr zJ)^Ork+XA+Tiu=MACzmU8>?~|t2`79O)-W>keP0}5RN9lZ+o#cy*9_X^np%GU@#%9 zgpnh)T{ijdV5!y;h)Z2>c><3I5SxD zV^M6Q=+D!EH|T*lTntcxqNE2f+(Y*7i*kc;WSNZzacXs(kq;>CaS8p9sY^DsScRq* zbT3*HFYV2Ys?c;;WTd3`o3KOLXYuT9F@8* zQ*zVN$J&*QKMXtLR+H#oR<`V|ckzOWwHvb-eK_mogUr?+&xDwa=L|boJwX#qZ!!$! z2w-3rUd5O)$A8+7kGghy_<3p4#ha2AAoX0&DCH>)HQhR0Gkt#K_xyDJU4^&O3gZnw z@~x~ihS9b_MV1S)klD(!*UM%Lo&|N4YG_V}NnP?@5)o6kDqCyje@cn7;@e>R-cfJqcBpyNdE{{9=)by0;3#I67u3uP4qUpGD5g z1N<0V$M3pHEPT@os&p1~xHS2yUIcf|ZRO0mT&ixH&KdTiW`urc#52SCoC&4h*BQS- z)MZxG$N8Rj)VOnm$?(NKvPzfwk;gS7@r$n1|MR_o z$Bfksx=i{IOVld2tq9?i5{pVXwNQn?Q$9N^>N-9<HH2tcn7Qc z1dx})`NN?#eScmIsonmH;wGTC*^z{sAk(z3LLpVb3w6y3*fZ~TLp!Fw!`EEvF>j!N zHu0hGh5f1ka0Y({T2qfvPhNHLGH{V)@wrz?Qg*%>F;Qp;z3nb1B@LF0bNx=!s4pWq zrU}BtYZ7%E_3+c>H87LHFO1+NJBb`V??j_EwZ(1J;fS8Y``0R0pTxTMKZnN$!=pJK z-YGp8-d-~d?}Z-%gLwFnR+OMEI;qJhfXk0#9hyvM4rZV+=` zK=@0j?C12Lq=yZ`DxKe*Z$Md!BYkv2b0{I47vt#KZ$~`Oz};x5bdID1=z+J&(tb$D zN1IBGIvm=G)>*EdI*EpjxQ&z~J)U1jZ?m89$y11VK4)~PP3h(0=KY+2bt2DGegD`w z-phBa-w0gx3x!JkB9w4Z5C3ZTMFX0A?;jEgO9$R0Ug<@r_h#ePJBh^MhVdD~_@4hY zzW2B*Xu0(!)h7wD2cXJqBGYh38%ZwJ;Cv|mK$QIXK>m-#<4(QutMajkUW&ib4ocAe z&t95t*iqz~#Tvm4mJ}Me!R&9~Lu5HNJI`TY+@^Q4)FXG)CtN6vpn$1&>TU1x;%m)E zHHGvQuY=RNe3b6Hb!zpq)>l4`@%mYN%LSVllTh&L9A^iSC-u>Z&RtZn)<$dy%jgAK zRs(AQEU%ztFO*HI1-K#}(4q*FrQ0g`I2*H{D#1c%fKzBQNyuq|nq%nSiY8A+O6GlD zP!xJN%b;K_7%U3vW<$x+NT4Jnb|22VdU-%lCal72Be0n!Nznwm<=`#I~dCL(yR^J!%Pt#I_W|aacGZD+?LMz;~ zp}Kq3<+Wy#55QfU8mAN_((c6+f5n*S)p-a6-m@#rt^JI(|Yn+jj4flK_Az9c+4O|I&T7xK~5c!mx49dESB07 z+b1~$d~glZYDK@dEzl-T4=BD~nPJwyteu*uKQEJ!wX16N#C({SlES|}RoIw-V8D4j zR4BmHs4xz7nu*}MIQB*WU=s*1ENE!q&so}v)zE00a6ypWLix0;cfsvzI<#-~fKE7v z*6phuqeq4HIVyDwA>;bnMn6SWuj*Sq*|#Rl92tXA(tOI=T;4rbaIXZP zc9PIHv+rSTki!(_`qr;=YF#>63=+sQ_XHXl)8Ysj3Ohcl_qe^7p z-DfRvTI}xUZ$OBqM8?4&L_xw6#1^|Wp z1&wdUZzumSZb*wE+vn0SdB-Kl>2QDF{ezurY0Gn~ybmE)I?U`w_-LVSiL=YB+Lz!{ zmK=rq@{4BcFfD$cF?-6>z3NwmZs}*!d5{fUrdT`xk#MM0yaSMY;sH|bZQ`!f41RaM za=^(ogmYEEhG+qM1i%X}m?m9%32el`XKgDVGswT|d2X<+HiLO!$4Z1F+ph1I+m=R| zxSY~%ynKF!;|_(q?vJd&Un(u`A2>d|&tIe~nsX|;JEYDWD3?K-`+&u8aFJQv2zx)& zIcAs5Xuxinf|g$T%^#Tp#D$_Za{ibS^RU$$n($5?<``8ls-)|cl2&gAO`LzZpDCTe z8q$+yaI#-}Zj}$<<~K)JM|$(h#>anzbZ=)j|JeAp9U8|Kt+#5~W0L$;v@g%lxS5fF zQ1F+C@%KAIDxo=4JbjOhT^}vS zt}RWpdH~%0p;_<&a%@K$_7jfOO+O0jtxPu7=SAilP2jxEm zB(^*zxUz?n#y%hjKcS6;O2!DFR=j`%Zf`i*=C!TD8AN!*gwz?^F0|y2X3m^jt-@0C zOk9c4%%Qyv1T63vZuOy1ZL^({tAULx; zt;T+$5`%EP!PMow%OKUG|zWXQZUYYAx3(EBbDd-GPxFhUMdP>1XQR?sa%? zST8zK7Cd`gQ$IPrV6(pGi(Z^eV)3LFJu9mqnYh-WM5k^sH=6i4D;|bJcW86MO{`0T z1A_osy}T?ZEM>26rZSdMo$>1=@YMOK9*m5CcvlNM_sD>reDL2k)7(YqRXAEoAZzrU ztyUdn2Bob}iwRX%m=!VJG#Jb&Q2sG)DVhRDUlzU2 z^#GC*kqn%K!anbsobL}qOVRvz z@Vm^6f{r)G=T7`d4{P$^Deit`ieMiYr_$}bq|V8u7DhT;`I1j!)KoMM!)q3?rC$cJ z#%t^3izX&l!`YgJ3+IAWDg+r66rXkLc5lc1P;bl`lH#G<-@o`ByhRynJ5O+~k%+S; zS>1(3(NuinQvAfT^~@F>Xx&K$rc=;a#EB!rLJ2}Xnzrun7&wf1HxuPAVol(d z$CSSqs__al@75Cw*QRvbsl%qV|6vup$(3{8_RAhcZh^rN(dfBBOUKn(#ZMK2oJ_U@ zvsg9nu05;xy^V0LhMUyKo$-KsPADcl2u*__ZD2`6U3RLMEWQM-65NnA&_bR-X=_ZT z0=B*yfB0t!?XZm$!_LO6qE_+7(mQbRc{mX!^E6Zr7Nbe>O*_g|KiF}&sutDZjitRlXf$oBFl_hvZDmwGA{8~wm^ zaay}l*T+r8UlV+PtY;48-l}hH*_9Hxl{fU;+hTge8PnRwcU1($-)>x|4BrqK%mY^W zgVD6B{w^#+xMO(k$gzBjYuIgtu>n`B1Br(XzXVTLqJ_k6oW5b*NhAkA3uIC3$I8p= z+2H@t-vEd7Y1xXN?}dFk8<))#Nrmy31-W*NhV2;|Qg1Nw^p#LB$A_P1P~~GHq!;~F z$LU3Ug4ms9zD&u`*3~?j{YIa$bs48)C%7Oiu1d*t`CCToC5bjw)qWoW(7BQH>}qr? z7;B;zte4)A*x`R*$Z)g^`PLwI^(3ZRLh`#$D*A8UN_3vftfyD1(D7e_L(v&H{C&zm zovlyih_)P9V3?JNwy)55>#@9#`N+P zOA_PPgXfZtM|SdZ$OkIqFK3^(A$KmUIENS;UjDg`D!{F|ed;!@*zHQH!}^-7-&Uq7 zEXKIG`GaV1$I*N59eCL$Qgxl<9+>_^X>B3)nElCJ5FQPpw%&5lhkKy(vp3pK;-2wK z%8}MnBd3sXMJ`;syIgM|_hUj{oV;Vj|5mfq>QEkS_C=zrmE8S`vsb85qN`ka!p zBJ^brJ9U0g)rsZI&Gv)_;iJ#9SAbKXJ&q2FXGWH(RVR+;qxIF!45pJeU5E{J&E2WS8`1*+Ey1_DBb*yF@W}++A8Xg~*S?k6H5a7t{X6 z?;$|T%Zi886(jgOI3`qAX(>k&EI34HA1IYfc(m{~6%yF2lMkoIe>}aY$-R~P5tdP@ z5rRevS4-_EaEY_-8uvCUe=qZPxt|>N^V+zdmIh`FTb0HvyI{4s0CPf|(YXZuWjS}4 zzHCxmRdQQ)wWh|CYi>|i%M?5ey(Ij@ZoEDY+IR`e-C&jpmXLeGEFqai&9Y$Bb32DX z$@J0Kfa1G9i|~NgxoY5Wk{th3*N8YLvj((Nz=Kry^e*#xQjBF?~zR0a4e+F+E9 ztoVhg8O(?kHy4{LS4fa5FahXldw-2Z=+X?9ybQ|HIzQR8jN>U!?}I!UwW+;O;|T5Z z;;U&V{5_TA#seI@Pw};!X@u~M+H5yPFvAc4>ag)66BRKASG+ zbXzEu12jRbe$)4lx{ZZ=P$RSM5)UXI>u;B5Haks%D`&^gC#J@)LnH7RA(uDmImw?o z_+u`LOa$$dbkgL)qwa4l$ie#X#Fw!oesoX4DG_1cj=Lm+PJ=UG;=Fd)t(gY59C2V@b>lX$xnL{?LVuUD%aU{M1LgFgkR%!5MHr9Kc^QT z(3uDov_{i67lRe9NbirQ-MDD+Y8$A@>?&mkyFnlelj+ zC6WLhvaArj!mKr%P|s+wj8|#;P)1Xa_Y@e+>}#kU$BVhSlE7m%Pa?6VOv7>V*`PR~ z<-SC6?xhGvp=+-0;uZuog^4XGGp`%Y=;7e`FOk;2CFF#x_SzgVP0pS7Ct%Yn_@hQc zrfu)m0dRwmZT9s@LM_*vl65cOaI>RmD51Y{M5fnO5D=HO4EZSEotA+d05NySKlSQ0 zqZIsq4baG)duIVm-0@O=0HL?E3OOcf!G<%2XP^<%q8#G_bYj!do@yFZ)Ok$qf#NuxEtf2$_#y9$Z zi_BTe{?mVZ=_Axj7d1XS`1eftG1ar78?)-r9$o>$8r9cg6-TVcD4ucX0Y}R23`1n8 zr4S?WIuQ*QwViFDKN*3zSkpuIU&h;g8#Ufb-){VSyu4R_>WF|4A@P2uE_fZ9;)8pC ziNMRDzq#e8%~|^MX@AK5KHqyKOos(2L>1aM3e-zIua=w5z>gi84k{=i6r=Rwu;;ps zkPsWjW0F?;nWFNK?M9H*$~;BBXNg1G*19Huf#z52vW9V}w(Ea1E@-(%#OcmRHgw0X zTU>#t^-xnpp#z$_#RR}{UchAvu{T#Ppil7+|LfVj=YB!2ZpY!HucGS}+uAhZPkKo@~!dN^H$_TC75&Am&H z4;8FhmgKjBxykI^B~@P^hel&R@G3gJulXT+HVB{$>v>fr_3jY03Yiwm#X5++=}!Wc z=0Bgl*b5lW#qJ1?_WFCVhXz5$;J(?vZ0vV20f8Xgwc=Xn0(Lf?kf$J6IfWTYIJem}gK*l0wXJ z;!l`?K>VsHl0kG`gx+m|@-v4rGfDexMR251aygXQbyfEPVJH=$29pYdIdwFcG2+9) z)bw5)W`e_#!)+g+py+$F?Ljz2zl8&P7&fyL@j}}*B4dokT0ew`sRHGpMsISC z{oeM^Tn{zA7k-$bis2^U!fFQ69qqvFu~3ioUTcnFh-d!@w31{rh;kgk%YK4b_j>b@ zre5j(t*LErT=GlsGByXa)rV*ZUOp0vw#hN%!?ZsIDNKXj7vXVV0qbZa=l_fZ-U@r~ zXe5jOVjVrx^|ArA58b0eW7 zDB;_ZVmDPL`pFR=J>xT_+4R|*sxKJbnD+|Nv@lSrV&Y8Tu^;IZW#ggkY5FKcvg8s? zs+O(~-zj6f&ww>dv7vu}qb8^PpE8(+G&XA>+qwI^c2;^5jfG2h8D0a-MOQ~d95kqiC3{! zAn~c-x?b5hbPO5In)G-e>&qY*iKI5F>^)TIIMMFUxcg=jYE)WNra$}ZC1i!ZYp|@k zw?N+eHZbAzQKREoc^i`=IE^+XpXyPAlqhcE^xt&nFJNca|L zusxYS96p@n|K7%3lInmVXHS`r^4^6I0OQNteymCg_C&&2?2617RxcI-2kLj6l@6~K zfHT9e=n4UUqW;)9ON{A$_vjI^F`k_Rwy>$2g2rAP#Z8H!A{WDXTgk z6^EcIq%VahQ(vIGMtkK$%(P{^x!H2DAxa03)5 z*Lf(k*C4ci>@h-;pA;Jr3rF&guG63Hu76N`yamz!E4hHqgZ1df)S?9)#VsK$ctz{Hr|b2B z-xFoF2Q8>%XJrDdetfJsMv(0o90EYJmHFw6-$=a=np9hm%5)ttj8RIewbBcRk|>2a z`gmfAr=6(u0L&fFjY}yHSK5!4i~|Ay1CR;;X%5UOJQNSO3bI!pczw69kO+zrC_Sw8 zgJna0et0Tv`Q4v^H>1ed5AT%$TeDB(4S<-R&JzEWWbz!~m{F@;_Z1~nJp=LgSB>8t zn}>dgxxOD>2!vDFrg|YMl&hTWn-LgY&rx}G^m~;#B6-KdhW~~yH<@&OB0DF60pc^W zwsY1WCqCW`p)YR|>q5){&`r>WfkKx+fo;|^DCD;ghv!ejUr+Tj{3Qekk9m+Y$&snDusWKgeMyv6~| zkVwDw2X{M&{`@YDl4+=~99&34ovR zAFjes)W1<~mAVIAu;IZ9ELA+pW=eil)w^+qOeZkJ$%#r#u7vuo8;JO z{pmJur5>9hP3F%gFI=kfwgxLaM)hs%mDC&-hh+jSc3s$Ew!=%P`%PYc3*AaPv}aM* zu;KoYBWsl6Ee^4#TMMDJ)wDRIw`+t`6`MBDtC0_$NCm(R`iYBUZC-K`lW@KL%&*g9 z&klVQqLKAut$VGa!D3XLhjCKC(qDTkaUJxpBm+>rCPNuVDXpistD?+ z66jtjMA+aAQ_UeSo17^7jO;s#=)r5ion3cH7M4=N@RpSy%m4{XL{@bXJ27H>I8NV2 z#Tj~Vbcm-A;_ulA1b-YFNoOZWk68cHZ*fcBlK$f6oi<^LM(2sscU%h~uavZG-!c8` zwULk%euQg)KH*^_&28rIhWwz#r58cy*Kx6h69ZqiiFOR13|@SNv=r$w_=MQ-Fa$!_ zL!}KA{(QWyt~JY@sEGott8lNC*FFB1fX$iN8T)IB>Wbxvi^EjjoAot+2}=SX7F*yh zEt9^kI~!l8q9aGr^(qZ?ZKNpvgm=X)6Uk4dw|^9!F$rQp(=f5mQC)BN z3^Hxhow`{mnQwBY?ytAu5d1H1ql@n)IGmhoR0Kkh@JkKRtu`W`_=Q7j$IApsG92bs zrGyaU13Gd4roJNa+-*wFlPUpBG+y%xj&%UWnKb88Z-$k1nrS}ZCY zx5C4VoO3@1j+nd^eYwVTLNKiI6UJXdaF#s(WyIk87Yzb+Th?QJaD{boHBsn(-Ht3w zQBwOp(|__$TRJ>XM>0q+M}Q1*5*g{ALGt&=+L$msJ~Cr^XxPtu|Ec@zUXyX%9e6f6 zd<_>}kdgRfPjIl6$^UV@zcwGN4DrJ>L91Kz?Pn;9P;t$KQ z|C%u`0Sqvn-4qAACh|N6g%H89C}9r15R zFil1Lz?m@JCO!>S7dY&F1?V1tk!YPUgk`WZu_F|sO+3$Q%yO-z3+58^UxeYh1KYuo_583eF7$PTws)kFKc z8eJ7vI8YlT=j`$Lc`FmtCgSwZA*eXU`LaS&+!?AqPf`o>I{0$v^OZ}{ia@KrnUyoQR=>l4hR`{J+(HbdFpGZBSxz%7SYIwII0zkg)9 zu9<@SA!G_>q9+6^6&X8t zRA&qh7pgrS#jST4Xt^=DW)xz_K^)&!_<&2H@4Vpnq_SyWO^eV@6((`MU^C%g)8VaV zz5)tjMdc@TaGQDT9|eg81%dAkdohBUx9kL7RbH4Ty+k1jk-WxCe))%?Isy;ofSOG? zC2HgZgxIc!Mh=^Z+U=%~L;$SS1e2SWs;SQ3w(7}U(d5x*Em7i$FbT;}amrWCOi^Aa zQ4{97IhczmYFa6XSwrZJH!pIaKorqX>h z8x6kRQbs&4Fvg!yVW&Sii5)R=&kI~yZM)^+Qde%nRN5o&T@lptuZm^5B8VBGL4|#| zb!%Yj)@a~oO7VzBpZB1SH)XowL0K(T`|6s*m(vEeZ>E)xl;tyd?Z`7fAJfon9gSOi z4eeb79yVWR2PdN*CaF?K-HG#U%Fr4gNhrnSGksMbSw4K50!_=TzINjMMRS9KTRxR& zSdDY6mG_T4Kb5N|%tcyoOhPzH=A)l{D1p?MMq=c{(^ZZH)!C$;h!DM=Q^{D`aY@Wk zLF3uy;2?Oc>mwTWVQ(0Mmr&5Bs@>C+z)Z6PPm@^gp-&ac#W7=tHtP%$j@NHJdt?^M zsDy?bFmQG~^v?*yg}{BOYSIMqs%mPiE7GCaVNUuY*z>u5KPXpkx-$8qTQv~hY#n@= z+tRN#+_m*AZa?vT5O|!`&GFph*CO)3lmn12*ye1k+r&rStx^UeGmjgBdKBO=mcw+= ziEkXq%Ni=k&$N**p?u0(Z=!JMo6w}euoR>OQe(ndoDM;Z+#iAPn*J}(7QSrbX9BH0 za?9-hJbM!!3L1D4!fr$GB)gW-z=)m%*5Mr$jUj;9-GeDX(X1dsv!#~*fAWJmq**WvJge)!ePBU259#2>H|A=Gj|k0C1H z9&hkb*T)rc6kLD+Ol~TFwY-4vB)E;M6Wxu1=4m|5!1a&pr%|>IzHi242~Uq?bHf)z zT17_86p`9<$~HSBl#?dbUmoJJBrP0lLHNeUU76Y&@OQzdD<`RWTcVA9dW836 zs8xB#93A~ApDF*{XT1_GA5AZ2qj`|R>Ghp0KxxCSu}#{3)#7?zpllTs6r7rv z06-R|zgM)Mkpd zg6}(Tf-q0c*+}1C?pP*o$m8$3p0?^Y6$I=^^CsZD2rq0fOzk`X_Hlr|?90}0JyB>e z(;;A2fWqcZO}*H93q=yW!rD~=i0ZP4ti`LOq>OIbS~^-bnMS<`#W-Zt0Sm29^SP4v zbcmeD3Powphvre@6Z(0+ry!&}#Zsq(B(}l}|HOBbH4|7iet?^Dpxb*;z{Wsuf z_rj&MT@(+$Lw)O(AQ~Nt{=Iy2)SHX%>}$}%#=H0LhayXCw(gvX=Pf>qg^l3-{-yT} z=DB+T5HcQybg1-YUaMO0>Xp+Y&c?d6NK6wX{$~W}wLm54tD1@}1q;gyAhm^oh>M5- z7JKAkJE&VfP@phY?B>4AHSA0h+uPXy>QpPYv$&2xdJAN>)XuUKlaqrcRan4w3ov6X zfDMjEr+||xv_t%QQownd1{we{gqIG70+>5cX*$XZJQ25vsodF*yF)IxDv>sABY)K zEk0ianph)1AG;N8{a}MUm+4+O?XAP-VEWoP5#H7l*-fQARQP40RzHq&&3)G|6ScUM zX!~IjQ*d`b8Qc;!{+Le1mUxBfgvVvmq#pJxy4r3}Q33jrud$j=z00J255NDXLa zHlOsn-7k=4GNdYf(ubXk+OiBnVq#(iSk6oA-dpRE)^S+NoG<6>c~JsuxKca5N6Kw)y|camw9Wvd zFHyRl2dLo|02M14U%LqOHwL}g04N?ANlZxqke&;DpE?<~Km}oM1P2T*43^o!UYIWn zC!VhLB}%vQ_VNOTUaMBa13?uv71ohe$?u)Oq^ufEYgY_-z*t5=H7{JL-AS*M_Y9aB ziNnFN0XuKZXVxH^d-s(5#DNe#!9!#Y44FjugL~Q!AG)`$H5-gP0o}|51J{AAZmq%+H2Yr4?%{=L72JE+v&Q7iYQ9nOT&(d%esqs@2CQaUto@el z@~Q8XCRLpbgKIRK<#_!?O40K%ij=Iz5*SSpk6@$>UmpR`{V}SMt+f^6L43x2!VVl>UIS-ebjcnVp05G<5dh+eGLF zOV_?BgS|*s{Fqcc4h*cF@9Y>$e}1{n=bmDf$jxRxEfK>=iJK%8cr(>1(aZ_#r)?G< z5fOl!4@DRi3&}SvzuDgY8pgu2IgGBlU=Ld#EX#&k1*m?XFOU`2NDVBjeGv4&2Hv!! zm0s9af3*5FfI`Mg`2c^la^uwO01$Q1E9NG^b^t_f=p)Od)|sJ}PJ)Nly^!bbMxpnb z4D(W#>d6PLi~}<|kMSTUv3yjVDf z%^uP9fl(>1KJZMN#Umi+P2RC5GBY8y|9QWtT!>4dL`8VOEOi$)L-3P&3sLbv+N@Hk z2(jfigZnedG2+pjTfm9Cug*W1_Dj}q?3w!b$5w8;orMCc#r{+R-0d(~&DGNu*?Hp~ z^jv8cbKeHfEYd6IKZ-g3)3Y8e_<6~d%`#G@3db^)A3Afz6W1NXH*ZbNmjRamZ2r08 zuu<-$j>&_)B`1eyA5d|7KhD*!EGwx%Roe^biK$Iftua~WOkO3@zSEay?jK&!%`5_} z{;Z-T%^I)4$0028SEaAs=zRmhiYMZK@i>@1+aCSdH`qfl5xij%5U#=Q)(f#;-)adZe06?(C9udJHG}L8cz_b*0_RWXjp*9y z>PM0$lV8IiE~?>D9sK#~%;kiWb5R99tML3O`1LekQ+8vykeNR2Xox8`-D@n?Fn zVpQl89X}9U8f2k0p}a=Nj-}%UTTFyug}ZJj(EsD>%eNW@q%u>4mw^f%@V#@M#yEU? zYRh*AsNI6))xtP!ax*S5d=MyDnJ+64DL71;ZO--3e{fWwks78R{L{FZc zG&LuNRCw8Vhe#qG!>Ny#dV(7JLz%U96Sz<`)6{fW#K>&ekdA67;7@iKO|bLcN)uyW z3HMw%$zyCRszppUBVC-DvU4AlK6NlG?+4fpC)fZQ^I0C+N9lO-2Z1L(1PuXA&iK>} z@X{B_Gsd{dfCODrM5sxsn2a@W~TW1 zofhCN7*!jIG#bdW+5k^v@cs5ez(tCOCs)M5Co@AV$7?3vF)wxL9ZmVAP)IRENF^Sa zfV~iT`=fP@fADK)9chSd#U*EO{UU6g;`kHN5Zb_gI8Xtt7`)Jsl?(v{y-~uOz(EC3 zpS%he303RXjLKl^Y#NyqvgGJr3hbs=DiWfewnWY(TQkO}0NY-UQ~(5eV{Z6rKf_Wm zvrtA}{NVtIm6^hynZDVRt0TfL2-+d6B5nI+b9u}m zsUH%7PoWzHKy6!1T0!pWryX>;VtM}WT*dP%^Qh|wAC@Ki#PktTs8^9i_CS>AMWC8v z16u^B$j{8D*4^3%eht!~j*WT>|E^SN!4hpCAX}Yk4Jqg=G}WRe12k7&hZM7k##S&> zVEQ?&AUK_-)=5lA=*m=8R;51*r)LLBOF7r_ujr5hK>flzlb2ofTVO$HlGgUdB4X`w zJm9!{S9Wj&;@-Urh`ZYLIIYhc^;cRFS@DSMI-dHLCYAb?mQNXJXv7j@D>ifBQ3z~< zQNCR{GGwv*5`yi<=ZixX>LuBQUtJiu$OPmTBG}Ew>mk-mB?yp8|E6j+kT}R?ziCo) z7B{X;_~X+ki*K;@V_c7$A#I)IG71+ygr1 z!PsO*nT;-wmi^td)C?nNZbP2}I`lJ%EaJC}>MMl4~R57Nh6~X%a z8|W5?@jo{5Ciw%A`JD%)J3CMffgfooF^5szc(x+X&pS5Ym4 zVlKa}NQvdbIJ!W~l{LwtZv()24_Qkb)ZOU(wit2n%x}bpKB_qlU#OXhY5QFyHP9ix zoLZ@J5=rbL}nsJzA6B7l~gw+rA4DT=F-%d%0o z+ng1j4VN+}Wi<#b_LJF9FdKW}jWRAq;uxt2AuL7;imDfE?Ce_*27HsdoQQ4y`4qnO ztAvYPeCsnjqbw8wID?eTSc8$(&D z^z`&%mJ(_C!ie?70a_iV1(R)5C~Q_@E8l34TfOsoQ2p1E$%?)=J|i|0|7bHyv{x7P zE%C@r@K*q9|483h3TglIGls<&A9sF zaOL|QcNX?5+!wD0YYli_J+P4Weks)+bT%g6=N_bizy~G6rAXKDMJm4(4Cg12gaZ`# zIM41+*IP>0jrq1aU}|}kvp@%jO~Aw}CSIRY2r*k14ge>1xt1lHzFkbd>C2fHrLN`# z2=9G@@*k8ae+1tyDjv`46er@a%&^_l>&+g4fN|Pslk~CTHUv<{03p9Jfnw00(b47k zq~FSC7GA$C6$z%NAd=t3{LF=-wCS6%Nc+uf6%14&^*Bx-t*67rc72|(o}b8Sz#=3+ zLMVoi%_x#}n?me5W2Tzy_%q>PRj;8w{PyN&6a##Hihj9MnR!r0SsS#OBC(J_v$<&F z6^SqW{+g`jsw^EL#o91+9H4nS=_{lDYw=B!RY_m1$&B%GnQAVZ< zOgL4Klf|PmGplCdbNAfeB~wlW#jrf}i`)U`S(74_PtSYgvg9FCnKTc6LR5+h`R(0H zDfQIWR@NGB#XPcWq?F{?7v5MLI0HvCPcB6Bkc7!xX44XxT)@eL)WznkZi|;Ga*^)N za}QAiB*tO@JNXMAs+Azq$*3%bny`4j>zJaknojn*y^xTv0O#%}tFj6mB2%fB9=Qle zTg%c*r53lL@R28C9^6tX)GeV*bx8_6ko-;fAPywi0m89H^RGCTN{7?r5G)QAe z*vP}GEfH>K1HaE8q#vg6JA~bLh$Q0yHB-y1G_dDlhw_a6-(89bE z#~isu^t1Twt(TOEjga}wxL)9XvmR6;bw$0%Yyyj|pUll>LBDTAmNUUTnIxSreA)%$ zn)OoIMwfnO*CtRf8&cn+p`2-pb-gnDNkq+NA{dCc#CAxwD-QPU>fc{vJF7Y~RqVhHF0`y5F+xcP$~hgv zvjZ;Z82T9O3ZN;dI;NO0W@^TeOp$|bUAU;I_6Ts-}TX{2B%uid06;lR-rS_^W@MMtCq(V?$oOW=qzri!} zO5c_bSZoCuDmt`2rO#Y?r2*t4WD8;BSlsrTKM(gjAJh--)T1_ctA0;rl+o^a67~}dHwsw$6DM|nrHO>wUg3;1M2`cqUhxsH^`Hb${4*?X3c_#B800kwdO9Yc z8^$@2`P}I*Fa5Nybf)$qI#W@n1g68xFV~o(&Wb_~J&ANObavG~Q%rrvTtJTYXLKXK za-g)3VsXgBVfA}GMf15$d4}W`smo(^c8{-Lx9Eg$NRs2JFgzcMNpn-dK(fj8{2SfC z1L}9i{e_Yx+CVbg^p3vs!#b2lNsC3$t%E z(_Vk`t!<%X)X(&hqEbl$q)W1CGLlQRH8qf=iIu#((*iOX-`j@#Gm4aYNb0y2U}=UE zV^lbDdI3d!)Evp)X?HjE85pmKK1wNkW2oHNvW?QyF(yWMj-XsU7SHf?f#$8%splcn zSMNusl%dFtmsrF@X4|+01{+XGGn5Z#u7Xu;HRZQl>}Pk_Hebu!c-#FJ-v&72%wKeF zUKd2s%WzLQQNNXWY0=4tDl!ae4c5m--qDlN)lE8(54>8mzF7VGvO|juB?py&)xxEP zSJXhR+(&3~#p>bb9f1U)Px4=YDNE^$D1@9(w%8StqixezA6y@JX!W|SJO}uKRLf}b zg%*&^C=a(TOwJ{pIWnjmL@Avl+RnfZNoTOsdk*Pp*w*&ZAK}OMz-J~722)JD;3o|> z&xRUA)f68XueS>{j@FN7Mi+jLRCQd=n|kD|r3iUM`uksg*-(In;}T$?KRPgS!6d74 z=@4|T%2%;hQy|4?c#s!JO|_` zw_CDb_b4wE4JF=Se=$krs8`~3C&bd0ghW+v7EE2x7FPizjA(uqDb{AYJIh`M z4Ste;&{}xT1#>A3myQQR#7cD!V)Q)p@#*Q&6MZ=*=2@!{CE(&K1mW8#jRijDmK1i0 zq%OW6VNT8b0bkAf{?5By6+*AG=k`iRvdwGiEpd6wNs}P0T!#SO+*sDspCgV-&_|~f z?x)-+`kL`NB8PJSAs!)wT5&;Cb#O3tc$&b#^86E=H@*n9^AX=>Y-Ct%k;iBD%cYic zAu-MGLtr#0-M)Q0k545C>E^NpRYKUjs_Am{7D8ESWyR(*D-}@WfP$4e*jy+rIXG4f z5e&Q{sc}2c>A1i3bRpzpnRlvE4emJ$#lTUT7sXpAyqX9%Z=6a4Um(V@AOLmRsg@{O zwuC|`3UgR&#i=_Dls)xVitc&G!x6a~wS+VEShBy9Yg2`R{!K(gdK3k(@?-|+g{6)j z7xf*Q8iitWJveAkvg&#p((Rz7d{5Stk{;MQR0 z#Ip}ju|D%qLhk~JsxzO`*T*+S8Ti=-%%x5J8@ctfayrN$YL@i6MVq3WC6n5L(qXmB z2^eZ7pHIz(uiS%;=hV=V*dfa~gID$WE@j5kcW92-?=t1z~&5ebA5Lsl6JzXK$ z;@twr6fBj*Aq|HD*-#7|mD}$WJ5xDHuk3YB(>Z;#J&>(Znjf1@DIqf~*Y4xfpRLKNB7qLjBWQm0EQuOCmT&prgFDEJ6khB-3Va^Bv z0fCtZ1T|O{P!>O!cp6h>OfdOk{Ui_Ynz~jsw1-Gk^BZLW1@`_#K1_jwfF%E;we z0aG*X9O{@9Hv9Kc_vXis9z6qkXt3N6>{rcnCX1A*FbmUg6<bxsKfC3Y)@F6YL=aMfb( zyzjFe>{<7`Q2YJ|;{p@wqye*nzLrvcx+m1ZH^c5fw2Xz~A8^h5>@Ru|O(GWZMSXSYGsIJVzbA7ll9w>v*@6|yXozapM6Cyk}d5W{~ul70gmPWw*NF~ zNku6{Lsp5TL`ta?WhIh5qGZqPkyI3kjIuI9iHvMfl8i{UkeTeg*Z;h!_`dJ|y^r5< z{Elyr=YH<{v##qp&-1#jW8VU6gc1iFVQH^Sn0t|%zRiC+b*qee6cyKHVW>ua zj=LY)Y3AnQ5`W%4G&oq$N;bW)Vfy3_YHF!T`Kkjx2bFEV@>CJgAKAv8!$bQ!tXk@p z|Fonj+2180Uous@b6_miN)*7F2?EBMSsF=YEHq-`@Z@5ft)Me%< z3(*hhatWFZC6C-5Y(J*<35Z5zjNDS+l&0>n_x6nNXA{T$5g@nnG(A(I&^{B0y*jL@o?P z+|DY;;$u7*zOrcdrbxJvJU73e$?VD!K(Dyza5tkRcDhSC$*#lQ#I9o-=XQ%qumyr|e-YDeeJ?CdA_X{LxxpmAljzI-~w4N=$ z*BbdoISbXT>sN)D_0%p*nU1XFOt-hce;rzdr!Fr%CWSVSeOW_bf+rAbX8d|~D|-3^ zM8o?FhJ@Q$L`&{&DljhOTbM!r8IPYmiZ5Fu-9ou(H0KYTV!m@6Cw4s?LOD+VimYV` zcl)~zBmeIDM*?(n-Ct)?b}0zapqQB0gOW^8KLF4o8Wd9uUIYz02c@1Zui3ub1O4Rh zJa2Y;o{0XF*q=U%W`1TN4!?bU!Ja*&J0`Uz&?_a8TW&n|_|vPU$zIB~6KCb0^i{tQ zvb0R&dy@?~0$l(PmgwqQG6GI~T5Dw&b6m+5f$n=7lXT+I~PxJgH~{`mj{>tn^rH;tJG(OvQBsN@ifk<@vf!`$2m z&|*6Ij5;1CD2<(|mt#CYobc>spQX&BGlfiS=rdG!3^YGCQuISJ5%)a zD%{Ycom_AB10mRoi61gFCDHv)b~0_V7|{cf8@Jl4>Sk%kUCSz`Y$v-@0ST&`Vq$L8 z#Dp0C{`Nw=zSRsW6i}k(pbODaO7{7bm!{881_H!@NH&`^!NXq=mKM|WRpJGAaYmAH zt)$Vh{W&kbZ9m<3=%Bo6bM<}=UITwo!?VX?>{e)U5C7R=YuBzlG5LE>&t}##oTBfT zPQ+n>)xKEuX?kMlo%vYb&)7_CuKMPE!i4EnJC9bdKxmjRJu?B<+4cFyjfRX=wfixV zW?-tOO3HuxFK|4@EcrdFY+2R>>brJ zxmX1FUAC@3z-m<5h51dK)tFw-=9Bm2T>@%@+bQ-$x?hc(OYqyP%{YzlKXh9pIG+ z>$gmX4jOuKF~ge|7|Px=mAn=Buog$2j_d%mAwvkMED?LJ-mt@D__t(_kh2{jDC8}U z-fYD3oGY%NhxgaJfHJoyYo^p26xeo}tj;N-KahL#Ih(HaX{XO^pJq4s9vD}S0HDLd z-e5DPSZ}VSPk*R&vMY9MXzuie9fxmY?Yt9hr4Hi@Q4D!4#W3Zrk z-*DgD1xmHWM*Bp5E>`^>5W-j2jdJ|&h^wcTSnVS)mJI4`2j^jrkJW~ z6O8%`c>pk`Or$1(gpcRV6?lE=VbFCNi zi;BuQQU^ZMy2@K%mTQw+wO%sMef&)V^a5vX9A zB%moiQZRZBy2o7ai%>-uj60DdqkwU}c5IuV)Io`_;Dxf*c+pVno2f-5xD9%({Svy+wrV1aG7lAk=?-6apYu7FXuw&xd z>$e~J)UKV@mq!FYk&)-q&&aaDpHk9^cCU#)F*~k)ld|WD@J@?QRx5Ac^n;&s(n zqdd(X^gdwg`2JevjZ|NtX{FpYMH3H8thvnEL>Cc?$Gw(=*)wCZucY&>zM`e_nl1aL zBMlKouvWfBn{GwHcF@qhm2aNw@uVL5a+WpC_nUj?*P}nn8#2+k{hhJJjSXP_bnie{ zC)D!@Xt&*<#tCliG>ZU)qNX42Ham?KtqmoZeV{s>^p^inYMbEpcm*L@2 z<_YofVV9_curj{(u{j*R4TX&L)m!3}J*8Ez3;*TsRv60C1avfrE5GTdt`8$?G`HYSV3|MhPov@-zDB_rXg?>%$b|;aJso{Vs@; z3in*@E*;;dctQ8#*HE;@JTEDit_fXJn9a3hy&l%>1>$mpSchGo742|#m@11T1vfug zhEA82m-g*`XAz`4=soIDo3vRHK2Vw40AuU#!qVeCiDf7d1h%LO4~W>`3_c6f`*Ii^ zP{Z6kPiAMPPC_Cj#W-B|G;rUQ?769|xw3PV8T=2}96_minqAx45&3-|;h0?u@up3# zGb#kA|IV)Q@z(L-GI3X{OFcBS=zm^&ZXC@lO}4XE{Ls$|2n>vAc=PlL!K>fQYr5~| zruRK8AB<(miK*@|y9<}QUVYruYLvQ9JTO{uw_;andm!!c+Xi^5#X0xKH&^%bXA-lq)ge_)iEN#p@fo)}drns3;7Xzv9fibs5JV)fi!;f)GklqYmat>4vjMi}4exn|wY^i{lmDZuI<93dbMzY6l!{7z%i-Vf z+k#{Eq#rSCKGRcJ@WTIujU{IO`t{tIds&20h*}Kmw$wjWFg{WD|t_k~KY;0_*zKw7ejRfDx zF{Ikmn^xr?Twhy}F>hEnp$SfqOgf63-~&k-n-9^+d}cm#i}SvR}lAJHD2?L&X$gXu6YXfbTHv5143r+)pR%DvY|2>&aqsOJU!hL3v=9@MXi zzn*s5XB;=%6L|R;l#J1S++;z=x4LQ-O@ko6uu+oT3>~w*)6+`jOv1$M;OE>7U6Wa- z!HyG3_j^j**!WthjPea-AucNG3Xidv>K${fV2bZEPsx{mQXI>lPHld%>GsVGUpnVz ziVa^nei#Oo>6nb}vBC^C<>?!lnVFB?sx0Eq902B#HB`)RtHkMeAOpMs zI_hq-2#-Q*>5MX3DXpj9-_2cC+L6~jF2GSgs#!!MCzmdfb_-H&KJ(u01Pu-zQ`V+i z+u*QC@h3hIhE(uidUres#zY+XX~Um~JId|^RIxtPlDVCUXr@6?(q%v^&_WYN7!L*~ zWE7O-Yk3yCQqMbW0uc`r<+Zh6nOmykAUVNIv$hlLV=DQ@XNU9om&rv*G4&0f^WfL+ zNDyDmv0qV=EBW=x>PDj;o3^L2ChHIyLN9YzCEFe@dFOXpg5XnD$uLLIm(BYbrq5Z4 z6Lg|Z9Fr#}#l5EkYc}op+(i1wXusgD8i!rALhtDdGc9l|9HOUj%0sREWzOdp)QLR; zqxRa^GT_z3A8<_oQhd0BMkOOxyar~6>(Q5D2YPqWM?E2Q(o(a-+`JCWv$Kg_ zV;}?f)pyg#{IcOigc4@G^UJf`1BaE(%tpb*@mY=Poa{P3Qg79}A}Ea08i>44_0dDz z+~Jm!)#~mO47>KkADFI`*RMyjXhbg~S+&F#zc(cwa=R#izsl-GP}5OqPiAp%bwbU% zxk}-!ih}~#@}Q2K!(COe47>)7I1F6mPfa!v)VhkzH8X6fl5#gUI{KSwH~~7m8IIrX z$C}%y;s0cMdfJa$t5efq%^9#NoP&o)ut1*z0<*hxtX>J4g8H{x**YE@ z3Q(T_?sMO49gn#1p!LqVh9_f9l{JuQ-S zyHycTtp9|BT~^8BI`Sd9s$NI9X)(Twgg=t+tO3Z*YBUM!ozTO<72JOKh6McAOxfzp zTUF?vOn5aJ85zw=XG9F8m`^}342rNWT8?{e?iRDY&iAkjQe0_0Nk3}e>At3YaH_yc zsuJgotc~rengW12Y`OL5m}2@sdZo>gXXOvV551Y$nnE{;^a8d@MzZs-?m*7$B@#yW z)yZk)$gX27QfZ@pek{d+&cgHw=Xp>&jZ4v~{9!oR`@sVC+^_y2)B{_UKq^BCPYA&y z#2Nbf%KB;&+Hqj|^O#GJ8j`6Knz5X$qKB51+x8_1Lc~S(vxlvs7+k# zs7p;q61UXQ)Ffc{1g(lCMNcoEnSlE(XprH}!L_fT_Nm z%X=C0ogWq0ia!r;KgZ?cLU<6rq~Z>p*mXKbflZKRe8!aT6|zy*lDu9b$0m2?u1fB! zSJF|t_2WTi83}bdZr{FLN`)fKxyREh5t;=SYKf>*s}K1*2bn)@-lwmFwItQ=y>0#8 zOCeU=aMY#PJlW!pn*##0T}_I-8rKZLC)@Ma;587Wg!8db2XD?Irzkrgp0t9< zymT!>jNXFT$O?PJsJOQwlRumC!P&k0_JI*3$~F%Ef|BovpI?*{(Qh2|3Q#C-pZWwX z_lQdnJ*=?1OT-qh-_^Am?Ug+}ao8g?+DV+)&MuE0J-rp5_~I~H2*(@_=zec~WP(99 z=eff7Hs6ALj;FR3tByq*F*@3K1QUvMV;Re{*e7iV@@;hu*?UT!eM=8f)018E_CsiB z=<6%5e;pHU9WPjwS0--49S=k6wKZY&3W=lY!P=MV^LQ7KHK!Ra{?|Z}7m!gplZTE! z0sKg{winm0H?>gk+A;+@t7aRpFMC;Vx!lP=X~_1CNUuGjJ$lYB;UsE}{9f<8sqt+J z;~2_aZC!>q)@8z!@7lbU6HyH#i)MwpP|f3chejMvz!IvZ@_jw=gSnurjPUx4| z>lQ}q^G@0G*u)#(`NJMxo4=|bL~Wzkk^Lz{Uak?WnR0JB!}k@w>_(b`6jAvEsF!2p~csbo=l9(S?3kxE5>5W7IVIvP!ywv z`s-Ii{1J4k&1&{~nG!?SMNJ38v&PK?%zxJc^QQ%6lRx`1VmYdU8$e31- ziS`c>5_ao3CjIz*EF*vNLigot>%jHG(yepiH5;eLcYfixnYO84d4A^=UpksFA z+e8gDH8n80ZReDf8j$Wo6)N>Q@91mK$1;1A;(W)eBgb91@~PE#REj-L=O6tQ`yMAO9U)4V%p8I2&u z(mJK}iKyQD3C+|21gFq?ziwcc#xV>3!AKo~5>X$HiW4S?#1!FG8xdOEtMMB7)Upa( z&}*g=VIfQk>&x>OFY4f|lMv4@ER;vGmKqZg4o7$Z0-u1k%Q>9sR(a$ARLzBtfVLq5 z5nZG7)FrOx`QBHhk94gD&FNWq4aTcIx<6((e%V;&O}Xs~7wb#ywra0ehNibZ7fyNE z;bjlX2YDK{`p#z>+voou%Zt+Ca@H(N)V43rpsv%jpF4ROFGVn>k4jF?DQs(u z#OVkaqV*i&JTFG*wE99^Io5Wv66y}vt2gq#Po5k?q{~Eux#;?l9IM6zj{+%gkD8O2 z*sr`QPcywW7n=ZP7fCAYP2a-APDgm>{k%BKRn__RSXWZpwOxrXnEIIe`r~Hqx#wGn z-gniONmg)O{UDGgFC@z?=4W8U{5n=S^zyRd5zTL$E*;YI{$_y7h+J1mw%KNf4(Trj z*}sUUIPc-eZKOT2|1l3B6Q!$HBhQG5h={;3cC;+O(xEaYqUJyk&gDLVfwmAiMw-K+ zJBbh@=oW`UNQm9?JL4S}! zR`+<|p^k4E)-B~NrJjRrA+pgW;0JMWG9XEvHuNA(uEhK{qH$SKe0@}c&P1SkJ63kF zth994qw8NH%#b|LZl=+)qv427bw2`KvUp8E2m&qES<_b9s9^0sgY~y5`dng$N1AoFmbrS>7g&u`#mBqr zHq!A@et8Cu-fOHQa~i%s*r@Luh$F8d$nm0#R?e~={#|;4VQqi?WNDa~BlF~2gSOJI z={m>O<)`VUPOOTF(PjVQrqQ;Q%Lgtk43gUH149;PU+JTv$F=+|_INvg_gK#vRps|# zOUR)EpH){!n655+)*IC0Oa0EU;A}*tg$2T5EOq&^ zz){E=w`|#BY_y#I5#x~~M^2njO6L6h)%eF$>Gx1Q;5q6pskY|pG`<1|vRY1;)+CUm zQxhVZHr!cp3nD8KawB8oqq?iqAbgH!Sv-rls#5o)luRw(y7P*B+0UC2_z zt7cX*f8I1YJ>I?f6L0^)bC#S%i8S|a!ulRFsNPuCe@c}qNBwHx3V!oQp$~rd03;uZ zfOi#0D=8^yWQFY?m@a-|LIbGu@#L=ZsFs;e7}Se?hjNYinV8xj8}+7z`#y!d4S{uE z{b%=Za19oS=Zhb#IaU1 z#~ZmE=~;jHkkJJnwmq>?soLtr0S9&2_2_HvzIv)o8?o<;*nz8NXXam=G5)?G^DN4U?cWp0Rwt0EX7<7#C5d0Ce%q>r~72kq0tQnVKVOG zuQvtR6}>ajl`-u4lzVPNX5yr&%`0!$x%NGFc@2*3nmx0IxhK~=_{|b7lG~D#c*AZ_ zN}^~Re@yg1PoLk|rP`*MXEw#JOy=IbvsL!w8TWe=$fUc2ndgFNztYe*kB_TFx!caA zj^C&e$ql=ad$7hMjmN#utNYsId+Yg%H1_w`MLD$`dLu3Bm)}{$f8MQ&LMqQl3Koak zDLb0psQK*EjV_I)nn#+lS1<)xZY?&TY$Qn}nGghYf@WuZAxEYO_$Ori$<|G*H(lul zS9@-SEWvUaha#IzWbYUL;ks(bp4FHz)yOPG94&hBVzhj6jFzhH?1fVfzklD56HY%v zFL5UEX4$Wo&-I?r5~`uyZ{z}6`iw_@`DZw)%`uNUIJ}GDJDA$&+hxb@KAS0!Ec(Oc zQrF%L6WLnb68#z7OG$H8?ho8VF7|Iqz3*Oeu-oQb?y1bzma(atqHfIDn;2_0)dZMb zdZJN<-9=o{j_b)jI>u+BSWEX?CeO!BoNf!RijpB0;ih=)f-)+-D+Vp|z~B(n7>?mH zd7mCKoU|S9ZvsJLhd`%-=iUZdGvqu>SwtZ`vC@mYV|E;n3A}lkx;siN;RktmDvWd5 zGY%l>q_o#f7*2H`()4l#t`s1o>z;{z_;7vrURa{cHgEZYR13!)X+Jt6X|I8DENjpO zf{av0*B@tyXEEsraYCN6!rK)Nd!M?Bz1|d?qP3G%F93^QHo(U^S|asV+$GxY?&9W$ zn(k!YIT&Ns{**_rPj)Cd;>gs`PZsoSvwY#Ho8Jb>($93;-4jTbKg!n5{ateRpMlXJ zOE7i?i9`$zJjVZPa1IlLLycUA@vOP2lj6OqIE#TnfDK2uVuf^D?14vCeVOKPHXO1D zn|%DJ6u4%oa1pS>8Dc}fPDwWlZK9{|IhhTG%?Lu#&aDMMhF08LJEiJEA4(&yJ6j7i9pZ4axVX6QypU`_7W&}9g9^}1GqHqfbIZ>)3fdzr zEsi=rJo~i9A8DGpZ!C@D5~%WQ==V+L9V%wObvX8Uu$M;32gfV4#u+|Uub+NPbaRg= zeD0S%9z+sW^|&RWpG&Htw+Kk%X}L~TrC)M+en{SuAEF~)S1{@fYAt7;TH`&z;wb#Q z22I$|YKPQl4MxC6!6^Sga$WQGBkIT{g4)!BR$O328de48fSgC%o6IeQxu?dO-L6@Nn_%l1H4S?p~f2nrwxRw!p7&pXh81B6mp zUF#?^Tp&SgH4O<3wK?$`L2@81c9(7ZzQNtLFR#}Ca(>2hYwfyq3Akp%8!8XLLG1zx zbf5%T5%wjr;3mtUIty3aZ8ZRffylx!nkp4$c)B8D<+;&bWWWt4IBG>xRb|bs>s;G7 zclOpkhK&Z@e2w~bZo@Wt442+)RX)MR$KD=Py8n2MEB%zn&VKcZ&;C>Udis@4M2@kJ zDa*qp^h`9;KYmqQ?>M*d*ymxjobBw`2OK&M*$6!2d1Bn6%><$D+EN~Q=EM3NTK_Xk@A4NKl;D7_*XIm2Jd*phtpAw0= zJ$-pFyGh-)vL=IFziqNR>C%&zk6z7{y5E~^2>cO#DR;Ksv+aeu9bcGNn8;n<(Mz6K ze1_unYJzveesf>*I%&Iy>vM^AV8CEE&<*%XMnx^@cpVPSERQTSAplh>Vg$vl5610Vi z=@069(hASK*DVpVFr%D*r@~!14sU~X^ihe4AK|eE@57cmyKg7@U3%Qaas}rK?@xkv zhhhmT|13lr5o4iJEl4XVhD;9oAXV^XMG7Js`FGk(M+prY0L#(eHQcPM9Vo(}kfbAL zxaF?b4LJCB$To8HmSS5dLTqZJ4md2nv{`28A-xfB`@B!%w3aWe;X-NA=zTKX@C4))Fh-k0hY&WH?+bQhQ->@lGx^r#VxK zbXKODHSuk4RdcA!^;9aO<$zxG6+Pp*{pB%~gyP8C249O)GktSLU0ofjC~O_O0SIYf z;cD@04BGYdJ_|;$Ad9<72LM${1%gryz>-d%KAn-vO8S8?DO2Uca;C$Bj=}WTkDCK1%vTnxf~Kp0!8aeuVh@uab*DB09^utni=& zJg?1LLTDqNUfx#lr2DwmjQipF*+&P@|KkL8lR9m`La@dNIWb7MQ_=(IPb6ZT5=e%Q zfH)SdtuyLoe}}qL(DUs*s#*NZ^wiJy0OcO-;CaTS{y9=Ilxp4YAIW`SLKw;~15qQw z0VZoOATBO0cvEcO=YD=2#sQ!;XK>t&C=nde-JOjXqyWC6bBJ^Wmbqbj6WM=;_cTSU z?*c7AZ0?j2xtXkq(p*t)Xxfc>Cf2y}%`M|W1+C;5gA46!BH1$f`4(2a4r>^?J?un$ zzlgE+(o4_3>)S*B_sf1qoGw@+g*fTg*XM;qwVx?DUAR<8cJryLPG}E$VgZl3V)!#2 z_Ao-$l0ocrXh;mF#|My~KHx-6wJs9o6sFbDQUr1;ueYeJBZT|4UI!84XsITts%l~~ zJRr|JSMq}I!z1>$b2qyQo{uQAgEP>R89AN9gnE9t=hMqn%3Lqiq%JlzsWZh)w;jAU zSLf9-^upyr=}~3rOPN0ynVKBMP9>U6eEOZ-KFb|@Df8MuR7K0!&i&Po10$RS%|s0Q zf){FqL4Z>6xJghB@fyk6x8)^~DD)np5+chTzf8}qc@f9Sq{O*%pRtm6qIL@41PeTn z%PQ0#X~N`e(D;gCD9CDC6|Z^v^l5BQDxva}B2d2E+}w!P`oV_e*Dv27_r6dU2yN{z zbZQ&x1?FfP$KXwGp;IeXFSP2J;h~u0M<*uNy|PW?Y>A7StXS)+fDq5|$g4D!7#6;X^fOl?tQqO+w0py%mw5tuqQ#sEsXMS1x+uDD!#Y*%6GDM z$nAK{y04aK6A6V#U}$b`1{vv%!;JvhW9;Yd8RIl^ez!&H)#o=qWCpF-b2wscn`)ju zCACtg46Yqs^E6|+<&G*9RT>2Y4~>%FP|mK2ZH)!-mtCyLh)P)VSM-DRWCa8SO6f#E zKX2de;L)yw1BWHjUl)MG7iTh2)dgS+r0oLz*(+dhEl^)$4rh*_(gB;F1J*+I&j!{Q zN?NR1mV09Beu2T9!9BSG&y-aju!?G!Yeab2nJ*ZC*Qk%>w5o-J<}>5GRYNH+xKR&7 z+V;Dj;{E7K#FZl2)9{`tG`=DejCs!Y#*~@1y+uj9wavIuC7D- z{dsOxB2|dm{NWxxKFiiG9&iV+Mf`9mz#ZRq;My?qmWawTXV2z3v&9>`nst9g(DgG~ zFBR^e7peQCb$4HnMXtcLH%8roLaM&+!t}n5i>67l_Lf#WH$D>9K4Fp8?KbVD==o=3 z6GNu5f$)JP0TgXC_==QWN7B77#l^(WP>b&1Qu~Bd$*6|R{ef{ z=}#XAR35%C3-jbuV>PpqcXM-yhB?((4D0+1ReKcHo? z{^O=|#KRsy&JnoG4V|oa@7_K2hM2k%DAe_I>eH^AA_u)Ug5eg{>jsVUiHAsdhdo3H zZ;fgGKHDIyi`|2Ba|SE20;!}phNYvg8lnoyY#D9Az=d;z0(T5dZYa(AoSYu3rFt@`l+>BV~cCwhB`^)X8b&K^rg?+;?4u9wmYBFKX8BaB;bkSf`>gn zvfCx$xk!+L4~K_`v-Y0aE)fQ351FIUp2|a>$UzaO04vidAMf&le$*TP?~b>rLw_tM%rG z_v>o8gtWF|9Z4lU>(Qa6hkI0gix!Ht4mnNIpZ9z(lz-GoH#_4_Yev%we;)FCr?to( zh>$=mkUy}P`Int|$EsgZYkwh+~WQz*MdVllmbJbM}04JqaqmkZY#sW%mb zJ~yv1{pGQmb&cYWoj+aA+R-bPOfO8{k2N_zOY#jGmH&AHVGpnKe%#h50SLm)I{}q* zU${cTWv|?o-2SHaw)LndK&Qj6<;CK5S^NkbcJ3zc0L}g9TIYfyS%Md2qDwr2bayZX z)Pz{$5EQM_Y@@Dj`LP;%l>V8IzW=k}lrq{t`uX!L_@a^DP^QGTpOi@_hA5u2qzOrT zm91_Ho=3fp4TKbs-IYxAZ#&jcnI93DdWV1jNQ{i^mFJ^F|D ztu`9r(+biU(rbs$KXezEO<%>m0E-GbuV9u)I*PKx#F2UE-vZU(J&hjbBzwU(KLe8w zhQ77&{FJz3py%1%{H&!g@@aObV_5WT`*Nk<&^?#piP8IdyW}Ii)sWAdl6+pHi}Py(PAS!@5!k;3Y`oe3Z^C5l@%-8dZ3+G>NQcl*)c;@*w8272tEfM|c z6Z>}@N!hgoP(YbDY%?)U&f;l4m zx>JOgJO3Ow?KlYnhsG;FKGD`HaC%|k3*H=3W~G=c>2O|G@?6S>tnpzq&yS{ext%l7m9bi!{La*;yffsP9}FL9RnQyB6qJ1RDv7=E zuhOu5g6)QeXU`oD@ahDw%l+s)(;2bNXMO8ZCb6fA@cVdTsGjx_kfY z)6@SxwTII|saamSY~=%Da_6t?2e8c)FfNsOjCB2+aW%ZBccC`%)m1>W|6MHfUf-9k ze7N|f7fZe*;a6cq_kxm>oNT|tzrP@nb{{MN9Y`WM64fOau11CRAD59C*D=y@v|s1N zE!c4SE<>@$)=iE7e#`y}3T~2ui*~~+Shx%hqdwoA`HIJ?+ff_R-esZGF{SEavEO1d zYqiz6RQq)EGw2;hQWabhoTK|q`XVD)eAJs+DUPeJs1FYmusjaGO8;WOBnpFVO%~J9 z(SdSOyCT}p-(LaJN;r+6X6UQ4>?T~Ok?rl0<6E(P+?U*;Wrur%3OlUA-%I6~TY&-1 z<}F(;BlJRKt)Zd8qWa|7vuhF(Z_WFZn5@~-==pAQ=CSK3zt8ASsry=3IEB-CU)E$NPHZX?3+=yIBNdS5-`@lkpD%28 zV=N)!)1Ss;0ucdb*Ax`|>jY4>0m&jKy8`5JdZ+OO(M)}UW$ha}3xR*=z(F_Jz9YKd zhvC1YtlrZffR$-^w52DDP3kYN-avO4jwF(PC+=)KDY3jEoPBDY1_U5Fh~}fZRsl9*XJ>B91>f=5IWyY;hgnTOU1_8f>16I?5!Y{4?2Z zG|BigTp(?8mXRT7vso4trdVyiU{H9fbRJ*L7_xbY)P&b28p2`8%ye<9rH|+tG#@$-Et9AshM7#;1z|A?v zz}8gHq&%r;c=nB#m+U?2UtoH}^<0rZm^;uCOMCeDEZI&8e*}>zST3kRoY4!H z*LDrwMimX-=!5p$Nq~^t1tzZ|R{>ad#A;SZAx5T+OOo{}CcU5QTi6tI)s6`4(H_ov zl{I%$My&6)CGbiusG5<-ooe-YyB1Ir0!IPhI~Gkzi}DxU)zA&ai9g)%^4%k4r_PGl zgC`*u>C~dPNPPSD?R%?B5#WhY=bds{={!L)|4=HblEnot#|${?VK@3&j`E=Q#s z=wVScmr)B*X)ea!*Y_%lC!iqVT*n3vZ|{A8FuKG`>ML%^Fev*zdl|#2@#XQ$OV8eq ze|N>Am2#w_w#jIup{G}0ccdW4cC8sx&m2djB)B!3hQJO}hfkF#IY`+IF6f`5} z=4;4Or$70*ypxQUr< zguD27OIgW@r&Tw9zQ~LwZM(JgFBXtwF{CMX6+jI9YdRD&tt)Gx66&mY!+sLFt8L{8 z2y{^YE)|aB@sU)Vda{{6wT0E-352J7ZI6yp5jzJ9LWK>MkS=ESScFFdiB$ON zu}r|99cJJQSRy1OWb@}H3CGtjMmy?-ZHtvTQ3A=VbATkQq}PC&N2aHO)Jth_D8E3ONIt^{zy{@ssFGQWp2Bax%WH_;ZW4b!ODxvyIByS1i#4n2C61~!JGDp;6o6-njFntbX7D4mq|z}MHynW$YOG8<4elR+jd5J~^21%*Or z0O4(q2-+1pRC_D-lATrB{^MOm36qMYeIO8q0~479+XKv%fC4~@UjrlrvN4qJD6$F= z(+XgkYlZ3{kR$uWlVr;;msp>pw)kogy_%4nWH({;!&#J}eondIqLFyTE?&GS*tJSR zPyqV)Jh$z%)YK!xc7YA=ZuyMvDj}!4w~^ufoWm4=qz=xPP-M)0-C6qzHL=0DT*zL`RZFwzi?yj(k8 zt>S@tQ~J4PLaNOsJ>D)a@RUu~J4p9OqQ!JvUZVKT@<@;AjOqA7vA$|a#qqkKlJ?eC z7BL7+P<|n}e2KXw?f%rKuo?mdTf=Era81vTn0BJN}OiYMy^=9?W;#{n^&FjJ3T&YvPIh-D%{I0X2GFplI=3p4~ zhU`oocrWl9%olxOARGLUq4eQB42x{CsBl6&LG?B05g7n~Qme zF4nsi4%S9R4T>vnW?<-RCEfdI!j_b6!p5RVz@G}Aorl>j!IdEG@4TvXLFmB(u2TKj ztoMoi_~cG&0A5N~Bkzz2YCBvi9Gk_JJ9VK@cK_IfnsUjEWwmZ{g~$brRw*zB>zJ~Z z>7AezY=NqI@0Z(c4xV)8W z%r=}BX0SG%nYwa283`Tvp8F!g!mI_xy4nNn#MXR&`Sp357EVv=6QBnx04eA~K|lD~ zQ$UnJHn78NWk;>12l7x4>MGV5K~1e0tOb^s22^jw5EE|Rym{Xx z|8kUwwKI>>SP1whju}kQn@ak`@~-1C_(%__NkiLWv#1q zj9Kl?2viw=b2aWG|3Rq)%}W!h+%^#FU1X(CXwHHpDqg~W%Ie|{q)Iff4s2F8mZu%OsvXd-39fJ zwZ%s#Wcd(MXre@BP|a18`#}0}I!u%uQRJ zQ&i16D=e&~scG4ZqSv1i-lp^%(W~R#wW|pMx-As6rW*+y49?r&XH=6m_`V|W)!Sd< z!#HUDj+kCUe&qYWsS(&fNHI)@f1gx;x6fNt2QV25M#Xmi%o3WS5YnejyI#08*C@km zjf{Hbt`K&{hww64wj@obD9kRz)+GC*2{`x<$Zgg70`EX-P*&Z zO`DRz_*?c`>LHzNTXH>Q_wdG&wP%Ie?eyLUMX{O?`DL(r;FWxNZ-(f-@vrl5WA`r1 z%Bq^1sSb5lL`6CPL@sKQ<-G^oX~=Z${;_~?Ln<4M{GdLQRCO$H$o$ZdZqg?(mPkP3 zhi_Ht6q7OBj_pS|#FI0_p6ysA32?-2!x6uT007W2=HcR{OTfWyUc6WY1`Ve7m6aY4 zVH|aBrLf(3SYJPncn}smVDDl2j4in{xJe;v*vq9QQ3o87Uc{4trMweXwY!zL0rT>^(8`$dg`Ks}&U?gFFL zSZ-e5TcilI^SOSS#qT#l8+Y=K4;?(&wVVmc6TG1XK582!V6|$J&y~VGIm`I(6Q$3V zlfqe%qiNe)9VhfB@}eaFCFL;TE1Em0z;SY;48*`#T<{(Fj9TxjNn>B(R82z052W<0 zO`wgoJw&e0U~7I^g!q{4@I&qrnz8Z}fb|ThhloLjE%c@Z`5Yex)D($}h_DFUOjg-0 z{LD4&9_b0c_uQFb)Tz-&JyH4n>3#Me%okI^@|pSI;bx52Cys=L|NGRMH^5NYr4OyS=ml;_!gW^ zHD(2w1Gt@vE}C})mvb%u6RN1JY%$2TeuzlLHm~U|6F*&uy>MfHW4yKny7Ihsk@@>L z_N)A^yfjtdq1k1iqHmy7_rb!0D%UXO&y(A$EE?GCR{#dArOV!+pT!0Pxpx4k-tHwJ zjfLwfh&^9-?KGeWZf4hIGOT=l^k|GjQevx{2WJ6jA$ z_Vr6fNCwaGOPzOR;ZV1G=pnjcK3_oFls6Z{?YFdt=r|cFOt4s0$%u(tPqy1-ap#j7 z>59Uala3H1%AboCa$;ZiCd&Qd^$hJAyuVF3836b$K~Yr17lsZ4o}OIHetqIqpz;eg!jh`xxH*=0Y&(S|{Y6dUq!wHh^V%D8Qehs0$-UbX*WWuG zE3_B?jf!gjUY=~%!lJ=aGh0|R!NW_2Th3Q()h~6mb%mG5@+9Yf;WNCc>x;u}u-m!W zeu9ST!=S%+lf-Y+`zxKB1*4WcfDqT;2dH6)xK51jcDOetpV359J^0MZrZocH|XLyq*wd$C7M>4&TA!FT=o$lmrp)7LZME3pV zzWEt_ABWyp6}q~F9K5yZ1_dBkO zlTJcU$0ff*z2tZHL7u$$Y)e)y$rym+9m!<0OXNo(^8<_TW8Z<};X~vPG-(R>X7(1X z!~Q)rV86iiWTcg7fcp2}?yn?JC9eMEb%-2)uQL<;@quk|1OSOgsnAi79G1M`ZUD25 zD(f>yPj3=KjxJ~a0sQb4rzJ1ofgUpkpjQ^qx+I~c7$2VD{yeU`@Gy)a*gFIUDM6ex z-^~8=*>~clz8DA+vC9qqGn*GTDZae)d?W2g`!hV>$7R$+132A#_m?b}=mk8M3>}Sh z?309+z&#{y2xckHIj;cOenjxcuH{PyfkZk(1gU>66(-!A41`=HaA`b@?_!Do=`A|w z89;d=K^)|@6fX%@0oO%AnX7-X0OG%+KVLq?KGXb?IcHP2@0D|_sD{##{p1}_p*_{> z;mga1SD{&=@4Zd`)~Njz>@~WFFL;C1SSV<*e$jk~xDRnc;OB;kHl@_7Xpoe|_ZrtNaBo-S?ivM>3P?RJr*(c5K$KF_39Bc1$BTYqKi<@sq?NJ`S}y7RlyFEUYsJM6W zbS05LYUvfl6}A#dvJ!i-+lUVX%ysEID!*Q$`aU?7P7Lh4fq`Xp%n{BaH@b zkDc=X)P_qE_qxSdwPz#}Az*m1+mD3T-onH;@=eebUqj=YhMYK8yqm`#ZU zjo+AoGazKnqQLFq#E|AwEB@Uv=J3*IT=t)56xp!kLB2uaz4#z8CQILc`+vW-Z1HQ@ zcYZDT2BgX*CV-I+JBpnCjzP8pygp)}(hZjEWD75FYZiIg0Jj2_f1jhLx>f+&HOLOMC{!4dHxM)mOSc3Sf}|_iFS^L!@c%P zmKUc_VUfYz?ea#Ffp;e8s(W}nqs0VHqgC6vK8p2=GirYxz4RDOa1NAV_S_m2H!qpm zO-Lrd)Vi#AIYhVX5Z$?N=`(*$GvbXYPwm^7D>=C;&(8eI&G1Vi@4P=t(Cso(nd2By zGF)zAI6+gatY>L zu+LLV-;k25xN2FXuls7E73RMYzmAcCJk;L=o3f95IQmsd;{F>?s;C*agldX9a266HTf(P--Aq&6kwQtF*;Y~K3TYMwZd+;fe z)}ahyu#ZctmJ%SO|BmO!rM~$8@B#!$FqJItOE)sZY%*Fe zSs9F+iv!@aG-_E1QuWUe+Z)`!LJIaGlnwu$(99dTC?vs~OAH!*^9FHxZO5kM%tyPC zznj-y{S2!nm%ivPgW6fIWstwEUEXA*G-uYx@u6VpqudURefeCn=J3K_lCDv7|MZXN z+=xnaf3IwoAx#>CT7}g`UrEgBeAL-XS0g#j^uMd|gZh#l=1$HX(ht@2R`YHziz1y` z`ovBvgbZnN$0nOJum@K@Tr~J3B=zND%)2n=RAiJiOI8Lsdjv)9rYoJf*CK3VgV>I` z!GxzbK72&C$1_#(xKy|8cb zMoSmJeg7^T2^+|&*J$amE^%fsv;WyX_HPk0{(178C5|2b?mv41kq7h%3nQ|DeaX<{ z5C5}q2{ZNghsT#375KZQ4lYTs1AP16A9h-@86~udLnvn%Fj5dmJlz&gXDp$NG>a z`}0de4HDrI?ss=yv3p)Z^Y;sIlinWRLBYAO`1e4W$R-p0_kiI<-|RN*ru_LwUdhbC zp;sO$xo#T=3RHYR*1?r5Tbn=Lg#EIGyB8sKS8zKgkWz5eth;D{v7z$xa*~S?u;#I@ zsuE+O7!TTyordgFkz-bP^}>^o2Tm&<5aGIOA#X&3$07XB>!*Qt5+EegO2EB%@!KX# zdQJiA_{i$X%AJv(aL#R3wqWttnao6Cdn zOBz5eU~D@8#O%_M{SYa}ptkOxAEo3&B?AmD%atW!xcm~Jkt}A9pFNudv9OnkDN0l( z`YIPH4uyp5{_C2Nyx4}mFFU2fdVuGOZSQ$;SvV6cmx$M6Mge|zdVZPb4POchEJ3hb z(EmG?Dy#P`{G7o2A1Z)cg_NO!!va-zz$tG3VoYQa035~tyxC>-0NUf}(G$TK)M8LX z*vpre&ddYVsQCamy!D*4v^2&a;Jys>$q=Ihnjh$qj)j+jwR-vaSu|!l zfGT$4tD zaj%?bXlMvEN_yui*3*qq=JWgHylJS|$#~Nbv@iIoPoF>QRlhxDcF9s-Vj%SUZ^;IT zW3X~q`ecsmmG?OJBgHUuc&@oJ79*0u)$;^P6IGhW737rU(p)TM1>R7_zVz(r{gGT^ zEN|&`oK4;%LI07wVSR&;`Cvi8Ak|2W&RL~Pna@2vLyO~f(-S(K@1AWbML{R+Kw28f zi*4v}MOoob;yx6wc>d0l{UaQ^_IBr0N6^T$j6)84iD)9wyal`-4;1>+W8*ikNd?b9 za7rX3LLxh5-HiNhUi2Iq>4j({(Bjv23tGV2y5LjO6kcUCu-=3Cm}sCP=)2mNAD*EIi_8=wc!kJUjsQLYK z9moAjuZk<^&r%4Mu6!^_oJfemSBM4};kWvP&HHLcKHOulg`^bO3ogpvUlGKTbY*C$ z_zrvRrjK_~jq0(P=RGvQpf*iCoK6?otn{_taAPS{v-K~8$VPf0{S9k4sNaH=9?&(f z^{TK8_txLUgfqR@=@?;o1Huu~p*gg8c&h%&iUkdtP}B~UdmYT1&v+k45)APA&L_=$ z;H&FDyEDYvgqYBd#Kd$2-fn(Cq>(o~No)%DQ3&Y@`fdX8{V>peYGo7VK9tdMM&pYH zv@VDnJx;U(xgx801vQC|ZG`|x(g&q>1eLN-kt5AhZ1Sneu5cP75$*KrmX?#(eH35* znLx4TL%QA++lCxygm6MQ;e=>RfsiWd*XPfH_uuuP>)^?`3HLdzrw5g1QMQ&2-CxY9 zl?}ze82$3odJk?2y6;gA*{O021GvH~XnQ8};`Azu*Mkk#m9^AkjBAPRjG>{zx7Iay z7(o;ua$y!Z@PmF0ltcl1OiI`W)BF`zknDh0D+BZR4ZYUNMJ{-}u?Cqe#UtwiJ&Nqb zpc9!`&keL_Z`5Unf-C6N!RD}L%5bAchOxyQO60hW#awWIa*rr7a8mr}8)4Jlx(O7< z>OxJ+63r-7M1j0z48;eu$xaQRU}_&x{t|g+d-dg$uYr&vI#wwTVwuIFd_1d?kt&9tX`{Yb)=Oh^-SuQ%)MqDoyERwuWzIj9e}65@YAWv457)7E#`td8y;`~`^+EKQZ1h-g$H^Ypg4C3(ZG^drcO_fye4ADx%WT4BKrYv>W%h z`$LMuB%_6W~8b66r zk&^eTK8v1qIx)A29tYbfsI#4hpEgOpKOH@Z0+t&G`XWgjnZw`5sWu+FD`7+K0JXnI zX_`rQIje$F!6jWyVi<*{`D2S7ra(Fk5!D_oijSk5C|CPo= zHw~f30jw*f^n03Fkp8TF5-dSeVx#V~r5;=LhhDm_67}gXbB@???oT%-*I1TAYu5l- zwsj1*Dr9t)XyazAq|0k+w9kvh>RcfD#&U-jW55}LdLXZlb3cU({LGt*LqhF@`#0R0ogs+piep6T9JO&^PeJ%Z4yf!M;~%bvd5* zUCe#xeCH#h!wN?2A4xqYX9wJG=R<`viNctz38GLlp&`gs*n-{Yl$tWI8|-xXrueIW+v{$mh_1GVuS{d-JHA z*S38;Hd_OYLPYaCsWfV!(mbeap+uBI8buM=G;8ionl&gyB}vghMS~_&DO7jeNh%sC zQs3h$d%ydS@4vtGTi>-_>sjl4p4ZTQe?HfBp2vBd$9bH8C~m{J9`P6RuE(Zi*yyD| zXq1C)88Lvv{p_{IfNUXdNv#nOgcmI6a_oyF;TeE27aw?PYwKvf_x81ffCAEnisbJb z_$T+9YN#~WA@C4m&gq0bcQDi3Fuh8J^Ou|p(rTaVAS-J*zV<|SE+YF_We(Xnq>XosLn_?YiiRq> zuSTZIRDW7DY;r@%Mpf9h=O1=~gEQ4pSu6OMA0jgT?keg84xnj*oRB%Sq%XoKVSg`- zGgd8`wtu+|AC366p(QJ@Hywivuyds$fUur`-s=AIFcTwL7@6WlnkFeq-Ti?br*YEz zf1q!POk-Zy%ooHxj)x^|1-WAj<*4NsNc@z#yaf{eii(bAi||3`T2NvO^Z_;4#yVCz zZA;yrWgNv;)rCP5F3t14%DcR`K`!tJi)oU!9E};Gh`I-iy;rh;0;jr#S{)Q*y5UD7 z&I*i)+F(54*{rekd^@!efo?;dV8z#ugXWM&5rIOVkbrJj$zXO8SwmoN02ounVQ}P` z|A03_tj4#uhsVn2u%6TpnlJ3fb2{XebfS0=kl?Mv-81m@e;SlcpFOMyYhaDXT;?(Ht#+Dv1Dy@Cg@xxG;`PgdFmdt8AfJ zaCs|Ra|`C4TVW%GF%AW`pDa%XF(k`tJ9Do^1CiU&w=QUBB?M1=fAZw7In)9ea#``w z{!<-76+bYfB63fOT}z-F8|ubnH+@4wo)%>jyR-_{zk)@MW#^qnVQH2fR(%&g>Y3GP zSm{yzR>vOf^O%Vd35uuJ)Xq}#AJDGTY_OuLe-Fp9rfY7Y5dYk@ zu0#u|R0)m~&o+6%8LLMAFoN9!oT+E`lr9pNMI`O?sTd(*2iMHt4k6cCo|66>nGuFz zyUY<~rs;WLijOH4$A(e9(nQz+VpwGgopQlKC3Ybz8}}WCfrh|1P~GsHNJj7w+42Ei zhnr0A!XB>GwJ$s>Ce(eK%e@;oh$WaDt$*XzM17%0?%d9>1pVuEuiOfb8uZW=AJgM@ zudxf-xJHI+`co#^(KUB=BbJw9CdDL=%pNXt@Vuh}E5d0(<{)kl{kd0WlV7{%p-QeF z${Jhpr`9lL94qQ8d(8IXsv>c2VA+o`NA;T0%qD4vLlHKvYW^czYm!gCG#HccL+I+W z2EvP*phr2uHB%|I#;i7^P(;?CqaTsXG@)FG82BLGtnJSoP5t?d?0)&gpc2d31?L+K zNIx|y#@>YK&$tDEUBrLULqRPcS~bCYLcPfC-=Odju`-GikCP7}X5wVra<&J^(tS?} zJS((*+0Q^|sD{ey1|2b4!Ka{9x%p79^mPbsZwNYcZWRuJ&0iXv#46HTcqfM4EzWpQ z1abwUKXNWsG*1JTF&`Yl^SOicplU03s3w@i!IZ7QZ(Ax7TX>$x*p_)?daM2otBfo3 zHMVi>_73IP4CQkI9tZ}$Q!paMDlgqU1?a-o@jo)}T3hCmwKB{dml9=xwBvm&rE0kx zBEl$XQ?j{2%+ck^{MM@ZW>X6yRzGIgRt42jl82oE)d0xH%)EYq)l8A26O%V^YA7-XG)l=z;<8T7hV!apZ>C>M84zRzEn93Za`tz`wT&%#S7G!H zo4(AYR}lvw8A=|?hV^V2LNW4WoArUQ?R!0N5B<&|72*FAeVBhDha9E?FFAU>=p5=n z4sos}&T)naX0J??o?iNeX!Zzxzn&X6@a|~XwK@S~Un9lVwsFu?uRj2%cPa@McU0_p zYcSudyL>Cz4a?O(f`r<2Jik097lTv5ijNQud4Nso-?YBQV&QEi-!Y$d2#>aM?#FT8 zTM<(hu!und-94R8SshNfD{b*bU5JfuK;bKvfBT$}${MJMhQj=6dp6dK)L3ahdTOO) z7Rjiz>A)}ipz)(cv-z9tkPS#xhe|wFNQ~pj`m>J#4p1)T-d!>OYvJLr~_8DavS=CEV9!l|_$E#h>XEq(0NV$3SgWc>mvMb#YLk}htQ=i4Y->;K8i$)8nE z238HbHsg9g{~Q^A+v?GEQSu8ya0_F%dHwKqVET{^(>@iY zRXiGMY0>5uK+WoDcf=mpKuUcAne|eRq?rE0mr;D$dGO%EraO_{B>8~^5R(5J7~+Dr z$4j!u(o>k1e1Fkmn%ioh^~{P4U}>#0aV<|_ADrzthS6#A=L~F+{j_%}bne8Pk7zrf ze6*ow83r>ObIuzMmqMn6_Zg;`k8?;CW)d`G%XzH*rY9|0uM0+Bs2en}z$r$^vtDea z`2@;v0vJeXmL9aJ6WHvcqw6wwXmlS*I?*dM^T1le`qlV*rj{c^=HYWnLk{KsmiG3v zIUaF%DY!O2k%tO!!$Zqm2Zy|#n@0?KE0ziwU8$+v&fr=DkQZyC;I}A4UwxJxsmt`H z#dRX>9F^ZIhvWnH=l+F9IgPD%D?Cgr_vEl9*yi`q-tUXLX{T7q%KH^H2SS;WU6sG@ ztvD&`Z|^6X5vm6)bZta|h~E@&7>aeOVC>W_A_j5)P)+(A~iK8Ddk^y)B@}ewd&XlCM*@dk5{?$i^kEH zSx&zKVzJFN3%R#G2K)nDnKkl%K;3HCp{~MKExao#IS{X=Eb0y%lTe=eAqm03;4i3P zlNEqxJct>a*PFlU7esO}AsACd3|)^+hOk|)n%D=s-VA2~xEMr|qlP-PYtDTP9n+Y&3?N-BQ>%sBVt9pwk=3H=^h@?84SFfOlj&;0;v+x$B?>miICO5MKq zu2qL6qof`7dh#5-#qXS7Z5GUVD}XoL!AK0WmVRg&$QW}|8@dHx|yruT8z1d6f$eF za4Fp|H(%g~~-6x0f_{fD20mBP8%^N8!R(ujfREimwy{tOqEjXXO9 z^037bY>7AJL6O?`s(U_Xb{S^k7)f?214?e~3zLt@g;Pg4%xT+d0yC8dvtm!ZwF|n) zeGF3R#vGQMlYx|oUEUT)%vE|W{84q8^}MiAmDO=%g2m(BWSnC`JF5J4Gr6R7C~&xV z^m!#DhMsM3B}N)Wsm~%dZa6T4$fYKh6_u4-gc}3da`XL%ov4~kX$pS z6Pkr@A=6-8QY>(YS@qL3n_LoMa96|Q1@FmL?XP(b>HPpcJ?%^s4!@qyPh`fvT)u-m zOJ_^l)W8UWX<6GZCAIig6@dGvC3eGaoAN}ktsRRBQ{)or5IUw_0*V@Goi4{pAI5Pf zyb4d$3j+^tlADENTxNgISfuLb8{YLO5DCK38IoBRwpMaalnq(LJU*Q@9*L?`$EXi>4y zo1pZ)I%=AJ{Lg|a48Q9t;vpi$z9ohW(q z!^0`yDpGC&w@SS?J#u8>$Q>FL>k}ou1b~sDjKcsw@ErCm6I@mbFC%&I-~cSIL4qKZ z%&?qcb6~kDi+lsLA-r2#ayD&#*0H2`dh?%{38EJoV=$@Oj{=$ zY?_D_0t(>M_k+uu?hIpX8R!>wyE)p^lhB0||B-=2W%olMwvlw>>SP%1PEZQY+3ZU_ zOYLx0{|pve<>S@EFR!XDOjt?R3&q`#hjNY_gxxvcY@9HCgavmFNM?u(50ntR>dCsD+USoP&wmxPV3*Cm)@+sn?;$TcEeWuJzh> zm|MHx_Y1S|r_9vGbb@Qll9gqXWm8v16IaJ*r^_=6E+X2U@StuPQlLy9{yYIa%T*pZ)B`- zf3%ELd@dr%TV5Xp1Dd-q_DNB;%8M-%#HSoaWzmT<(;F~V_|ynX5;q>2nQ`>bTEGUc zGO-G*1Jf2bJxY7YwvvK}@X1GUBz2&|7fh)%{!fD~;Q||4#Q@9B`|`#DHP+UCBh`LY z=20NtLkg40qoOFdQ(6LkflN!c#!yY6Ua?!d%2%a^F9-4U+={pYnl*8vuz`*C-hOVO zabLINP*hM;v_Qd3^R7lGWl>&&@B%26Io;kUrAk3YU?m8*Ok-W$4a^u;{QU9`_=O@m zhd_~E@iCENE~K~(Q!K<~&0<|fGkH(VZzsTVlk!+^NX$Q%Jhj;&T-|Rd2$`3B_5JLB z^>px)oYi;?zY40Ew~p0tkVP1j;czmIh>hz9&K^nWddo3p+YgU;&V4er zdjNY8*fCzH==kj!#$5ZVkLq+-&Lq!Pa@^P ztv(Se&!#RoslcutGd&qi5*n8cUxMeDUcL~v=MSH-!#WPryT#sfGUAi-oYmHyW%GxR zgLgdmN5DZplt-Wy11JS2O;H0@_J&Fjs?jOa1o0Rvqr5?ePG}y*dW^%7>Zbq_u*+Ux zpC2B_x!@u6SOoJgegFJMLL z`}262O2p49OZ{dq_O%gim}IF$nQFeoIcA^>?Aa|@Q0tZy;iMeB<%oZuaHh9!YC{!3 z=wa_OJ44uo$fFH=t1LlgdodAbd5Rl=1w`9M8HULF_Vu%WZXi4tx>>31ZJ=VOhC3!g zL{d@NA;f^tRnR-vKV=_=+)iC z_O006@_1J;oIZyh!4}u9Sv*^1T9mej=}C6xY0yvP`(T=}d5WHDsvTe? zU1|>p*6{fJ`iAvj0x?*|4Qdw!$9hg<<=5z6yaBr1SV%BFKHJLgRQ zlmNA_*Z}b#b^0uNP7xL8c>oAx>QZ1Hf{J|QD6|kV5Z>ydqPL7hT~hCXA5%DvecDMw zVpU#&saKMHa=gCBKDYVlY!WI0Vs!Z1hPoTMbv`I=;RQU!yc3P7T6@QYvN z{Uu$N0A||f-v#Flr?+bA;!YS~@=i?ukTJYs{rVG#AY$4D&K~nW+ADiwZ>izo;jI`; z2F-Eh5i{3xPXLfwcNREr#ocka=K`1uxxw@(OOlo5>Eedj1{LO8_^%jR=I<&&sc zMuTl~@WM+~6j9;ck;)y>D~9SYem1mH1vzV`&goNuLiY&+k)5w z#>#R&hkr(ZeDVuLo0;{Z9r4L^$d7mrv&nJ55;Bsu_ocA4#v||$zi#E`H8eDYD&9xX zZNK%GMCm?#6@F)TR2(>Ci9^{>KnMU{EZ{o*wTn;UQ1xnYy49XY>_c7UKhfZ1+~bFF zFmPOwSqUb+)vk?KHO@i*z(yN^t2o(9*Pf^F=O*%*qIQ3@5F4M}RO$_>DBaWUEQ45b z#}ID6WyP9?vOJAGgjFzRniGx4H1|+8-*7|xP%E7cn6a#ThlhdCKzh_28_2)5E&3pL z1b>S%_tR}5XpZfm3%Cg~mO6kQA;10uOi_~d4HEwClskBYxUs3JgdzkbBN2AgvX>cU zBw?-Z&f?F_Ax*O-Jx9Bklu5!?@O2;t=Qo^)dm5fa!xgCw4v}0O8dn3h{JE0+^nxWi zs&jt-o^YweWz5%FXIj>i-=jPDT}-Y z5=s^5JO&Qy_b+{UhJRE&tFLZH6>`bVIUR@j_phvke?_xxLBD@H7Cu@J$o~b30XsW{B&@NHT==0ERuwCT z8tObXa({IF&~$L90zP2rCu|5K1hKtvq_aQwL49xt&2>li|Ertd4%uBT9< z1O<@#9X1!BK4(e0dN+|FfIFB^GYkKR3vjw8irfkMq0sgbj9Yv0XTw5c2>N_cM@DIZ zbQBGYaC+~-Y1_^Y^POApVEUZjSNfx z=Prgz^-vF!QhW`j{aNqipw8j(Y?K|k{~T90&F5nRr3%jo47}z(9$edYbG?n6oI*&r z=fJ~c`dSy_EWnGQVO|-2QAr?~mn}RXXOBOrqT?&8?kZdCx^AMvDpfLN?=e zy+q4F71jYZ`TLRhBAq54Vu+DLz;5+>lyQi7)3jqalQNJ)*c?YPjVHTvcO69g(J<^r zbR?QI$IBxa7Jkz06^mC;3M~1xX=@A;t4RsW{-i`FB>v_rSp>0H)pV!RG8=W#~`X?uZErF&aghi^c0b7sR)dfJW5KOHL!4ig;xjB&$!j%a=YAXQWOhF=wN@Y@?cFWt&{-y6Snj9 z_zdVenh3gOt^6wM-djU4jz=S%?0I{kORy)R$U=OiXhSCwF?>Cq(74 z2tt{m^qm?0=~-MePUunno~z34y1ev2Xqc zhl~NUD0`91haYg;XJi8Ne0ecqNJXS0azSNiLMS7B+&USwmJ6alN01s4F^4N50oC^i z5(t7%KF+v(6EWf)zSq(`uoEa(Us7wB_e~22kTX#?;@^{nqIGyPcbwX`1=phIl#gF79X(`=z-uf?k>0PGW)tfc<(ut5`rUvpsc~`lad?F0 z<7`aD$k#eM`HAgYqP(MNp56vPe1f!P7*3C&zJTB?!EPpSg9|;2wD?uG)1-f(!O4F3 z`tAKm(`M20ev1J?R41?(q4ufyNo35>QuKu?p*p>NsjR#v%_093i#H zT`bxo#a`=djONH-bCC z^LX$WirXQww>&}4fl=dp=0Z0K{Wv5X_T~NyPA*5>Oxb(+Typ8 z+S^E0g{aAnipa$%(v-XJ&~OyAg9wbe8|*H9<<9|D6I#RQI(eD&qDTki4r#&}Qs5Hb zK)eRnL$-#znO^+Bbn}|k?pcuw>F(jlK~Q9=eksd4GQ2t<>yatxxhT5TwFf07+B0)Z zEt-D7&VS|E_&~CSEpPn@&s=vZ5j&yhYw;=*0H_rs6Ge6Vf$G;He=oq!u>#5=# z7E5Didxy)T5$J(^N1qU=QT}ZNkOHEc2OUR}3TDBSqc0>QsrG$#LT{7TE_4@g0mpHi zY(1M4%-P&to9m^3ZvhekQ3)N~rW2~Tum_OfL1`j%t&z65dOSrPeL*y#RGyO!Y~f}- zZ$Xj7Ryz;Fy&C}rB`V2s=BHj7F9x-38f^{Kx3@_OjmLS-L(yfI4p2R_D+%`#EbjU> zNFNEJHxbt4&i0}0@CiEO3}5t7NbetxG|*9`=(>_8_|Jxu(zM>emURl6r0?W%`5+5*$QsAZ5}=VZloZojv%Dv(47bWJ~`&v zBZ5$at<4ALH;eif$$$u@HBo88|3M4~w)7|hDj~=56v=snIk4W2$B1m8dPgq42Gin< zij&cTq$gk{5W{l&kDwd|2bheKn8cRdR!{xM2Rn|mq;7QWTJ;M11{Ye*_?sP&0-#T} zSlG}VEU%-!wZU^*{Tc$qyWsnP%9ID_S>dQ(h98~D-`CxNpM)^F+_>j0em?1)IrWj{ zDWEwC7qI!@EfoA&6&jIO#7wTE~d`b@jPqD!v=bQ~%e<+pqK;E_p zVd&Z}v+kv$ge-_t#Vv+NXY>=q$*>S zIOXAeQ&OHGh`+#>fLT6>InroHetZWCVk?^Su+Puidrx@rHXcj~t2VDA2kItFu+|oQ zM7bpLw*Yh`gOD$5ju}SMCT@m}{nGe9u&Z_U;RJ7{h!+MVBb3b!tP}|0!6LBDf<~S;#G(ADCeHv#W3m@iR96JG-)tZ}aNfAIOKl zKk#a174GFn{+%Ve)(V_+5N0L+{I;C7`|ZpBj>KH3aukj5{r7_^mILAv2KFrK&I90j z%RW;i_j({c-^AMiHIJiTg!>I2mZntopX5+h`1&kcZ4}sBcj=!8^R|#Uyx_7UvG0GR zSXttPc#Z#wpn2Qr6&ce&AMax`_^FOUiU|}Aw|Ay%l@!~Yz73x~$S{0cx335g@PC8a z2fs-~LU1I%O^lhqhY*3O2w40j&Kv)E@{h0FLNF!5Yg33i(V&Ae$*m@sji^9yg)MI> zfJ23F5769p&nAFlNc>{&HDjw}Cp>b9{}$Sw;f%xn9H#VV1OC88$XVVkVN+kZ>3qql z6jh#clP{o?MQ$%e7hmU_0ihwmH{yDZW^tIk2m3-OXE#f>$Gdq2e!r;VZJ z6teG9d=mV?$B;p?=J_JD*WcA902y6%#N}^gw}0+u`iBeX4TbC|6{cBikK3O7b5IKU zk3!f47K?oi2T=Rs38mZs3_07DYxNa9HXl5TYDkVxuz>7A z4*UWF*?5=e-a>ojIv|*zlY_DIf|5z2y$j;d0m}jC2wEB%(3dfXaF&o%3%{IwOjQT7 z*o6!cTA4zI!%6oWy%AzFjX&yM#}_1sgnB@8mN*?AnnDM~Y>hDbfcJ6E@a0l8&YdYr z#S)enO(tpC?gnIUD^#L35;&VAlp*li@orB&<7ZkVO+$>@*y1~Q&6x9roUsuTnt>hvfREN%b z1q1iCF)QFEwEOiI9?nkG4@bzf4@jes;m|3<)))MQ0xkB{H9;u;Xee;V@^eu2>=Wsz zDurj?Lxt9bH0sucs#i)v;y5TH^KbdBcpvwiUSvSGF4f6PL~3(eg$$qEK_3`R*#V4M zoCJp0)RkV%rpL{er&y|BV_VWpI+_>{!s*pUbZr!R*0o3rUHimP`2_q+=LL6V?9|~p zPxQ(I*#RB6+`CXjL)#G;1L#Ax><|erP7!D!Bor&l|b>!C+$k}O&em_`hVF??b{y1X12faLQ+NLv~ z(#hVI)n8DA{eyX2BNUUx_@kD#*MXd^;I^wy+2;nc50LAut;YppWUZsSJ`yQ&_ z7lBZxEdbP0BSh(&h8tQc?52h?h)`C0^cx3W0!A=$iVG?NgsaTHN1=QM!vfvck*cW! z&`LZ&JtN3O1cX30JLsOq{~L3w1ckQ|aG7U~pT4eNT|=*m49=jCL5mxjPvwS9Mtz8I zH*Cv)kcNNHC6?w?^{x8|xiburIFdL)wJ@uN1K|UK2xUT?7~aHySO!+V2wM>j5no5p zY97b&L~+NF{PJ&x9TJrGrYn*RZxD)~CZWi<`n;h*j(+jh@7O)9eyV5=oDAoAfcxT7 z7n87ZvH_|8{!cWg&S3bX66H#WtbEltDG8ws2B!Xj4@N{h1b6^zi@E}2;DEGasEUND zf*-g>vk;E62w{Q>{k&ZoTTh`xUdpQks^k#4Lqt!#a5}E(Ggj6@!^i)3CWLq@N`EyL z3rk4kvnhj(IMt-kZk(*_jzT9S235b~;LGcTUPb$e2>21Le?aei2G=D+4N`}BBnfE{ zIEeXCB?MWby2v+6(|~BB$3*GFBofGu<*^9~L}G-V!Xw;$YGJhliWXNi-Jyi9)4a6o z+VsNN1sl#dKSD6y?}K!8bi?rI*ire^(Jq>w(83YUn+Jj(*nbt_s=i*V z@@w88`BwWOIwHIyx)zo0TD*b$RWf->%4Y@k6gj=P7OC<1?af!Hy@8A8?R})11U}$Po;ydo>4;&7|j!9IUW>7|; zWYi8^iI0k?PC7U^NIy|CuK=|G0*)N)yEpMIi9;eRHoMJKkD^aRw2hF#30$~y0f0ES zK-_w%Ed4Ac;yOuexOt+pu%p|2IC8gZ`!~J&bG&zQtg*7M*rAELjgh|H-CGrEa zHPQ`4M_com=&=^UVEzR2y3Ge+d|W>px%z;^<+gruY;np7ruF`2P=!B!zEKfMZc-2- zxFDtZEi2%xTMVsq7o?gH;GxcFMj=JExFH;M5p^7L3Rnp#o00;wTU6$6%L)i&;+#RD z45&iLaMv^Ve#BfCw5k`nM72Wk{&(dj<_EF+uZ+LpZ~?wbp* zimq6n+%I4c!hn2iN<{2B4lIL4MI}H`Z6r_Hy>YReWK_eC$S5gh1ob<%z#<%{(W?$M zUm?DWJEVE$cI#pEiSETYa274VHprx=2BKo%e!=sTpHYBa_dWCnhjwNlKd^1HyM>-Z zQJ&{hQFU$yo%JeMLcao6u<2el(sOT1Pm*&m zTuefA+q(Xz9RJ@7{r}~K-dj(9Es99hVe**|(W=fu7xzM5j$b-=b{0Oi6HsOMzWlMU0COWK>da4hSbqvf3uTqiqU2?#J%!#bi~ibXa)@Uohl4$)6ZfBd zv4eC6=H66*RfWFLZ9yz_B532OPaus*=i4j=()nzFE?~Iew(G!0P#3)iZ8CAI0B8=9 zz$vh~)+KIsMbhBINii%#81Gm5#7D;0+TNRC{ zfyM+5^Ian-V#aim890Oqr({m0S09CDkyOBif|Et0kDGDzHz+F*Y5?_fY}wAM?0nT1 zS4tHufj*1$%rR}C4t#y#k^i8v)u>mq04%J4g?z?#(ayPPl(dJoD`5skR?$f?0sj*xS(J>8W)_Z++) z#9Dk}$T9WO__n~RitQ$RZVD(bi0%wZeC&T0v;K|_D8!&yc<*epRwy%FP%TobNWX_H z<3zcH!~72X4C_fE3yiS_t%?oV5oq;zq4W{kN=QGfxHT1ivJYGo9#%Y2x(=dTSI39-PC<|O7A$kXU>zv52cvQ9Rdy3fwKR_+bh?x<0%R)( zkq_YqP{xP60YgxRE+85x`mT1=H@koqXiAqktmsstJr}-X@rCuS7dmT6YrdxnRMcUU; zONOmQ z<8!~9A%4llpHy}(`32kmAWNMgfsU~BCT*r!G~i+jrN98R|G-m(~m_Ma7b6s zVqq!oN9z;olpH8YZnVa?VN%r9h{1LDkANK|!{L-6n*|wiRr)@>(Dhkb0gO=S7@f>Q z_xX1F_NsReK)wgM>hFQn%IEDl?1KVVXWd+Xytm4lwJU)38RE$*<%8aK+L5c; zQ2 zrk}drvVLwf?M>v0={$k1b(4dyY8|Z$4sO?Y%(F<)`Ku#gCd%)aSXA(9YU7_Sm5I6#e+_4@HCpF`u8OxW|; zBo>M-W7hVbLNBI4B$A348J3UYo)}wzgq~c!)`qq60`D@N>hzeS+@SFufgaRuBGr2? z!e}0N5Weix2^(xnrDuZx8%;(cJq_e_SGLND*eHJpGN7&HeNadpaC|L4d>&&l#BE3{ zK6*2D&kzBfj#)cbtx4D>wnW()7f%-g6tuNXFjI3~ZHvC)XWgpqLZK_#qZJ5E^(K5V z8%4rWuV(Yf5z~)xcz<6gDXQO`$4)Bg=a@6HGBy656yN*V@|Jk4Ilk8z*T=**x&V_ zZ$mncI#eIz*R{8s-RTN;9D^tW#9Ob>SM-{3z}rd>(`Wdh134CBBXD!O@*FZd7k{G4 z;R*9oJJtNPNVfpEg0|B5V?X-Tl^EOyA)8k$+wP1gxf4~>_Oqaim&I^SL%O)7xF3!JuQS8=xs6;;cV2qJbu-%wdm>YlOtq zE>9n8jut^zXzcq{Fv_Z5Fa7GKUorM3`H}(AoQ%C;%{c(!KP3qAc1V<@Z41#X)E#<> z=@#((lK7lIzFv?edISRM3J0V0HiaBGhJa`W8ELYf#(14TU&jUnI(oaGPU^VzUm%p3 z#e_h@@o&1Vz;a{+8w@uPP4f^h^}aerSyR!>YE3OYH8{e1wg{g_VtZRyVsSk0W|kw>F38<6R6o>+|%lrTF~PKTy)W=HwQ)3cH;k%XSoPnGhAd#ki_EhsR(q(ME%Y`cK6r_dMYeLW&_yG`vEOCiC0KH9L z5=?<2ryB(fNJWBoZQ-%B&E5g+pJSg|(=n!NmSB;E)#hWf7syHZ=?tM#m=)9A`LFY2 z)Vh8gWbkzx&)`=BnyPhy4ACE3HO97iI7NY`R_xpfj#&l&exH!o;96=()^LpHk?>iG z*H9U@&p5@P$(F)>^ay&C>D6Qw^17*07i**$mn03>BFyZdIk@KOE!^9oZR^!;hO))y z+&l3y1?sp5bI2IZ1T(s{s49(o{K%-~XkNdXDWJCJw{N4W#Ndc}-ZJ|+!>?jISFw6g zmHY1;)9m*dd}rJY9a44lu*@P+Y zA1JiOQ*J+qKy_>i?Re3%im?)TCHxLcFS5pH!s)J8XkpeY_#(m>NsxTIk|hV* z^Scq5xhezEi~IpFhS^b{hG@&gF(5{kpTfEKA)Q9Z3o{Ggkp2p&uV2#_YruzTz~Gjl zF>I(;pP|K?GxM#11<#~+?K=4*#@z}RTx6?u>K zI%x-N4Oe=N?hMf^c?YmQH0^M}9Wk%)Fs))|j)`I9i2t;ixcUKpB(Iwnn0$cHFnGn< zihLy-9{T7a+Cso{s30daumgrDv1FI5Z9Qp@U9?@vrqLQZ-`{?t{W<(16aCL ze~c>iTtZ30p(Z|rLnIZ9#n_jPssk7Z?<-x67Wo`4)+}SyeY*!&S}{BVyE-x3aeOu) z10`R&zW8>8B>R@H5JY%?pGbHhI`a}T=3$Z(;ogZJCe$_+6(1JoE<)u<)saQ13^SWK z`z8D?>Si7Y#l~WYF&92?#Jqwp{oSBXM}4C%0ju8e{4)wU%N9$!>X!wuvYxZ=d7PZd z26u^Ts4e+)Qr4XnFdqRT4{c|#drtYA)r0Yb2j)!!>W*Wu);y*zD%k2c3U~YXL2 zXiNcE%iLX`@ly%jn?*0yZ*dy+E@nMhiNP$AhrPe)($5pgyx7c|Aq078u~nR^)`ls` zEvk;^bwiW6HVtuy(TF&-tNg6Tw=1o`pd{jYxHFhioBOMeXI`Jnoo`DO;6b8|Nn>;D z9t}_&&rztpp9I_v{B*WR!y`gz0u?LZoRkJ@Gj@7mlUW052`SqXxT6vIfs9;tjcIo}$-JFc zZKTkehJxuqGs7Vm$E3DCBA?x@5`h^5sJ;!p+6#fEU3n%7xgi94Cr@HUZtN)#Tq26CzVR#DIqJ0FWgmX8JK_>3U_i zgUubbTAv8mGvN{!5VGq>svBXAop^12YU%cdVHUCNzbGmWTK$Nb!UOK?^7m?tAdbz) z-TtUf_2sZ)rma5$)ed#_ zi!(&1i5_Q+&3*`y^V+!#wSpq!YqkqK66*Qd?nKC>ZhLk{K-DEY zzM;2j?I1BQh<8RE_#%+ejEd%WWMv%6`-Z(z8u2DkXbpP~UzsYjdr*vV>1X#Gs~*j_ zYP0m8nT6Q~4H3x9<31{2$mqkz6#-dBeZm%A%e85_0LFqHM9kysl`=Mb@i=Dte5Z&m zfJK4rJBl+K5RF7){&g$jx+qI&Gc&8!J-+dcJUeM9a_bh}908wqC+rOx)(MJfvHn29 zYW8}C7uJQ=IS~sR5nj|gz*|s*huVNq(s-Nj2ilZOE~2TTZQ~WQg{9>5Yk8Ge)3)a* z`Uxv~V>Dm`dcP}zpoT-koy>i{_^}nPj;^IMAEyggL8ev{9R?gEl^K^O$ny_uh@L;V z66{aY@pVb0Jv{l@+ry&S#vekgAc?OK0TpJ!ZZCcF`Ik2Gp>1M989Z8ZJ`TqN9Nkea ziCA&`@m4Tv@)^n!n!*G9PuIZ%?E{N4(W$hZeJndIxd zt`5yL`H<>3k22kV^5DvNUDeNZJQbolly@BJy5V1tY^M7fZSlov-fefG?|jSOl%`n; zTBZIC@V#!5798m;bYtpNxV2uAISd@4OU$aGdt>G$8GM$fT0X0;^EwsA8g7fQrS`YL zyuMp-LAJ3c>)MOiGlFh3aS)f_%JF_y@=*&|*NZ!_Ql{Up0OAVQOyS+K%T_NHwJ8Lv zI}DwkVQ5jWgMk&3Vk{$3@4=Z}uW_R%P#C^qOfgo`uW{JO_(W>Ctk>t)5SJ;oZ;8j# ztF@~3seVd}V~r)^zidjD@xkarkem3NU{ogr>}3%*TBjX(*Eo842v8w!Xj)j7*k_r& zMEn{Erl(dDgZk;)$3N?ftzh`2nf9O$4+Y*rlx^QUW(1&jZBXE0kcgPUu3}=zDXIp+z$A9HJRzWY6j-(bbUL{K7+)X(mX=Ml zV9;kZIQj->RbnC>;+Rk<)A7lm;pm^Jg98V}!U9$Qlu1vW$R?gUj!X6MCaR}u2Sg3v zE76X&ofgYZG&*h`;9b;t7yMNpOjo=OdZ*xgaMt-a&@Xh2LJZ<1OCuy7P@eob zp7!RDC#fUHJJzR#3XyT#`u^6duxf23qQ578@6%p}shB;uZjDQb3>F`=!VSg(cRXtm zv%1}?SHZ6scjh!Q?)+>BujGX7b{W-*hSJ2X*rX zmlNA2$Fb6^SS{}qSngtlvyT) z$$Fp-xnFP%My!7#@L%_z&oN7b6Q(BQWW;h8H7uhh+?I%g22mW9ZqmHIk%WDeY_mC| zZ+rbuO@lKdOF-=na5g5o35tQ_dvBTG7Uxf)GcBh%99SaRI@PuuF0_Z`6(QZmJFdf} zYU~Y|3z~6YFw_2CpsW_hDPvy5WzN*m}xaF ziFZ;D$lH+SAU6L%V5}`?c+>U2@L7pcjG_{T;4wvXMc_^Cifb;P@P$Hhq2 zGb`P{OxJPn9ZYT+CWDaS`Pu5NNfZye8P`?YU(p0N;zftLB3n11{Fp%!qH+5lgl@=( zTZ2*R%F8h7)eWqtyBkTXr}AT~#5m6xzJ7P2`@>1}F*lGs(?f`eneR={poklAzic_@^+?>bjQ-tKq(kFQ$|Cqy1u*QGfdsVrop|RU&D7>MqR~)}67QLj25KDI zI8qZRvrXOVXoe9s)rRFS+!>iVePGt`KX+W+(MH@D&>RZ7nNEMr$DV-R7J zDw0{my9^G{d1n(_SNz^sOdf`f(LbzRVXja7677wZ__(R42ZH0tSW(59Dh3hzIaf(F zawqtTH_C{qUNQ!A^$}>W#mf@BC=sT48~6rCQ}&vV8?wPsp#gJh@Njc@=^u;XM^?F( z`yXLk^HxPTby-v{MX}r8B5)s!^0dbveiYrhrvkOYQ=@E?G;YDTtbe$G3bdG#6_5v_ z<DckhVz77%;K%Iq<_OhWBA@&2}P)MP-b^9Cg-#?v^l zz!ftCvAO5~kfIeQQZrTX1+c{QH1xGeHIe7q> zUi`4Yb*BvbSwCxlSR-?jl%11CGUb$HbwMh6+?Ru>nEfTwS;+=`5Q!>-0__zdDo-!_ zizgZ4GLg|RF(c(a zDV3lxe8%J%;lY;piCBmvn7%;0JC$NAIQbF{OJ3VT@V{gjQLJMJ2By4$hJl^e%J^b{ zIdf?VE{x}&ox}=$QnWw(NO)YVW=Myl3L9gpIGVevx(1Np4*~^mAty$XrVC z!ylh>wNa%oZG40aJ{IGxw;p?63{!V|FGDUiQqf0iqNHkCRspjDjw zRiC2HWZ$xoP?w&sixMr51rqC* z=GDI;K1r^AKdn8cNZ%NIEi}fxLBw1lLT$Wmwq;Y|82;H2P#A?YsJ-!0b^{Psi-Gsw z4@7!6LPgld%y(S~JiGA|zlsGhvdC^~Q%5OMz1)A#S4h&40o1`Q(OOqSo@?6d=Z+V}gl21jcQJY-6IM z-G6ztdxhQKruE5d$ho`+aY8>KT12hM?R2`Ph!IQ(<{?-0UonXf9DW`7GRYjomv;XvruvEw$U@Tg}G2@?7c32k!E{NY@Jeu%1sVnaSIFnYx-C4-$ zk+x;aE`*TsBo%xHSzfT~Ak`+l<-NyqsrnckH1ZFU(I;f!5{!+G?ySobC2LJo5HjfGx3#UR3)Oa1tE+q_D7%_MkiSOAFlcXcG>Eq+1eFVKqXlNLY&|#o4Q4Fr|w{6gRUE2I7 zyNhO#Z5a(vIU*k?Mcsd%JQh}S<~yB;ajcRoPrlYv_?Sq?vL?#qAp$S?LS5`S+ZbJ1aoFdN`tV=*y$( z!HkOunto&jf(FFc-X6LobC&n|?;WPUet=j|{;`9QZYpqYFUu}Aki}`FEQ@gS0pvoz z^jvSiBm2wa6$PlmrP@tX`Cr(PQJ&9w9Y;AzPhEBUYiw*^#xUP8QoMK@d@`^9TL=~E z8~FEmyAb;sqGQ3BgsgZ0v`Q&R|6^)^M#rFZlsOsarj9=UbCn5wZyrl_nQZX+h|I+9 zy*>u>XJoc5-Ce7j`Ip*@+&+x^5uLS8z)@5lRB1W6xp`_UbJLYQ6+i(*0ADZ)#Cjit z2zy1!F5&!i;T>?hXrBBzx|@vbgy`Jxs)$R{bDE8}JzolZS|0;sR{@0s^(o68(tRLC zfZWGv2(Cs6oC}f}x?)8VhMf71okt5AB7j4QT3NH;qw7i$lW+0W zrO2P{BghP^m@+a5Uq?Uofp#qK$uuG}T$45n86Ct_8!4L0>|rdSg65kHj631FaShZ% zi1K$bTJ86>T=W3W(j$au%OigDh!6YGL`-g4cd>sgVQma1{H`4L2~GA)@wcR{92xiu z){7}pqp0!Bh!?`xJf?zfI<~x>oUbAlm8ha54)(J?uY^9!!*);JCMb!35`?WhM>dl% zh({OGmm=u@U9@YQPi-EA_frWH4aWwJwB8c>Dxyu#o0~+Y`o54tYvq$!$&u3tx+cs^ z*uTHFG`ns*$L=6<7UPj>?Ze@%XX7RJvU6s)W|_@?m6R_%(;sJb9zuCV%ci1 zz$wz=GGimtwS!1|0geonOcpBdkwa4yVWs5c=>w??e3kAu|72|WgoQ7A99Nj`!?_|2 zcDZ1E8>e?fM1`jpz81ms8ycdQ;{krvRaVTFAh`EUoI^W*)A9rzVHW@<#}uzN=;o3< zFI*{c`wV_hee#M;E=&XY$BL!NG?vw;KhnF`2%d22Q~&Yy@Q|-D>evc+xQ_GmiJW{& z;DWdu8uXoD*-1k+#b7hFZ-#{a7Slx;u{BDbZSJgE7x+rfA|$J$kH+Vmc6|C2Xgo6M zcVhzGwU0c1nqCDL0haED0wArLNet!R4;x5Db%7o!t&`-v( zK@7)?KzY>YRoi+MEIN=!I})dbBH+^})Te5IP-Lmcjf1$<8+uEK)G@DVoz@`HMq*qB zh^J*h{>u*KI=E=Fei2Grd5PD?(_>u-w0gv2q?0$DX+NqXz+zka(1Ap8vYdtd-DJ;Z zh6jphraMg_W$kndCv6KnOO*$KLwI37wOha<@MKQ0B}z|*l6>i~{_^N;Z7L|01i7yU zh~yT}Uef;(Z_f&vmB)w zAQW4b(e5NvTk{>D6C9itOwYx6(ynT;SL~o}^@I~6*!30(R@-Nw2sCl|Rb;k0Gv@yv z;@$+D%C!v}UYaOHB9bwR2C+$o%+V&2RHjV3P*gHzo*GzbkRfSMBvL6#Hp&vw{ZybQT9*9vTU??3wF^X^@hi9+^$KEe>iI5Fs&t}i+ws(b6&OjAmG3esIb z2rP^!LJp!)2hr~kE}T&G@^iN(!~_v#-!(%Al_u0_ZWyzi8Ai~P49iSNQz(gnb7him zb=2DWJP+#xfDwoOmN&3>NF-EbM6}F6z@lH>Yx@>0j9^khO4bP+txsN$N%u90NYQX9 zr1;e_dNh=kc9gjJl}xNOy6$!{Z8@usz4!PaTXPa-=hDR>^t2V1Zzy&xdgEf53j)sd)=+y=tkcEjx|A z;uI3UG``;>7A0Oj6zZnAcLv(S&Mx&;Q2bDlP{xFOI4rPRTv?-|LnS>~0Q-#=j^R8D<7%m*!;f zKQkl$Pk8vKqn;cCTkjE0g6Ql#3#Ne@h>(nSL1c9fv6|H-P<0{&_ZRv#B0g_>q^{9aa zM`D-LzW)B#uiMZb!V*7oB+dB8(GYztpCL2~wFxNUXW$nQHo6J9jL}L0fW?d>)5T{X z1M?lk0QT9dtLh68g%Z-42a}Y1`vFiXIM`M(;?8!C(qSHnDs7w(pr}s5K|Ik-_7&6x zo`q_CrwB^W3hQC5QF;;8fqu&<%8`Wk1Wk)gk%SP1JRt=r(P4R?qa?pTCio!UNgDJU z90+W0^fkK9S<2h!k_|8;43-_j*=O9vs0G`aj<79i+^liGXFrM|}baP79wNZ>$M zxhIrHh)7;)JYl|5JhPyFizdB1+>Lc98n~TsaRNpYA#0$6?13;uc^E(i=_GH^uaOBD z-D@Rs3IHDzTs!D^7#57sxJG8PQTfXP1ZMui0=zn4A*KNr^&%W0#AlfBiIF~m*=5ZC zF}sYBFWBXF(j_!Tm+jBP{eS_rj5#=_dkY5tL8WjRGr-Q^KXhv7`7&2n8gjHhd+C4D zQoS`Y5u-BftrS-Qg&R1(BM2DvceF-#Sfh24h~5Dma>IZz>jj$N#gSwRi6vS>cOv5} zEQf@o$SOHPOoxcdGz{RfGT7#?h%1pJ|1qzgadI!zA>|J~M!R&hioYL2H>64=L2MQ~ z2)frHf0A&3bWf;m2%sOFT*g+^{UXrAdeJV^+uN=lcTp?e{NW-PxBE^P|J+835MfcR|>#}0& z<{}=M8(7?f^(SBG3O&m}g0<1RX9|C-tg-ReSG>2)!GYlV${fR@ibY83&e7LYjy4LBbj1)8JjM5mF!n{!3L3WymC8VKFs zvN`4)4Wts@#`g$Odqo4m(c~A=L_YNSK6no7Y7WPa5YkEy><;mXgE7B?+sPwqW;h7o zfkBA{1;7f0Hx{hrd*yv_edHrU`8YaelkG5Pe?$~T@U+S3JqP~&8Mp#H65`2OL~i0q zsMb>m!02DQ6Y&Kw4ZwD=6YrDIaP%Wc?Din#9R3rT`4=1JY7!)(>KX-^L7>khOM_p} z1Z#t2=!l)?pdtt6Y@ z9)x-VeNF1=!zqKC;!oPnACrw8r}5Vfk35$CYUoc)ZOJPMi&RdY){mQZ~7rOmytzyBPY4 zeT_1JI|yRd*4D0FRh_^Vu2TDvpRJBNw{CO<2bv$j!n)~^n3+jY$tmq*!pQ}9G7+sE zhvj+Ofg!B;^qMJP#Xx`a>-)u*iC7+3X(6Zt5Xv1AWZ0J9Ki+~PR|J$oO7J*|LDd>{ zX3l3HBjsPtBO^AsXZg$LPzfLa2cg6$dOFY9xIn!HjH-_zsz;_(9X^aQKP)9PGp!x+ zm_S!j$@)J%bQ(692W){jXQW}kcR4r_2_|9prlVpFXnv{~(+&}?25tN!x!4lZ*kBqk zU2TFi?jp>P&0q4SaPs+qB|&pAFh^hzj>Zi5kVc2et1kBkxP2lN!>tUCIYDKs_2 z^(ejs1Z)5s;gg*5^2aThUZUR?{O;awlgqc?@3Aw)JSq=D%ysY>m1;s_isViS^HK`L z0mLzFpe6OX%n}+^@4dS~8MP!_hnTHlIbqjEFa6x;Ego646Tv0ng`m_yeb|V|KL=Zn zbVppnLk*LCePIm6c^u(hEKn|98(>Bqn(DdLO=Z`)b!v(8RRW${>?HQ@(jc#1a%{?R0xs@&P8xRX3BA_uTaekbL zOmSj}>kPnmGCY&ak4aGSQXCrmjDn4}JQ2_bX3iJl21Mh0HQBu!n>iM;tIRwh5?@1HJT*N_x3{vQEl&<8lriBGW zd=lw|hv$*QfT9s3*P;cA{VNYKScGbA{pU)-Xb5cqJI{3d6t{Bwp4PLin-?(^mScd^ z36uTba_Ou>_Kn5@!p(pzr`C#UMSKNu4a@MY9ST%mBA$MhdZ6MqxW8 zh}XLe7tnr>%y^y|EmkQgfV+w4e$C!Ah7;6{{C7nX%HRjE?Qk*9xp#gKq86dYak>Gj z??7Ipp4LY{S*m*pf^}e%uJRAfK~UvC)otm?XN@KrHcH?F90)wj1ZX0}pd)$s{p%(^ zAbdoPo=o2*>9UCnDD^ltj_BJVL#?2sU5?REIF0ZHM5EVdGCq_3r$2y#5Ig{aqABc7 zGA|IY?7+L>*;g5$yAw7@oLCgREM8D1*SSVv4yH}w3BW-@8q-8@-F`oaYwz<4@Qzn~ zu{r8tcuy{bOY%dn z)Btfdv_1VHNl3H_aBtrUvEFd50cWH12COUx1IGhE71S6KHKnUU<%)p)>KfRDyFM+! zD1ZTEpQ|~EcJQe67PQ|GF+T}_-*60zmnzt|Ed4_|D{4gz|7Sgg)b52WuAE zyr#Us;%YRK-IU@vl=SM=D{nBgA75?% z+%%hH28gjY&+bM1t0c8$Zd-;t6%^!2Q&9D`U3TI3GPRnebNb}2W39~;l&YS!(0bRI^*c2Vu}J9!ENm9s z3ulM0fWI^xXMXPzIb=-AAkCZ;Co&y4)e6zJ#|KU^3MwfNJ<{`S6&8?p#E_Ah2~=jF z*o@J52{EHZqA(!rdx>rf!icHHk=!qv-%7%EBLReWl2S}Z*HLKR{MU!A-+2(muBD)AtxEJW+Kva_Q_;DA`_=DidGC^Y37Yrr_>q8rlt=3ika~_aNsktoRK1Sz4I4y=(vVR<_pennfj@XF83Of4u;@t#(5hxFKw4#{l!9+ZJTK1#(_|}+P;2792v|cI|cxgM3nCLE0 z{k)Rr-n#ZZy-Ic(73)^7UTtvj;I*hIMoBVV<9w8!($GQYk4AXKxNpZ6gn@fGj5mvm zC)+u(JaOEixNlCcDpYL&FNKkKTL6_l^m77}gNZ5)XlQX<-)EFJJp@R=52`z~K`^9M ze%&n3o|)=nje7LtiDHNFwd>a*mm5j@ei9?K`iofyK2KgeN;|^P8hH5FUVgXNRk8QG z{3~l?7ebh|?8vvnqzLkS{F3El3u$$B>rs=@0O_Hi49NFZry&5LWJ&$|FIg`7Fyo2r z>+BDYV!UahMoOQRNF>u(m-`H2VvVrCSP9a9(BVpS->J@S#;w$jhiSZNvWfc`Z0*r$ zi;zlt&zI`z9QZ*}NJU~@kVFay0P#r;Ly}FG^DivG@v`I27mhy&8y;nm>sKAg=+5sK zP()zq^TWQc{_-Wm>!RbwyqSqSQIQ2D%-AoI9r<3DxmUTKMCAQ-N7y?+=GgxN2qDNh zudc2xl_$h?33ivwTejp)wn<1JGy)uvJMeCcj*bpN-I31GyYLt(Cpg8^k>S#9E%)tn z1j6zmS{|Ku3M#>`x}!GEmG5;5g)exmL1 zs1O`+`!l)`K`}ntXR&MI3#lbmynVGeU2n+;8|rI@iOePkmpS zIPXQkK{f^{S38|XUJ2Z&9w7BXN}Xns6&cH{i2Aa?G@Xhzi;ufS??}Tg83 z1Y+JT9Sv1!eO5=+UQbIH^6>DqgHm82qJ8S1kr5fr7mdXO3O7f%wDrfkc94Vt1=z4% zPMeU7Q6umg?%`%(L>Nm)(BKV9{^W-J%YZ2=Ro-B$U-JK!+K& zk%7U6$AjvczupQni?}yaiqpRsF6OHTN&lRO2O`%-SY=k=W%9>B=SI^w(PS?P6V9Ut zO7t}5NZ23Pzkk1l1^?cYjoI?qlLJwCig0?UEVZM%^$_AQxppu+#@`=P#_797?_yLL zeGN94?`WZb8|P}==!H3p7%d#U5(au$FrR_W2?SvrcF?(bl5p+O?L&J?qVqEur)rBg#Dl^rw3+o|z=o7vI#JOb>?mz1y zquKf6fUfNTVhfH`2Nad}yeGnW6+X&OKp3O46W;&n)2DrXeOqc^A@%$QB8W;q%5e2n zRUG`)h3ey&rzZuRe>N1M)KMvn`KITs{9r;99R1>)r?J~uy-w(C*sy_C(sb|bY~Jjs z@ta8d+JHf?Uak>)Z>t8ni(Zei^FHc$>8Ho?<@SDuO)Ay}YQ8W|kX^QH8JWwrH%-xy zs@Xt@35u5q3A6MjXf)0qV*o|A%g6+=Lx%Lkm>hDzP2%EyfLd?e8lJk?Oo&ea8Gvge z-YQ|6Gl3=g+PiUF1b;4)qevaMc|jSd8Yqroo>^e)kD@A%ymKpdZ=}{A=W+ar54s|t z;#YoG30p~5d<-N?-%cIN#}6OM!Pm1JT!VbHHz@$Iqo`i4Cg6ih=<4die%yu>zsGi; z_plxKM`rf*1h@%9u1mESr+tLgkA4oxg%}`kByL!>yBJB2xS-(nDS)=vQ0t?fBqx(f z93f8-ZWA&dI&w$iWMN^5-Zel1<&JZG-(LP2$(xx1xCH4tVwzES{sRj@)%J!s#J7zL z!-$ng+_ZtX`@vPs6kD!@gaq8<39B=Xi=0piVxWW_G%z4+J1{;@!rg>#16Jxp^eK^8 zRTq#Dq^rAIr9W7Yj9AV-WNPY7*q-%yQ>Lk2#bLNXp}*r8YsNo#a1?wqTHE0$GB?$65oVfm zP(p7#KB}4Nh>(emKW;WN5&X3*h*f}}l9q_1ks5`|sdvuhUmeJ-+W(M5!h9{(y4UdHLM+hP0Zt#=FiX_U+87u|T4~nX^{BQaCh+C!YKP|+kDEQWLu2NQ4fR1APR`4{0SjZd0x?oRK*|1@ zbdDhv`E>jYCVfGBC;{n6k0Tr$kIYFX>MGOgxl970K5336bYRJ>bq8@zaD_qas0M3} z#=mA`>T||@6B~M-iv&t9^9Pk*nA$~e0}K6?E46*~7ZUBLGb z?*OK)bDcF}6iKTRr5*MooVOMREZ>H?)U$g{8m~Sby%U|Fwdn4l7Wy$Rk@FhrxoH*~ z3A`xq+yy+KmKkaOTC#jiMOBq+Ek*O;lP6-?Vq#*;L*AnvO%x(SG>G&p>XIju)|Jv# z{L8%?x1k@6+(*+?uY1JvaHw-6$Hm39g(I+A3paA+szBq2mB56t92tR&aWqRPl7wha zEiMHW2I3?h0INysu197TTF?s1FrDF1TloKD!fE{938%fiJuJQA8t1EXn_h6s&br6~)jJ@7_R@Mg8AKGuzS6YJV?eR5Kkw^7Q)o%Qm*v+lh(B+TX`pA5@n%KT`=PYg)VqFGn^=T z&1!kbyA%XJ{Sf}E@Fp$uXwnAO@lhN(Z4Q>Nz!50ivi~Hx%HJiOJ{>KZ1uZLj@cw}#-U+C@+qSWWx=dGiyjf%eOLyD%(_U+qA zNvZ3*#8_<5)ghSgCbIsJ_7b^p zcB+A?=|oo3l=!3(NlYBqI3tLD%E>fyb7r!9MzMpb8|4(zEtW3ekMb5#^QwsESbAG1 zU=O$3kJiBlvO8D%UUe;RVZtHWE-h`H#GP&Nc_gbI>3k(HMP!W_{%rq{dx$j0hFsy@J0AUp%dQK)X?kw51- zV|__R_f4{xH=;xjc|_TjD_3a7%@DQVvbY2JEF`|H-XmyWCxFI>(-LRzOGELeRpapC z!=pXq$*4tAM4thik{i)6qhq7Jw{AK3w<}Zc7+~ISzBJ&*zLo1XZ{>=-UM7#@ zz$gptsG=euOuQEGjhX4`GQ3?1;S^1|AMQY0MIPY0_;{}j3=BY0O7y{&&70A~H-SEw zv_lW#O)3I7SjxrZzBWBl>_#%!HZ8`AGl)tHf9SCk}vWo;|=w!Z|?k86Fd9E}QZX z)J6&zXXn$0?wbp0uM%jHEPGsY5t0qUnT;Le-9TFP6Lq;W7VJKF~g zi{La3xw;XM1{Z052Lm2yQ)8x+)u^ijkXN#M(cvkoDa1}7SY(D9ZTX#i+7+7Qktklz z2%5>q@-}oG#)~}kaq^>kZp5gh*F&Qh*O{qXObrZ(D4gS~M|PId+srcC#+WEAWR6#p zowzbl??>UA9$QAnsLw+TLN}x2a#wTo%xto-vic5cTS1vd=@okB2+F%oC=22FaUM#R zY-f)vD<9x7c_~4OzjKypF3LQG>_YAWYL_x$Z%G$FEI<3U!cL9HRv;F^(TCX8IDbSA z+Lzm%j9T>~O);PIfY3yvL4Dji>WcgN3DPJ~sZpxR{+FD)1`rbUlnILy`I{h@tJm77~=xwK3kZJpObEDG%xoz2dc=sZCmIpN(RjB2>a*nUhE+%w7 zJcl<ENoHup3n8JT#FVS5##ZARLIxQ zD*Vl7{AWX!Y)LrXapb9kZ#OpAQr4iE@st^_<^{BV!p{ckLEdBJXDiw& zCme*UlkoS1<0ZMYxqCyT+z^5V^P!vNKEO$~)2d0%jCGlyQ&oDzAh*qZA71dCL;TNN zO2u9d0w3>6Xmh@R1oH&#VIxXZ^~OXM0(x{rOR4N8r0_}o{_MkoGlit(!KdrNg9kwM zCBj&M@=Z%2>cL<#LeUgJp%#pVZ^P%#&s2k|SHSiTn@Dtt2+!KJsES2uozDi}jQY;y zvVd&wwF7!9`tB-5s_xA6@hEo8S+y`a)U!;$Ke?FBFy|FNytQYr`x7v^mT`X)E^=%D{b&ypkiRc@8Ah0#l zG&9xI`qC)8V2pO)JiCE`0pW$no#$pLx*in;4J8*S#SzM#!qN8=;VJV8`0RRRD&czz3PX-Q=F3nrFZtHOu&iBXu+uBYQzC>~l5+|Uc zOQu(uP}01FeWF3#c-*$}sG6FZ+^R8Ds4`%5`(QAM)a-S43iK37aD3u8i|5g3jN?v_@ZF>s%ZCgQ zz@LX)B5-OFq{y-Hm$G_AA^M;e7sB#>xVcR1k+S=c3rqe(3 z+#{Ug=^P?o6KokF8fXb z5|6h+$~EVHe6J-jR~o5@;4lD2zLA`q%#ui$-e~NdhBazLu!N$06FBaO0!Y6pqc5Gl zp?MzYD!ktf-V~;qo`tF7-TV`%+S-2}^0MUoL%xY2a3n8MR0L`?GJlhGx%7DpzfLFy zhtQ?2FobXr(2Fe9rAwC}-aDcKjtoqOE(dKRvOmU60F@K~-qO2BWYpk<|3re0*={qS z&94nPfx#b79y|~+>!*hx(vA7;@XnSH(o{`pS?~0OW#~jrSB;_OQ_HC?BRG9h%R~i~ zUpgkX3;E1ghW|u{VQ|KEf}U-7EItpNWkEflAPGD7XKARnYbB3emIR`o{=qpU;>UmC zS6W}u$A!>$0Qm#2aWWa?l8NZ@(xnm6Uj_Q52y67sg-NqFNz}_;B&{bQzq-KqKJ>G( zo-U?$LTI;FCP<#ad8gXz+>BgAw^)bc=H}+1dD&n?zbwIR@NLX-T5q-D)hhg_hu18e z!ezSIkwcl-hA=WrNTB;8D0WbKoT4j#pwGU z*z(MX|I7$#%$W6*c^52Xd;r={u*a`ndBFpd22L291p(i_R8^&+iVMhRRI-)-PTkxK z3A;5P0Gxc3&yc2&w{S_oo`HAFlvYml-cP492Y)}X47+{wFA(6(zQeP&F?2=HxBCaY zXOF@Sx-;i2LhvSZ4t5J&oGTGBe@=}1*>1}mNminRh+Dp^SOxDd^;sWz0l<}J@qzpua``$UVzFW3>w!d9Rv=+~PXNlD8AJ}eu!>0fK@BdSz6IT&jl^`c{@#nHRa8k8q_6nOq zX%{x$-O`E)^!N!~9EU;hsypQFXlqECt($}E1V6`PxKcpc{)@tK#!u(pn)S6o+(-qB zH7;2`e@Su$>v=HKI%I%cDNFynaNLb2fBn&g*+2S7{;2S;`w*GE5AJn^Z2C1uNb$&3 z@FQ3@2l>S|Y|6v2unraCWwUV)ytt5Z#0qvE%^8g_5r`WB@>2!^$%R9mXt#$S2riTw zQsjX8h*ZNjV*@g?-b1>D zB!3y}GwPv}P_9Ms7{SYmsJY}!a4*TWJ5hjGL0w3qMr>19UoZ&COs!ZQKoRaBV33jo z%KQlZVAyg|vN9}#U$R1wv$anOL@m@Bh9D636{Sgv>U!F}A<$uY6g?cBKa%_rkxggKyx9wg>#9pR zKkXfuB@k8T5wIs%9^i!q@X4mBh9)v@6`&n#IHlPZ-2Cn>mSfs-{UmZFS-GD;^kb>N3cFRyw{0i|KDa_?0+!3ItFBQBFXC5+GAS<50bgc2m+c!y6nM+YrAzK z8FzjQ4~F_|^ynKmk0HQ7nsWNUFTem%bUGVJTAL@o3rI*vsGtH4N?FV(gL|Br=?iL#dacHJ1u4IDo}O?HtF{%g|jNtP>EhiFCU1->K4VCdxJRHm&( zbKSjrH||ubjo7s{B~|sK*%O(eV{?mx0Bq*V$-sLc5GID!_-9C>My1muM=sOKyk9{uiy` zh=`PW9E#V8E=R~7aQ~ByabC;CR6v|+78}7@p$8va_53oDLZTfAhNiExpRVo)Vpl-J zm6cBftwGIYT>}085(ok`#+-sF3;nN-s{PuOMsQtlw&)j>z+eKa2{6zdnhP0G#z4tu zJKzV;aN!F5gU^9bhMQh@FPGAZ2T5x~QGG4U+z~GnRB0EQ(Q*)xA+mrM0m|OrcZ3(* zBj$GlJO4dE&D~@hpqzs?XwWhW&g8>>QeAG{8t6WKIpi@oP7IolvX6bav;Fqj?0p9h za#$OJNpbFn<6GI>{wqWgQ1NXLwFOk`KA_rk0xi=iS!hpkJVW=mE%&9G&}jbeNgn@z zJInhN@R%h1M{6U}f7$_}o6Nlz?7o+Ky~wk2bBA1zdbtxwm*!)*8sOwD2#;X{@7_3Y z=+IgVepf9C$!*V7E8iWpS9}A()I$Bj9PeMPTw|1qONz*V%VLKRb!i!ykGEwdz%Kaq5VdG163PLr zNWWT7p)hVB^L~rMmuqTpn<7LBNT0;c-?DWpc!wP%qX^5(fZ0VgXRI8tKJeqfj8jdy zIW8|2_8FU)+%%4Ddynlv*eX=tN^Bho=)eR-?;je!)q9_VCbz>oy_ad=&6&Upb);gn ziV#CI%ErJSpCPm;$nq7DvT#>d7vWOFZiBqo0QYv)Z}5JRF718nSiF&%%Z96@T@(Hm zc@~oYQFs7lgrZnCiEJH@P}cTu=!qs^4z^WjBaX|FJwd{8?@5%RN~U*mOSATonG|ps zxoFh@MTwA9h=6CbrGWv&sbUKX3LK9LRfg?DEf8$wq6J$8wwg)tswwtFfH=n(dZN+8 z`n5Gf$bJ1BA9Y{d>1*|+KAo{xybU76%U=HlJ0Tz)^T!574$+J20*mQ*9L&=ovbC0@ z1zc`@jeODq56jBR&i#0A_CEt8tNDMP`*Fb5_s6yv(1vt9hCobfMiinvNn=&Gw2b0t zTuDN&o>DlRGqW~rEAOyQ^!R$-3>}VIJrB?p+1UX-?cTEr?wVpXb0e-iz{JN7htS;ILF7+q?b? zrH8XW)|*K!&j7eF7kjml(T-5TVBe;BW$kJUKujXq8ydjoAT+S7Ymt$@0}(L<*qm+3 z2yFg|)R^RZASM!eI2P&NZW8Ot@5CYvTaaB>^AQCqh-0mb9{pPGmk#&YI!P;}ii(84 zyh7#$A}y5=6N`o~C8m1K7;}n<`WhM*Nj|$1$D#dRj64%{&B79s@~M_wdxs}Hf4R$) z!Cja2fzy8m&kE z%}`mgwaat;a&D>k%*;_T!%(pV8yeJ<5L8aL(&a2O_XMMB1K&^82T^ zBpcIvUJ&J~EnA3sD)RDh=Jg_EMjohe0OncNN_UWDC(0}=i-OLb4W<-4NoQ{T;Olla`{5fKAXZZ6g=LGB%XPdTbizxE(&!3agggr3QQ25e2UW3{n{7DJ{zfA9} zYD1*4ob@9jtAbfNZVKM5-22f@P(Y zn9mx3kg_Vxfb5BfpZK>N41M^EwZd-+D?hIVdhT|VsO{P{arP9A3HHo@=ey5RT7ZdM zT?QsA%kcj+Z5lUpTtx5zNHj1~c+Vh81k|1m)Eouu6T*M4 zgCrrAQU*f8dD>2k?9A5@wyBP~dLa(aRYzLkbj{00kL>V@UbEE#Y^UUhJxWROM?S`q zW$dxi0$44H$w>MtD6m19E6qP!=sEdfPcc=llO2vPAit(m zYI-d!(RUl_NaJf|P;l<~su9ZK)D$tt7!g$Z=Z}^3+t&@B6w;y^_w5D%f}hYOl{~5; zsY#lNKLsVe{%KV%a@O@i=iT-BB#}{l9RK(C^1nD&d{=i-=gMNG(oM57QZMXv{p(v@ z7k#W|^Oj+YSMM2KKbLM_(E9tHTsOLn)xHQ8}orTZ1m=ymfTi})EsaSa%+E^Xq{7!Z{4B8?(Gz>sWTkF`k+I zj?}#Q?{MV!`{o(WsH?F&!wsZn&;OEq$6q!r^14Fr%-<~e>|bDj z-10Wr0I~gk!U}186-%fW=5GAizwgb&2^qEM%#u$ z_u2#+nSa|7?LkjbxQ?k0xEtqaprY~gKqPS0banVh3$DQPkWB(Liy({4j zbu#o&Q*D~>F?Bgfo0DzFjt+lK&6&=p6mJ*kkj1S{+WeAF znLBD&Eo+m!qvBR~pI}|xym2Gb$2*nVl*F#@?y?5HB`rI$~ zMcl5RO3m~7Kb=XEt+DX=(9Is?K3K=zcUwS<94epHnSFYo8L z-g3omdvli4-CG=bJZEI17Td3_NV2X9P4!EOT%^Bxb#K{W-qP@{tWK48jrN_aLzDf&Q5nOw<Ex(`cZUdeB@h9?M8_PBg*{)CWnvg^;Q4;-uweF>S)U@u%obb| zv#99ltL3cn6Gh@>Uw-;FeTm{54jJ)xIC%KdfvD_>pACh1jz{_*=k!h0RW{p7OUw9n zw|CcB9DU2JG`#hzBc1E=2ZN!MM7QuThi|Fr2TV)_zDn#!s9)Vad{VxBsMsos@gC`);fHL{v~z;dm4$ z$D-gvTN zSjEfv=&3`B(Iv;zbr#L`R>ODt<8ybNwf_0zRny0@`Z2S;Yb`y~h-ncNfpriIZ!8IH zseAAj7J$Xn5D{ECYsf@~*TY_`cYV3J%HxeqvhI`52EJ>$?dah1cHgp8V#2k_{qk1vW$&~{wptp8ek^}|&MRuYNR7Rc zM?jp8jsEDDx59%RUK$b%y8;*6BrI;5m|mt3x%&Eb4%f|F#U!>YmnddwFfzZNylZJ_ za^$Y9%n`2s+d7@UDtKjgr=1EWq}3xxtKw?977&4UCq zRta(U{3*~9W|;F5bxyOV;A!>~`nzA=er`vb3JRM2`Z$$ETQ5+#YQd+q`3vvE}U9*eCJyXO~uV z=SlGGH~o_4qG#o4ci30m%C|80ICt>Ptx-=-upB=>LvO6BnQ_hZkxP2sFSzA}*8zK* z#DWHIo(zL8eRU1SFGPLff_?)mB|Jw_OR{IvCXamB*N*S+=# zYk8B?@HO!mFZY;O-M$;&#$L~?;hVNhcoTT%9dnuXbj^|OjGEjuS9ct@G@gHso1N(V z2KWBC8=lKS(ns~{))~1pqYPhaan$wR-2RX&v?6wi-pO8v8vp6kTr28z|B)Bxj+sr2 z*L^rm^*aA6(M@*`2gf3=yOlpdfq&`WI85(Sno)MPovAFGUg(<@r8sy_|8>}=t(!)> z_1=PH`MzN|B1<;Isrb~LOLwQnG8eW}-Lt2;x>=(0ysR2plPv6x|4L|o&%T2i#goKR z$zhr0say7r_nxl7(06JSBlYtmAInq1k~ta4o3g*u>HgWBUX}9IjAt5%3Z>%I{6WUy zO=JISd1qq@k4tUcBgw{=riS}HzTe@Q>HB2A!oe_c^n~ z$#NOT8eUP~bxxR5`TJXYM$bv{I~Nrqxuj*5+|Ceat?9ZI zq~7q{p2o*|`oL$Jg=+|3nndXHQ#xMGt1YMds&@sdx$Fw{)iXD3a{a?D^qD8wh^adYW={K1~|AH4GGg zI8bbOSL*qIrONh>z8S0P-kdA1!@g$cOlM1oq5H5YFR`z z=NyMnCioNy45Wme(!$s-NZv-g`GFjH)f1%)co0ftxo5N_a{ct`dD&Iin3R^;+-fx3Nm~6RE~6IzO(eV8s(ZfVGzoul*Q@W)!-XqUHY=zu-r6r%Z1}b z`BRDKOj5f98A4{JTO<=UEWYz@nYZhvuKN4&&wk5n;c-}`+1L7gbMcC9j=h)VA56Y{ zvmFj~8f&R`$sXCKhR7=h>#&aB-E0*Ee_1b@b;Mfr#1T7c_3e0C|NPv$Ter@hTOw!p zB<0S;$@LjrfeuJRNOpV(o2u5ifb*jqt03 zJu1|)!}hg@qO=2q&5F0FOdLu%^=6A~6~9M!Df{M#&sihV$qoCB4&I1<-nae9qp?L> z?PC%W;!#pkT6dJA7RH^5KP$p>EU4Wc4gD4dU?}`ZS4|uL+)Sl zBs29oA7A-X?e~-44E;Z_vbx-jO7Znj&G-mvw_}@v_vs|DWg)*;e+;rzNwfJhpcFIS zd-3ht2d{!+(~}>iCp#T5P_OK@lVEFALqJuhi@e~QE7m+OVv<#h?@29isN}-C7BWYeTzUWA^UiHerk@;D6Rxy% zA1}-kJmA)6?_)b1k{&fi9U1B7Op7efY>B?BJ|4M9)9TH59quaO__3+%z#*!sTNBR> z0H#06&PfnsbXs7oW98_@pr8lkz7B8N+6NUKj(_Q!nhG_Yem2%VVxOEe!pTGl$QH_B zc=Wm5IpB(hSI2Hw)x}E!zv@|DP`Z7giV(`)c38a=SIK%B5iaIo|D|%f_c>qg5J8o1 zlT=;v%^}GPibG`eHbh)(Y`Ca*r*^C=II>;$r=b}~&m;TsA6rkfOFZ@*yQ0*V5adHp_8o|EVx z_uO0W^TJrODgy>dj-rYh<5HKEC9gH}maoluz4`t^xJw zFt$}c;LLws#mZ58761DZh^aKJVc@B*?N$akulRCTzKL7yeO8!$H)X5tUWW$#rdp1k zr%D%pD{icNfIPviCjMTBhPwJbqo%4G{UaC6tQ8yaR>oZSl7N&P)tpCk#ieB@) zaK{JFqs8BjCAs(c>U@{pzL-SBlRV0?JuL^a48BMcC?yo7R>9NbW-QObv{;pvhA&_C zOT?EXo^hKK z$o|gD*3Vt6E^Kf~{N9){2ANfz_e9kex$!o}lNVle zcrLvqUpAjzs(S1DE!>}2-bUdG9;}EEl*(eAJ2`Rob9v*>onHU`{Vq45xoc_g{MU^O zPF-u7J&1E}NsBpa)!emO_Wk!~Km6xsg%vpFKO6hspS|;+pH;|OJ^xwNxqBqfV;UTtM?+cYQQLcRVS%m!tWBSMo>^ss)(Fbo)qVeCx`(T%*H>X<=dj zkQl@N{T51=W%19wr2>g(42P1?fCo+xtJi06Z;5PGvm-c;zrwEiv;tl|gjuD|M^WNK zo$>JDTM;h!Qe{y1m#kPzz-i!gsC)r~M_X_g`Wm2qXdY?+-b#$ukaqziu<;bSgBB?LB>ZNX=pM#*H_i zzf>DU8_dWZdWvtuqc~ds$BLwG{Kjy50n=b0?;q@^9kHfQBd=WpINX{)1~A?PmFciW za#HsnKI{O6zX`XI0ic+uE}=b1rw1Eft#F_aA3 zH`+`+G!5?NZo>Fze)yjo-<-fhog~a6>b?JX$0Y$lXW@~)cG1hAE zk%Af_a|Ewi%lMHsBp-8YFvCd(eNpJ?C5>5R3Jkl=dNlh+zolq`eCA}=|k^r{+oiDEXW~3ylM6>$G$hH-kiNpSaRTQD#>XFFB2$Dj|v>gv4@m`@>v z2SSI212iLMyF<9R=l~% zAdelgc6%2Xu1$ZQFgkJ+Hif92HE#q*Q_MMl7*92qhZ`nUK#2I0%)o&6c>Q_-FZe=) zh0FdK4>k(*5wC}$4QQ)s`6eFBs8WH-OQ9&HXj|Ev6TNIQ_!xSHL{dWqWzH{8O{2_+ zWDDeyZbCq->?U%AWuI0I+~`=st9`VR2Q)U`mzS$|X=N|r^td8aQs~Uvbt#RA-#DW1 zONb2vZKzd}I^yHUk3kLClD)jW>dTiI(#wKD1L%#bbQ=)91@9NsWESo8mYZk`!USi4 zln7!1DL?l%c!O5~>JqCWJs*X=Ff%`xv!=0+?q@ICdcL{|he(}iM94O!Tlg=8AKhFh=ewWtBjTE)p)|I=T%xG88{epLZhRA#h~TSIBurm!A50+v7<9f4L4sUGD+440GS!n-ggRQ&ue&2;-T+#| zPFam1S|Oo}y~@#1R+KOkRyEhYbU9YxGjNTx0hslpPPqZCLRFjkRWh8K(05A?gghKk z;dX7Md^YKrg9*={KL>*b#4$J{ruIE|c5II({h!cbTz`G3k-Q{neh-wzE0mp-#X)S* z@H(f6s_s`L7OIElZ`~cY&XSB8A$rEZj71X`i$obbg*^C%-E}>XJy9N@cGYL}p;kFf zNP2kddhbmTo{R>l$yC+UOqbeQE=pZN(c-Z_r-sbUBiNIeYXw7X9fh`<}&83BN% zGtOETW9Q0KxrCq~5wrv%!ly_$7nCcd>7P*;c& zDN5_ZSJUCR6m9n=uRkYDx<}c=LgLYM;Xim^V1?*(yZ`htkc`pTg1Mm=80Sv7bredC zKVNqqELo6vpj4QIQa_=F@Zk0-RN;@GJo&_-rs7HU5l!QrMAZdt2%wqwRwk68$KjS- z!|qvbGIfWepU`Qd?E?-dug8f|*7I9d?S;Ri>TR%;9^<>7Gn(Kwye<2K-(t5 z{}Xa4o3?Hx`fjLcvR?|k@;Sva4~6sun_v7c?ajqLCAzgDF#azC&2nf<(;YNV#* zc*7s{ZL0Z`*fAx2wcCA{G0_B;AWEj{3+m-6Lh;7-8r`nOPcp2^Ib?H8sQ!l-gj2xmmZ3bXjE}-MQBl|S&yzGi1Yn^mERoXsZ$lLV<%9M zT>Ylr2 z)^HZEYkNOFfkqDKq0@M4nhzJGqQwX@IY*6O6h*@{!Xl7C;j}`8@8oBt(PFd_u~O;W~X zWT-^LB-#vQ^WW4*j}2H0cu4Hq7e6;}A#4DxWYMNA5)$aAXa@BOG%vr3#po9)c-w`9cW{Yy6j<2Y zKQuO5pJ4}+c(q)BTf2Kc7@-!PXUs%%)c7MB0}jSdZ(9SSle&wKB~_I#S&BFb-`Lyt>;#)eS|8 z(9=>lE6X)mfd9?^%-6vMC$2RKUIl~vkxpB2(K`pqO7@G6p^Gm%wyvG4ljGxm3G$`x z8N*`?04@P9w1j8=bBF`yFZuW9cK`cx+vY#V^538P{okL{ng1Nq?B@g(;;~c&n!&pm zxUrnkM6fW7dG5)?=AWz!Nmz;Z+>`ap;UhOi=H6lz9=bxU1WzKx+dqdd5JEnQ1@?p1 zqDmDiRO2jhEV?lZ4TQ?i9YL)-SRcO7*)L-w3R!sBL0S%>#UxJ!WyIBZ?#smHzfAC7 zU-r6m?#t#1$!Q96D;`gniVL_|Qo+qSin|Z+*>>8@1`NUAe{P1dd+w^yL~6sc?Yckj z_~#SUT*vNFlWpUR;tkZ)y7R7w@HBQZ8nMc6x>~tybz2~9t+}7uRZ0K{hjov4gpZHN z5%JmR_HCfLW|-#HRWUjXC?yKq-UqI~W0o8f=~HHI?OnDSFv!@En#;-u-Wn?Z>Pn^$ ze2yBvxg)v7s>y;a?L|SsS^aZmk58*Xs-N~6m~mW-;#ZdDmD&B_?%U>$K+CT|PrcUb^wxd8 zF{$A-;1pTRvRS3$h)YaESnah9Zx++m+JChWpsWmj3`c8#_xt!1MYVoaiW~RnTFL%6 zv*zkZ!E+7b2b`S<@hd=4^2F~kdX}$Jx_%c*>o0ivJ@N9>=pFx~ujc^As(oLJvPsD* zLeik9>=hX)LX(U{RQ6t>l#!7wS^ZF&M#FyX>=7ztZ?C;a*8e`|yqdm$m#$0S_l$e5 z=YF1(IEg=%buUf;io0^Wqt^Lltq)1vTvosSXcx;I$rvb0Al}h4TCL};Wy;+7l?Nu5;c$Cccj!ce=6Ek>e(jL@mFa{%enNqqyi1dTdZm2@ zL0MITagLhHQf-+@m$e-m4^c|`1zO7A9pmd(N{D-qeLG8Z`I=GuNN~RI?L9tFTK1FF zzdtZ(GB31!@?&A|HNI!d0vB?kRXEk6L_hUIK0-7p1D6iEn<8j0NvuTekigjtykWpS zI+1tO#BiY`VNPh#Sd+B<(9pSacK)_+9z5969({Zusy@@CQC(nBxjFM9U6hMzgQ>HT z&P|2<5!17dSItdoT$Zv{;Gkh;qHN@X$WTqh_v4ZEILnET0&kU^wrgCaTWU@q_Mi&6 z>^%DZtM+p3AkNx+q4K=e#midnlD0%U*c-f#$h74lo09a;i^JN^)eeO!01ajzIZV*e`pL z(~0<|NZ@30p63e7h3D!#1ibU#Ff7(H3ABEc$eWms)%3D_d0&9jV7J%pRg=7Jal&wh zGM7tbBxZX$tSs&zWoDy7_klK{x3@1?7kaGZ_70^DA8Z`YTVjz|synLfpq(oL)#phg z){rRD-1!{m&XW5{OLj9c(e3oE^Sy2wE>pgp^pPzy)g=e|cwcSnziO>AHvT_ni@83J zm^Sm`ZBY^;@5d5&N2c7G1&Alc1IQdX3yykBwsbvtv@|sN^eiuL^Hk{ugG&l=)*6G& z_z5{mo?JMc=qx&Ta5j71;VhBATfF7j95@Q2CQ^`*IxnEAbASJ+m9RRPG?rmEC@a@) z*s&|Y_e^sDUC#URG*#vww&%ldWt7%{T`{AEgVCu?{WFgRV=J$;KNY(_7OH(UbuQH- zt#$RwH`cS1PmZMH6RRX}seIWt^KT!Q)AG1JQ~X^*{EGEl%BqqwZ2mJ^n_R5Ntc(Os|yQ=Zk&0^|1i4j(g@ zu@{_p@0bRrO-Rm)eT(?EqCff0Cy@pnY60;HHw`|!J)5eY?o!hQcCx_h zc*IbkzP!cvs7J_#KD>IGbV?{@=tav=SnjTW2 zNLE<9=W~``fu|C5)Xcyk((AmaW-xfxMt70mwR!E?s?5QE@>F^MF(hg_-(ddHnb9qa$MbE_$7n9!@e%pPCixLQEP zarVTwuVao`5)+L|VKJ@Uei_YuaCQmMbD0=t=viCd^@W}&?FF6OhD&2Oe4_83%;ZZ~ z6r_?8KWW$=Yq6T^>z2FdwESvAr*?W{MTO03$E@#`E%|VkHYS$UFS5_J=}LX_%U9yx z7et8X4l{gr(N^%G5B)OS!ji%v`-taM`^t33g9;m$Ub)wWs~nN_FW-y@)jPUO2e$EM z=PdqjK_pPI&8eo3>q$LV1+?m>%#~8fLcQodQ|yJS>@EAD$>tWVCW%E5m_Mif9k#+O zhdoXFDQENH2%^{SE$7+@n@r@1Kc+yVhIq-Sj}< zEX$=@r@bNK#)oW>+a)7ejQ8#&sBK$WVBd7YG-)w^go$=4%bMo^59uJ&y}+(}(0S7t z$x@}LwcO^6%L;MA7Yh_b6bRkPr1z-cy@Io?X)Au@V(_V~yIy>0G{aNX_`eIJ)9-lb7$8}i%Ne{m@Zc%GK4^1u!iRK1pyF*#5Bhm!!Yj7C0`E8I)ga>r* z(!{3M;|--8+^mwRilG(K*sT0MG^i>lC7e`^R7!v%7Ew>K%z|$MJjTSiujK|{~R#Wt^xPK$_WjfuT3G2PSjqpt4G zg8TC{=Tps%jniK$L<*7TH=sGgG5SU3REqJzZzZvQpP+B0v-dttukr`Mw2Jg=Q4hUm z+rK=&=U&_qSfA`^noRLx8g$P~> zROZV&YTB7No;2d|Ht}Y5M}?xz*hDxXH0ah0Yq+=JBMGNtbL~Ig7YnUc>!F+E8F8%FAoiVeLD~%E@}o5(`x4R>s>UN zCwMcgOLEGYahW@71mhMf`Ln17)qYIJK5_XGbG$PSXQ|%c@?+c;dRd}P5_whMb9FT- zKdGB|Fdq2TaWp1P%xD}sN2{PS<7$3T@rB4cx(#2MhDLwXUsI*;%*-BnbxFN?;m3td zJdSdYY@J3w%3KSiGj4DCJUGECn$c`4E;jIFPojcR@QM3}od)lhr-ngukl$*K<#jCg z7(rgPK5XJ>*9^{rMDqE45l!l{Hw<@Zdj%*(50*>S1Wl%xCfRji3TAPpU){Kdz4^v30>Y zLo3}60XJ%BW_W*N+P)`z&}Dw?LT8Bw5+ZABGf$4zXUT~d%Bz3*D$qJR{tbx^pFVw> zZl-M>z&a-;nERHNrB#?-6A&a~rI!cfwyLz?tv*0nCEQ4YG>pgTMxH=$BPUTfT51MC z#E02ExtfcE55>S&luWRfPmXXhb+I`?aQ6P^IC2?dn$4-no289X+oUZfF;e@P(sa3k@HkN^1t_%Hi${OBMe4ie-x2G@ z$IPKGQV0={8FlAViym`*)=hhZ+vR@-bdG}tJ2Do%UfC)Q2|G#NT-D+nIx!})z5L~N zk(gtN7Rtu;%TuXb^jV-6F7uMB4<`#0*bjn(WU0nC#Pw|IpCjcO{h{_%YL9(uXy*7- z0a$$TrRf~jQu9I)>$iv0o0sU!Q+4kc!?z+Dv-B#Dl9WY}4{9=MMLRq77j4s5fA!k* z^fzvyH^~rgHw!E-BT=M$f)v4KT5$(Co4IY^@&F_g>1T7mzi`5KpjLhL1aHpNkX8fgFf7B{E zY>`UZ)UQ1;qwjq&``MXNdg#2VI7{rW0Zh9aGhd9vx#eqCo0U)E0~+UUB~iF?7@lYY z7xj2c(ny8a8J0`t^_o(fm3d!<^QBG}9o^wUY8FgK|KSx=ZIXe)JpHiZ^(EuSxzGr; zXT|YrLlUDMq`8Ti>9&Ffi`DL?t7AXft8Z%N_J3PU`*Qr)9S7MM27_V6yTfm9XAUV} zX#cVBy;$0SIA?Ka2D+NVBEOHMU+e9Jk18iMTo`#nrTa8Fxnuj-VCyVjuD#w+VxMSH ztFnp9hm3*JfZL)^m_JQgJJ#SF%*}#RM-5v8cl4g3Fcvp8)>M;@dn7E$tgw#K(lI3X zLY&7@D5W~f$NW!ykm%+lr0;HD&ioiZDD?y|^HYL}wCcjZc_ zyhFzlU)PigRIUCflSKCELhaqK6Ac${Hixa;{u;SOE=;D3 zCn@{0`Bt;|JXXw%w@U7Ad(pJF$b)4)R-Ecd5nNqwl%;3&F%8gtdIb5!;^8C{_xSd9G=^EAsLL1M zg|vkSup9tOr@A}GpguRQ{Cvenr`>-S%v`U=F3FNE4WbF-Ip?+T=cYm0LnMU)(PH35 zMGM=HaDT*~9}&oQCKTqM0{JwmF(bC#hJ(96O7(^}Vp^frW#SLf74NGq#KGX#Ap3AI z>$25%KMn@UK)%PF&?^H4L1tFSbhK6eBtt2Yi2B;Vuh`SM)V5&o#(U5IY1MsFvQ7~q z7>g365ApQt=e<|hPW zFYl3d!c) z+pz+c`N5*M@~l2!kCo_o3=oHNWlE_Q$1yRBwTdu(a3DRlJ3cC=0D3kdZ}C24IdjS= zK5akp28gRqP#fzMWPNZ2Vne3w3BwsWLs3JCS>DC!ER&GrTs86A=#9Q43?2RW;e>-S2_t*e98dDTgETBAyhUB`yu_ zD#hy1rPO!|K4k|t4!ke((=`+;-?yh=fWkdycI+|a?@<^|fdjIXpKfx~F+7&bZ z8(r;~S-gkT@!(Gs>*P=+Ko%$5Nn$2RfXqSu5c&b}aEUBDVTo9w97W_nufRK*ZYiF3 z{c9N0_*tRW2%SVY&XC^(Cp@AM9K#hjV3rDZZU84El||Tc7y^~1<6Jx(U;h#X6$Mj0uVS|)9qk(unryOlUdi52P)`S&~RfNs2J@%T@A?nP?9M6UbcG(;3n5g3n1 z2}{VdY$y=$(yR%AfMsUPVyIORytUqf0*it_7xcrrA(>g$flzp(qwueR?x@@`gD3b0 z6=BLnkzWI`LlkE2^#(FJiv1esgRMxj-oSl};=cw;qEE7aP_S~V+}_HnV1@D$t_=XS zDS6DP$1@)#`foI*ztSZwCq>-m+cPK&2U~o|CsILSdl+j0Vk9q)B)&oi$Med`D8q9oxb}HzmQ5PjMi2kmLg~J?rk+k$ zhpZ&koE%lXSYE@d0p)bv~KPCCIlIc%UBDy0Jhh*Uu*n!KsZ^M+F(r7UpB)zqzNPRcySUT0QYW~O%^ z{hHQib1xHr`yVGMsarh}NUhjKWCVzG^YG_YJKnG&nEf=DlJEqER3Dy9C~qzuAq41% z$275Iq+Lwp#v~$SXmFRIGOF!XsPqF$@hD2*FDUU8uJaQnLR+**_M-cLVEQ`TOZ!FTwa^CnhXJNl@NNhrU;a2!Mt5Z+8+bsY*cfhdU+TEbhjI5AY?AEDZT ztWR(b{;P=P1gM@a$?7?1o<}a!PBIZ?l!*+|*}e4y0ZG^@_XM^2$gH)-aC@Z%OsvXL zwmw^uwb`G$!M`FqN$Kt-AJ1b~t-uJn{j*uk`kU2c4bj=P5;iMG@5R1wlW|@epXiI9& zWRq03@av!1ov3=lkYJqxb8NAIw`VtDT8<=YM=+fibloLK8c&0Wtd}|S6I6;aWWd-S z_py|Ee(@!@TKf{iiRulMuV{7{3f1jZV|kfZtwi!UI3=Y@lu>%-D9@I~i`c5%#Kj=blj!5OiC3JoGz zzrlKwRVsh3Zs>B_+~5ARdAuaIU!(Wj0W|27bb>&KW6+;0UZ6_}i6$xPq#ZW#$#YZk z)sXbk!Q+9B=9-+SYU`ec|F6BHcO%|69gPy*kc$dFL7l8qPFIzn>0*00uDIH08|0XNa;4V*` z;9Ja<9|bEHm~`Lb?zC(R0yR#`0s7L7p!vaGTsA?{oqbmKJtncJ;$qVsN4wM z{sJ1uE9w{y2$cw}EUaM6Sj%{=E$&V-nPai2|8D3XqEtnwbXNqW(g}=8C~*l9Whqcq zev&EbDp>9oz$S2#DIQF>|9d(F%weUwdzn+hCNBKbjl*{qII$$mMZZ6n#ukmoiIFh4 zNAMIv>KwJFTjhrJZ7}po_d`7B^fu^E5uLn84m4x8Bh?es<#l_FUOSTPgABNbEb1zF zH%Q1KGw|OHoi%bO|B6ulg%fb+g8-L9ySL|0Va{eRl5inzguGH8_FU3m+$*?f2&>4OB4MFkG$H6v(dm9`rRAg%Xw4wr;T9o zK`2$|fKV#w4nfjw*brU1`ImHgKrO3pp%eqv#g+# zkc4v;2W&j**QnS|AUAO=6Gxd)Pagk>^7)?H2K`v3iLjSM_pS@vRAJ@YWJO;6A~5#( zzOvxB(4-hVf87pJzS%QUveDdyB<__DJP0oMG-`E9?;4cnCOtkS&;?O)#*q zw>-_MdkpT-=u5nVLu$}~7pSJg*uXYVF}&|U&2~vf2#TohhEWANDbTu1XzxMaWAQ9V zqvh~20m!^eilPT(r~9|DQuyFD_>(>pl!x$iMK?zrjbP2btiPiFPOWqDlOGk9KH1zs zO@p9tjysxdM?=aD_B~cYndqiYGKK50$#*9Md6m0(qldcD_gI;^+r|AkG6Oc%aAeXr z#xm8$aJe2kQcNuAXePWPFj9$gMK_(Wxy<^Te@@K)?B6p4-y;9|1C6B2>~c*0-TWMc z+T#||fxH*G;61^$$3lK4(9G`I57FdVG-#43C1D98!qRR=SD%Sk>A<9mUA+6SS&|w~ zpCkjPWhq62re%i}Ol4f6iR$ts8KEuknFoh|iIig&UNpID0O2A0;<+NaA^vW=@FwbmT94&b@3XJGdnb+tHRxUh z2X$h8S#B7j`%N`am4II`0_0i{v$|)CLbX$FN#FTj2z{n19yO{#@*fT52IQ9?Q=9#X zo!iMJjqC&VD$w}NSBztf_P>(DhX^*a-9Ef;V^A9g)!m~+y48w1;523;tZW8NzFFOz zu24Jv!wFrYp3S_MIm*n6w2KKWJ2P=y3V8Y#P2D&6OJ^HFJ)FiR{0c@Lz5@Eqz*9tS zaWt?rCb#6olGPJkr{M9xC4C(XHA9yi#(#m_aD>PrIP3@%oV${64Vbvt^eNs2EdJEj zT|YLmCfKvFE4Z_-e7C*>Zv#X9`3j}eR+H;3LRG7G`l7jOn2nF+g^3$=Z|?q7o@|3!-J!8I_ulr!)1Rj1_Jj)F7&w9A90v zi0RgRYVmzkL()Ao*iwA{_|?t6M2Gi6@akHIT7-+e_4Gd$il0}{``Q(oei;(&&{-l* zJL2qi$$YM$n!K|^XRBP!o>ehah!iKQ?>lQ%Lknn8P}_?J`#k6r(2zGBS0BCB7*0+? z!roq}+j4g3OW-~iP9{7&JS( zr2(4Qf1wc{HE0ZHV`e7rES-ySLG!u1)7fnQi@9_Z+D9ki1qQ8moB6kO?5tz5vbQ6$Vh?HxOdO^_4$maBqOB#pB7pijG}!ICVldJ|=_sToC}qu>AtBFH7zTON3n^A^{0uiv`i)QGf-0UF)xUt{4nfg5RcyZ^)Jr( zmqt;E1a)>m2QYGoNaV^B-L}qZ87-@X)jF}oIy1!9S*%)=Ez16flxHb;qAM!&T7fDwlc$K+}+&B<@TY*SgTCULG%}L;mr1AxFS$w(kNS@P}BkxnfdNTf{o$3NJx(QZAKNUMT&3|!vpxn;jWk=@+;b10L?lb|f!ob(#yxjqI;iT}aXraa#FzXM_Q z6Nt|>${J>qmIs_BEDzw@7W&Vh=>RA?8X9{MqXS+5NHR@Kf+vt$LE1YmAuXb9;YH_;5#$NjUH5YCZ0nJ31DYCL+?o9=UTa zHh2Y1xi9#g5xT^*n;&t$Zi+IEnlZmjj9*)HOT1$m8#Bt~C4WsnyE-N?RCsG)d(#Rj z2g2C>-}B3&U@Td@<7;w^F6{=t!_)W^9yb5yW)`cJ^vav@ZCpq%0}2fh5oXnh+B^Bb zBKDgh7s2__h~TbV0_hLE?d;)s!W&IihbyDTZVIe;{|=AhFL=xti^_1vv_)Cs9X$x} zAlu|2Z&Cnvy=%^QGvum({R3KwCwJ>C6}No|V~C_s{Jc+T^u>WqD0utO zYVc(@FEVoV^Mq+{e@!E+J)|`HdZ9~!t+j2PX|5AYzoxzVHLaqSPiZu9q064FwfNs@ z@&-%9ZacqXgs8#)Bt&8CF~1;3qa3?i@@YdDAzTgXkR32NnM-Dl7npsHtL!kk{AAYL zBB6@Dh7zAzi5=kf1h(mXj>U8)`|eL^EhIh`5VUBVV|PzJO&gOER=5lS`@`{l1HPyA z;7{R8{a;9Ai}lneOPS0nK1?pW;sti0CJ02xR8we85tA7xdVAU;c%%2#jRa%I!k+B7 z{HLb~0@PmqQ-BWNJji9+;WTk3z$5}=nB-181up97k&M6(ayJq0$jFzP7`keOz`9-F;pNQ&uLo`n${wA zC|jwQH4~HALdPG&i#E0qbXHMh)fqdES>vbNZF2itiSMlXjhdi&Z{D$3lyQICyyR!q z3O;VUy3F{CKG3cJdz@j-Llil@^(e~ON+dB^oWgMs?k_lu8B})>aC2} z-nQ}UC9fO(A_^9lB8oOZ{CrH(@9~X$mP)o_t)}bTu(8i-o$XouUG3u&KzV4%a#B zm?DBYUj%mQB$sQm7Up**h^-ZX+P=3jjUO{3-C#zBL=mnL@8%wTU8@B!Mzm!H|)?8xOI-Xr(j3LiP5MISS+^+9)^E_MvSpy)!O zAiX*>LpMvA+4(Y9e~#C`vtt~$d+i{^)S%nfrqANmhI42XX-0*B_5xz` zX<=V90!66B?|hwtMPqp7W|704+cs5Zy9)AkW^(hWDJfT**imGeektSclE4@*;NQ7F zs8h#`%Iq#ViHFPF+P6>a=$+f{olrVKHTa4x{@=x((>E2l|X-jP_n$qGxC6t3Uwp0XgrOm5!m_ad5WA0YQF>0 zg1cIFT-Di1lJNeY$wmClY!I=cULjzD30ya#PQZD7XSbLm_v6Z`DT(KZ=>ZkI3&yq5 zeT>v-@LD(YyF%0{n2syz3BJ;U&DgGjtl`nvfHmAu5)Dof^YBZY;^3M1l{>1#_Uk+%RTEJciP=8zqJ6AZ3(_H3+1rD|5{oqfLtzwoW#x1olH*< zJ#E{{@n1ljd0}HkJYZ}a+|CD+wyPG6QIRky0LdQ^=0dj+)D)RYQMpWaNtbl*ev6Wy zgN6c0Nst(Z5zQhH0>Zx_-|)qj&hkW-Mg>Qb{6CkDz(hVh3|aaty0jkke=Tjd4YNJA zbo8ZW*>MtkKpHAJ=@G`rfHOKq+X}HSVyWO?UMAz}F?U>+ugu!-b1|~oHY#ipWwR8a z?iAh7+#Bq0EXvv;80D4DZbEF1;5{HKiWqi>eVGAJG8ETkzM#uYzIP+Nr+GFF$}5JR zja`sm4S<)Fg)Bh)>hLR@C6dJf)x_zyrvCGBiepLqM(He5KQ+9~#F=nJ3 zAbW1bocl;_$l5watt(D|=>2uOff;?j+fnc3O!UDg{)cgBH%Fd# z6RX#M0>{Ut98O-s_{_m>r#Dw&ZJ{!JcY-L}r!@dgnAVu$%UA2mc_75gsVVUW70w4jy@qQ4y%Psk%AN8&8Z3d3dy$#DqDpGE5E z^D*BJ9_+L~98F$w5RF<|O8y@pkC{CQDKmSc^2#G2)sBxj4UMr;AhAkC$G#jhUm*T( zZVkXgL<8dK&|TjJo65;MFUOsysxg@$?@Y%GJ&Tb)*k?4p^+ogbU)PG~ZK(So^ts6s&BmV{Tzq*@1x>b|B3&8oY9gL!9xK8m|vk=XbFG2aQTnfLfXg|1c}Vx1&JI(BdK{#WI~$VdJ|4YCK4v#kqM?~>1dcG^~i?GXQiT0ttn>Au{!gJ(O`Sk6-&^|RAu zmS9CP^zly@88o)HZcVC(Mk^3(qZ0N6x$Vr&+nPu8Mn3sctk%t$&h~~xQd+Nin}3KR zqsS$xUGhHQtEk;jy*$LSZG(_s`&@0r$1BgUfW{>S_8&I8-++gcnQ8~H+5x7|28w0h z=#wSiYI`=ue_OjX;r7ty{#O$zwG!8_OO%U;KHPf$rZB@rd@bTNT+RLPC#zn*l@5J${Pf3pvuiJg9IpC~1t5ktPviYR8!KB~C%xUY z@y(N@>vSYsaf0POUHiexLQb)wi=-jrIf#x#F#jeoJwa`c3)EAIUf#qTy z&Yu)MW5pgbK)`2lL1>2(_=?J!m%$4{A!KcHgaT2UH;CGeAokxR$El7AAB_0aIuP-XDkv-3m7M`l4NbSk&8w^zMcOM`RM5FmH3!J>EMSxTLmjT zG{*clh~z!0H45%=XON%nd$wg*Q3C}Lt5X|Q_g5QLU3vxFLLzF1^AMwpcMCjE4n3Ur zilJglOc2;!ce7xI0`q(gatuS88~-g!g&D(^fk=)4Kj}KF{}hw*Qk_OCzd1c)glZLJ z7hvIvO?x!DN};N=#l+}w{-@tJ?EAOMGuxv^OsxGarina__LR^5a^`vNPDObg5t-m9F6u1F3U>>XGiNvzgS# z^|DUAt#aUe^xL#GlSc@(GlXWC+jDK5ce0JG!X*NzYxb4)f+C%CK(J&=-fE7~c}jNZUMp5u%G)P8#Ai37 zzWyc@iZpAReB^07K-eTtjmIr4as5pqQdrkEu^GB|hp@?{Xgi->>wj#*SwXwDNjgs> z2e!$Zs-VMe^l#VSgz3HD+9uD3?y(RyDG;sav-|WPn}{jWt!?-@)M>9l^RK8-}2t)#1av0_CVeXsK#_cagw>BlH zxkk#>H5f;S?mggVM*T`?*F;Eu^B0I$cfh@a?XCMQdnUb1&ah>s5M2tHn{Jx(Q&iIC zy!BmpI-~fGkyY(Y(G1%~+v$bRo!fHa@p|bvw0Sp~9P*F|7wA4XNZl$A6@6gy_SVNI ztq?YQnw!yf@86F6r0d{zp%x;U=${E8BXeUjp4xi6r)hUASbx9FZ$Fvg@x$=5D zkihqItL-k$y3EFmlY4cU_4nXhK5Lm$UwnvF%4(sWW3&Q3@l9TiIuzyjK z`=uM(4M`)mqZlk$%bJ6ihKd!KrCe5Ai=}^+d4@E;B~ZJ#cKAB2B)soO zY)V!kBXF@}gg~6i8#Qz%*d~WQq};HGicz}~t4bx2pxW1eesC%Bv}Q}q%g0CrAma~7 z(&{Bd6E$u_t_&sy_6BvwCbDBt{y?F0yqoz{1+5I{fjc7Cq6&o?VcyUO%u0l}MsW`d z_cYwRUe#m6$Ed3<8mHX&QlO;+aRIvM(vZ&;Or3)ow_zkC%&sH zslND0nfI;&2$FYy`vC@Ou!a81DS}bI0fp}I)5(xWZPEJQv!FH*=JMfzGuIN>a+R2F z@8jhxM5bL_0xv9VOJY66j{$rP0hkyCxZ*tlU|b~k@KF@t_`BpKv>^_c=PLAIGxjfM z3Ci}H1FF0RW&5#oB>28N7F`F4RbOey0=X?|j7J}WbG$Dbit`LFILhj%ldY8mgSr{n znF!sPY;EV|2=3v7*v{aRVdr!b@Ix;wbI%DJ#;gRoGj?|Jxe7?tZi14kU3iUD_z^ns zZ5XNO+)<$rxr>tO_?Ie%->!lYPL)rI_Z*!3`fW?S^F!;I)8fqsET3iNmHBJGEGw~b z37lXGn)gpFRWFu_mPe!#+(MZ7zQ;#pFCz&e`bX#8D*U>-8W+{23;%%_F-e<fHN3%>u`$ literal 0 HcmV?d00001 diff --git a/gui/src/components/QuotaBars.tsx b/gui/src/components/QuotaBars.tsx index 8b8fd9c4e6..a1a32e1cfa 100644 --- a/gui/src/components/QuotaBars.tsx +++ b/gui/src/components/QuotaBars.tsx @@ -339,6 +339,7 @@ export default function QuotaBars({ {rows.map(row => ( +
{label} {hasReset ? t("codexAuth.resets") : ""} {reset.day} diff --git a/gui/src/styles/provider-quota.css b/gui/src/styles/provider-quota.css index 05f616b20c..37fb87223a 100644 --- a/gui/src/styles/provider-quota.css +++ b/gui/src/styles/provider-quota.css @@ -101,3 +101,18 @@ color: var(--amber); font-size: 12px; } + +/* Long subscription labels and reset/value text need separate rows in narrow cards. */ +.quota-compact { container: quota-compact / inline-size; } +@container quota-compact (max-width: 440px) { + .quota-row--credits { + grid-template-columns: max-content max-content minmax(0, 1fr) max-content; + row-gap: 4px; + } + .quota-row--credits .quota-label { grid-column: 1 / -1; grid-row: 1; } + .quota-row--credits .quota-reset-label { grid-column: 1; grid-row: 2; } + .quota-row--credits .quota-reset-day { grid-column: 2; grid-row: 2; } + .quota-row--credits .quota-reset-time { grid-column: 3 / -1; grid-row: 2; } + .quota-row--credits .bar { grid-column: 1 / 4; grid-row: 3; } + .quota-row--credits .quota-val { grid-column: 4; grid-row: 3; white-space: nowrap; } +} From 59a2f0d01f7141a1c971aafad927a7b789f4315a Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:55:06 +0900 Subject: [PATCH 11/99] fix(quota): align Antigravity probes with fixed outbound destinations --- .../src/content/docs/fr/guides/providers.md | 3 + .../src/content/docs/guides/providers.md | 3 + .../src/content/docs/ja/guides/providers.md | 3 + .../src/content/docs/ko/guides/providers.md | 3 + .../src/content/docs/ru/guides/providers.md | 3 + .../src/content/docs/tr/guides/providers.md | 3 + .../content/docs/zh-cn/guides/providers.md | 3 + .../content/docs/zh-tw/guides/providers.md | 3 + src/providers/quota.ts | 41 ++- .../providers/provider-account-quota.test.ts | 106 ++++++- tests/providers/provider-quota.test.ts | 264 ++++++++++++++++-- 11 files changed, 392 insertions(+), 43 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 5de4260813..988ca03cf7 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -124,6 +124,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | Connexion PKCE expérimentale, transport HTTP/2 en direct et découverte de modèles filtrés par compte. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Expérimental. Flux d'appareil GitHub et échange `copilot_internal` (client OAuth de VS Code). Nécessite un abonnement Copilot actif ; il ne s'agit pas d'une API tierce officielle. | +Les vérifications de quota Google Antigravity utilisent des points de terminaison Google fixes, y compris le repli vers la liste des modèles. Elles prennent en charge le DNS Fake-IP transparent pour ces destinations en conservant la vérification TLS, le refus des redirections et les contrôles des adresses privées. Une URL de base personnalisée ne modifie que les requêtes de modèles ; `NO_PROXY` conserve la politique de connexion directe. + + Après un échec définitif d'actualisation de Nous, exécutez `ocx login nous` pour vous réauthentifier. Pour les préréglages canoniques du forfait Kimi Coding (`kimi` pour la connexion au compte et `kimi-code` diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 255c0d8dc4..05f586a816 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -125,6 +125,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | +Google Antigravity account and provider quota probes use fixed Google accounting endpoints, including the models fallback. They support transparent Fake-IP DNS for those destinations while retaining TLS verification, redirect rejection and private-address checks. A custom provider base URL changes model requests, not quota destinations; `NO_PROXY` continues to select the direct-route policy. + + After a terminal Nous refresh failure, run `ocx login nous` to reauthenticate. For the canonical Kimi Coding Plan presets (`kimi` account login and `kimi-code` API key), diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 51d018f478..33be9fc694 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -114,6 +114,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。 | +Google Antigravity のアカウント・プロバイダーのクォータ確認は、モデル一覧へのフォールバックも含め、固定の Google エンドポイントを使用します。その宛先では透過 Fake-IP DNS に対応し、TLS 検証、リダイレクト拒否、プライベートアドレス検査を維持します。カスタム base URL はモデル要求にのみ適用されます。`NO_PROXY` は直接接続のポリシーを維持します。 + + Nous の refresh が終端失敗した場合は、再認証に `ocx login nous` を実行してください。 正規の Kimi Coding Plan プリセット(`kimi` アカウントログインと `kimi-code` API key)では、 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 3c28be0a59..7781cc4f52 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -113,6 +113,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. | +Google Antigravity 계정·제공자 할당량 확인은 모델 목록 폴백을 포함해 고정된 Google 회계 엔드포인트를 사용합니다. 해당 목적지의 투명 Fake-IP DNS를 지원하며 TLS 검증, 리다이렉트 거부, 사설 주소 검사는 유지합니다. 사용자 지정 base URL은 모델 요청에만 적용되며 할당량 목적지는 바꾸지 않습니다. `NO_PROXY`는 기존 직접 연결 정책을 유지합니다. + + Nous refresh가 종료 실패한 경우, `ocx login nous`로 재인증하세요. 정식 Kimi Coding Plan 프리셋(`kimi` 계정 로그인과 `kimi-code` API key)의 경우, opencodex는 diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index b99eb88ec2..b057cf77c6 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -123,6 +123,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | +Проверки квот аккаунтов и провайдера Google Antigravity используют фиксированные адреса Google, включая резервный запрос списка моделей. Для этих адресов поддерживается прозрачный Fake-IP DNS с сохранением проверки TLS, запрета перенаправлений и проверки частных адресов. Пользовательский base URL меняет только запросы моделей; `NO_PROXY` сохраняет политику прямого подключения. + + После терминального сбоя обновления Nous выполните `ocx login nous`, чтобы пройти повторную аутентификацию. Для канонических пресетов Kimi Coding Plan (вход через аккаунт `kimi` и API-ключ `kimi-code`) diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 4559133d43..c5564e74a1 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -138,6 +138,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | Deneysel PKCE girişi, canlı HTTP/2 aktarımı ve hesap filtreli model keşfi. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Deneysel. GitHub cihaz akışı + `copilot_internal` değişimi (VS Code OAuth istemcisi). Aktif bir Copilot aboneliği gerektirir; resmi bir üçüncü taraf API değildir. | +Google Antigravity hesap ve sağlayıcı kota sorguları, model listesine geri dönüş dahil sabit Google uç noktalarını kullanır. Bu hedefler için şeffaf Fake-IP DNS desteklenirken TLS doğrulaması, yönlendirme reddi ve özel adres kontrolleri korunur. Özel base URL yalnızca model isteklerini değiştirir; `NO_PROXY` doğrudan bağlantı politikasını korur. + + Uç bir Nous yenileme hatasından sonra yeniden kimlik doğrulamak için `ocx login nous` çalıştırın. 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 a4bbab6186..3fb72a4e6a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -104,6 +104,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | +Google Antigravity 账户和提供方的配额查询(包括模型列表回退)使用固定的 Google 计量端点。这些目标支持透明 Fake-IP DNS,同时保留 TLS 验证、重定向拒绝和私有地址检查。自定义 base URL 仅改变模型请求,不改变配额目标;`NO_PROXY` 仍使用直连策略。 + + Nous refresh 发生终止性失败后,请运行 `ocx login nous` 重新认证。 对于规范的 Kimi Coding Plan 预设(`kimi` 账号登录和 `kimi-code` API key),opencodex diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 0c20867124..96cf91ccfb 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -112,6 +112,9 @@ ocx logout | `cursor` | `cursor` | `https://api2.cursor.sh` | 實驗性 PKCE 登入、即時 HTTP/2 transport 與按帳號篩選的模型探索。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 實驗性。GitHub device flow + `copilot_internal` exchange(VS Code OAuth client)。需要有效 Copilot 訂閱;不是官方第三方 API。 | +Google Antigravity 帳戶與供應商的配額查詢(包括模型清單備援)使用固定的 Google 計量端點。這些目標支援透明 Fake-IP DNS,同時保留 TLS 驗證、重新導向拒絕與私有位址檢查。自訂 base URL 只改變模型請求,不改變配額目標;`NO_PROXY` 仍使用直連政策。 + + 終端 Nous refresh 失敗後,執行 `ocx login nous` 重新認證。 對 canonical Kimi Coding Plan preset(`kimi` 帳號登入與 `kimi-code` API key),opencodex 只會把 caller diff --git a/src/providers/quota.ts b/src/providers/quota.ts index bb35c7ead1..7136cd3c70 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -2607,11 +2607,22 @@ function parseAntigravityQuotaSummary(body: Record | null): Pro } const ANTIGRAVITY_ACCOUNT_QUOTA_BASE = "https://daily-cloudcode-pa.googleapis.com"; -let antigravityOutboundDependencies: ProviderOutboundDependencies = {}; +const ANTIGRAVITY_QUOTA_SUMMARY_URL = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`; +const ANTIGRAVITY_QUOTA_MODELS_URL = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:fetchAvailableModels`; -/** Test seam: inject resolver/pinned transport for the per-account Antigravity probe. */ +/** Only these fixed accounting destinations may use transparent Fake-IP DNS. */ +export function isCanonicalAntigravityQuotaUrl(name: string, url: string): boolean { + return name === "google-antigravity" + && (url === ANTIGRAVITY_QUOTA_SUMMARY_URL || url === ANTIGRAVITY_QUOTA_MODELS_URL); +} + +let antigravityOutboundDependencies: ProviderOutboundDependencies = { + isCanonicalUrl: isCanonicalAntigravityQuotaUrl, +}; + +/** Test seam: inject resolver/pinned transport for provider and per-account probes. */ export function setAntigravityAccountQuotaTransportForTests(dependencies: ProviderOutboundDependencies | null): void { - antigravityOutboundDependencies = dependencies ?? {}; + antigravityOutboundDependencies = { ...dependencies, isCanonicalUrl: isCanonicalAntigravityQuotaUrl }; } /** @@ -2622,7 +2633,7 @@ export function setAntigravityAccountQuotaTransportForTests(dependencies: Provid * A redirect or non-2xx yields null (unavailable), never a partial row. */ export async function fetchAntigravityUsageQuota(accessToken: string, projectId: string): Promise { - const summaryUrl = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`; + const summaryUrl = ANTIGRAVITY_QUOTA_SUMMARY_URL; try { const summaryResponse = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, summaryUrl, { headers: { @@ -2644,7 +2655,7 @@ export async function fetchAntigravityUsageQuota(accessToken: string, projectId: // Fallback to fetchAvailableModels on error } - const url = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:fetchAvailableModels`; + const url = ANTIGRAVITY_QUOTA_MODELS_URL; const response = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { headers: { Accept: "application/json", @@ -2662,7 +2673,7 @@ export async function fetchAntigravityUsageQuota(accessToken: string, projectId: return { customWindows, updatedAt: Date.now() }; } -async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig): Promise { +async function fetchAntigravityQuota(provider: string): Promise { const credential = getCredential("google-antigravity"); if (!credential?.projectId) return null; let accessToken: string; @@ -2671,13 +2682,12 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig } catch { return null; } - const baseUrl = (config.baseUrl || ANTIGRAVITY_ACCOUNT_QUOTA_BASE).replace(/\/+$/, ""); - // The summary probe is pinned to Google's own host through the provider-outbound + // Both probes are pinned to Google's own host through the provider-outbound // transport, mirroring `fetchAntigravityUsageQuota` above: a configured `baseUrl` is a - // routing choice for requests, not a second source of Google's accounting, and this - // request carries the account bearer. - const summaryUrl = `${ANTIGRAVITY_ACCOUNT_QUOTA_BASE}/v1internal:retrieveUserQuotaSummary`; + // routing choice for requests, not a second source of Google's accounting, and these + // requests carry the account bearer. + const summaryUrl = ANTIGRAVITY_QUOTA_SUMMARY_URL; try { const summaryResponse = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, summaryUrl, { headers: { @@ -2701,8 +2711,8 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig // Fallback on network/fetch error } - const response = await fetch(`${baseUrl}/v1internal:fetchAvailableModels`, { - method: "POST", + const url = ANTIGRAVITY_QUOTA_MODELS_URL; + const response = await providerOutboundPost("google-antigravity", { baseUrl: ANTIGRAVITY_ACCOUNT_QUOTA_BASE }, url, { headers: { Accept: "application/json", "Content-Type": "application/json", @@ -2711,7 +2721,8 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig }, body: JSON.stringify({ project: credential.projectId }), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); + }, antigravityOutboundDependencies); + if (await providerRedirectError(response, url)) return null; if (!response.ok) return null; const customWindows = antigravityWindowsFromModels(asRecord(await readQuotaJson(response))); if (customWindows.length === 0) return null; @@ -2783,7 +2794,7 @@ async function maybeFetchProviderQuota( } if (provider.authMode === "oauth" && explicitAccountReader(name)) return await fetchExplicitCurrentQuota(name, provider, config); if (provider.authMode === "oauth" && name === "anthropic") return fetchAnthropicQuota(name); - if (provider.authMode === "oauth" && name === "google-antigravity") return fetchAntigravityQuota(name, provider); + if (provider.authMode === "oauth" && name === "google-antigravity") return await fetchAntigravityQuota(name); if (provider.authMode === "oauth" && name === "kiro") return fetchKiroQuota(name); // Passive providers (meta-muse): Meta publishes no quota endpoint, so there is no // probe to run — the row is the active account's last in-band observation. diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index 1855e92d66..989f55a410 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.test.ts @@ -15,6 +15,7 @@ import { supportsPerAccountQuota, providerOAuthAccountQuotaMode, } from "../../src/providers/quota"; +import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; import { removeTreeWithRetry } from "../helpers/remove-tree"; const originalFetch = globalThis.fetch; @@ -681,7 +682,21 @@ describe("google-antigravity per-account quota (#1082)", () => { }); } - afterEach(() => setAntigravityAccountQuotaTransportForTests(null)); + const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); + const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); + const summaryUrl = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"; + const modelsUrl = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; + + beforeEach(() => { + for (const key of proxyKeys) delete process.env[key]; + }); + afterEach(() => { + setAntigravityAccountQuotaTransportForTests(null); + for (const key of proxyKeys) { + if (originalProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalProxyEnv[key]; + } + }); test("probes each account with its own bearer and project id on the fixed Google host using retrieveUserQuotaSummary", async () => { const expires = Date.now() + 60 * 60_000; @@ -748,6 +763,95 @@ describe("google-antigravity per-account quota (#1082)", () => { expect(byId[idA]!.quota!.customWindows![0]!.resetAt).toBeDefined(); }); + for (const fallback of [false, true]) { + test(`Fake-IP ${fallback ? "models fallback" : "summary"} keeps each account bearer and project separate`, async () => { + const expires = Date.now() + 3600_000; + await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); + await saveCredential("google-antigravity", { access: "agy-second", refresh: "r2", expires, projectId: "proj-second", accountId: "agy-b", email: "b@example.com" }); + let plainFetchCalls = 0; + globalThis.fetch = (async () => { plainFetchCalls += 1; throw new Error("unexpected raw quota fetch"); }) as typeof fetch; + const resolved: Array<{ url: string; benchmark?: boolean; private?: boolean; mihomo?: boolean }> = []; + const posted: Array<{ url: string; auth: string | null; project: string; address: string; tls?: boolean; signal: boolean }> = []; + setAntigravityAccountQuotaTransportForTests(null); + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (url, options) => { + const policy = typeof options === "object" ? options : undefined; + resolved.push({ url, benchmark: policy?.allowBenchmarkAddresses, private: policy?.allowPrivateNetwork, mihomo: policy?.allowMihomoIpv6FakeIp }); + if (!policy?.allowBenchmarkAddresses) throw new Error("benchmark address rejected"); + return { hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "198.18.56.214", family: 4 }], privateNetwork: false }; + }, + pinnedPost: async (url, pinned, body, signal, options) => { + const auth = new Headers(options?.headers).get("authorization"); + posted.push({ url, auth, project: String(JSON.parse(body).project), address: pinned.address, tls: options?.rejectUnauthorized, signal: signal instanceof AbortSignal }); + if (url === summaryUrl && fallback) return new Response(null, { status: 404 }); + const [gem, cla]: [number, number] = auth === "Bearer agy-first" ? [0.86, 0.38] : [0.97, 0.91]; + return new Response(url === summaryUrl ? antigravitySummaryBody(gem, cla) : antigravityBody(gem, cla)); + }, + }); + const rows = await fetchProviderAccountQuotas("google-antigravity"); + const urls = fallback ? [summaryUrl, modelsUrl] : [summaryUrl]; + expect(resolved).toHaveLength(urls.length * 2); + expect(posted).toHaveLength(urls.length * 2); + for (const url of urls) { + expect(resolved.filter(row => row.url === url)).toEqual([ + { url, benchmark: true, private: false, mihomo: false }, + { url, benchmark: true, private: false, mihomo: false }, + ]); + } + for (const [auth, project] of [["Bearer agy-first", "proj-first"], ["Bearer agy-second", "proj-second"]]) { + expect(posted.filter(row => row.auth === auth)).toEqual(urls.map(url => ({ url, auth, project, address: "198.18.56.214", tls: true, signal: true }))); + } + const byId = Object.fromEntries(rows.map(row => [row.accountId, row])); + expect(byId[idFor("a@example.com")]?.quota?.customWindows?.map(w => w.percent)).toEqual(fallback ? [14, 62] : [14, 14, 62, 62]); + expect(byId[idFor("b@example.com")]?.quota?.customWindows?.map(w => w.percent)).toEqual(fallback ? [3, 9] : [3, 3, 9, 9]); + expect(plainFetchCalls).toBe(0); + }); + } + + test("NO_PROXY denial preserves an unavailable account row without sending its bearer", async () => { + await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires: Date.now() + 3600_000, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); + process.env.no_proxy = "daily-cloudcode-pa.googleapis.com"; + const admitted: Array = []; + let posted = 0; + let plainFetchCalls = 0; + globalThis.fetch = (async () => { plainFetchCalls += 1; throw new Error("unexpected raw quota fetch"); }) as typeof fetch; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (_url, options) => { + const allow = typeof options === "object" ? options?.allowBenchmarkAddresses : undefined; + admitted.push(allow); + if (!allow) throw new Error("benchmark address rejected"); + return { hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "198.18.56.214", family: 4 }], privateNetwork: false }; + }, + pinnedPost: async () => { posted += 1; return new Response(antigravitySummaryBody(0.5, 0.5)); }, + }); + expect(await fetchProviderAccountQuotas("google-antigravity")).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true }]); + expect(admitted).toEqual([false, false]); + expect(posted).toBe(0); + expect(plainFetchCalls).toBe(0); + }); + + for (const status of [302, 307, 308, 401, 403]) { + for (const fallback of [false, true]) { + test(`account ${fallback ? "models" : "summary"} ${status} returns unavailable without following Location`, async () => { + await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires: Date.now() + 3600_000, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); + const posted: string[] = []; + let plainFetchCalls = 0; + globalThis.fetch = (async () => { plainFetchCalls += 1; throw new Error("unexpected raw quota fetch"); }) as typeof fetch; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), + pinnedPost: async url => { + posted.push(url); + if (url === summaryUrl && fallback) return new Response(null, { status: 404 }); + return new Response(null, { status, headers: { location: "https://daily-cloudcode-pa.googleapis.com/redirect-target" } }); + }, + }); + expect(await fetchProviderAccountQuotas("google-antigravity")).toEqual([{ accountId: idFor("a@example.com"), quota: null, unavailable: true }]); + expect(posted).toEqual(fallback ? [summaryUrl, modelsUrl] : [summaryUrl]); + expect(plainFetchCalls).toBe(0); + }); + } + } + test("a rejected destination never receives a bearer; the row is unavailable, not 0%", async () => { const expires = Date.now() + 60 * 60_000; await saveCredential("google-antigravity", { access: "agy-first", refresh: "r1", expires, projectId: "proj-first", accountId: "agy-a", email: "a@example.com" }); diff --git a/tests/providers/provider-quota.test.ts b/tests/providers/provider-quota.test.ts index 5a02e53f28..9c12869b04 100644 --- a/tests/providers/provider-quota.test.ts +++ b/tests/providers/provider-quota.test.ts @@ -13,6 +13,7 @@ import { saveCredential } from "../../src/oauth/store"; import { clearProviderQuotaCache, fetchProviderQuotaReports, + isCanonicalAntigravityQuotaUrl, parseOllamaCloudQuota, parseXaiCreditsResponse, QUOTA_RESPONSE_MAX_BYTES, @@ -21,7 +22,10 @@ import { setProviderQuotaBeforePublishForTests, } from "../../src/providers/quota"; import type { OcxConfig } from "../../src/types"; +import { PROXY_ENV_KEYS } from "../../src/lib/proxy-env"; import { repoPath } from "../helpers/repo-root"; +const proxyKeys = PROXY_ENV_KEYS.flatMap(key => [key, key.toLowerCase()]); +const originalProxyEnv = Object.fromEntries(proxyKeys.map(key => [key, process.env[key]])); const originalFetch = globalThis.fetch; const previousOpencodexHome = process.env.OPENCODEX_HOME; const previousCodexHome = process.env.CODEX_HOME; @@ -75,6 +79,7 @@ function testConfig(): OcxConfig { } beforeEach(() => { + for (const key of proxyKeys) delete process.env[key]; opencodexHome = mkdtempSync(join(tmpdir(), "ocx-quota-")); codexHome = mkdtempSync(join(tmpdir(), "codex-quota-")); process.env.OPENCODEX_HOME = opencodexHome; @@ -90,6 +95,10 @@ beforeEach(() => { }); afterEach(() => { + for (const key of proxyKeys) { + if (originalProxyEnv[key] === undefined) delete process.env[key]; + else process.env[key] = originalProxyEnv[key]; + } globalThis.fetch = originalFetch; clearAccountQuota(); clearProviderQuotaCache(); @@ -203,15 +212,39 @@ describe("fetchProviderQuotaReports", () => { await saveCredential("google-antigravity", { access: "agy-access-secret", refresh: "agy-refresh-secret", expires: Date.now() + 3600_000, projectId: "agy-project-secret" }); await saveCredential("kimi", { access: "kimi-access-secret", refresh: "kimi-refresh-secret", expires: Date.now() + 3600_000 }); - // The Antigravity summary probe is pinned to Google's host through the provider-outbound - // transport and never touches globalThis.fetch; without this seam the test would make a - // real network request. A 404 here exercises the fetchAvailableModels fallback below. + const seen: { url: string; authorization?: string; body?: string }[] = []; + // Both Antigravity accounting requests use the pinned transport. Keep the + // summary unavailable so this fixture still exercises the models fallback. setAntigravityAccountQuotaTransportForTests({ resolveAddresses: async () => ({ hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }), - pinnedPost: async () => new Response("not found", { status: 404 }), + pinnedPost: async (url, _pinned, body, _signal, options) => { + seen.push({ url, authorization: new Headers(options?.headers).get("authorization") ?? undefined, body }); + if (url === "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels") { + return new Response(JSON.stringify({ + models: { + "gemini-3.6-flash-medium": { + displayName: "Gemini 3.6 Flash (Medium)", + quotaInfo: { remainingFraction: 0.64, resetTime: "2026-07-05T14:00:00Z" }, + }, + "claude-sonnet-4.6": { + displayName: "Claude Sonnet", + quotaInfoByTier: { + sonnet: { remainingFraction: 0.21, resetTime: "2026-07-05T15:00:00Z" }, + }, + }, + autocomplete: { + displayName: "Autocomplete", + quotaInfo: { remainingFraction: 0.01, resetTime: "2026-07-05T16:00:00Z" }, + }, + }, + rawProject: "agy-project-secret", + rawToken: "agy-access-secret", + }), { status: 200, headers: { "content-type": "application/json" } }); + } + return new Response("not found", { status: 404 }); + }, }); - const seen: { url: string; authorization?: string; body?: string }[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const headers = init?.headers as Record | undefined; @@ -258,28 +291,6 @@ describe("fetchProviderQuotaReports", () => { billingCycleEnd: "2026-08-01T00:00:00.000Z", }), { status: 200, headers: { "content-type": "application/json" } }); } - if (url === "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels") { - return new Response(JSON.stringify({ - models: { - "gemini-3.6-flash-medium": { - displayName: "Gemini 3.6 Flash (Medium)", - quotaInfo: { remainingFraction: 0.64, resetTime: "2026-07-05T14:00:00Z" }, - }, - "claude-sonnet-4.6": { - displayName: "Claude Sonnet", - quotaInfoByTier: { - sonnet: { remainingFraction: 0.21, resetTime: "2026-07-05T15:00:00Z" }, - }, - }, - autocomplete: { - displayName: "Autocomplete", - quotaInfo: { remainingFraction: 0.01, resetTime: "2026-07-05T16:00:00Z" }, - }, - }, - rawProject: "agy-project-secret", - rawToken: "agy-access-secret", - }), { status: 200, headers: { "content-type": "application/json" } }); - } if (url === "https://api.kimi.com/coding/v1/usages") { return new Response(JSON.stringify({ user: { userId: "kimi-user-secret", businessId: "kimi-business-secret" }, @@ -3078,6 +3089,205 @@ describe("fetchProviderQuotaReports", () => { expect(posted).toEqual(["https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"]); }); + describe("Google Antigravity canonical quota transport (#3781)", () => { + const summaryUrl = "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary"; + const modelsUrl = "https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels"; + const summaryBody = JSON.stringify({ groups: [{ displayName: "Gemini", buckets: [{ window: "5h", remainingFraction: 0.6 }] }] }); + const modelsBody = JSON.stringify({ models: { gemini: { quotaInfo: { remainingFraction: 0.75 } } } }); + const publicAddress = { hostname: "daily-cloudcode-pa.googleapis.com", addresses: [{ address: "142.250.0.1", family: 4 }], privateNetwork: false }; + let plainFetchCalls: string[]; + + function config(baseUrl = "https://daily-cloudcode-pa.googleapis.com"): OcxConfig { + return { + defaultProvider: "google-antigravity", + providers: { "google-antigravity": { adapter: "google", authMode: "oauth", baseUrl, allowPrivateNetwork: true } }, + } as OcxConfig; + } + + beforeEach(async () => { + await saveCredential("google-antigravity", { + access: "agy-canonical-access", refresh: "agy-canonical-refresh", expires: Date.now() + 3600_000, projectId: "agy-canonical-project", + }); + plainFetchCalls = []; + globalThis.fetch = (async (input) => { + plainFetchCalls.push(String(input)); + throw new Error("unexpected quota-owned raw fetch"); + }) as typeof fetch; + }); + + test("canonical proof accepts only the two exact Google accounting URLs", () => { + for (const url of [summaryUrl, modelsUrl]) { + expect(isCanonicalAntigravityQuotaUrl("google-antigravity", url)).toBe(true); + expect(isCanonicalAntigravityQuotaUrl("custom", url)).toBe(false); + for (const candidate of [ + "", "not a URL", url.replace("https:", "http:"), + url.replace(".googleapis.com", ".googleapis.com.evil.example"), + url.replace("daily-cloudcode-pa", "cloudcode-pa"), + url.replace("https://", "https://user:pass@"), + url.replace(".com/", ".com:443/"), url.replace(".com/", ".com:8443/"), + url.replace("https://", "HTTPS://"), `${url}/`, `${url}/extra`, + `${url}?token=secret`, `${url}#fragment`, ` ${url}`, + url.replace("v1internal:", "v1internal%3A"), + url.replace("v1internal:", "prefix/v1internal:"), + "https://daily-cloudcode-pa.googleapis.com/v1internal:other", + "https://198.18.0.1/v1internal:fetchAvailableModels", + "https://127.0.0.1/v1internal:fetchAvailableModels", + "https://169.254.169.254/v1internal:fetchAvailableModels", + ]) expect(isCanonicalAntigravityQuotaUrl("google-antigravity", candidate)).toBe(false); + } + }); + + for (const fallback of [false, true]) { + test(`production proof survives reset for Fake-IP ${fallback ? "fallback" : "summary"}`, async () => { + const resolved: Array<{ url: string; benchmark?: boolean; private?: boolean; mihomo?: boolean }> = []; + const posted: Array<{ url: string; address: string; tls?: boolean; auth: string | null; body: string; signal: boolean }> = []; + setAntigravityAccountQuotaTransportForTests({ isCanonicalUrl: () => false }); + setAntigravityAccountQuotaTransportForTests(null); + // Resolver/pinned-only overrides must retain the production canonical proof. + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (url, options) => { + const policy = typeof options === "object" ? options : undefined; + resolved.push({ url, benchmark: policy?.allowBenchmarkAddresses, private: policy?.allowPrivateNetwork, mihomo: policy?.allowMihomoIpv6FakeIp }); + if (!policy?.allowBenchmarkAddresses) throw new Error("benchmark address rejected"); + return { ...publicAddress, addresses: [{ address: "198.18.56.214", family: 4 }] }; + }, + pinnedPost: async (url, pinned, body, signal, options) => { + posted.push({ url, address: pinned.address, tls: options?.rejectUnauthorized, auth: new Headers(options?.headers).get("authorization"), body, signal: signal instanceof AbortSignal }); + if (url === summaryUrl && fallback) return new Response(null, { status: 404 }); + return new Response(url === summaryUrl ? summaryBody : modelsBody); + }, + }); + const result = await fetchProviderQuotaReports(config(), true); + const urls = fallback ? [summaryUrl, modelsUrl] : [summaryUrl]; + expect(resolved).toEqual(urls.map(url => ({ url, benchmark: true, private: false, mihomo: false }))); + expect(posted).toEqual(urls.map(url => ({ url, address: "198.18.56.214", tls: true, auth: "Bearer agy-canonical-access", body: JSON.stringify({ project: "agy-canonical-project" }), signal: true }))); + expect(result.reports[0]?.source).toBe(fallback ? "google-antigravity:fetchAvailableModels" : "google-antigravity:retrieveUserQuotaSummary"); + expect(result.reports[0]?.quota.customWindows).toEqual([{ label: "Gem", percent: fallback ? 25 : 40 }]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const baseUrl of ["https://custom.example/v1", "http://127.0.0.1:1/", "https://169.254.169.254/", "https://daily-cloudcode-pa.googleapis.com.evil.example/"]) { + test(`models fallback ignores configured destination ${baseUrl}`, async () => { + const resolved: Array<{ url: string; private?: boolean }> = []; + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (url, options) => { + resolved.push({ url, private: typeof options === "object" ? options?.allowPrivateNetwork : undefined }); + return publicAddress; + }, + pinnedPost: async (url) => { + posted.push(url); + return url === summaryUrl ? new Response(null, { status: 404 }) : new Response(modelsBody); + }, + }); + const result = await fetchProviderQuotaReports(config(baseUrl), true); + expect(result.reports[0]?.quota.customWindows).toEqual([{ label: "Gem", percent: 25 }]); + expect(resolved).toEqual([{ url: summaryUrl, private: false }, { url: modelsUrl, private: false }]); + expect(posted).toEqual([summaryUrl, modelsUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const noProxy of ["daily-cloudcode-pa.googleapis.com", "*"]) { + test(`NO_PROXY ${noProxy} keeps benchmark DNS blocked`, async () => { + process.env.NO_PROXY = noProxy; + const admitted: Array = []; + let posted = 0; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async (_url, options) => { + const allow = typeof options === "object" ? options?.allowBenchmarkAddresses : undefined; + admitted.push(allow); + if (!allow) throw new Error("benchmark address rejected"); + return publicAddress; + }, + pinnedPost: async () => { posted += 1; return new Response(summaryBody); }, + }); + expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]); + expect(admitted).toEqual([false, false]); + expect(posted).toBe(0); + expect(plainFetchCalls).toEqual([]); + }); + } + + test("resolved-address policy rejection cannot escape to raw fallback fetch", async () => { + // The real classifier's mixed-address cases live in destination-policy-resolved.test.ts; + // this checks that quota cannot bypass its rejection through a second transport. + const resolved: string[] = []; + let posted = 0; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async url => { resolved.push(url); throw new Error("provider URL resolves to metadata"); }, + pinnedPost: async () => { posted += 1; return new Response(modelsBody); }, + }); + expect((await fetchProviderQuotaReports(config("https://custom.example"), true)).reports).toEqual([]); + expect(resolved).toEqual([summaryUrl, modelsUrl]); + expect(posted).toBe(0); + expect(plainFetchCalls).toEqual([]); + }); + + for (const summary of ["{}", "invalid JSON"]) { + test(`unusable summary ${summary} falls back through the fixed models transport`, async () => { + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => publicAddress, + pinnedPost: async url => { + posted.push(url); + return new Response(url === summaryUrl ? summary : modelsBody); + }, + }); + const result = await fetchProviderQuotaReports(config(), true); + expect(result.reports[0]?.quota.customWindows).toEqual([{ label: "Gem", percent: 25 }]); + expect(posted).toEqual([summaryUrl, modelsUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const status of [200, 500]) { + test(`unusable models payload with HTTP ${status} produces no fabricated quota`, async () => { + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => publicAddress, + pinnedPost: async url => { + posted.push(url); + return url === summaryUrl ? new Response(null, { status: 404 }) : new Response("invalid JSON", { status }); + }, + }); + expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]); + expect(posted).toEqual([summaryUrl, modelsUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const status of [302, 307, 308, 401, 403]) { + test(`summary ${status} terminates without a models request`, async () => { + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => publicAddress, + pinnedPost: async url => { posted.push(url); return new Response(null, { status, headers: { location: modelsUrl } }); }, + }); + expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]); + expect(posted).toEqual([summaryUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + + for (const status of [302, 307, 308]) { + test(`models ${status} does not follow even a same-host redirect`, async () => { + const posted: string[] = []; + setAntigravityAccountQuotaTransportForTests({ + resolveAddresses: async () => publicAddress, + pinnedPost: async url => { + posted.push(url); + return url === summaryUrl ? new Response(null, { status: 404 }) : new Response(null, { status, headers: { location: summaryUrl } }); + }, + }); + expect((await fetchProviderQuotaReports(config(), true)).reports).toEqual([]); + expect(posted).toEqual([summaryUrl, modelsUrl]); + expect(plainFetchCalls).toEqual([]); + }); + } + }); + test("Ollama Cloud maps 5-hour session and weekly windows from /api/usage (legacy plan)", async () => { const seen: Array<{ url: string; authorization?: string; redirect?: RequestRedirect }> = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { From cb84ad895d0101887c0fae3707676a6c31aa07e4 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 01:58:25 +0900 Subject: [PATCH 12/99] perf(logs): poll verified request-history deltas without losing updates Reimplements #3250 with snapshot-prefix validation, process epochs and current response fences. Co-authored-by: chilung --- .../pr-screenshots/t4-log-polling-81a8.png | Bin 0 -> 261934 bytes .../content/docs/reference/management-api.md | 8 + gui/src/pages/Logs.tsx | 23 ++- gui/src/pages/log-poll.ts | 38 ++++ gui/tests/log-poll.test.ts | 39 ++++ gui/tests/logs-auto-refresh.test.tsx | 182 +++++++++++++++++- src/server/management/logs-usage-routes.ts | 11 +- src/server/request-log-cursor.ts | 84 ++++++++ .../management-api-logs-metrics.test.ts | 137 +++++++++++++ tests/server/server-management-auth.test.ts | 60 ++++++ tests/usage/request-log.test.ts | 95 +++++++++ 11 files changed, 660 insertions(+), 17 deletions(-) create mode 100644 docs-site/public/pr-screenshots/t4-log-polling-81a8.png create mode 100644 gui/src/pages/log-poll.ts create mode 100644 gui/tests/log-poll.test.ts create mode 100644 src/server/request-log-cursor.ts diff --git a/docs-site/public/pr-screenshots/t4-log-polling-81a8.png b/docs-site/public/pr-screenshots/t4-log-polling-81a8.png new file mode 100644 index 0000000000000000000000000000000000000000..4dd53042e94eecd25fe5cc80d73cf50b43ff302e GIT binary patch literal 261934 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�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 key: resourceKey, active: false, request: 0 }); + const logPollRef = useRef<{ key: string; cursor: string | null; rows: LogEntry[] }>( + { key: resourceKey, cursor: null, rows: [] }, + ); // Invalidate the old resource at commit, before passive resource-loader effects. // A late body read must not mutate this page's clock, cache or retry state. useLayoutEffect(() => { const clock = { key: resourceKey, active: true, request: 0 }; filterClockRef.current = clock; + // Cached display rows never establish a cursor, including A -> B -> A. + logPollRef.current = { key: resourceKey, cursor: null, rows: [] }; setFilterClockNow(Date.now()); return () => { clock.active = false; }; }, [resourceKey]); @@ -462,16 +468,22 @@ export default function Logs({ apiBase }: { apiBase: string }) { logRetryRef.current = retry; } if (retry.failures > 0 && Date.now() < retry.nextAttemptAt) throw retry.error; + const poll = logPollRef.current; + const cursor = poll.key === resourceKey ? poll.cursor : null; + const url = `${apiBase}/api/logs?limit=2000${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`; try { - const res = await fetch(`${apiBase}/api/logs?limit=2000`, { signal }); + const res = await fetch(url, { signal }); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`.trim()); - const body = await res.json() as LogEntry[] | { logs?: LogEntry[]; generatedAt?: unknown }; + const body: unknown = await res.json(); const receivedAt = performance.now(); - const raw = Array.isArray(body) ? body : (body.logs ?? []); - const next = raw.map(sanitizeLogEntryRouteDecision); + const parsed = parseLogPollResponse(body); + const incoming = parsed.rows.map(sanitizeLogEntryRouteDecision); + const next = cursor && parsed.cursor && !parsed.reset + ? mergeLogDelta(poll.rows, incoming) : incoming; // The resource-store generation guard runs only after this loader returns. // Guard these local side effects here as fetch/body readers may ignore abort. if (!isCurrent()) throw signal.reason ?? new DOMException("Obsolete log request", "AbortError"); + logPollRef.current = { key: resourceKey, cursor: parsed.cursor, rows: next }; // Reconcile when the accepted snapshot changes, using the latest user state // rather than filters captured when the request started. Persist disappearance // as All so a later ring cannot resurrect a cleared selection. @@ -488,7 +500,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { if (previous.model === nextModel && previous.provider === nextProvider) return previous; return { ...previous, model: nextModel, provider: nextProvider }; }); - const sample = logsClockAnchor(Array.isArray(body) ? undefined : body.generatedAt, receivedAt); + const sample = logsClockAnchor(parsed.generatedAt, receivedAt); if (sample) clock.anchor = sample; setFilterClockNow(logsClockNow(clock.anchor, receivedAt, Date.now())); logRetryRef.current = { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }; @@ -525,6 +537,7 @@ export default function Logs({ apiBase }: { apiBase: string }) { const fetchLogs = logsResource.refresh; const retryLogs = useCallback(() => { logRetryRef.current = { key: resourceKey, failures: 0, nextAttemptAt: 0, error: null }; + logPollRef.current = { key: resourceKey, cursor: null, rows: [] }; fetchLogs({ forceLoading: true }); }, [fetchLogs, resourceKey]); diff --git a/gui/src/pages/log-poll.ts b/gui/src/pages/log-poll.ts new file mode 100644 index 0000000000..ad13d986f7 --- /dev/null +++ b/gui/src/pages/log-poll.ts @@ -0,0 +1,38 @@ +export interface ParsedLogPollResponse { + rows: T[]; + cursor: string | null; + reset: boolean; + generatedAt?: unknown; + timeZone?: string; + total?: number; +} + +/** Legacy responses replace the window; malformed cursor responses keep last-good data. */ +export function parseLogPollResponse(body: unknown): ParsedLogPollResponse { + if (Array.isArray(body)) return { rows: body as T[], cursor: null, reset: false }; + if (!body || typeof body !== "object") throw new Error("Invalid log response"); + const value = body as Record; + const hasCursor = Object.hasOwn(value, "cursor") || Object.hasOwn(value, "reset"); + if ((value.logs !== undefined && !Array.isArray(value.logs)) + || (hasCursor && (!Array.isArray(value.logs) + || typeof value.cursor !== "string" || value.cursor.length === 0 || value.cursor.length > 512 + || !/^[A-Za-z0-9_-]+$/.test(value.cursor) || typeof value.reset !== "boolean"))) { + throw new Error("Invalid log response"); + } + return { + rows: (value.logs ?? []) as T[], + cursor: hasCursor ? value.cursor as string : null, + reset: value.reset === true, + generatedAt: value.generatedAt, + ...(typeof value.timeZone === "string" ? { timeZone: value.timeZone } : {}), + ...(typeof value.total === "number" && Number.isFinite(value.total) && value.total >= 0 + ? { total: value.total } : {}), + }; +} + +/** Updates/removals arrive as resets. Preserve order and even repeated IDs in valid suffixes. */ +export function mergeLogDelta(previous: T[], incoming: readonly T[], cap = 2000): T[] { + if (incoming.length === 0 && previous.length <= cap) return previous; + const merged = [...previous, ...incoming]; + return merged.length > cap ? merged.slice(merged.length - cap) : merged; +} diff --git a/gui/tests/log-poll.test.ts b/gui/tests/log-poll.test.ts new file mode 100644 index 0000000000..d7c2559a1f --- /dev/null +++ b/gui/tests/log-poll.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { mergeLogDelta, parseLogPollResponse } from "../src/pages/log-poll"; + +describe("log polling protocol", () => { + test("legacy arrays and envelopes replace snapshots without a cursor", () => { + const rows = [{ requestId: "a" }]; + expect(parseLogPollResponse(rows)).toEqual({ rows, cursor: null, reset: false }); + expect(parseLogPollResponse({ logs: rows, generatedAt: 123, timeZone: "UTC", total: 5 })) + .toEqual({ rows, cursor: null, reset: false, generatedAt: 123, timeZone: "UTC", total: 5 }); + expect(parseLogPollResponse({ logs: rows, generatedAt: "bad" }).generatedAt).toBe("bad"); + }); + + test("empty deltas and resets retain clock and window metadata", () => { + for (const reset of [false, true]) { + expect(parseLogPollResponse({ logs: [], cursor: "opaque-cursor", reset, generatedAt: 456, total: 2, timeZone: "UTC" })) + .toEqual({ rows: [], cursor: "opaque-cursor", reset, generatedAt: 456, total: 2, timeZone: "UTC" }); + } + }); + + test("invalid cursor envelopes fail instead of clearing accepted rows", () => { + for (const body of [null, "bad", { logs: {} }, { logs: [], cursor: null, reset: false }, + { logs: [], cursor: "", reset: false }, { logs: [], cursor: "c", reset: "false" }, + { logs: [], cursor: "c" }, { logs: [], reset: false }, { cursor: "c", reset: false }, + { logs: [], cursor: "a".repeat(513), reset: false }, { logs: [], cursor: " c ", reset: false }]) { + expect(() => parseLogPollResponse(body)).toThrow("Invalid log response"); + } + }); + + test("append preserves order and repeated IDs without mutating inputs; cap keeps newest rows", () => { + const previous = [{ requestId: "same", value: 1 }, { requestId: "other", value: 2 }]; + const incoming = [{ requestId: "same", value: 3 }]; + expect(mergeLogDelta(previous, incoming)).toEqual([...previous, ...incoming]); + expect(mergeLogDelta(previous, incoming, 2)).toEqual([previous[1], incoming[0]]); + expect(mergeLogDelta(previous, [])).toBe(previous); + expect(mergeLogDelta(previous, [], 1)).toEqual([previous[1]]); + expect(previous).toEqual([{ requestId: "same", value: 1 }, { requestId: "other", value: 2 }]); + expect(incoming).toEqual([{ requestId: "same", value: 3 }]); + }); +}); diff --git a/gui/tests/logs-auto-refresh.test.tsx b/gui/tests/logs-auto-refresh.test.tsx index 44366f146b..200b6c4f23 100644 --- a/gui/tests/logs-auto-refresh.test.tsx +++ b/gui/tests/logs-auto-refresh.test.tsx @@ -6,7 +6,7 @@ import { LanguageProvider } from "../src/i18n/provider"; import { clearClientResourceStoresForTests } from "../src/client-resource"; import Logs from "../src/pages/Logs"; -const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT", "ResizeObserver"] as const; +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT", "ResizeObserver"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; let testWindow: Window; const originalFetch = globalThis.fetch; @@ -89,6 +89,7 @@ beforeEach(() => { window: { configurable: true, value: testWindow }, navigator: { configurable: true, value: testWindow.navigator }, localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, }); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; installLayoutStubs(testWindow); @@ -952,6 +953,156 @@ function proxyLogEnvelope(generatedAt: unknown, logs: unknown[]) { return { generatedAt, timeZone: "UTC", total: logs.length, logs }; } +function cursorLogEnvelope(generatedAt: unknown, logs: unknown[], cursor: string, reset = false) { + return { ...proxyLogEnvelope(generatedAt, logs), cursor, reset }; +} + +test("Logs: append, empty delta, mutation reset and legacy fallback keep the complete window", async () => { + const urls: string[] = []; + let step = 0; + const responses = [ + cursorLogEnvelope(PROXY_NOW, [sampleLog], "c0"), + cursorLogEnvelope(PROXY_NOW, [], "c0"), + cursorLogEnvelope(PROXY_NOW, [updatedLog], "c1"), + cursorLogEnvelope(PROXY_NOW, [], "c1"), + cursorLogEnvelope(PROXY_NOW, [{ ...sampleLog, model: "gpt-mutated", durationMs: 987 }], "c2", true), + [updatedLog], + { logs: [sampleLog] }, + ]; + globalThis.fetch = (async input => { + const url = String(input); + if (!url.includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + urls.push(url); + return jsonResponse(responses[step]); + }) as typeof fetch; + const { root, container } = await mountLogs(); + try { + await flushMicrotasks(); + expect(urls[0]).toBe("http://localhost/api/logs?limit=2000"); + step = 1; + await advanceSilentRefresh(); + expect(urls.at(-1)).toContain("cursor=c0"); + expect(visibleRequestIds(container)).toEqual(["req-1"]); + step = 2; + await advanceSilentRefresh(); + expect(visibleRequestIds(container)).toEqual(["req-2", "req-1"]); + await changeLogSelect(container, "Model", "gpt-test"); + step = 3; + await advanceSilentRefresh(); + expect(container.querySelector('select[aria-label="Model"]')!.value).toBe("gpt-test"); + expect(visibleRequestIds(container)).toEqual(["req-1"]); + step = 4; + await advanceSilentRefresh(); + expect(urls.at(-1)).toContain("cursor=c1"); + expect(visibleRequestIds(container)).toEqual(["req-1"]); + expectTableLoaded(container, "gpt-mutated"); + expect(container.textContent).toContain("987"); + expect(container.querySelector('select[aria-label="Model"]')!.value).toBe(""); + step = 5; + await advanceSilentRefresh(); + expect(visibleRequestIds(container)).toEqual(["req-2"]); + step = 6; + await advanceSilentRefresh(); + expect(urls.at(-1)).toBe("http://localhost/api/logs?limit=2000"); + expect(visibleRequestIds(container)).toEqual(["req-1"]); + } finally { + await act(async () => { root.unmount(); }); + } +}); + +test("Logs: an empty delta advances the proxy clock without discarding retained rows", async () => { + let step = 0; + const row = { ...sampleLog, timestamp: PROXY_NOW - 5 * 60_000 }; + globalThis.fetch = (async input => { + if (!String(input).includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + return jsonResponse(cursorLogEnvelope(step ? PROXY_NOW + 20 * 60_000 : PROXY_NOW, step ? [] : [row], "same")); + }) as typeof fetch; + const { root, container } = await mountLogs(); + try { + await flushMicrotasks(); + await changeLogSelect(container, "Time", "15m"); + expect(visibleRequestIds(container)).toEqual(["req-1"]); + step = 1; + await advanceSilentRefresh(); + expect(visibleRequestIds(container)).toEqual([]); + await changeLogSelect(container, "Time", "all"); + expect(visibleRequestIds(container)).toEqual(["req-1"]); + expect(JSON.parse(sessionStorage.getItem("ocx.logs.list.v1:http://localhost")!)).toHaveLength(1); + } finally { + await act(async () => { root.unmount(); }); + } +}); + +test("Logs: malformed polls preserve cursor/cache, back off, and explicit retry reads a full snapshot", async () => { + let failing = false; + const urls: string[] = []; + globalThis.fetch = (async input => { + const url = String(input); + if (!url.includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + urls.push(url); + if (failing) return jsonResponse({ logs: [], cursor: "poison", reset: "false" }); + return jsonResponse(cursorLogEnvelope(PROXY_NOW, url.includes("cursor=") ? [] : [sampleLog], "good")); + }) as typeof fetch; + const { root, container } = await mountLogs(); + try { + await flushMicrotasks(); + failing = true; + await advanceSilentRefresh(); + const count = urls.length; + await advanceSilentRefresh(); + expect(urls).toHaveLength(count); + await advanceSilentRefresh(14000); + expect(container.textContent).toContain("Could not load request logs."); + expect(visibleRequestIds(container)).toEqual(["req-1"]); + expect(urls.slice(1).every(url => url.includes("cursor=good"))).toBe(true); + expect(JSON.parse(sessionStorage.getItem("ocx.logs.list.v1:http://localhost")!)).toHaveLength(1); + failing = false; + await act(async () => { clickRetry(container); }); + await flushMicrotasks(); + expect(urls.at(-1)).toBe("http://localhost/api/logs?limit=2000"); + expectTableLoaded(container, "gpt-test"); + } finally { + await act(async () => { root.unmount(); }); + } +}); + +test("Logs: A to B to A and remount start without a cached cursor", async () => { + const urls: string[] = []; + globalThis.fetch = (async input => { + const url = String(input); + if (!url.includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + urls.push(url); + const name = url.startsWith("http://proxy-a/") ? "a" : "b"; + return jsonResponse(cursorLogEnvelope(PROXY_NOW, url.includes("cursor=") ? [] : [ + { ...sampleLog, requestId: name }, + ], `cursor-${name}`)); + }) as typeof fetch; + const first = await mountLogs("http://proxy-a"); + try { + await flushMicrotasks(); + await advanceSilentRefresh(); + expect(urls.at(-1)).toContain("cursor=cursor-a"); + for (const name of ["b", "a"]) { + const start = urls.length; + await renderLogsAt(first.root, `http://proxy-${name}`); + await advanceSilentRefresh(); + expect(urls[start]).toBe(`http://proxy-${name}/api/logs?limit=2000`); + expect(visibleRequestIds(first.container)).toEqual([name]); + } + } finally { + await act(async () => { first.root.unmount(); }); + } + const start = urls.length; + const remount = await mountLogs("http://proxy-a"); + try { + await advanceSilentRefresh(); + expect(urls[start]).toBe("http://proxy-a/api/logs?limit=2000"); + expect(visibleRequestIds(remount.container)).toEqual(["a"]); + } finally { + await act(async () => { remount.root.unmount(); }); + } +}); + async function renderLogsAt(root: Root, apiBase: string): Promise { await act(async () => { root.render(); @@ -1104,6 +1255,7 @@ function delayedLogBody() { test("Logs: a late body from an aborted old apiBase cannot poison the new proxy clock", async () => { const late = delayedLogBody(); + const urls: string[] = []; let oldSignal: AbortSignal | undefined; let oldRequests = 0; const wall = jest.spyOn(Date, "now").mockReturnValue(PROXY_NOW + 6 * 60 * 60_000); @@ -1113,14 +1265,15 @@ test("Logs: a late body from an aborted old apiBase cannot poison the new proxy globalThis.fetch = (async (input, init) => { const url = String(input); if (!url.includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + urls.push(url); if (url.startsWith("http://proxy-a/")) { oldRequests++; oldSignal = init?.signal ?? undefined; return late.response; } - return jsonResponse(proxyLogEnvelope(PROXY_NOW, [ + return jsonResponse(cursorLogEnvelope(PROXY_NOW, url.includes("cursor=") ? [] : [ { ...sampleLog, requestId: "proxy-b", timestamp: PROXY_NOW - 60_000 }, - ])); + ], "cursor-b")); }) as typeof fetch; let mounted: Awaited> | undefined; try { @@ -1136,9 +1289,13 @@ test("Logs: a late body from an aborted old apiBase cannot poison the new proxy await act(async () => { container.querySelector(".logs-auto-refresh input")!.click(); }); await flushMicrotasks(); expect(visibleRequestIds(container)).toEqual(["proxy-b"]); - await act(async () => { late.resolve(proxyLogEnvelope(PROXY_NOW + 12 * 60 * 60_000, [])); }); + await act(async () => { late.resolve(cursorLogEnvelope(PROXY_NOW + 12 * 60 * 60_000, [], "poison", true)); }); await flushMicrotasks(); expect(visibleRequestIds(container)).toEqual(["proxy-b"]); + expect(JSON.parse(sessionStorage.getItem("ocx.logs.list.v1:http://proxy-b")!)).toHaveLength(1); + await act(async () => { container.querySelector(".logs-auto-refresh input")!.click(); }); + await advanceSilentRefresh(); + expect(urls.at(-1)).toContain("cursor=cursor-b"); expect(container.querySelector('select[aria-label="Model"]')!.value).toBe("gpt-test"); expect(container.querySelector('select[aria-label="Provider"]')!.value).toBe("openai"); monotonic += 30_000; @@ -1158,6 +1315,7 @@ test("Logs: a late body from an aborted old apiBase cannot poison the new proxy test("Logs: aborting an in-flight refresh before pausing cannot replace the accepted clock", async () => { const late = delayedLogBody(); + const urls: string[] = []; let requests = 0; let lateSignal: AbortSignal | undefined; const wall = jest.spyOn(Date, "now").mockReturnValue(PROXY_NOW - 6 * 60 * 60_000); @@ -1166,14 +1324,15 @@ test("Logs: aborting an in-flight refresh before pausing cannot replace the acce const clock = trackFilterClock(); globalThis.fetch = (async (input, init) => { if (!String(input).includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); + urls.push(String(input)); requests++; if (requests === 2) { lateSignal = init?.signal ?? undefined; return late.response; } - return jsonResponse(proxyLogEnvelope(PROXY_NOW, [ + return jsonResponse(cursorLogEnvelope(PROXY_NOW, String(input).includes("cursor=") ? [] : [ { ...sampleLog, requestId: "current", timestamp: PROXY_NOW - 60_000 }, - ])); + ], "accepted-cursor")); }) as typeof fetch; let mounted: Awaited> | undefined; try { @@ -1187,7 +1346,7 @@ test("Logs: aborting an in-flight refresh before pausing cannot replace the acce await flushMicrotasks(); expect(lateSignal?.aborted).toBe(true); const pausedRequests = requests; - await act(async () => { late.resolve(proxyLogEnvelope(PROXY_NOW + 12 * 60 * 60_000, [])); }); + await act(async () => { late.resolve(cursorLogEnvelope(PROXY_NOW + 12 * 60 * 60_000, [], "poison", true)); }); await flushMicrotasks(); expect(visibleRequestIds(container)).toEqual(["current"]); monotonic += 30_000; @@ -1195,6 +1354,9 @@ test("Logs: aborting an in-flight refresh before pausing cannot replace the acce await flushMicrotasks(); expect(visibleRequestIds(container)).toEqual(["current"]); expect(requests).toBe(pausedRequests); + await act(async () => { container.querySelector(".logs-auto-refresh input")!.click(); }); + await advanceSilentRefresh(); + expect(urls.at(-1)).toContain("cursor=accepted-cursor"); } finally { try { if (mounted) await act(async () => { mounted!.root.unmount(); }); @@ -1244,7 +1406,7 @@ test("Logs: a pending refresh reconciles the user's latest selection rather than globalThis.fetch = (async input => { if (!String(input).includes("/api/logs")) return jsonResponse({ timeZone: "UTC" }); requests++; - return requests === 1 ? jsonResponse(original) : late.response; + return requests === 1 ? jsonResponse(cursorLogEnvelope(PROXY_NOW, original, "initial")) : late.response; }) as typeof fetch; const { root, container } = await mountLogs(); try { @@ -1258,10 +1420,10 @@ test("Logs: a pending refresh reconciles the user's latest selection rather than await changeLogSelect(container, "Status", "errors"); expect(visibleRequestIds(container)).toEqual(["b"]); await act(async () => { - late.resolve([ + late.resolve(cursorLogEnvelope(PROXY_NOW, [ { ...original[0]!, requestId: "other", model: "model-other" }, { ...original[1]!, requestId: "current", model: "MODEL-B", provider: "XAI" }, - ]); + ], "replaced", true)); }); await flushMicrotasks(); expect(container.querySelector('select[aria-label="Model"]')!.value).toBe("MODEL-B"); diff --git a/src/server/management/logs-usage-routes.ts b/src/server/management/logs-usage-routes.ts index dfb3c74df0..ecbd6f655d 100644 --- a/src/server/management/logs-usage-routes.ts +++ b/src/server/management/logs-usage-routes.ts @@ -66,6 +66,7 @@ import { import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types"; import { drainAndShutdown } from "../lifecycle"; import { filterRequestLogs, filteredRequestLogCount, getRequestLogEntries, type RequestLogEntry } from "../request-log"; +import { decodeRequestLogCursor, selectRequestLogPoll } from "../request-log-cursor"; import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; import { userCostOverlayVersion } from "../../usage/user-cost-overlays"; import type { PersistedUsageAttempt } from "../../usage/log"; @@ -106,14 +107,20 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise MAX_CURSOR_LENGTH || !/^[A-Za-z0-9_-]+$/.test(raw)) return null; + try { + const bytes = Buffer.from(raw, "base64url"); + if (bytes.toString("base64url") !== raw) return null; + const value: unknown = JSON.parse(bytes.toString("utf8")); + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const row = value as Record; + const keys = Object.keys(row).sort().join(","); + if (row.v === 1 && keys === "id,t,v" + && typeof row.t === "number" && Number.isFinite(row.t) && row.t >= 0 + && typeof row.id === "string" && row.id.length > 0 && row.id.length <= 256) { + return { v: 1, t: row.t, id: row.id }; + } + if (row.v !== 2 || keys !== "e,h,n,q,v" + || typeof row.e !== "string" || !/^[a-f0-9]{32}$/.test(row.e) + || typeof row.n !== "number" || !Number.isSafeInteger(row.n) || row.n < 0 || row.n > MAX_WINDOW_ROWS + || typeof row.q !== "string" || !/^[a-f0-9]{64}$/.test(row.q) + || typeof row.h !== "string" || !/^[a-f0-9]{64}$/.test(row.h)) return null; + return { v: 2, e: row.e, n: row.n, q: row.q, h: row.h }; + } catch { + return null; + } +} + +/** + * Compare the current projected window, not ring identities: live entries and + * display-time pricing can change without append. This saves response bytes for + * stable prefixes; DTO projection and hashing still cost O(window bytes). + * No per-client rows or history are retained. The route calls this synchronously + * after projecting the full filtered/paginated window. + */ +export function selectRequestLogPoll( + rows: readonly T[], + params: URLSearchParams, + cursor: RequestLogCursor | null, + epoch = processEpoch, +): { logs: T[]; cursor: string; reset: boolean } { + const query = new URLSearchParams(params); + query.delete("cursor"); + query.sort(); + const queryDigest = createHash("sha256").update(query.toString()).digest("hex"); + const candidate = cursor?.v === 2 && cursor.e === epoch && cursor.q === queryDigest + && cursor.n <= rows.length ? cursor : null; + const full = createHash("sha256"); + const prefix = createHash("sha256"); + for (let index = 0; index < rows.length; index++) { + // JSON escapes embedded newlines, so the delimiter frames each whole row. + const serialized = JSON.stringify(rows[index]) + "\n"; + full.update(serialized); + if (candidate && index < candidate.n) prefix.update(serialized); + } + const unchangedPrefix = candidate !== null && prefix.digest("hex") === candidate.h; + const next: SnapshotCursor = { v: 2, e: epoch, n: rows.length, q: queryDigest, h: full.digest("hex") }; + return { + logs: rows.slice(unchangedPrefix ? candidate.n : 0), + cursor: Buffer.from(JSON.stringify(next)).toString("base64url"), + reset: cursor !== null && !unchangedPrefix, + }; +} diff --git a/tests/server/management-api-logs-metrics.test.ts b/tests/server/management-api-logs-metrics.test.ts index 76fbd8024e..f811810809 100644 --- a/tests/server/management-api-logs-metrics.test.ts +++ b/tests/server/management-api-logs-metrics.test.ts @@ -7,6 +7,7 @@ import { usageLogPath } from "../../src/usage/log"; import { addRequestLog, clearRequestLogsForTests, + evictOldestRequestLogForBudget, getRequestLogEntries, type RequestLogEntry, } from "../../src/server/request-log"; @@ -14,6 +15,30 @@ import type { OcxConfig } from "../../src/types"; import { buildRouteDecisionTrace } from "../../src/routing/trace"; import { summarizeUsage } from "../../src/usage/summary"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { refreshUserCostOverlays } from "../../src/usage/user-cost-overlays"; + +interface LogPollEnvelope { + logs: Array>; + cursor: string; + reset: boolean; + generatedAt: number; + timeZone: string; + total: number; +} + +async function readLogPoll(query = "", cursor?: string): Promise { + const url = new URL(`http://localhost/api/logs?${query}`); + if (cursor) url.searchParams.set("cursor", cursor); + const before = Date.now(); + const response = await handleManagementAPI(new Request(url), url, config); + expect(response?.status).toBe(200); + const body = await response!.json() as LogPollEnvelope; + expect(body.generatedAt).toBeGreaterThanOrEqual(before); + expect(body.generatedAt).toBeLessThanOrEqual(Date.now()); + expect(body.timeZone).toBe(Intl.DateTimeFormat().resolvedOptions().timeZone); + expect(typeof body.cursor).toBe("string"); + return body; +} const config = { providers: [] } as unknown as OcxConfig; @@ -294,3 +319,115 @@ describe("GET /api/logs display metrics", () => { }); }); import { ManagementRequest as Request } from "../helpers/management-auth"; + + +describe("GET /api/logs snapshot polling", () => { + beforeEach(() => clearRequestLogsForTests()); + + test("poll application equals full reads across append, nested live mutation, eviction and clear", async () => { + let accepted: Array> = []; + let cursor: string | undefined; + const check = async (reset: boolean, deltaLength: number) => { + const poll = await readLogPoll("limit=2000", cursor); + expect(poll.reset).toBe(reset); + expect(poll.logs).toHaveLength(deltaLength); + accepted = !cursor || poll.reset ? poll.logs : [...accepted, ...poll.logs]; + const snapshot = await readLogPoll("limit=2000"); + expect(accepted).toEqual(snapshot.logs); + expect(poll.total).toBe(snapshot.total); + cursor = poll.cursor; + }; + await check(false, 0); + addRequestLog(baseEntry({ requestId: "older", usage: { inputTokens: 10, outputTokens: 5 } })); + await check(false, 1); + await check(false, 0); + addRequestLog(baseEntry({ requestId: "newest", firstOutputMs: 4 })); + await check(false, 1); + getRequestLogEntries()[0]!.usage!.outputTokens = 15; + await check(true, 2); + getRequestLogEntries()[1]!.status = 500; + delete getRequestLogEntries()[1]!.firstOutputMs; + await check(true, 2); + getRequestLogEntries()[0]!.attempts = [{ + ordinal: 1, provider: "anthropic", model: "claude-3-haiku-20240307", adapter: "anthropic", + status: 200, durationMs: 50, sendCount: 1, recoveryKinds: [], usageStatus: "reported", + usage: { inputTokens: 10, outputTokens: 5 }, + }]; + await check(true, 2); + getRequestLogEntries()[0]!.attempts![0]!.usage!.outputTokens = 20; + await check(true, 2); + // The newest cursor anchor survives this real memory-budget eviction. + evictOldestRequestLogForBudget(); + await check(true, 1); + clearRequestLogsForTests(); + await check(true, 0); + await check(false, 0); + }); + + test("pagination/filter changes and shifted windows reset against the full filtered snapshot", async () => { + for (const [requestId, provider] of [["a", "anthropic"], ["b", "openai"], ["c", "anthropic"]] as const) { + addRequestLog(baseEntry({ requestId, provider })); + } + let query = "provider=anthropic&limit=1&offset=1"; + const initial = await readLogPoll(query); + expect(initial.logs.map(row => row.requestId)).toEqual(["a"]); + expect(initial.total).toBe(2); + addRequestLog(baseEntry({ requestId: "d", provider: "anthropic" })); + let poll = await readLogPoll(query, initial.cursor); + expect(poll.reset).toBe(true); + expect(poll.logs).toEqual((await readLogPoll(query)).logs); + expect(poll.logs.map(row => row.requestId)).toEqual(["c"]); + expect(poll.total).toBe(3); + for (const changed of ["provider=openai&limit=1", "tail=2&limit=1", "model=absent", "status=5xx", "conversation=absent"]) { + query = changed; + poll = await readLogPoll(query, poll.cursor); + const full = await readLogPoll(query); + expect(poll.reset).toBe(true); + expect(poll.logs).toEqual(full.logs); + expect(poll.total).toBe(full.total); + } + const filtered = await readLogPoll("provider=openai"); + addRequestLog(baseEntry({ requestId: "not-in-filter", provider: "anthropic" })); + expect(await readLogPoll("provider=openai", filtered.cursor)) + .toMatchObject({ logs: [], reset: false, cursor: filtered.cursor, total: 1 }); + }); + + test("display-time cost changes reset even when raw entries are unchanged", async () => { + const priceConfig: OcxConfig = { port: 0, defaultProvider: "fixture", providers: { fixture: { + adapter: "openai-chat", baseUrl: "https://example.test/v1", models: ["fixture-model"], + modelCosts: { "fixture-model": { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } }, + } } }; + try { + refreshUserCostOverlays(priceConfig); + addRequestLog(baseEntry({ provider: "fixture", model: "fixture-model", usage: { inputTokens: 100, outputTokens: 10 } })); + const initial = await readLogPoll(); + const rawBefore = structuredClone(getRequestLogEntries()); + priceConfig.providers.fixture!.modelCosts!["fixture-model"]!.output = 20; + refreshUserCostOverlays(priceConfig); + const changed = await readLogPoll("", initial.cursor); + expect(changed.reset).toBe(true); + expect(changed.logs[0]!.displayMetrics).not.toEqual(initial.logs[0]!.displayMetrics); + expect(changed.logs).toEqual((await readLogPoll()).logs); + expect(getRequestLogEntries()).toEqual(rawBefore); + } finally { + refreshUserCostOverlays(config); + } + }); + + test("legacy cursors reset; invalid cursors return generic errors without reflecting input", async () => { + addRequestLog(baseEntry({ requestId: "private-row" })); + const legacy = Buffer.from(JSON.stringify({ v: 1, t: 1, id: "private-row" })).toString("base64url"); + const poll = await readLogPoll("provider=anthropic", legacy); + expect(poll.reset).toBe(true); + const payload = Buffer.from(poll.cursor, "base64url").toString(); + expect(payload).not.toContain("private-row"); + expect(payload).not.toContain("anthropic"); + for (const cursor of ["", "private-invalid-cursor", "x".repeat(513)]) { + const url = new URL("http://localhost/api/logs"); + url.searchParams.set("cursor", cursor); + const response = await handleManagementAPI(new Request(url), url, config); + expect(response?.status).toBe(400); + expect(await response!.json()).toEqual({ error: { code: "invalid_cursor", message: "invalid cursor" } }); + } + }); +}); diff --git a/tests/server/server-management-auth.test.ts b/tests/server/server-management-auth.test.ts index c1bc56f70a..340ad70bd3 100644 --- a/tests/server/server-management-auth.test.ts +++ b/tests/server/server-management-auth.test.ts @@ -1652,3 +1652,63 @@ describe("codex app-server restart routes ride the management gate", () => { } }); }); + + +test("log cursors remain behind management admission and origin gates", async () => { + const config = remoteConfig(); + saveConfig(config); + const state = initializeManagementAuthState(config); + if (!state.available) throw new Error("expected management auth state"); + const server = startServer(0, { managementAuthState: state }); + const origin = server.url.origin; + const token = "ocx_session_log_cursor_test"; + state.sessions.set(token, { + serverOrigin: origin, browserOrigin: origin, csrfToken: "csrf-log-test", + expiresAt: Date.now() + 60_000, issuance: "loopback", + }); + const adminHeaders = { "x-opencodex-api-key": "admin-secret" }; + const acceptedHeaders: HeadersInit[] = [adminHeaders, { + Origin: origin, "x-opencodex-api-key": token, "x-opencodex-gui-origin": origin, + }]; + try { + const initial = await fetch(new URL("/api/logs", server.url), { headers: adminHeaders }); + expect(initial.status).toBe(200); + const body = await initial.json() as { cursor: string }; + expect(typeof body.cursor).toBe("string"); + for (const suffix of ["", `?cursor=${body.cursor}`, "?cursor=malformed"]) { + const url = new URL(`/api/logs${suffix}`, server.url); + for (const credential of [undefined, "data-secret", "wrong-admin"]) { + const response = await fetch(url, { headers: credential ? { "x-opencodex-api-key": credential } : {} }); + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: "opencodex admin token required" }); + } + const foreign = await fetch(url, { headers: { ...adminHeaders, Origin: "https://attacker.test" } }); + expect(foreign.status).toBe(403); + await foreign.text(); + for (const headers of acceptedHeaders) { + const allowed = await fetch(url, { headers }); + expect(allowed.status).toBe(suffix.includes("malformed") ? 400 : 200); + await allowed.text(); + } + } + } finally { + await server.stop(true); + } +}, SERVER_BUDGET_MS); + +test("unavailable management authority rejects log cursors before parsing", async () => { + saveConfig(remoteConfig()); + const server = startServer(0, { managementAuthState: { available: false, reason: "fixture unavailable" } }); + try { + const legacy = Buffer.from(JSON.stringify({ v: 1, t: 1, id: "fixture" })).toString("base64url"); + for (const suffix of ["", `?cursor=${legacy}`, "?cursor=malformed"]) { + const response = await fetch(new URL(`/api/logs${suffix}`, server.url), { + headers: { "x-opencodex-api-key": "admin-secret" }, + }); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ error: "management API unavailable" }); + } + } finally { + await server.stop(true); + } +}, SERVER_BUDGET_MS); diff --git a/tests/usage/request-log.test.ts b/tests/usage/request-log.test.ts index 02e26e764b..1a3b4e3264 100644 --- a/tests/usage/request-log.test.ts +++ b/tests/usage/request-log.test.ts @@ -38,6 +38,7 @@ import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { decodeRequestLogCursor, selectRequestLogPoll } from "../../src/server/request-log-cursor"; async function* replayAdapterEvents(events: AdapterEvent[]): AsyncGenerator { for (const event of events) yield event; @@ -1808,3 +1809,97 @@ describe("request log restart hydrate", () => { } }); }); + + +describe("request log snapshot cursor", () => { + const epoch = "a".repeat(32); + const query = new URLSearchParams("limit=2000"); + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url"); + + test("codec bounds and canonical encoding reject malformed or type-confused input", () => { + const poll = selectRequestLogPoll([], query, null, epoch); + const payload = JSON.parse(Buffer.from(poll.cursor, "base64url").toString()); + expect(decodeRequestLogCursor(poll.cursor)).toEqual(payload); + for (const raw of ["", "!", "a".repeat(513), `${poll.cursor}=`, ` ${poll.cursor}`, + encode(null), encode([]), encode({ ...payload, v: 3 }), encode({ ...payload, n: -1 }), + encode({ ...payload, n: 2001 }), encode({ ...payload, n: 0.5 }), encode({ ...payload, n: "0" }), + encode({ ...payload, h: "x".repeat(64) }), encode({ ...payload, q: null }), + encode({ ...payload, e: "short" }), encode({ ...payload, extra: true }), + encode({ v: 1, t: -1, id: "row" }), encode({ v: 1, t: 1, id: "" }), + encode({ v: 1, t: "1", id: "row" }), encode({ v: 1, t: 1, id: "x".repeat(257) })]) { + expect(decodeRequestLogCursor(raw)).toBeNull(); + } + const legacy = decodeRequestLogCursor(encode({ v: 1, t: 1, id: "row" })); + expect(legacy).toEqual({ v: 1, t: 1, id: "row" }); + expect(selectRequestLogPoll([], query, legacy, epoch).reset).toBe(true); + }); + + test("stable empty and populated snapshots produce empty deltas, appends preserve repeated IDs", () => { + const empty = selectRequestLogPoll([], query, null, epoch); + expect(empty.reset).toBe(false); + expect(selectRequestLogPoll([], query, decodeRequestLogCursor(empty.cursor), epoch)).toEqual(empty); + const rows = [log({ requestId: "same" })]; + const first = selectRequestLogPoll(rows, query, decodeRequestLogCursor(empty.cursor), epoch); + expect(first.logs).toEqual(rows); + const cursor = decodeRequestLogCursor(first.cursor); + expect(selectRequestLogPoll(rows, query, cursor, epoch)).toEqual({ ...first, logs: [] }); + rows.push(log({ requestId: "same", status: 500 })); + expect(selectRequestLogPoll(rows, query, cursor, epoch)).toMatchObject({ logs: [rows[1]], reset: false }); + }); + + test("in-place older/newest/nested changes, field removal, reorder and eviction reset the whole window", () => { + const original = [ + log({ requestId: "older", usage: { inputTokens: 10, outputTokens: 5 }, firstOutputMs: 3 }), + log({ requestId: "newest" }), + ]; + const cursor = decodeRequestLogCursor(selectRequestLogPoll(original, query, null, epoch).cursor); + const mutations: Array<(rows: RequestLogEntry[]) => void> = [ + rows => { rows[0]!.status = 500; }, + rows => { rows[1]!.durationMs = 22; }, + rows => { rows[0]!.usage!.outputTokens = 6; }, + rows => { delete rows[0]!.firstOutputMs; }, + rows => { rows[0] = log({ requestId: "replacement" }); }, + rows => { rows.reverse(); }, + rows => { rows.shift(); }, + rows => { rows.length = 0; }, + ]; + for (const mutate of mutations) { + const rows = structuredClone(original); + mutate(rows); + expect(selectRequestLogPoll(rows, query, cursor, epoch)).toMatchObject({ logs: rows, reset: true }); + } + // Same hydrated IDs and values do not make an old process cursor valid. + expect(selectRequestLogPoll(original, query, cursor, "b".repeat(32))) + .toMatchObject({ logs: original, reset: true }); + }); + + test("query identity ignores cursor and parameter ordering but binds filters and pagination", () => { + const rows = [log({ requestId: "private-row", conversationId: "private-conversation" })]; + const first = selectRequestLogPoll(rows, new URLSearchParams("provider=private-provider&limit=1"), null, epoch); + const cursor = decodeRequestLogCursor(first.cursor); + const raw = Buffer.from(first.cursor, "base64url").toString(); + for (const value of ["private-row", "private-conversation", "private-provider"]) expect(raw).not.toContain(value); + expect(selectRequestLogPoll(rows, new URLSearchParams(`limit=1&cursor=${first.cursor}&provider=private-provider`), cursor, epoch).logs) + .toEqual([]); + for (const changed of ["provider=other&limit=1", "provider=private-provider&limit=2", "provider=private-provider&limit=1&offset=1"]) { + expect(selectRequestLogPoll(rows, new URLSearchParams(changed), cursor, epoch).reset).toBe(true); + } + const duplicated = selectRequestLogPoll(rows, new URLSearchParams("provider=a&provider=b"), null, epoch); + expect(selectRequestLogPoll(rows, new URLSearchParams("provider=b&provider=a"), decodeRequestLogCursor(duplicated.cursor), epoch).reset) + .toBe(true); + }); + + test("a full-window rollover resets; a stale fingerprint cannot suppress current rows", () => { + const rows = Array.from({ length: 2000 }, (_, index) => log({ requestId: `row-${index}`, timestamp: 2000 - index })); + const initial = selectRequestLogPoll(rows, query, null, epoch); + const cursor = decodeRequestLogCursor(initial.cursor); + expect(cursor).toMatchObject({ v: 2, n: 2000 }); + rows.shift(); + rows.push(log({ requestId: "new", timestamp: 0 })); + expect(selectRequestLogPoll(rows, query, cursor, epoch)).toMatchObject({ logs: rows, reset: true }); + const payload = JSON.parse(Buffer.from(initial.cursor, "base64url").toString()); + const stale = decodeRequestLogCursor(encode({ ...payload, h: "0".repeat(64) })); + expect(stale).not.toBeNull(); + expect(selectRequestLogPoll(rows, query, stale, epoch)).toMatchObject({ logs: rows, reset: true }); + }); +}); From ff5996d97fbffb889cd144d82ad9628560a55967 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 02:01:21 +0900 Subject: [PATCH 13/99] fix(config): add exclusive initial configuration publication [skip ci] Reimplement #3632 with private hardening before bytes, no-replace hard-link publication, descriptor-identity cleanup and no truncation of shared/published inodes. Existing saveConfig updates remain unchanged. Consumer follows in the next stack layer; final CI pending. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> Co-authored-by: Yumi --- src/config.ts | 51 +++++- src/config/initialize.ts | 132 +++++++++++++++ tests/config/config-mutation-lock.test.ts | 190 +++++++++++++++++++++- 3 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 src/config/initialize.ts diff --git a/src/config.ts b/src/config.ts index 728b3969ea..d5ef05c33f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, truncateSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; @@ -123,6 +123,7 @@ export { type AtomicWriteIO, } from "./config/atomic-write"; import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths"; +import { InitialConfigPublicationError, publishInitialConfigNoReplace, type InitialConfigPublicationIO } from "./config/initialize"; import { describeProxyForLog, readWindowsSystemProxy, @@ -2811,6 +2812,16 @@ export function readConfigDiagnostics(): ConfigDiagnostics { return readConfigFileSnapshot().diagnostics; } +/** Read-only init preflight. Occupied unsafe entries are never treated as absence. */ +export function observeInitialConfigState(): "missing" | "exists" | "invalid" { + try { + if (!lstatSync(getConfigPath()).isFile()) return "invalid"; + } catch (error) { + return isMissingPathError(error) ? "missing" : "invalid"; + } + return readConfigFileSnapshot().diagnostics.source === "file" ? "exists" : "invalid"; +} + /** * The persisted config, plus a digest of the EXACT bytes it was parsed from. * @@ -3123,6 +3134,44 @@ function persistConfigUnlocked(config: OcxConfig): boolean { return true; } +export type PersistedConfigInitializationOutcome = "created" | "exists" | "invalid"; + +/** Initialize only a missing config; ordinary explicit updates still use saveConfig. */ +export function initializePersistedConfigIfMissing( + config: OcxConfig, + io?: Partial, +): PersistedConfigInitializationOutcome { + assertNotRealHomeUnderTest(getConfigDir()); + const before = observeInitialConfigState(); + if (before !== "missing") return before; + let published = false; + try { + const persisted = withConfigMutationLockSync((): OcxConfig | "exists" | "invalid" => { + const current = observeInitialConfigState(); + if (current !== "missing") return current; + const projected = projectCustomModelCatalogMigration(undefined, projectConfigRebaseProvenance(config)); + if (!validateConfigCandidate(projected).ok) throw new Error("Initial configuration is invalid."); + if (!publishInitialConfigNoReplace(getConfigPath(), JSON.stringify(projected, null, 2) + "\n", io)) { + return observeInitialConfigState() === "exists" ? "exists" : "invalid"; + } + published = true; + recordOwnedConfigPath(getConfigDir(), getConfigPath()); + bumpGenerationForCooperatingConfigWrite(); + return projected; + }); + if (typeof persisted === "string") return persisted; + adoptCustomModelCatalogMigration(config, persisted); + if (persisted.configRebaseProvenance === undefined) delete config.configRebaseProvenance; + else config.configRebaseProvenance = structuredClone(persisted.configRebaseProvenance); + clearPendingConfigTopLevelDeletions(config); + refreshUserCostOverlays(persisted); + return "created"; + } catch (cause) { + if (published) throw new InitialConfigPublicationError("published", false, false, { cause }); + throw cause; + } +} + /** Persist `config` to config.json under the config-mutation lock. */ export function saveConfig(config: OcxConfig): void { // Keep the real-home assertion ahead of even lock-directory preparation. diff --git a/src/config/initialize.ts b/src/config/initialize.ts new file mode 100644 index 0000000000..864b09b036 --- /dev/null +++ b/src/config/initialize.ts @@ -0,0 +1,132 @@ +import { + closeSync, constants, fchmodSync, fstatSync, linkSync, lstatSync, + openSync, unlinkSync, writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { assertNotRealHomeUnderTest } from "../lib/test-home-guard"; +import { forgetEphemeralSecretPath, hardenSecretPath } from "../lib/windows-secret-acl"; +import { isMissingPathError, nextAtomicTempSequence } from "./atomic-write"; + +type PublicationState = "not-published" | "published" | "uncertain"; + +/** Messages contain no candidate bytes or raw filesystem error text. */ +export class InitialConfigPublicationError extends Error { + constructor( + readonly publication: PublicationState, + readonly residualTemp: boolean, + readonly hardLinkUnavailable: boolean, + options?: ErrorOptions, + ) { + super(hardLinkUnavailable + ? "Initial config requires hard-link publication; the filesystem or its permissions denied it." + : "Initial config publication did not finish.", options); + this.name = "InitialConfigPublicationError"; + } +} + +/** Narrow fault boundary; publication must be a single link operation. */ +export interface InitialConfigPublicationIO { + harden(fd: number, temp: string, target: string): void; + write(fd: number, bytes: string): void; + link(temp: string, target: string): void; + unlink(temp: string): void; + close(fd: number): void; +} + +function hardenInitialConfig(fd: number, temp: string, target: string): void { + if (process.platform === "win32") { + hardenSecretPath(temp, { required: true, timeoutMemoKey: target }); + } else { + fchmodSync(fd, 0o600); + } +} + +function identifiesDescriptor(fd: number, path: string): boolean { + const opened = fstatSync(fd); + const entry = lstatSync(path); + return opened.isFile() && entry.isFile() + && opened.dev === entry.dev && opened.ino === entry.ino; +} + +function verifyPrivateTemp(fd: number, temp: string): void { + if (!identifiesDescriptor(fd, temp) + || (process.platform !== "win32" && (fstatSync(fd).mode & 0o777) !== 0o600)) { + throw new Error("Initial config temporary file identity or permissions changed."); + } +} + +function removeOwnedTemp(fd: number, temp: string, unlink: (path: string) => void): boolean { + for (let attempt = 0; attempt < 2; attempt++) { + try { + if (!identifiesDescriptor(fd, temp)) return false; + unlink(temp); + forgetEphemeralSecretPath(temp); + return true; + } catch (error) { + if (isMissingPathError(error)) { + forgetEphemeralSecretPath(temp); + return true; + } + } + } + return false; +} + +/** + * Publish complete bytes without replacing any entry at target. Never truncate: + * even an error from link can mean a remote filesystem already published the inode. + * Cleanup removes only our temporary name, never the target or another inode. + */ +export function publishInitialConfigNoReplace( + target: string, + bytes: string, + io: Partial = {}, +): boolean { + assertNotRealHomeUnderTest(dirname(target)); + const temp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; + let fd: number | undefined; + let publication: PublicationState = "not-published"; + let collided = false; + let failure: unknown; + let failed = false; + let hardLinkUnavailable = false; + let residualTemp = false; + try { + fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); + (io.harden ?? hardenInitialConfig)(fd, temp, target); + verifyPrivateTemp(fd, temp); + (io.write ?? ((descriptor: number, value: string) => writeFileSync(descriptor, value, { encoding: "utf8" })))(fd, bytes); + verifyPrivateTemp(fd, temp); + try { + publication = "uncertain"; + (io.link ?? linkSync)(temp, target); + publication = "published"; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + // EEXIST normally means a competitor won. A shared target means our + // publication may nevertheless have happened (e.g. a remote FS retry). + if (code === "EEXIST" && !identifiesDescriptor(fd, target)) collided = true; + else { + hardLinkUnavailable = ["EOPNOTSUPP", "ENOTSUP", "ENOSYS", "EXDEV", "EPERM"].includes(code ?? ""); + throw error; + } + } + if (!collided && !identifiesDescriptor(fd, target)) { + throw new Error("Initial config published target identity changed."); + } + } catch (error) { + failed = true; + failure = error; + } finally { + if (fd !== undefined) { + // Unlink-only cleanup preserves all bytes if another name shares this inode. + residualTemp = !removeOwnedTemp(fd, temp, io.unlink ?? unlinkSync); + try { (io.close ?? closeSync)(fd); } + catch (error) { if (!failed) failure = error; failed = true; } + } + } + if (failed || residualTemp) { + throw new InitialConfigPublicationError(publication, residualTemp, hardLinkUnavailable, { cause: failure }); + } + return !collided; +} diff --git a/tests/config/config-mutation-lock.test.ts b/tests/config/config-mutation-lock.test.ts index c0b37c28b4..32e305c321 100644 --- a/tests/config/config-mutation-lock.test.ts +++ b/tests/config/config-mutation-lock.test.ts @@ -1,8 +1,10 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { closeSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; -import { ConfigMutationLockError, loadConfig, saveConfig, withConfigMutationLockSync } from "../../src/config"; +import { ConfigMutationLockError, deleteConfigTopLevelKey, getConfigPath, initializePersistedConfigIfMissing, loadConfig, observeInitialConfigState, readConfigGeneration, saveConfig, withConfigMutationLockSync } from "../../src/config"; +import { InitialConfigPublicationError, publishInitialConfigNoReplace } from "../../src/config/initialize"; +import { nextAtomicTempSequence } from "../../src/config/atomic-write"; import { CodexCredentialRefreshLockTimeoutError, getCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; import type { OcxConfig } from "../../src/types"; import { ManagementRequest, managementHeaders } from "../helpers/management-auth"; @@ -79,6 +81,11 @@ test("a live cross-process holder is not stolen and runtime writers fail immedia } const startedAt = performance.now(); expect(() => saveConfig(config(20200))).toThrow(ConfigMutationLockError); + // A busy initializer must not steal the holder even when its target is absent. + unlinkSync(getConfigPath()); + expect(() => initializePersistedConfigIfMissing(config(20200))).toThrow(ConfigMutationLockError); + expect(existsSync(getConfigPath())).toBe(false); + writeFileSync(getConfigPath(), JSON.stringify(config())); expect(() => saveCodexAccountCredential("busy-account", { accessToken: "busy-access", refreshToken: "busy-refresh", @@ -139,6 +146,185 @@ test("a throwing mutation releases the lock and leaves writers available", () => expect(loadConfig().port).toBe(50500); }); +const initTemps = () => readdirSync(testRoot).filter(name => name.includes(".ocx.") && name.endsWith(".tmp")); + +test("initial creation keeps candidate values and existing bytes; the explicit saver still updates", () => { + const candidate = { ...config(21001), operatorNote: "keep unknown fields" }; + expect(initializePersistedConfigIfMissing(candidate)).toBe("created"); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8"))).toEqual(candidate); + if (process.platform !== "win32") expect(lstatSync(getConfigPath()).mode & 0o777).toBe(0o600); + expect(readConfigGeneration()).toMatchObject({ generation: { value: 1 } }); + const bytes = readFileSync(getConfigPath(), "utf8"); + expect(initializePersistedConfigIfMissing(config(21002))).toBe("exists"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + saveConfig(config(21003)); + expect(loadConfig().port).toBe(21003); + expect(initTemps()).toEqual([]); +}); + +test.each(["", "not-json\n", '{"port":"broken"}', '\uFEFF{ "port":21002, "providers":{}, "defaultProvider":"openai", "unknown":42 }\n'])( + "init preserves occupied bytes without lock or backup creation: %j", bytes => { + writeFileSync(getConfigPath(), bytes); + const candidate = config(21001); + const original = structuredClone(candidate); + expect(initializePersistedConfigIfMissing(candidate)).toBe(bytes.startsWith("\uFEFF") ? "exists" : "invalid"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + expect(candidate).toEqual(original); + expect(readdirSync(testRoot)).toEqual(["config.json"]); + }, +); + +test("init refuses a directory and a dangling symlink without following either", () => { + mkdirSync(getConfigPath()); + expect(observeInitialConfigState()).toBe("invalid"); + expect(initializePersistedConfigIfMissing(config())).toBe("invalid"); + removeTreeWithRetry(getConfigPath()); + const absent = join(testRoot, "absent"); + symlinkSync(absent, getConfigPath(), "file"); + expect(initializePersistedConfigIfMissing(config())).toBe("invalid"); + expect(lstatSync(getConfigPath()).isSymbolicLink()).toBe(true); + expect(existsSync(absent)).toBe(false); +}); + +test("real link collision preserves the winner and does not advance generation or mutate the candidate", () => { + withConfigMutationLockSync(() => {}); + const generation = readConfigGeneration(); + const winner = JSON.stringify(config(21002)) + "\n"; + const candidate = config(21001); + const original = structuredClone(candidate); + expect(initializePersistedConfigIfMissing(candidate, { + link(temp, target) { + writeFileSync(target, winner, { flag: "wx" }); + linkSync(temp, target); + }, + })).toBe("exists"); + expect(readFileSync(getConfigPath(), "utf8")).toBe(winner); + expect(readConfigGeneration()).toEqual(generation); + expect(candidate).toEqual(original); + expect(initTemps()).toEqual([]); +}); + +test("exclusive temp collision does not remove or modify somebody else's file", () => { + const sequence = nextAtomicTempSequence() + 1; + const occupied = `${getConfigPath()}.ocx.${process.pid}.${sequence}.tmp`; + writeFileSync(occupied, "other staged bytes", { flag: "wx" }); + expect(() => publishInitialConfigNoReplace(getConfigPath(), "candidate bytes")).toThrow(InitialConfigPublicationError); + expect(readFileSync(occupied, "utf8")).toBe("other staged bytes"); + expect(existsSync(getConfigPath())).toBe(false); +}); + +test("failed hardening occurs before candidate bytes are written", () => { + let wrote = false; + expect(() => initializePersistedConfigIfMissing(config(), { + harden(_fd, temp) { + expect(readFileSync(temp, "utf8")).toBe(""); + throw new Error("ACL denied"); + }, + write() { wrote = true; }, + })).toThrow(InitialConfigPublicationError); + expect(wrote).toBe(false); + expect(existsSync(getConfigPath())).toBe(false); + expect(initTemps()).toEqual([]); +}); + +test("partial write failure removes only the unpublished temporary name", () => { + expect(() => initializePersistedConfigIfMissing(config(), { + write(fd, bytes) { writeFileSync(fd, bytes.slice(0, 10)); throw new Error("disk full"); }, + })).toThrow(InitialConfigPublicationError); + expect(existsSync(getConfigPath())).toBe(false); + expect(initTemps()).toEqual([]); +}); + +test.each(["EOPNOTSUPP", "ENOTSUP", "ENOSYS", "EXDEV", "EPERM"])("unsupported/denied link %s never falls back to replacement", code => { + try { + initializePersistedConfigIfMissing(config(), { + link() { throw Object.assign(new Error("do not print raw error"), { code }); }, + }); + throw new Error("expected link refusal"); + } catch (error) { + expect(error).toBeInstanceOf(InitialConfigPublicationError); + expect((error as InitialConfigPublicationError).hardLinkUnavailable).toBe(true); + } + expect(existsSync(getConfigPath())).toBe(false); + expect(initTemps()).toEqual([]); +}); + +test("a syscall error after a real link leaves the entire published candidate intact", () => { + const bytes = 'complete candidate bytes\n'; + expect(() => publishInitialConfigNoReplace(getConfigPath(), bytes, { + link(temp, target) { linkSync(temp, target); throw Object.assign(new Error("uncertain completion"), { code: "EIO" }); }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + expect(initTemps()).toEqual([]); +}); + +test("post-link identity failure cannot remove a concurrent replacement", () => { + expect(() => initializePersistedConfigIfMissing(config(21001), { + link(temp, target) { + linkSync(temp, target); + const replacement = join(testRoot, "replacement"); + writeFileSync(replacement, "concurrent-winner\n"); + renameSync(replacement, target); + }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(getConfigPath(), "utf8")).toBe("concurrent-winner\n"); +}); + +test("cleanup failure retains full published/shared bytes and closes the descriptor", () => { + let closed = false; + let failure: unknown; + try { + publishInitialConfigNoReplace(getConfigPath(), "complete bytes", { + unlink() { throw new Error("sharing violation"); }, + close(fd) { closed = true; closeSync(fd); }, + }); + } catch (error) { failure = error; } + expect(failure).toMatchObject({ publication: "published", residualTemp: true }); + expect(closed).toBe(true); + expect(readFileSync(getConfigPath(), "utf8")).toBe("complete bytes"); + const temps = initTemps(); + expect(temps).toHaveLength(1); + expect(readFileSync(join(testRoot, temps[0]!), "utf8")).toBe("complete bytes"); +}); + +test("a shared unpublished inode is never scrubbed", () => { + const otherName = join(testRoot, "shared-candidate"); + expect(() => publishInitialConfigNoReplace(getConfigPath(), "candidate bytes", { + link(temp) { linkSync(temp, otherName); throw new Error("publication failed"); }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(otherName, "utf8")).toBe("candidate bytes"); + expect(existsSync(getConfigPath())).toBe(false); + expect(initTemps()).toEqual([]); +}); + +test("a swapped temporary symlink is neither written through nor removed as our inode", () => { + const victim = join(testRoot, "victim"); + writeFileSync(victim, "untouched"); + expect(() => publishInitialConfigNoReplace(getConfigPath(), "candidate bytes", { + harden(_fd, temp) { unlinkSync(temp); symlinkSync(victim, temp, "file"); }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(victim, "utf8")).toBe("untouched"); + expect(existsSync(getConfigPath())).toBe(false); + expect(lstatSync(join(testRoot, initTemps()[0]!)).isSymbolicLink()).toBe(true); +}); + +test("descriptor close failure cannot scrub an already published config", () => { + expect(() => publishInitialConfigNoReplace(getConfigPath(), "complete bytes", { + close(fd) { closeSync(fd); throw new Error("close failed"); }, + })).toThrow(InitialConfigPublicationError); + expect(readFileSync(getConfigPath(), "utf8")).toBe("complete bytes"); +}); + +test("successful init adopts deletion provenance before a subsequent explicit save", () => { + const candidate = config(); + deleteConfigTopLevelKey(candidate, "hostname"); + expect(initializePersistedConfigIfMissing(candidate)).toBe("created"); + expect(candidate.configRebaseProvenance).toEqual({ version: 1, deletedTopLevelKeys: ["hostname"] }); + candidate.hostname = "127.0.0.1"; + saveConfig(candidate); + expect(JSON.parse(readFileSync(getConfigPath(), "utf8")).hostname).toBe("127.0.0.1"); +}); + test("management API maps config mutation lock contention to retryable 503", async () => { saveConfig(config()); const readyPath = join(testRoot, "mgmt-holder-ready"); From 6eec1265c756e406baa28688661d59e258aa7319 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 02:04:57 +0900 Subject: [PATCH 14/99] feat(models): save picker order without replacing the featured roster Reimplements the picker feature from #3383 against current catalog and discovery contracts. Preserves native/featured bands and documents native advertised-choice effects. Co-authored-by: x3M3x --- .../pr-screenshots/t4-model-picker-81a8.png | Bin 0 -> 365701 bytes .../content/docs/fr/guides/model-ordering.md | 10 +- .../src/content/docs/guides/model-ordering.md | 11 +- .../content/docs/ja/guides/model-ordering.md | 11 +- .../content/docs/ko/guides/model-ordering.md | 10 +- .../content/docs/ru/guides/model-ordering.md | 10 +- .../content/docs/tr/guides/model-ordering.md | 10 +- .../docs/zh-cn/guides/model-ordering.md | 10 +- .../docs/zh-tw/guides/model-ordering.md | 8 + gui/src/i18n/de.ts | 16 +- gui/src/i18n/en.ts | 16 +- gui/src/i18n/fr.ts | 16 +- gui/src/i18n/ja.ts | 16 +- gui/src/i18n/ko.ts | 16 +- gui/src/i18n/ru.ts | 16 +- gui/src/i18n/tr.ts | 16 +- gui/src/i18n/zh-TW.ts | 16 +- gui/src/i18n/zh.ts | 16 +- gui/src/model-picker-order.ts | 103 ++++++++++++ gui/src/pages/Models.tsx | 115 +++++++++++++- gui/tests/model-picker-order.test.ts | 57 +++++++ gui/tests/models-status-toast.test.tsx | 140 ++++++++++++++++ src/claude/model-info.ts | 16 +- src/codex/catalog.ts | 2 +- src/codex/catalog/sync.ts | 30 +++- src/server/index.ts | 2 + .../management/agent-settings-routes.ts | 119 ++++++++++++-- src/types/config.ts | 2 + structure/03_catalog-and-subagents.md | 15 ++ structure/05_gui-and-management-api.md | 24 +++ .../claude-desktop-discovery.test.ts | 20 ++- .../claude-model-info.test.ts | 26 +++ .../catalog-full-picker-order.test.ts | 21 ++- .../codex-catalog-model-picker-order.test.ts | 30 +++- .../routing/subagent-roster-retention.test.ts | 150 +++++++++++++++++- tests/server/config.test.ts | 16 ++ 36 files changed, 1067 insertions(+), 45 deletions(-) create mode 100644 docs-site/public/pr-screenshots/t4-model-picker-81a8.png create mode 100644 gui/src/model-picker-order.ts create mode 100644 gui/tests/model-picker-order.test.ts diff --git a/docs-site/public/pr-screenshots/t4-model-picker-81a8.png b/docs-site/public/pr-screenshots/t4-model-picker-81a8.png new file mode 100644 index 0000000000000000000000000000000000000000..0754aefc3ce1c0f2bd94b31100c282a264c5ef9a GIT binary patch literal 365701 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 gTT)}tg%v#V zzL9vBfZHjkG+G$Z*Ry~LItK~`fqiOG(~BSzf)gR{?z)_ZAtYQslet3gnv~Y%`hApD znP$3&D|-RLLxp#;6B3!rhq@Be$yf7km%(~{Sy@@B?l(S$Av#488Nka9kKpObUslX5 zEiVV*(&^Wgs8$;P>m&&rF+4o_14!$#ga;O#^6AdeXDGXstE(%Q{bmWr`mx}Oa&lUm zEfM=9lcqeYOJ924h>7mc}?{94|W6@U$Eok{ppr^m+Yt3hkSM?~qDS)bs zR=Lc0cbEIdkx~M*@yGa+w7MZGaV?#>lQl=y)+}|6)VMYFOG)%;)jNY4Ej^JoY%Gib!AZZmz3)3p2w*u*2?;e|G9Lgsd+t)BGB+06u+ROY2r;J-0hp0kocS}_t$b@9>HP^kwgg*j7Ax)gQ$YUqv(8=O)}+_Fh;rZ8&F+3lo6b6bwA6=IO_QdaFBA4j_gO?#kYV$;nux%#1>|1i+O zKz;cE)pGEF`y3%MBH|0(d%NXMDHA1>pPV#xo;)nXgHlh6Fv%rZ!aV}gTT zj0qGW<0**dtJ_<9WR}FgO{P;Wo@;bw9QUJZT9c!a7$$4=MmLpsPAwai&CU4GH@58R z>|`NSzMjsX<~C137q&t$nlKqLT6lkEvB0Mpb7!u82wkDCzODq+!pCok;q3}!Fzr>$64B)-Y;RB+KQK14=`bU<7ixqXY z#$=GIl|aN~h~56_Q6#r%M_@E)T*76B4R4T;kTlru@s{!fC9f~BIuI8n{3SRg{G~TV z>{*EizX!#lZ{@siXf=a)1_!E9=w}8(YyZ${V?=aXa^ANoo-bPP7G%Zq-*cV@w6U5E zpPjEeG2BwO9vXX!dr8`5w6#bg42W8AzZF3OmD(h`mOD*q+}r-%-n-K=uywD~`RQ`| zJq*4pL(ayixVReo`KGnL1fa`coE{vANl36T7>zZ=uXt7n0IdaS2rt=dE({s6%0c_X zns~BQNdK8(4^LeGIg%*=dz_pqP*Bb!Qi!NJEP(92;_A?aPLf2pkhZrB1e32w3ua%% zKB~9H*Kf23JO(%pNK^h&Bkae;$@E@9gLjz6b}^@4s+0;(YCNZd-`{VBg8%jgytu2} zeUa<5-haD0*GPT-s~#L)?t~K=NSUR?;Br2hIY$WxWpZ)q#rb9T9yp)LT$->XNSM8d zeO|pNHWp?~SSo#D-}fn8#E2Bj`Rx20Te;*W|2EreOgQ5FQNc=%g0s=sXama^Qr5$b zq2<`0ngx_W&6%eh-BVDvcKs)6J&T*8`Na(v7yF9>m&ZF4@e(b>;P4(W)o7vodaTh1 z#j}@R2ieRgC?8p_fZ_t2uK_Hdl~=JmTZ%Gts?wRA1fM#sUCXW!;qrhlXF`D>j6vI!5OmDKxg9!y z-U!KN@uPnJyjvOQ&kI{yCaW9DmO~%TTiYqyMm8ng2GZY)zSBw8AtxUz$bKJ4=z~M& zbn%nGWb9?0f}a@baoPygoJliU5D0kg%qW zrI^w_3}!lCL}Eo;X9Rj1JeCV-@t3$&8z5QS-dq;DI0_4QUS9@O)S1sWeL7qbBg#=Z ze3~E&j?#ioX2(q9&CShT!_kQdSuj;dR(2AZ8QGuo*P<~L+Gh)4y~bMiwMjVl=IL|ZxpI?&J*^7_2e8)+KMx^esbT|uKxhvR*Yj7;4OU7eNk=TVHx z=CDMHrd#d2TBJF7r@q?)|Fh%q`Eobi3wenH&c5=kfm)yiPF3*6K8&Y82?ziMw(6v9=Pt04DfPUF0_nHR}oikHj@79 z-4zc*%ms%Ys@mzT@nv*k?q4NuT}ONXuU4saGRuz{e%pga#LgovMqZUkRxpxY%oK(mrAuBs&CVNK6x+p?rlf6gwC?RB|WRophwybQ~WMuCVvMzi5URS+8<9FZp z*B^QG=+SkZ=Xo6G@fy$Zy3T7o@#C@5X?}70&8jub&GJGpN8S_@s>(!i`ef!=8arN;be8!{j}gc0j}Kx zFCaKLcuubiJIjQ4EkSt~C?}_@c<^XTp^7%_{N@9*6rx|>2X8yNGvPodDj0cLl>))m zB-OKd49RvFm8g(mOK0urWskGNi-$C6Y`XRfz*mH|h+w-r&-Xp*HVbWW>#iyr=!3F7 zI6vH8s!!zM4>ffIh%}rNm!KfAm}(&c@ZaEeLUoYo3GzX6dhHLD_+q2!6d*~AU0xtaxlK*36%r8xGjX{{ARJhI4aQ>Cq0*N zbX{FP8Bq(B9sOjPn{+!CJ$%vkupBCEKJy>Fj`|NCJUn_IZtLr_8_$PpIn$%2?epGZ&aWE$@b*y(k zEEOuryVm1vj{0wOBqc9A3_vX-aTg%wz&>T{xUH6D+u#u;?fyf^DL2SvbPmvVKnOQCZ7~y+ z{*#ZR33t}NYwlx+6N*bgb2PQujdiNb-&1b7)lNK6P`&hP`kr5|MDnXkkmMR3z63HO zyJ}U5$06`1nG}}Ml08Zb3m=6zgw}TKZVb1sc~()Kr#m?zN9@{fF)8?q4d-AMk!`F! zsjKy5E0D0z*}AgxP3-Q3;F%_ii(a4|D84imCJ46`ACt1h6zuL7o8b*b;>Mk(vmWT^ zu7M0%)#QL~H#mLLabG8Gcn7i%^fa30M5A?*7c*E3ShHqw4J_o=y9o!nIo_%Mab{VPl%-qUQ>cjJeqJ~B!i_3@Ks5d7$IeC%h zahBfn@YVkNGSlWe+94RePJtNRacSSBE4~joXW;o-sQ8rl{p1e!RN35<8#qpk#P32R4 z)aeEbd~4=6IzDDd z*m86#<>f7G>|-{3=DcTh+`PPl43zp`rVE<4{pjfEicQW=6fYef^Yb&`qM~8! zocEcC+lc8tDDy#Q^Hgj=*z+A{?WPEIag?}GUHerwzQG%wr&H%#k89&YtCUV=i+#{m zEIwh_anwcBfL|#GNUhD~-Nl#*jQ_5LLTv|JBRV&j(vmeg8?f3xSLbZwJteb}o8akd& z#IiamL_;BUX3{-dXu^g7#=oBh%r39pg{BKf80|=t!C!+xtGaKiZ|Z6SECaQ8AVaPu z5nWiaimu1jVK|-dLWfz>UONlzaQ$nGN9kvZ$)ks^){hpyb2)gbAMH~^lo}QCMv^D3Jaq73} z)n&A+uezTKQOBn?K7fV!HU8PK+**5HY7rDaqpe4!?vL%q$qTn^Q)})Y@2xZL59Mg= zMu8+FF(Ld^w>!h^s;SpHFzdLnQgc)2D?R>iYP zZ-O6^85)MPK3w3?7INAcQu3k#xy5L?O`QP|_i#RFyEX0Qn5!@|Pqzn#ti~W0k)P6C zK0pHLoQ(acqGeQcw17$Xc%Qt6%Jn-gwvCtZ@Tmk`02W_fUM5aXhGT)Mx2|s1@d72E z=boXRP2NdX=nZ&-`dJRhSfybI@B7>c<%33gy4Ym z0NjFQG_{u;H+#E#4KPjzzc{z|GfPfH{0ex1@l)nN*mLF?BAv4lF+o(I& zu6Ef1JnX(V-{7Zvr_lZ2HS52BCISi-zD~RU^v2NkRGoYpCyV3~9=L|p9PhD=R}eia z$vVkrkPT~Y(qagOO)+(PHWr`q?OVjc;r4cy5r42Z)Z&~{|O)QC<%H+P=MUB zDvB61|ABRWm`S_x$;HFt0(CnimU?~R;TMc+e;`_ihx4^%?McXJl`-cmeq`xe`*n6I z%Bk8B?qz?qoq098U07Inrmx?Eg1{DNr$4p!E$J#oZ=V_+a`zYB!#I*c%JN5c*xj|M zt)#!h!()oq<{Sgfi?1(~9oS8}o7z88ZQfK`-{sfW zZ=q3CxpS!oZT`EOn)$p+d<)3vd!kWjnMO3scX*sEhHi$LqD1W@t8(cRb>16uVd4t% z^7PkqBP+i?QdJc{pH=9X;z<-W$)AO|VOj(YBq^I_Z$s9O^ZVMVT2B@t|AQ60W*olsw6wQSrBL#J z$ixJ|SE4zn=SP7qv^K7QY92|!tauXfg1PC+Dm$Xk4&=^_<&D(e^RtCrHp6El)9hIz zhAI1jcMcp9EB#B}-rgOpeXy9LWu9MSWKFD^krX_4x)@KWUe)>oM^kbu;@Z#y1mete zO|yBmP%{-y#zc9I#-HYMyQ;V-3>MPoLloLXjDPpTBqZ$HAnsy$@?c8W>*V2#y|(l3 z@fcy}M5E-)%q3Y`&zni&ou;EDE0%c2`%~U^c60u_t2Q7+CtTh2B=3yoHU!xDn);`j zD!<~-kjw*zr=_LkpVf?P^iyjJcoXuU)XRWGynd#zsb#?AN9COw{p+LQO@B5YPjsJ3C=#6@;;w~ZnAp52i=|Ru09(c0wH-LV`DGwnVh<7 zqbGPSNA*))=Z%D-Md#5XcC+&v=f|p$NRfJO9kM9bMJ8(ZL%@jU+FFG!hylaVm%5{) zXFK<9PENc;93cG-FrR-2x1C#eo>VG{#^6~3eud$r*AjAtM?JsJYU~A$`#AQna*_D; ztmBg%>@9WYLLt$HzVoyFRx%RiFVwQSUWZP=x8T_q{~2T`8>Sp5<|{A&Z8)HeciWO? z=2E1Iov!^FG;PS13fSBB%S0rF1{=P`xW#-3=e1guquHzoa(NM1^dVFV(}Kgj$f&)4 z*>u6P#&M+|iVL}Gz=%5ZBTHm423DlyC~~Fg1v2C~r=T{)swXzmjCIOm2XE#kgXr9Z z^VG94ONt_DQFbbzoL8DD{7rt;TDLI`Zd{-~SGNCVJfcufIZ7q`BcZ+t*H%ERWDEKE zl65dhFh*-k!i;8s-9#ZX%BDe0U3(o;p`bU9)x8ovgmQvg!TRpEMmSBax{ko=Bek{q zD=f7ZCJKi+2MW>3QPT?$vjZ_@1>!+Vi%$6w9R&n08WG1PzXulYNxI)7r_m?&R4YS_ z286V}S~q4rv>D)5z_B2r?8{6L^IQy|4G9YjEZU|6Y{_l6^cy&dL`AlGm7D(oC0%(z(La`J!{EeXrZhNQwgUB8NR^x>;hrx%*sIJ{>S{- zc!voTgAZ5Kem-lL0ATIqe1x5r`K z-+ZS;bM~YtTYlktOPo;3vr=PryH^nMyu}w2`*@6gD+#oJ+s=VJ?%Ktv0gPn z!lj+;eQk#nf`aRlF7Sd+Pdu1zH#Il+B6`WX+;?)%Wf-?8k^%GXB@ZKArV$4aeK zQ)Md3%gZZ=i^0Msz2oE9-S?HneaWr*jk0B)WnJNP1yQ(ok>hc(u~pvZ-+;m!`=OX@wu21;U}d;m8R9Cm5=S)|KsyW$KfVzrZRNqnAeA3}!rA}pqKfcRNO`xB~t34$O{Z{0Oj(fv{l>aSvx9yAi^b_E~FSQyo0mcKy%zAn2TiWxYeugh)rqJF2X0+$D%T5nt1343@r2uSh6MWk9u zZhc6_phIcd3(ir|?e};MJWJpMBwb`%o8H*a&3uukcPp!{Q8o;yxEafOEd#5~%Wuy| z{6gRS$bZbkn09j_yuSVdK}1A;Yx6)-Ma2QoiI#&MzQycEk%-w0WJDUxuq1gFIjz2! zQ{v?8nQaaV@QVcQKb!7kb-DEv;z5NSa%5|~Ze_LNBa|YwP`C9bTve;EeOWIdbQjS& zQJ@`} zRs4P5jCU2ic%mGfD*UP}-azd^(JWIC87?lo_{^feA<=?OnP@si_Wktaq}*zPu}-W3 zsH25Y*3Xh7#>GqQL3Jf5CACr%nV5Jcu)s}yQ0=&4FE4+E2*cA44w3;{StQr3_>YM+ zvd+)6#+^=I8V%YR6`d^$I0GDEig$2u&}-tpd4rXC@%k9AtmC3WN**W3vmd-`KzgIQ zVi0eboNn}|D?oke_)F`?-t3DP^wTkc=3-Y=y^{U?wZ9n2Ni`zmQ}BdaV*QoAYPkuB z>~!*!kW{>2U)6k|pukX8c>GAYa$LD519TZu!j7YKAVpP_SFBDPWHs8wDLMXL=r+qF zTZ`(kn>6s}{oKkC)L}B)Jc%bEczpM=4rhenbJGGk>~iV776BJ{b5&4OqA1&_=0C|<>RXw(6+U~ndey^iPCq#N6n!@oeK zCI4krny&uaXDU+UB$jOQDwlE0WI=8{@;Q*ML6r6cNX)q6>Lnce=AX^P_B)OR3+n&S~&ef`mua|fQzT0 zMiiqzbt8{Vp+vHwO zXk~}JTrfC}$PZ*F&36pv=_*tCJR`q!h%sajUM~L2m%*p?@0)r9vq_*S0A2P!7?W1l zV;m(fy4d7*Ev?QcTHyOJTX?(b-T|*aI>ymbPZB5e^*RP}G>gK4V$&P9 zJ&5uqY6vxP^fk>knZ7=8BTML`J@VXM@Zq0t75L{{1^?H#Ub-ldJ3ABfxvkWmc)6+z zj6Gw1Q6?c~_z};89y{$Jwc`gU)iOiZQN^BX0(5gAlZoa6A7pS_1}9vUFX`XsR?mkd zur(66maKJU1b@^XMCbcYAF@-iUCT6bV<&yO@|t@U8giu-FXs02o6S!h`t7 zR`u`aj^I$>Z|Rpj{10TtF@C$APESkfh|5lSPtE>%5ZoUTCGD!+eHS9*`pJh0aTWuV z5e6hABn8@EuQ_e?H#7%x>efnLVUbp)67xJQX$#2^$H&5-Xuqodt*n(ZLUI}I&biK z>VY^1U2-4PgcDx4eGt4Lm7*puKsI1?-(8u89-Q`RR6T-3lB_YK*mTqRBSJ zt=3^|{obo1^=SVZtIXbKQd{m*`IxNehj=~mJe%HlU;;FN5fZ{~qu{Q;)8 zh$99B~D5%f4@w{`KrIus@|{9PZkr3Mk`%k#JV`J4gUxSg#%uqE2wL+)*FBR4nG zaG%NZ3-a)&W_EP8wJG)}Dl7CnP*7&TwXGHk7{#FaRkvK;Q6_PF=^b3Y!@W5N^}twV z8v&cbYyZ!0r1`InJfIo3v$M6kv%9;y#asT+u`()?g)VVYrzz}!*b^@;BRwN6BO@)H zIBwd->B8=10bBegMFm4+)I^>&_Apb-VXGPLN5SH(Ka+n&C;uYX7gGP{w%}Lz4sx?| zZYip@XX=349tSx zf(NFuB8;E@e)~m#IauL>;6E#z@rzGZ*VV_H27E+f-o1O59~JYNs)37Ha5>h@>Bsffx31WlTD8drU|rzRp-cn!-OvauGAaTFgtQ%H>fr1?T7^>G%fy$ZRF2u5FjJ?7PT8qsa|+1W`w zzt$GFVP`J?XuDC1#(!=w`3LcIeZU4dF_WlK3+JWt3-mvA!#I9`2j=qsrACACP)!{J z#S)VqlR8ffPvL_A_6D>ey2XnsNSkPhYu8tFe zLjufnPwn|GBeuxAT$BtOQyF1Cl$GbpI14u^-^YOIMnh=<(;kqN=04;UXlPjIHV^b( zb87fV>1{Nq9`F&0_*IcmGtU%a#Paie%--2XcG8B2m5snAgfze|nlJv^J)D&0K1s7a zYy>s}FG&vxIg%0}jJlP8eU0<2y(ZFK!Vk51zGHs<{9-gb9qr^m?w_e|(D z`QQvrpIY1t-HdIFEKg|_`{mA7>zACOvK<%IY<2}>6rCVOBowu1w*fQ!Qg29MzjR2SUI5*wm?r&)g_ECZE?y0<#! z$ulQ--_B~i?$f6AR?d=0J$_I=%1#QszV#C0PhBSNxd=>f9or6pNiMf0cG zXtX)xhgh4*{`L*jgL@ZCa+o`I7%hu8XtZNIaw!nIo7#R=a4d=fHj z_J^|p8+AOBBH)o!IKvPqwYxs>)glVf5~u6UW7ZYx)P(McuPMbQ6p%ZM9RBoW8)YmF znTiM_X8pf<0buh=*-r_N8R?Ij>41hN*(lqs_Zk;d;Nl*Kh#xNIpG|v*>ILc+6b3IH zEN#KQ1tEfx5)+5Ct`GDmqN2z=<-mQ6K0ostZh*;NB$>-u#b+JSuV9M-w!Or9nKkMx zqhO)0+0vVP0KTqwn>?pH{zKqF7RI68GUr8=_C9}|Kd9Y-nUt|2;T#QtkSAQWKqhB}T15dM4U7JQ+{K&@5O=zcCwV|P5 zxemC%DRuW{We29foENw5fqLrd+GNexc5z?SJ>9qE<@}!d@SiCNr>B9RA_6$AHpti7 z?-bW~gK}F$^xe}h_3v!r4RDzSkdMI_{{#tp=Wm)O0Had=>#1kI42%DUMS=hK0HnZo zx#GkkWF)W#yaC0^U)u+VB&VecZ`R?WfI;#!Ta9&&cwh>+epa#bpPc(5SEL}ZH##)K zBYTw0srVKRvrjVz=3-%Y?l1N1|Ee@-mXDwWbv?+FNchu=SFKygmK_l{r&i*czIMm! zCL*?VS!TpJa+1G^Pr^(Mb*QE~2`6AU#(g+cP_w>NXdAzhi8)=4@XYUiescN$xyhOH zgv4oeN3ZfA--LkR$mII)o4`Qs69X;iD5|D}*^wJha4G)K$7@{O+TKp#&~Xu((10EUwx=iyQQ3aTC=Ok_tHcbjAHf+V0lqzcnSN$p2`7WqWt9WA{5N zF_P)|B>|6SY+G{EuQQjwEK$H#jd?nu9T~;VME8MN~n{z#xT}U`Z^xo-^pvC1^=csCA z0$$!)D%2^DazR%S^_a)~f1L8l+1hcxX40w)oT~jxR6cOk^E+CyVMgpJgcK#qz#cf931jQq7wU=zMLhAU^3)hW*)z zpKeAEL=Me-<$fT2zGLwB^Y@PpWznv3bRB*B1lnvY>ZzD(g~?J?j=!VDy}bq&AX<}g z+&?=Q;1qnsCNwZ*?~Q}p+xtCIblSm5z(#TXR%NjDn)mto(fYplGIN}}z|^~o9O-sK zR?`8xd4hF}-BQJ)*H7kRCwuh@L^q*Lw6`cYOXE=|(N^Os9a_ zlG3=K_UE-(np>YsrqwQ8iQ7|(<13AyOf4i>w zyev&%cw}JQb+7=6g)Z1RLn&(;HO~(3d4u>TUEvI{c_~+r+VYo42QSv03hkfnPd@Yz zX#IAPdL~+`SAxO==RcuH?BAgXwf{{CCnxJ4__FLwUm5wj`C;!V#wb5v7Rwb0mraXU zKe-lfZgs~7(=jkKX2?=3_7^hO^eOiYRr0^Z;aN zx7U~W$x70)(ATG~#G&V*Kbc6cyCoHw*ZnMb>X3)fqLH3nonqxv{J~(gEjNs(WnDKe*zV! zhJndk%IhLEjk%PwjY%-(4Mx$S-zA?3y2X)FI$A&Jmsv7=y|?yf^6&crx3fpkyT&f- z=sZEke^!trnft(o&{IXK_9EsbX67Ty;U}3I!G@`+ zDHn&aJ?i(&=eCgf%|(FHvF>2aaPI1L03DtK7Y`f+0-b&hyT{&1PH2cOpAxo5M$arx z)*J)cyt6(GCKW%&D-gkpUG$tI>7(AFj2VkAmV1mbh!3x9Hb*4)T&n^NDC5(me9o7V-V zM-3GihtBv>=rBeXmb+SHNIKnLV^sm+Cco9BdG*g+vw`X98|^{>d*-WsPT5-gHE7|G zK)kf@5<^lkpT&MJk~C36UIj&)9pix2yaZC+M3{Q`=eX0ToQ;doz*NIB zt17lu!Oo{-q`z@a_u+xg*H^NTk!j5Jj>NHXL<7k7>7EPcMFuUkX6d{88W?*VBi(8gInnGVhCPWJ-LuBDj|-g^H1HAN1>e(S^e*_h`M zlH%}vu2w0tm{?-;F#A%Z7Fdv{2&DG}zRx^6b&lFHZoIsUw{t%Ky|uL!Y5a~}8nnx| zpDclZ8AHddRJrcu6LC*Wuf4vntpeE*ETq^qMxl1e!VvnsVL5+q`%-9`L8)eR2I$bB52N)kjSI zm<1+gW`8a-AReHEukkGm8p_7gH}udIcGS&$q#_xXmpJc(mu9z1@v~u$jBkD?z)V5t z7J=^VH6=frO-fyzcV9T+To+Ih*q@cL=DULT1HjHa+K+^eM^c$Su#uX(4S!iUzbPgs zBjdlKNMrw8K8o+m3*vZImcrtYi07i21mlex^^TK7I6#f374tByep0A6jWkmQ@BT~= zGp5#ZeD)>2x8;YCmfN(055HTHPjlWPQfPYu7U%Y%@%cvaO3uc0MEIhiEv_CBqV^cu+r6?#HqKM$`e zc`ZhbmgNHN z^iW2rWovFNeO4tVAyJMOaRNTYk;4%(rYVgPvy^OKh*!p~@xp3%)n@sJH%#+vFotQ- z9&BS#jbW49+RU2tcM8CuCs~O)RgKr1$bh39j-#dS}y1Lnlly@4Ol4{~6 zy)-d4CT>f>>v|ThkhN?iK8l2+9Mn$~HhfvS1xBVr3kv2ykF7!utYYVBl@7(0>2yN4 z_yIs7n_9xzWF|P8UiV_vN-JF1KHl%j;Q#WQH);Lm!)kKRp*E{u?zC*GGn zvhdh01M(G7+BHTgd%*;>E8^!`oQ)eLPxjY-dX6Gvpe{4hf@I8a@K@=zq`7vE;IJ@$ z?Wf=ugA>O3VP~>}Wxy%?bv%y)xV4N(zP)`j3yYg!MkaJg4(7{*mhmMy9q(BglmhPq zak3pJ9UN6={xjuy@FFn8RoBWSr4$s84_oi~1|$i4qc4?-B({x4XjOB@+m5~-V*0Q9 z9P+>WZ@++l-SbQV98zdG(-yvZuU!SSF7DX+ywIolTa$L8I!POu%UMO`QRP5KJZ+BM z?P}%)XwW|7IXO**`@XdS?@b{1L3e`aP>5X|GXR$smD1_OWk(OfvcaZUF@G36=g?# zbj*_UmYQqxaSAoQSFv*r(%+#)mHYp_+nH(J20;1zguVyW|Dbu)er|ouHYFM`ZOwtv&ALGAnGqy^4nY#(7Hiy5XoKt@RP*Ln1Kwpm?nIB z_5AKL-Y9F)BlElKMVeP5@gDV!Bf$sY6L-4UmB`!yELgIuR~=EN zPi~N0#;n47+Lfm!;4{KN27K)4Ej_2+YEw*=KI_6`9I#*?ud_xks(~vvSlc9 z$Gl3KAiGKYxPHnOvX39F2iI=s(MK(=%lUk@8Y^dEVxq9|zk1vF)}@3D4PK?XK34PpLe{Mb;>Eu`Eu=&NgEn3mB zjMA+|WpU+a)kZOLJKaa?dkJjK!DRd#AvM<&etdtjq4*o-!EUn>ok5@P%VcjS>F!;6)uMyR6x^H<;H{xvJ$#J$si{*}tPa`iq2rn=N`uqDKjy|y5 z8g6KY-T*!xyMBe7cMPw?=-5WJ_o-XFeqcetMui=1>>>{&rF9_mU3qUgQD#{0gVU0e zq>|(*BzE)WO@oDONa5UyRgzyZ$l`AFSH52$E4Dd*38sw&P#gQlr7Hv|9Y6mSrR!5NsEqdxRAL@YvB=A%hD&uB=%Ft$j@Bnj`4>ld2a^2~MZd;n@Pk)kQ*$fH zZePD1_J)<+8TqZtD$SY6$jdh5pPx5vtvbR>@z2jR$D>E8mUy;=r_Cxi4evMJ&C)+e z2RoaVNC0vfh3nr%1fd1YLBr@eu%JLNcpu(bMKbHDqJi%>eIGu3I{M?YxN_o2o*lO6 zv7uB{agU#E4eFs?$w+kR(wAt%>H)yGhGbr>WnbN1v!Wd?@@7q_-|ZLY|zJa zwwYu#`&_c+vhv)>NOOm9PLa|-kbilkh`U}tcI?K_D5ZCuov7wtGAG+59@`P=N>_&Z znL9*8x0%pGp{i?o@pXSqy9#O3?m+|Zd4~9QBqV0SFh}5U8;W47pI`E<#`rRDYN|zU zt9!J#?AXbFZz`yb;hiHh(9zNwM-8l-6gI^Dd>hjCOzX4dHNtFco#B(8>Ux>{oyb!tFC&}Db;!|K zHW{bsL3dv5eO|i-p;-gSbBJig)0L?G$@5K6dtDo9?`(@cU)_)j2vby4RMVOn=wa!M z78GV+P~Dn@V!p^w94KWBl}{?|cXJ8|-YIS;AH0SMN5x>MOpcPM0&x_IeXe0%U?J~s zu_Qf-xliH~+Oy2sy239&x{xgEQtwl9uwcqjlMGWa{S3+OAHdwn1{ie?TU-x)QvKT- z!;4hw-5(au(CE;9ZoWkS)A27KKx5~-1S{$v{~CJy8r?ZmH}CML`qQ=CV}DxGR*3K5 z=39%~$bU$io<>+0C5d|fr5&r_ilP1_EDSvm!fn9J zIXs-ml;_IMCWPuJ%i02`%0soYwP43!o;PCZxSU1wA7^NPIzV zsz^9(OLS2{4GkS%2FrHjA>jq8HEAL+mC=JyoZ8RdjQ&3H@W7exQudZZAYKfX|E#I5 zzHs3JKGyy71j`ZbAv1DWws#)22d@u1}LBU zrujmb9QL!b@4lFNmLS(7X~W)WU5(h|9eLlK*W0oBk){MNGCfO3*ff8;M4+!a`ceNQ z+RThzUEr!}N4TZ=^{1?GBA>SnP~P`lVis9I-Fkc%qc;p8wsPzbfs%f?0id!Rs{4e| z9TJ1wQQ$d_8o>+yez0i*%-RZ;YK$+ET`!65P;meU>B@~a^|+5&gwztKfpSsH_|W;` zqitX0TSq{frNUqFJiW;tTkDfH4@Kxb|3-Y})$TY`pI)VJVZ10t;vSs7EFilbJKa&RN(568ru@yoE`R_ zDsHQA)AE+ZrJ*0eLgJtLro|du;a#u5mQ4gY@#}BVG(YJTym?IvepMjit@q8crMewl zt-b{CI%k^)zv1QlM#ms-&^6O-7OXaOO@w^I;c_Xp%mrSXJfsB2jL&zH)msvwW_Y7x z^s}~s1n)2GTmljHQv3TQNXlQ6J*8s5%fQ34HG|PisyO*Q5Tn9ucr)$y4{?kDbFpVb z3nxV-iSn9XrIj@TSeh(w_dXgK8tN^)BPlv~Wq&g4idyDG;NigUjGC5Dm7iY;JOb4H zO$4lM{qANrfecLrT1A=J##r6xYF)|xsV?=I&Nb>*yoQsE&DWPRHwN`Kl&Co13Rnf@ zG6yK`P*EX-&4^$QEBjs6Lv&B{*BJZ1udC5a6FM)gjQE#avJQFs42kUft#Hty=ODMo zdpXCoP4iF!RmCIyYZgBJx)1+X*yB1%NSht7?+yJ6sW}vh<_=Zo0-+9mT+KKAha( zWd{OMwZBC|8cZ9_$Qb3J#Pp=m)z!7x$i=!RW4zcX+YUKUQ|Wktg^E5*?H;5jwOh$? zHlDeD-tmJqZmGYBiBy&!3q>FJuMPJ1!iu>rpaZ67N(FLlDSv1~lnAM}*>W}hv%Lh@ zg_+oSdD+cYZv&4`l&qEn)Ut3zE?mrG&RcjdS` z^{|!CdxfFxskH78DoOfj;%|HTWq9zcZk|J&@J28$&9 zeJQ#>MbkAVU+6ZQavT=-h(EcxU`HeFeKeQ8w6p}*5|eCFQ5A&VmlS&)bxrCSbuhjy zAyljfMe<50ogqMsf3{FrVp97uO+aHjN;$tM<4&1zwCs<)M!lsU+{F2YvO4g;BDH{G z2FK3KoYB$T(%h=-IsEZ?X>RWQs^N<49t=tYVp4vc-jyu2`sB)uHTx-Ij!WhaYN2CM zYYAb;cJph?{$MXtY-cqVD-@1=iLi3Qt1f$^y8~Yuf<9nV_%{ifzP3sFh^v8->Usc* zJ0>RpC323TAN+>(r(m8@L0>B0_)x+kh)ZjuOKZbvqin;;seI~4nMm`K^PV`1tg(!Y zv752mQ#TnDpqMn~h?gDr62p5QCae2W)E;-Y`3|_>dJ%r`Hxrp`{V(c6JO{g|5_LOn zTl@PgX>~E}XS#AM3HSVyzNaqYxV0VFNg}*`^^e826YS} zja!3xZc)N|Q(LSp zBZ9zm6OW{ChWsLlDSmD*Ofk=kqZWI3wT97yd{tudzGk$OFZEP*W29Bn(n)IIF@K-k zEVBr3>_+`6|7&OyT0O<<_?J?t#n$A&#{$A;VSlBOq07~k`P9ITT*}KrDWx})_y#CP zM{gx;(Q^3F^)hfsg#jsR&8MmSARN_yMqAY*#)k{4JKTKyzb;4vbwP})H(vqcie};l z!t!B$8?C94?uu3^lew}@1^u6Q2KdWs?6@mfbXXt@; z<)18AD5=3BR$~3)!0{n6V)I+B6qW-vsTP-CLU1+$$6awXuiGpja+QPDhlh7&<~!>r z?F#!V|9a%`TObmR$F|~IE~QN9Bmz*+Tt|+n%b8>w@r~c5;r5s9PXcsb-s14#r{4FR zcZ6Aa3A!|QL|@%`;Ti4Ki2uMfnBX0X=~f6-GapN!r$}>QW15o3V%&CwLGG)@0X^i> zoY=HIqdn>9_|8GrgqY>f#v%AoGoVE~apRYwO~u`R-7A#}O+V!;Nf^nzx%Bd2vmLc@ z3nl;Wdrd>#D_vfK7SS}!_3!bx(thZ z(ThlOfBz`CJyH5o@OOccMgZSi+egeIc$c>~YcDBd4Uhn|^sgVEJy()|#=@w#gOBN7 zOkkh>QX5!{KbEAv8_Ln|lMbYHujkntKeZ*6f;{K7gqn2|v=|LU>F=!La-aHBhp$|t zMNGjB*Ap!Z`ju_>KmqHutozy=tI@)Sz``on7WWnQT}<`mUrhl+U7@NITVv9(x8;H# zw+1qBB&T#7VhFC-SE@2Ov5e=aJ})K_uc50Se544ZQn+&})wGY4{%&+NGdED^NahAy z^0Yuj_!T3Ni)vjYdxVZB<)G)6kMJ;^Slj%Wy390V_>3=?$MzF{(W~x^W_pqZrkskO zf~Vm{8TXBpy7pfDv&wugx8ZpsCA;#HdLR!Q!>SnNU zGs&+XU5H=gdqdku`=&v;Ik`qOx0Jns8;iM@N;5f!g!U=FFv6+ThRiIVkzGAu;O89- z%#5Ffeh4>(Qm3Was9Zl|78f_E!YBN=&_RU#fqzrEI8SmNx=t5S;g-Y=ZpM~bu^Ig* z7imDS-YxuIu75u#{3Mwm9b&Pz(m+yl{MUSzCUxv3Q|$kv>notDTHCEv6a)bYMH-bbXz4}~kPuMm6r{VP8w6@>-eWDw$n>Uzz~(Jfya#FnzQAc~4)l& zKB$u>PEh|hGnjg1b!wxtVqj~zKBg8ia^xzx^F12z`B_HnY5`WQ62)60J@Mzs6C2DS z^72Q_V*-!M)lp(beedcVUtRhA|8eD|5Ui@J?IX7}Fk#1K>$VJYA~ zzwgGzHLgaYaeZz;Bfb3xh4D`&71@IMU+-+@aVzoN9Ir-bX2idm@v}BUorm#1wou_F zm)M-F-!7Z?mT<~J{GuJM|4U;q;pGkc?@K=h8e_dBeLH3V6wJ5%r+NG5W?19*kYD&( z@mmTwuv1@ot4EKTua9EQn_@Tq$2P(sO?p`fZbgrI5zY+1v3qR5_N9v&d4AK+T4|Hs0I>kb-JoW$-QZ~Tu* z(utlMm}o;CK>op0>pFqVrFR6NtSs-i@rMV3|BCG(A^m&F1~vA%1_UEqG6Um(H*im4 zAleh4g^?#&c<3Eo9jQ@c>4AY4z+=yIWV^)w$h00EF14C6FeX0p*Cai; zS+6GcMc3^Avq=&y8;_sFSbak#a$k~=Fy!vI&r<8!h|Fb>1yMSIOfjdIpY`LviN;l~ z6ASALK+w5{Hm0jvh0N)HvBhwk4#u&wSmd>?rN~^(nXp_)^TYv!>#zT!W53=-U~qwj zW4*Ei7J6_UDLzg1heVJPyP^Mw3xXk?3ATpWu2Up6hsw@w_0P)ypWu=2~fRvR;y%Nly^Iit3kL*e(unOoh!v7~T`|kz2u)`*k z_;vO2`38ZrZ8iPdGWz!ue(QexxE6a1!3@8pd+dkRZk~P+*_MZq>m69At0eF5##;Lx znZ{kEPFMX>O@V)U_cQ|F-UuubY;I2ry0NW|D5__e)Kdy;N-c!R}}9xGE9(>RMa2el~mNaDo5+ z(Vs6RU}soE=1LzX8RluIV1SBMboNO-d-=%Ke8y{~oB%lG8pPp}c1QyzBiGd}c4lL) zN{bUn|5x}S*tAR+V#=)#i=allG0C`g^#+cU{^y9rwT}UlOr{^_ zFsx!$C45Eb#4GFGc$rzwB4BUh@Y1bjq;X32kkh8~ku;X(8X~WP#!_7IdhkD};D2!b z!+6C9`1H^M1fRavmCl8v{M!A{#_KQ&NGc)F=tYY|mQMRzxzw~TNsxz!#5$E}8EB+a z^9nxJx&7Bn;k9eO)bZY53liwLc5Nts9_KmWP=6KAK^GeO)zjPd{q88x<|@nwu;j5~ zk)hrZ4>{A-SbSq*#R`Zg0f5K*!W{Zrz!)R8)t!@4TU(1q8%5xxR$?q5B!sM69RQ|!9nkJQn9~Q9RpY@-NSo6^FD%KDFWphPQvN44`4kMHf}8_~ zCuJcnom9d#mEfQte&Wttqke>CUiFDjeEf7Ep9C--xqe|0jB^s0j=YC9m|QbmZoA95 zA+GUB#F$bK-RKgS#(;xrA?hGe)Z3JMh13o5m3Z1zEB#dx&xGvZebrFEZK z8fZ=fN{GiP<`r=|$g0%6e5q7olwQ%+?&b02%ixa>1^^ZYHO?oj1M6ofFs6+dUl!yn zeOh)3qr=gxCS^`M0i_awq?5YjF-+Fgv=Ed>{z%g4Aq~Ff)pxx602o)T4>wij9H2xC z#GQK#;v#KkE^K=J9+(RQq$Vu^v3h!X9Au3qWS(}(%;;vt#`GjVgAonw}N@fXx0CMO<0w(QeQ(~uf zc6sC-1#!Ed+*or|K&-oDickio<2eamo50(wgXq@+7NjtYM&7ymJ6=T^+EQbejg86` z4#zLdI8^}h2|a21t*NT5n)p`{ppae&TL^bGRiGoOtXw(ASuauZ4pdljRVq#hIYW+) zMn>evB0=Jo4tPxf<};1Ydltxd@wL+C-A{`B7X<^K!9p>JC)Tl&NaWtF0r^$ng@dwt z05Olg@)d6=FtGGf!ahC;x)b9DVK+N!bqgJ@czvQ+P2DiQ08_jsQ4tImR062H+b?x03{ZkVyW{MaWuFK1unmVmXa*yWICAW^ zf9{=sqg{gEBxEssbIId$#%{*hVu(Ab02Bebpkqwp8TeKHncB;dE8Ou249E1TY2>OX z{nGSCq`a-GtAp>Q|BxmQ%t_0gvDC3g(2RJc5N-KBFu#3&7AUNNWsctkTcQpkju^Ct zgsZsm7Ugy4k%~^JNmt>=1C$9I4v!T!8RZt*TE zIy%T^UcD+I!KC?gTY^v@2*>ynTzHcPm}`D&#%tTP8ih+qf?2x?Xkvt<+01m7gxNC z-Uqx^ej=fXF+QB}u3`9nu{cd36Jp{AZdkc1`;v_cRLB^{z+if1CmJ2_xnn-hSe+hE) z2!=0)eTnE0Rm{0!vb|2MGleKfKVf7{jsV4CvA^+`l2W zNXA6)*l$*BRZjwS+11ck*0bt;ZB0#zCr@yRD5)qHi+uuSNq;`8*#vGxAbz!saGpHD z@Cjjt7{YkG`qPYyQ{l^#(^EdDvk{wc=xHPiVKjiWvT7y1QT%XTi^^9&=E-4XFoS8A zbVmkPT9j*pIZC<83b6sN1qDgCZC0Q?yO#^jNGeryNRY0wsuKRzbopm4k~J9zSlT7< zwSr~8w04&;-n@D9H6UOVaNuoCx8e-R9WQ|0xOW&&G3hlBNCL8^MKutP)J+w&iBnLr-FwEmU$q#YX=H%bfp)DBMc|;KPYm+-fhz9yEP3+-6YcaC zA}c!{p0Nuy!;y#1f1ZD*W;5>hVD*Mg=^N%RIn?O(?T~jWtzK#c8r%W3Fp%XWGCH-| z(2ea47Y*+fWt|Wt8o5A1S((xsOF?xU-3$8WjGZWOdSFR}P=9quV)lUj4hmE}u1$Y> zKn4Z>mm;-f2Xsu`A*NWQ)5R?m#MzKaptbicQ4lvC^FU&QRYMVjGxo9|#8jSSrgphm z)=ZS8ukI8@M8xu;7p8juPfd~4B;iBjI91iXaKIxE?A$$WK(H^C{v=~D8TwtCVw}kN zdF{9DMVTs-PM1QVjW@y`ct$H&NCN^H7@}A3LtHgOa#92`Vb;bk_oMRr zZ233yLKwhVeln_q3rwoQoyp`w1v+op91UG>98-tVU#=Gz0jlV{i)|WPskcs$Gq`Yf z`jVm}4|oE9L(qDY zYk*Cf2{)+(C#rkQUny#ml)UhhQqRNn?RGtFz z4e;xUT=j)ajMC4HSV6=(T}DS9c(2}-!^~8{3en0P`pS>Wkp~>GTVS(gl_x^F8$qI$ z7s=7KaLs1%dpHJ0#ER0VB7KF#w#AN15fKqaUICyy1unt+4HQp3fu`|JA_<|R5jc6a z*DW+#mUgCnlp>fp->h>(m%EJO4f#vu{_znNpp-nNO4PT6lwmShsr{NNKeJ}6m! zD+zz`fY;(J46d?_{jGG0Py@N>QTd#mM6N` zO4GInU_RPkdb$JtV=mm|!z(UbrUrEt7o>Qi<@&Pwp_urL zuEf}gGgrWiwdPn`W7-J`fc%UlD)CM)azROL??HJtuna$@SIx#Iy&@wke+MBb$cfRA z$Uc!hwLj+D6Rxh*?jrOwc_h|PBn?wB2;HBEjO9l98d--wC;O|2lD_qaWR5%WtVa<6 z$@KF1y_W3$tDx3UcmW<*yHp`dy9H^upt;BqFJUe}-Qn?(3iIIqj?tuH=jtr>Ah|f(r)OBQ!j8 z;=M8IFK7=S5pDUF+@kB7DO;rPi`TVMO5;e%8ANaENf*nt+%_*6Z3eJ4#c_4@RL;-q z_@Kg4>w4~da{NHxx2LRaTW{~h`APq@WIP9O&8gb`zE7vj&RuDj6-FE9So~IBkx)g~ zrXK1gJeLmioM8?Nt$m<9)udV@A?%>XWIR?V5>6LOFQoFT_jPQ~bp*y34Z?-)eDUHu zU9Q;{vJt>UdSdzjCz|UJ1y8l^TdS2`U^dIEIskGpzQ|5pjqjj52CR#ZK@eL$PjwPx zMZng2(n*W*E{+5{Ii3Q_*c$R|z^L^0tx>%tWYZrB!ps`Zz4r*a>0h+~72i$F22RUi z7_$qsaset1gW()VRnd%mV0tLcJ{^h)6mkk%?WC!qTK+mMjg3!pUsc>FAF$p$J3QLK zg&Ioor(LKTrO+o!#!s=CbVJm~kS!E_I8*1ovgYSqR;)LW1}Q8bpYvErZ%*3eI}k%D zHtJaYrc2EUq?J53Rc&T(DJ}!0E)yd`M>ot% zA8ZP+Te3K^JBDmbo;HEtiP1>@>{!tVQ2&Gev{mP7MP`44y6pi`d`-<|2Yt=@+M0lM zBRE+)wQ6yD?(!O#>z4&7Cpwk#Z2502Jl_q|FJGIdS>5~M;n8cv4G4I+Gd-p|XlR)q z(j`D=V8tM9OL^$#vwMfVziJSt2Yi+xi0E}7>XglGw;N_6_A9hit2R$HjC`@z$d5CV z5G^@Pjc|2d-P4=HGsVM%ULb+z%O)={TcS5E@~tBuH9WFo|Sdv`b1*D1!hYplrg5eDbkQasOrB#5+?>h@)izF4v8{7#LQEOqiIHG2=4 zWd2fj{YXxgi1}h((w^sXZmdXBn}qCz4FieDi!)wOni8-hXCj~9rKjROd=<|oTs4@g z6lgSV9ol{oko^PO1f4B~?O=P`n-h_Tg?Kj9P=ReMBYo7%8Vif3zY zKVW8gLNTr-sbL8-BZ4i1EBU#42+PWlAwH5%rxx#u zblTF?@R*4&Cn!9G9EVXK-41H<_?ju)YJDz*ZmB%%z zU;R=VGAw()k+RfW9Po`@TdB1=HeaN;TjqQ@)vx6|@NxhOk&p^0{UBSqi-wtIqFI|wzretroM<+=*V&bLL}HRxH#1ItoNx9=l(J9eW@J3sRqth-_qiXRI5`Ypd<+(m!$}ZM*j+jo zU3=tw;n0;+>JoZ0N1byoIx_P63XFoKuC^iPrz(X;Mg1Ayy7Vox^3QbuW@AwHAk@Z3 z0}E9u0f{egBp}d$T$BS+Kci2U*74k*^_Q|on`3nqOii&!BGYObn^ol?Yq|rJ+~bwD zcZ?_$a1WHL&rY|TUkj$ zL?&#Rg$U8q@s5-J!r1YOt8HXt1Y~|dvH_cfV5fiZc)E!gyfKHjgk_vdsBl#}n6a``U`?*?m<-#Mde6ZGSUD0d^ z)e*4&?cWtb!f!sn-~}>xP-NH3O6xe>7&p1S@tsaZu|KkxYUk;3AEm#Z;{J{-uk*LaT_MG+y=f?#i9Jv81@66c)FY`nyoQ*?N( zEY4F+wpF`;&1Nz6`Y3hw$zhF(=QQHsKuJh7o3g80fXl(^=wyDS*s_GD+EDIvzWO{> z!y?~Ihrd15r*WApimm`pni^YFlV&l;^6 zVrbufIXanM)l+QPVcbsT8stHN^=73?&ixtdq>Dcn{`iPX-EW31$?{?vF{XKfF28K- zWg_RV*?lXkQ}&x>znp}Z(Y}$L9+K6vM4ZdMuW=$)TMO)ouc@^x0>0n##XM&LFXZhX zR5XNEUYI0P=L7LIr)~9-wYfxjUftao(TJsWbFb_^iiuUqz;ZGP8yNXsEwn}+v#)Jh-nWG5s9-FGzziJhG|-u?AiCNpb`Sc0z}byST#G4S!@ z`s?LRE!`@=t|MPe*&U?DZIszhbaYbu2AC;qd(9+vmbxofu-xu2k zBE*KI>W}{J$Qbp_5Z7rAqwkaSI{|HVHqC9yU)EdIpa{A$ZX(wv;kG%mDjW8#98`$8O=pM!-M*3Fk#oStbP&p(y& z_UFFRoD%Z1lVeFs)8&4^$I+J|gj~p(lbNDEK#(I!MQ#=R)gbRnl=RdaK5o2q{i6g(=k8$7$SQe68>gMa?GE6@3hKWJbC z4im1fJS^j*x6{&O#;IkrXx=Nc&=yEO8O(D^R`w8Vhd0WscIG8gs*J>25_f0d-qJ@X ziQgdRHH`ROoETAkn$8EwL1Y2CqNI3oJxrKxLNW6vqxr(^0^FUivkeckbCqgdDu4QN zeQ+Bj4vwBaejH;#)lHT^_lMc4Davwhze%2dt99Xhb0m`vhZEzMYoku1_Q!KXtJYgX zZw`GQ8xu8gtvsMtom+^YyS+n(6{u7=)AWgT<E0=z3?xa(ZC3KH`!NbGDUY9{bt7mp> z9`Gl5Zcam+@73=ICMpm#y9qp{r#E?&&f$<}`r~>`Y;8EKo$T!-eOEPwP|tge;yF;c zs*q4FlD@35lHG4zKFlPkW{`Ba){w5*g;Z%!FJdV_HZa$p##Tb#FxDU!t2V@%`MQWE3ic9 zXk_@@M%x(9Bm4cfV6FQrI>C#hSnDY_*~80=={#RwG4?F}Ek3KWMwie9bpbEK@oINm zoH6S)JPgF<$j&@owWeZ$m653EpY>5D5P&ODpHG7%Yk6TovEMj#hCwSm{=NoweP@m} z5~uJVlF6xYdpi~MpxXm?pz`qWt_9}o*!8PUUsM#~-Me@BY63K_NhKd0?{eT6PnH`M zt5bj3Jvy>Fdo*}?k~b~SDJdoOPzMR$0Lr~Te^zl??HNo@ji1!v$8{Ee;eI^K z@nC*tT94(18`Dl!;ifpn;?Xc?h>~`!h|K+X+oqNl;U#h6vx=4324P(#i>96Chh5j* zf8;<0=k1LOQ7m-B!!Ab(PVHz2NXJc{xisvIRE0!FqN1W^{4U>LQ$VLw;-jRd?r?E7 zVs+VcLUnt1`_NHG+oyZ&vpo@3Ox5U6H9CNEoFzZgVt0`Q3R|Ula4+EbSP^ zNLs#0xE->UwgBz0Mk&|Ra!Si<^U`gvt_kDvv8A;PEh&H|gXEM03DH3M+X_n#oj)nw zF*8QAIq^62L?@iK`Ez-Bv&S1S4F<|DzN_t=eJg)}i^kiV|~RR$Se|252?bAhyblK{;q^W2!n$5i<9sVv0M z<(NoA(A9q`OsO5I@qkHK(O^4}Q~}le1rgT6hMpdiow)}JKG^t1f`Wo?-*!z_us}P) zBYY|9Ui$0N245>sdkM5_Yy0^=IF!}s$PfzVd#O8Rilh6q275dzBNC^L6njyJ2iNooFovBIZlH3+YG=Q`wG*W5XVZdj1t6fTebp@J{ zKdpzDUIcs(jZib9s7c_Tz(hl*d2Sr!L7rh;7kXosLC+^(CLv)=Zffm>f}K5Mvtg{& z-L3xBfxWi&(3;7aM}f<}CJyk0Uyk-Tql!?m7Rxy(Pk=<@CyjEm`plb`$I)s%5gg-T z%J+WhH4hIjZ*85ku(R8&6>n@^AbPZG`fHqVF1pyYpD`j|25douz{R}3<#gEstpxMp zpU#)A(AG&yp(r^#s8&(o(e8Yj>$F4YyX3#SHC9w%tpVjp$B>hKF>&z-E}iae^@b&S zd3hDbmi`j?EwMVosh9F%_5E4>G>;cR$fcX}^Vs_JTerT%kT7?-(9%`T6sZz}r}HFamfh9>0%PLOQ8&xggrsEUkQu;-d-EL}Jnw z_oDW!f*E5J&T@ACq@>4P$fh&D6y`f*k$wtnB+7F*VUaNe zEp#((-poJU%KR*T?)SaU=13Zj1CN%P&##jvY#1W&`r|} z!}4bIElESDUE-a}7liZ9kG>q=d4!9Mf&#J1WV;-sNKHoKbB3gVplYo7 z^@ap{Nb7KOn$L75VKlz}mC_CND4SYR2|l;#J9qB*`fq3xPQt=>AFB!8 zqNmgNogniaMHr2`BUEl;Cg$yH_BHbAoJL-sb>ccyjY~jg92?-~GH`RFYd1ue6~EcR zryLv{#Am;>xkSRNzyIJti=M}i1F~g>ItX&b#Ke9wS{X)JN)?d$`S?gXQPR*9LRpgm z885GP#|)`bzx##I8^rH6@fxr)AAv&DOz2VA>%HMVbd zR6#F3j$AlEG?@*)iNovV9UHs$0pr90t~+A+ZS~J$xOnn4iD0JO<1G~xg{hE4%}cfh zFsU~ab#&8)_&nFhH=e&*?5cG8PKeY8sbu?` zu}2lt=VVS7ht8Md{S{jS>5}6=9jTp$9BM?Lm-;9On5xVEk$ft^#{OulKK}B|*wS(s z2nH{f*eknLlf88p=+Q=k0orcm!4> z`idzjq>_BE-$@Nhm55|;B38O5gnU!5@Hy+RUlKo~1EFfDTHQ(ijlpmPYqU<+#}(;4 z`y&GMWkZ0@j2F|^M0^<$UeU!9{rre4dc(DDy2}1RyY4ivqo&$jOiT@1%x<*|6@AUd zZS{dP3D^DhfXc@y*;sP#O&O_n$aLaB;RacX?NLRaW;@op}u=)YWy@g@L~sIO!_+yIxpAMF7|+=bE?K!%}Sg2IGykE zixGXqWqpP7*?EcxZi^qm`GFE&om>L*JPPL=gle6tBj3n{A+Lo~ogjQMdsq|8ZTlV! z!cv$0W;ov^0oS$i?9o*IPEX@&8C)Rsdpa_r%D(G-){>XQdU|9VRHEj#;z*WGmJuMqhAy>C4K)B-L33aCOF zK{|uy(?9GoS!EA$ZPt}l$E}HgHATL4>qY8lOS*vKYQ!-;_jIOAM}2(~kNu&y+Gx2E z*XFA^jZaS`@aZ#8d+&hM+e3@)8*HZS%HH1@vU1}f3Ae1VW0R71AKf486A!wupDg8M!$cb zEux?()cLlm*5<@0Ha7OU9QqkHx#wMc1{d7={Z(_!ND#F0yP^*cBxHHdY-0%6v5TXY zysDE`>V8AC>}MZUs_dWmpiBdYQ5Q*rC&q(hoqSiOz12Z68kJT5hWaCy%wNA=Fs3vs zl+t~5f`b5#E#i-=?XlLLwA4qbUG2`uqguxL)!yhYU%pg)nUM2?_1Qe^ z^=Wof@SNXXHvISo>Myvy?kMt=M}q2%-!vGH{AfpRehQ+i!zCjN4-3oWPD~lFR}wvZ ze9IG{MERPGnBI~y_n8caXadw)XpxGn8X5C_y?y+oJ$+uhDEPI#)rgAw(F98RrYb(+ zRBp!JS7~H@RYIB4rPOmwwx?~_x;Hc3W5V^BC7Kdt&{0z7q;93onYx?%MfwOt<70|8 zCGG~Vg!NvZ_$5kPL>gk5rOVVG$0(wT?ZtgL5dUW_oj)Fm)$R-IA%8jsOR}7I<1hF5 zF3;mhI4y7Az760A*TS!zo!P*XCdGZEFVe<0bve_;rs@?lzqrVv^@pPP66__H^~TAL z0blTai?hbIK)z$k9beyvZ7mIcr-+6FxQgSH%hdPVosa&3l{h=gC-_jqMVq|z8 zeRC=)Kk3eM+K+!Ru2~RD>F&7p#N-z-vefr#m7StLE__g+rcn~POrbclXC@cWJ9D-> zJiNHL7>RhHhvO9bM4|9#F*Ead_Y3yrur4Jv<;vFKVe88aB<&D~e8m|Mg%pl=-k~Hs z0FCSixKG};RtEu)XW7HUrq#sO_Bgu4kVzz}M_SJMU<+!uNy*9K&Sh7YO8LFUs~f)u z0Nzz~x)~2@qaP7}F7}pdgaid8;$A%CpgaZuDiGc@H8p|E-7TZj3ly?RhGa$qn><~n zL;X$1?K)uyn*#$jp=JE^jN9&J-3Wy?UYXtg7+4OQvkX;1I(q8qLcIG3<`<1NAy$LxPa+Aa$WF4ZrKszQ)-?F z-^1iRofEX=A)Z{zyTor%E;CPM)$WK9$T^oO6E<6r>!|V60wUf|dl~QMx_x|nwxIp6 zKde?_`nc`cTPpk~=v>^~6NZnyAY4vKLB9gwWZwKJP2>GaTYD)bn!@p4wE%!+H#K}T zk7jF_X#qQ+KjU&ebvTdBV2GQU*~9DI3wGk@mp6jL zLc3&Th4IFAQhxAIj-ZbD=i@(lDHqoTOrRwRjl0+6dmH0wLWC+(1w7S&X=p z6qe8c=$oQo1k<|>H1jJc%4Lhcel3t zj{mTlu$IMf-N;9r7DJ#LWygEd=*Ho9V-ojo8Q`>j$t;-`*NEnCC7=}}DnKYfKLa7% zK>CzrAZ2IU<0WpJ(Ek^`j)+9PwjN9x#iicZG$`bM`Xf?ELgtSA7xo;6FqYfwij+q0 zH-}$JW)6Oswo)oK1~F^wlu!t%!@^We_M!<0TH+ixkK@nxVu4xBWNS?G8rDbIoRiJy z`_yYwBe{!fCbCPaZKnt>lg|NPKYpm+!F_VE?0jkB@mPiXQ(M25aDZmz`A{@;1zIS7 z#(w`U)2l3(eO8>7=2@VexK*uWQMGsCc8i|k4bEMDA-CR(<7LnvwR$_D*Rim$w${K3 zH9!`7m!lJ>%L@>U;O%VZOyw35H^iD~JNrYap>d7caH<`})YLyGzW!*=ckys*(s}la ztv4#_5u}Q-!$8j3_CzeZh_q#JqT;Z_`MBirq(|*8VJr2IS2(8~mxtriS~_Zle`vot z@5ZTZcPsG`@M@GEDOyQLbf?GDO;(uHEbwErWM(D->E!8RxLRroMN9L+I>?U~@G!r! zwQX`mLIK9HtRt=#OJ?%qj8a{w=LIPyCq3p+XSw<20&pU3R;^_&Npo}b;m%j~_dt<@ z4i(9&q1peJG{H#CNyQfZ@`4{B#|^yJu(x%TTH3K=WftL<9`0#Yzv8`c_8@mC8#l6e*BXnqD!zx^2CyP z%xb>*EzP3Ky`#dPdU}qh`$m(Q_l2kpw$Vg_glVZZCH7+hn5pq+K?J-s6rPMG%Q2R( z^o|b)<svB~-9U1*Ipo4Z%KKF@1@;am5#)& zn5x~!$ircj;5B$K8+_oFG{3bK9v*HYs~L=k*x0yFiszJ5_}i`?6}y!WL~lvPN^0yj z#sK@RY4&JHbdDWB{SvWmFw@tbJ74nxvDT3}hnB+<3!`~y{%Zjwd^=0gCT8W=a~@#L zkYQoD40$dXhLbKW4>LY}iZ4nVO507^yHs6GA|{q6BkQBG9(HW=k%GnqZBMMJVk>xp z<#Qn*02vr&`Wl4qvl;7ddtuS_w6vfgtwMX2y6qi47QH$$R6E_Cn=Ix7C}chcs%lN@ z?4{pkmoOtVFWFEOafYVS-YSLlHJ*Hfa$4^g3(FzRcZ&X86)s81KtfWv><%dDO?Hm@R&Vklj3XXG z%WKceA`XYuq8{6WvT&7r!?eAs@aNRmLTqj-=5ebNLN#N`@e#`$WSk^@Gode0dRo@3 z=ldJ7PW`M|^M&;sLkSp!n1abNKO(WY+6E&E{RJ6ja(>Nf_oV-s8c@ji5iCo(pk8vC+lCdf~%Co#-2z?{oL5JI3~zsH(FLS*j&=Z0vXuTF)=Yc^JBl6 ziOZ69)Vl>{3!7YvKvNoj%*(sk`YkyOGYfCV@S5o5{u-;oojb3koT{?@?&i#AmoIwQ z@P6wDXw@W}ojWc~dUke90YP1)veEj}^ugOr-(gqykYzIN%@{TqrYp??s!6b^DaIh5 z8NaHk3c@E2^&bzrs}d{JAq;n8V4(8p?dADf_gO%GD@-oM^4l_#(TwU-PDcGd)%1V$ z%%d>m$w#eocKayqqv(kDe0(8~jxuS_=@RvvWtrI}PL3_$$7Xt)&#UqYWWUo+eyxXrm@X+;SxsUK2G-nX$!rr<6h^((ybP6QpIVA_ z`ySKtO~1dH8O?9~x0t1Fqgy_FRm?gW5W3;dKPWZuqd7%po@GGd#_tTFW=Cy<2y^!N zzZt(dOHp^r>$sP+RCesym%+>po6pO5kx(X)qJ)JvlHwofjCW-S3|ClBJDr(IK0C?h z)KUE+vZ!N>CTPO094yB1dDRjeaV+O&JL9PWsVJyT>g(%6jSin^JPWFYGBPqSLRhrO z4IKecd<*pRgIZ!|N5^ths8BK#t@ghHFQm4%tjc>bVIOlI%jh3?{%Zp;2?fsuGef|^ zz66|baEaLo{Bk0ys}^zkO}pB8$yAE z`!Ha*!DVF{fVXJ()J}kQY&ad9Ck#V@F?dE#eLCk$FDfd^ZvDkr1s>t{+z+=jr*i>Y z_K5_F!QR`_7%Z{+aLZG^eK_+x(@EUKn4y~Z|Naaa?+aEU;$nPlGPh1AlKQ1a#^#v!7wslChJuS|(XVOOvOIe`XD8(df=^LVQC!xW zrUAW;0U?|ESGWJ_u=F*if%X7hz0hBi)nwtY@%(q_Tdx`^BfSbLQsd3Dq?}_hwxO&B zZn_L9rY_YttZV!eqmrxzpYp60*ET}mDq`wXK4)Rlcv2RnxIB<8!sD*=skChmQF>^J z%_`HTb)ER_W#yQzWM?etrz|8&UulfNQuTpI;*$}*#ZvNnRkNldit2^D{R@+rK2yD1 z#%l3t+1DnQuY9cs*G#7Ul(zqw#7KawV+skK9ro@sn*ifa*HDHR)TuW8%C0e^&E)T7 zzkSd!yX5psq=8gL3uBC*5mp`g4OZ|8c*S_-s=_mRJ_u%$ z=?F%XDLdNVCQDS`o9{6swSCOAZxV^DdS5qqzhaY~UwW&oDS&#b9?u(ZHGh7x)+RS%LW?={RKFG9m+5LT%Q>+k)&1?C2g?~3Mh^!0-H`Qgfr9jV7yNv95j_<4v{ zd})5|1ZQ;om#x@;FMDZEZb`Wk`baQ&ebCr4SOj zW+tY6z@YqR!9}j_RJ1-3dFoOM2XhcUV5gWm<=O}c*xxW%Tr=AI$s9QK@b}AO)Z54k zZWoDdU$4S|fBR!2I=gFXdh=!JO)n->T|&Z-PvsI`4q<>8xF)>uSPd+(vd#R7;U_I$ zA9Z>ba5YrrzHCk)jA|lrAq@O`9qQg3xGu%NFJq|3UmOi3Y@RvXwkZ0cyn?_#_fMdm zxgdWHW#3cL?gIes3g|aNeI>K+(M~jl!qP+Bdk+b;``lH~8<U-C0Q%r=;#KPCYF2G&lL5P5#$?uAs>fuq)@bzZ=3>o@o~{91*7YJ?4sCV9ZeH zKgA$RPRL~E-fyx(z)Jm2x>_kT1tZj{fmZbK-~FZR)y#$cMVUokk-T}eiRS`W8%rTt zfAyy0V19?s{(tQ)p>*5DHO%4S+h2^?F~W!-TZU!1c3q=1 z(%mH@0!m5?NVhc7-5_1k9ny-FfTRctC@m>SO1FYENSCC5fP3Ov>v`An?!CY7hsTX; zd2nCX8RMMioMVicu4c?Al&b1tsy`IMau%6oUDUazBzvc@s4H!lDl98ES>s>p=kK+2 z7$pL)U-0Mkm$uNVr)6J_^MwreO<||YI*}KN<4`q;uk*Xd2#BcN{NJy?Jkkp#- zUtZs?*uo7VQZYS9g5MxSV`|KfZg>>ukuYj56*V9u68tF^lT7}u4!F{!FoG66>-_h6 z7`m1|dsXHe6IeYFoOM2p$64ipl<(GsO?n$#=Lvb z^YrnE`+2LBD_rT``k{IJV*rc;w}(_2>xkhS-owKC1CoC>7Ekys@2jB=cdOE0^|fP#LS(vlBFMkhVa+(9M%vBM7(4`tq84*7(sk(}^`4i4SWx z+n@e>v;OsSnS?ooz?SxMI?MdE(z3dS2hnue$rb|Cj`z%mh=-|UY#%YmI;o!F_}_T> zgk@)z4aPyCd4ZJi4?Yc}3~9dqd$HkN``2hMRa3*l&$$^YE-x_l*ZPaIvE7!OvQwm| zR3E{ko4*z^UyF#0<}v^ya0nsU8V$UB!+*VeIyRAI!nnw{qi0%-3r_q!9&ZIZD;fGZ zUp$Z@LWRUpf31~X_n=<&%RQNrJ-vY-C2KpYTIG=ltISW-Pb20dhIF`DXB^+Oxgtc4 zmrIKS9N2^gXBm+LW=dSG9$k&Kh^x&*T>qcq7x?GD<@Pwz+3nx!r5SPsC00VoK1RHM70|yyZzZkye)=%wO zy1M&tL~@&rQj|Tf`XoaA^gh=#_9NldZTEEklTv6)Yz6VBTbK}A^sH)RR6BVC9F0fh zgA)+Z?ZQb(l?+4M^v}>PpbeD#k5zj`RYWm~%E?lNwZh(G1!`HF%yqD}R7=)&2P4riOZyEfyV zomAzWj|7qVUe0gEj>~^*d24nZ{T2P|Z`9bCYB^JEn^h$Zj1{jBLH`vA8?m%T6iI{^T$44Jo6*TpAK3?liTRBmN=+t0#~-7EmDX zDo+v_?ovyu*2#1(UYdi>XnguHiQUM}kJNEo zUy!dk+%a!NwT`vIrW7g7DfHUN9m2W&7XnL={QcV}o-&vCGRI2(790EC5EVjuXh0b5 zqAGaANWq2}YQ-+s5X;ip=B!mn5$_w3l4(_YoPc#UD*c;WDbpGFyccO4_dzRr$p9}-=eF!Y z9(?(n&3XuYT)i==tgxh;GL^v*fB2i7$RReRVOg}OSuf1KjH^>b?5p@N)rMl;x(Y9G zllWgKA})Ye?=~#j*^p3Ry{3-6f3r+pVdNSz`>9?GXYJAIydO$CD?9L4jds;53DO` zw9!dKuH`38yHW<+?*FfMc%%BScc?|Hj~NyvZiAU8fSGU0wT2z*ppZw7d%N%>;I%3+ zfr}!;Fsdh}oY`^bRp%0j%X0${zsfptBZ?laC=F9NYUXUh5qrcqTvbE~aob;^$8`gY zncl18g_G~qwN1eKA#R9S`54u((UUcd;lM3|2y;wwKTL1SPCy&YdBTPMR$`S$!p<+b zW(|%Pk0s&oiovSPx88h>3t9Bn(&H3nNK3#Tjcy~QFT_ff>l7L*E2Oc(YV2+2u&2Yz}R-Iw0Z(5x?$5xBjS`5#ve#R3c70@a)|Fgx{ zkn1;xMU%qXCWh`@)JG;duTfvplNXc#Cq60cYV&og-j`D(le!8QBqH=!c{90@1d24K zkvEvGazG*H)jrHWBW80y8~^*E7FmL6!wFD^!=7T@$daJOoxdLS1&7n%_?lAw_UHD8 zrnPH0$TFmlBK9P_c^ZsgF*6@M#mf78Umc>%{92<^Xf?H8OAeTU80AofTbcX41Vc*C za3eEZgM4}1#NqNGa?`q~!@f<>7K22hc_>KjD&ql_DoLe3H_QJzr_~Z7Of67`4+ThD z#MSfPaoHSSi`$UOHG9it5|)Q(p2`bBdr?H@Qy=CFwB$c$GD@xx_KB-tZs6LotF^y& zHsdf-V||xb)?AQN%X8}?UqRT4$cz*}Ry3$M{)|oFev$N~b5d~IQ*s`6Nui=6c#uL$ zyLQfqoIRIi8;Qtg z)WmMlz^~6G7>oULJLRSMe+5Nhr9*nR!^+i2TEs*0jl9w!em7Fn2>ngqX_9y`$65PP zqLY_jp*fc_TKOSf!Q4MkI*p*vD$axGKFASnRQ(lJqvX#&7on&`=RXJL@yh7<$0vyZ z5SQQ8VkVrA(VbN@HbV+oHv#R|$RJ(gf}w;8=n&!f=qhUe2S}$N10zKw_V`j=?#(!y z0lB_^V*mez{qQ@n9tj8`3lvpnkdJDR)qheujK~5oph+FlguQIH79k_@KZjy11#DM% z*xwxCNqScpakY{Eg#Q1=hBX!1>YN2V*D%V@E7-^$1^<6bbAcJ6WnwFFa%|tb&caQd zPU`3QdJEq=`Cd%kuy3{S93`)wv~TJY$N&1y(&WNztzG*3!!+x*}xOMe7AJokkJybgQr z8(8M!FjtT&{pV=lBuDYNXWD{bMCKxP;DP_W+ab94 z*KW_kWF|p}tD!ND`dYZ}h2$Fncf2cFP$K+ih*=W3obK~FOZ_zpD&{7(E(*fL%$Ypq)M3bc@64F*d>Iq=JA68d-G))6Wa9xuD!={j z7(c6WUw6cjtK|+S`13b|$t2W)_?@&S9bT~S?mef6cd{fVit1$G!#@X|KeRz7?< zKI)|?c6F)pP$iE9acX)yap3yYP&T6_PtqQRC3MqjPo1F0sJtYqXIKMU2+M>3RxjqQAXC`9y_xd9tKJ<$tr(r6$OG zP-F_&(y``Uz_ICGk4Q^%ghr@7W!FVhjQD1_5&qD zS64SYI_1EVq3U3P1yj@KOe(FNtHyrbP!%3<;7oG28`;6rY?$gF9X;6oFlz@+p~oFD zV3+ve=n?xSU0~pEVBx7|;X66)f&wHtRr~wO3N2V}K_#STX|?Y~+&SU ze=OS=5%a!eShgW*Q(ILwY%(ITd_ce9GC>(j(vpkELN6dN(c3%P8Cp6QX{%1}O)oBf zCC-{PGRn;Q@#9C(JEwf#@Sp`ogFq@o5=3fGo)EZaxhtEPh{?%)u6|UY6<==jJSXRR zRcz`9qW=7vmfDF4@Bq0|HlDXAUcYVwm?#xi#(*n=kb`ctsy!-}SY60i2 z`0W=jbSvJYprM&Ls%O0gN$CkcuSXyBxdrc{>pQz+EEByex-DJ_i6CBrc{Eg4cRkv8 zxE^qVzQW8|IT673;d4D=SNvO`V$IdG9X({!xxSg|x&6)M9-1d=FAF<+%49N;OX}+~ z0%-X3i~7CuO+7Im4smkiz58)5VAr&dVuZUuKoIUCM_&#B0eR#{acR5kZ4L6sEX6z> zl-sgPc@NUh92{;4GRKA%bs;|D9Ae;{DZ+q=T7ky7K{WdOITN>F9w{U=43l_o+_>=?bN8eNNuZ~vSG&}RhL)Cn>U|A$ za!#^UU)pS;3Y{`JD_GDm-@XmLz^}`C#5Dc<{C*wJa$67B1Lb*d$qFeehnh|_z~Fp* zs=*)Td@xG{O1Z1R{LOIE0%2;rf0bqL7p+ptX?idvloW20ssZWrq-|Vk00R)TF41{C z3~UN{HLg7aYm?S@FvQFC2)LnYT3WED=UUmUEi9b>grL{vb-5wN(#wBYkyjd#kGoVI zAyifGPyS9uX8*jJi~5`Hhc_>VJD&{;hej+Vrz>|!^M=oTxt=H~*<}K8N>hFhWh`M! zE)(y<;W~GP$V|O-WSINgH+9y(@@}#h4~^zE5MwyA!$F>@X<}f{8Z#@FT6*qDGG?=P z4g1B6@3FAxZXFoNDrMZO1~#i`a}dMTwM1=0ka6h5vZ3;#z3ttegEMG z)C0jWM@cD;2PLfRs$dlsIZIcV~QT z{yPviD)y?9las;CWBDYEde&Y#IYJ;Xy1IDB^C)%O+B45V|6cN>?kOyV}Ft57DAA!mcnlf8)NZyG`NQjBqCZVJp zfdh;xIeB#;Yc6o!1biN{d_CFkU$}RxPma)=n&z@}cMHr84!*Cir!+eSkCm9o*vxflvcX^n^IzyRY*2 zF$lA~nVT06Eh~4NIXe$QT9*ZLgsto(sP#7iq5npMA%+@JpuGRj^SLJ`YTuBQx8^FRv_Sv!1<-d!ea}_ zHIY!y^JuoD>hKI4b>SrJ%Tle~GAg>OtZrl!uV-y(xx8Y;<;%&F47AJe$VgOlbZ{Ug zN8g>S>{6gnBg(7`R2Q9)S5+MXF{f$>H{kx`9bs!KwP(*HBe0=kk-dxa9?fSf}20Pj+S~r(We1` zzwSB^Ma0BPdL_c8BNIzbOF%n2{C)|nDS~>1z$5?$ zhW5Un=3_bk0xz;5EamG}Kn(=?1#sb~fbI&1q}5=oC)GW7IUD8;nh?(j48U*ywITYX zJYj*`m`#FGYf5yv{JrtC(oR_(J9E)*^Z7~H3?{(h(y-y@eBuegSiv23a&u36eh(~P;}>CXh8cz%IX<4;n40(6<-z}mM75R!@zUo3v^rvR zchVMtdRWlF{A_=bq>I980UpCPQ-PNU=C$uHFP`V;H<%58E6XM8Jxo)3+;I7@Fn6E* zHate{_oSqx3z`*^bZ(fHMag%XAF_kD#Y*3kCqF@_zj|57E@%x@{~)O19;b!DT-iIFzC*x@%9<(Zt8_8G&o+W(j5`iI5&FXrY2Ml^2xm&*pz z#I$(PK!^m7NTA&%#K)7yo~q<=Vev;J4=3A5o&7CQ77@ssIUBYz1d=wf#8e z7t~5Y?-9K`A(xWe@P&sawjj+=9SDu1#E9M*Ele1Do|A6nA8(km!ypsevYgAnc|J@) zK5%+^3RBV%hZ2Et_O5Oyyj4Jg z!E*UcK1cRv8W6j5e@@sqXz|Kj0%I2oRDQo2FvTV&Ac`4#^L+0_{~U9yg3Q>U)^6Nx z^0qL(<}H;h6wbF{#{c93nv|xAn1>o3->6!EyH+=CYGh<2NUNy&Zv@b67^bqT%>^`k z+bYPTy<%_C@oVu=IXQ8h&+Dghtd{7UZi5-3K=Ffg0Yq#3!0@c#`gtb4ny=7Cp4d%4 z)Y$iED=H9tl@L5WDG)HJSXJPp6_X1hPSFZDBe=Wo^}G<79}cvCpH~7y|QW3q-e0yg}7@yd*Z;^Hd#aT}-LzQUz zp8AieBfcT!AR-?HEtV=acEYb5JEm#cBmm~ClKX%GXFeX%}!PB$3DC-({y+~ zjFgtoHLalVPJt@eFk>uHnzLh9b)xpgazjg;Ns zdI0hDpq-QXP&|7K+@RO@OYYX#d)|yFB$RV*Nev?U-c@j z;5J88nbRzQ^p-j(xxnGRliKhZ9q#wCC49-rmFp%n5gu~&{FxdBotBes_s zI;(oFQ6=8G>L@P^y{|drY%8R#F=ZTWKm<5l<3P1q@fxD_G;ZtoHTD}Q*FOXv^z)B+ z*QuFzjxP$2(0A4o{uBg34@s(ccW*CjI&!{yk9RVGlrMzQlkBZ*#YF27pBnr+)WN(x z%GYFy+p4dnIfa@9n<|yb!A(oUfY$EzOY~AI3?I9_#SR4(B&=X2L1+eEh#7kZf}9vh za*RNuw98 zrz1o!Je5!=z2djfK;9J~5A}wspdM*~c5~@rlP3m{F2u*oD|u^yJYtl`|KOO~d}SWA zyr@`NT_?>h`K}-VLk&lTM&Yqr)85}dF9Zb880$5zy$}4=pvg-i${r`}WMOT5#r3Zq zQE!e9D0gW(V`5aw*W)IwrqdTcPEE)m|n+c zJ-r+YlUz5yzijYaT_u}t4x~#Ju{vA>K`X^*p|}N5Co5bdV#qkaYy#YQ*o4?<=PI`y zhYdeCy$@`rNQjV>l$anZFc!blpZ&WP1QC1=dQs89b$<(Z2hv7Kww!y-V81{s8eqSC z*J?0*G9SHJOsunf$$B6YOrW}+uYC$dldx0Tnfq-u-Ve_ypU~CjsEH3To{+KsDy~$? zaQ;auLoUTl#v_QW1+0+)G21)>4*D^x65qcc1mSj=wxO&9-;?NQaN!|4+goTu$1l#2 zk<1H9@>q~K&X+aoM0Fg<)GfNj7f&si{_>?ExIDbkU}9$H33Dw=O__w2Hi8gD9MLZ;dbu$KId3VG4`QehgWH>7v(cB2P5fE=crhR{xLuvgnbe1k?tPMdd_)<`V4jD!K5x8^-aI;$f+?laqOhb>wf7toK z`7P)x#bObo6B4Y4si0vH4B?yYeo!eElixZ$WdSqC1?tXcYdFt9&XKAL5yjJs!Lev=IVM~LtL)SBuqQWzvnK0+2RDP4fs#)v!8(>@S53L zI2iYk)!SctcNL$)EqAqq5WfX4rLoa(Gg7=cY300(fOMa6@4IrA8N`^#-9$J@L?JtC zZL$MLL1))AGU19Y`ukL6PwqJ6KA&41l zI)N%#RTjJI%0`2?Gl8zzby;CEyrT1aSSC9(>REH-!A++*pcL)~bIjjAB1Pq_`QvfE zw}{o=s{)dEmFrBi~*&+A|XyV^1FDt|UfT_9+7=Xi;U5Rd>ZeTzi z!6}1DX*^a(?_0Uq3@v%yHNSd=vV_z|v~%OuEi330w%h#sWAt@C_eHwjC$h?AcgQj7 zLno7Yx_dw!Xmae_;j9s&{a&gvr?Siq9E~`YvC*}8q(M@MMi%P^$uI#t1IFIY;CIH2 zUR)8Othnl&E8c|Wjsvk2#$g?Ko<+nyM1~0*W(GO(!{3T%Sc3~F2}P{U%<}FAg)ZNE z=A+EmS~`D@zraq=&p6>p(kmNss>pXn-m0VDzVgYi!4r%@%xT;Gwe{?7uY3aO?d1~P zZ`ID$bY8;Y=Ud=ye0Y;NokEm4q@aXQtEE;lP8DT`%h}r*mHbxilvHG1KARw7T)$5A zShjIfbcVk1%HZ%T(3qXe()r&MnP8vl`~e$AVE=m*5h#m8`CDFMP^0RL#{#iV6?i02 ztKg#kry^+n7aK$jQk%Vb)9p?f9vxb=b*k79J3D-tuL15fUDHCy(AY_?w# ze&yr|X>P7=#s*agDu%h0?=E+IYebou@zpW;;%WOQs=Tq>&|zKoG`SXlkfi&Ex*lY- z)9+DIg@;QU`_&m}=3W<;ylZ^Ecw<>8N#Xi;$;LMy-B$Ax?vrLSm?KjjERvBe5*$G6 zn#%U8wTGgj@)QC0xaVb>e1qHSH3M4OJxQ-?xVW98etQKXa`QGs!rRgWJ(d1H7G%vn zZf<_$(NF<=W@CG@x8zP*;|CHx}RH<3|w5|ge~-RytK5m+rlSg)43wf zo*}#O#P6tY%%xXv>U4c#h2(!i;KHf*%aBkm9p%g-rJ@}We&+GVTUppo*S>Mwn(pgw za&0QHUqs>lq#_xCyTI0bHV{eYKQ^>~+KG*amBHt+VcnZjenD1IN#7^=G>y~reD3$& z{defv0zPAMepS}HvlS7K(3zvwzr=Y^M-UPTl;gZG_M-BT__@RNw146xTny<5_v>%a zo<7-SHs2ECZ+(U@eM>3*$weObrz5dyfD^-yes$yvD9^>%X8jIuiXMY0LAY1naTnNh z`Z*b$y&G7S^QmNd#oL7}kV)8tIMi$$SSewUjAp2-s(LaoIY4y}lwdtkOUs}B(G9UfZrHWZPw#(}-K^>K)= zx1hdj$EgPE*!?1RIMJ+dP(P87#%xq-A9FeDXt&PmCf;PuFV-4g*%fbY`ElK0Ae|TM zHt&|My@ORbL?J;upO0HkVxpsGeKy$+kt{^j9#k*OWu_bn35hCHS)hTgf4Avp7Wr<7 zH%{SMw@N_Briy~XH|WQ8XoBtFwFC(tDN?g1h%v!IuBq%75AOXkU-Vrs=%{@Q7Zk11 zyV-(o)J)V6gM%k@%FUK1>kY(f=eM@(tGxaEGAAJbZJp^jJE|_13Xxroyt4lwA|!-t zc3~`1QX=b#j_K>-=Img6tY^O&L^6Zfd3BBocvUBih3a^&zB9TS8 zC&CtoCoL_+pN9X6t0^jrC+dno&m<&#Lr=CLJo_xbW_zyVWkowM%h27^7u zOz?%zXT2abOy2WVN%BVQsrd^okRvuiilg@GLLBX_rK&Umrqc&tUf}XR#$mc~FYDmY zRuePJ4HgxtaA9?_DyN?AVJ8^IN^fUR9z(R}qPMKwnP7cjGLUKMMSf``^5%Ct}Msx=B-K4#R>Oao<6Wl1EEbk}J3x&*O3!(H*5dRI2a3I&eO^ z(XU_AlR_1Fy-HT~A${<#U*j7)a4&D=wkgt`s@Z*7hwy~m($e)A`3)T3_dZ|@SMm4> z_-0DE3}fP}og(N3{gVrbz+NC@nW(a@++q_GMwx32SMi^&RwR28qotgfnmwoh-VL{R zVhIult*tD}tuNpxBM>#KyQkBNJo(0RYnnyBl=^L9Sv|@slf2qHi$0snD88gzBAiEY zRt6s&k(H799~gLssj0g@jGZ;{zx>{f2VsGqZa_e$!>d9%(_t!Xn*-L{sMSxaMUN}r z%Da^&P;l2DSCFVoK@Kr=^;;U@_3|>2+1c3(aMq}xzMskNi$Rjmn@g?ulXEjzT3qjA zK$E@7%P{?`q{4G;-{YU3Qvn z#gMsEeGZ1G`829;l8B8u9?)>3Iq2)@)d&7Ye8+X~Y_I>?!P6J~TdUeOUBAyc%EL9n zE$Bh)?@Glk*S?oHq8y8AkSLH|?|GH8kHP{8cj*JC(7i1((s=m?a>S3%PIcG%-+%11 z6suaIZqps6$|xz%?WQyE=IrD#AE1 zLeF$rr(2<&ScqBbXA3LQ&;fUE@|t! z&(EE0Cl8~_)}h?SUm?$8VCvmJrLbjK2FRc|%sNXWf5gJ2dE2ivzSbUT^ef@_3BcT5 zLJws*i+ueL6y&^$qNBT5`j?yjg-)Tz5` z9^qy_%J`8{e_mcrrGsfNB%9!CtT`Er*TB1xP~F8Fe$rNicPB*fzA2>F1s7L4oL(R> z^@RdGqjm*s@|jn~YdB?h+odKy^if8Us328F?m5k|8@HCIK^;~{^)qC(Egn0rrNTW~ z_sonay*%zRvvRb?RC?<^4pZvsZ$QlbP9#$(A7;5`R*x`>PXv3)tm3XXG=AaC(%>?P zi~WB3D4WR6dZv>~R;Kt_M5mHc;Zol8bT==N>dx08t|7W>^@Zi^u5H-VO12&Y92reg zI!0=ye0MH^lfe{A2gdB1LqkKD;#FAK^V#1dUnMcpuZI1q0WUrKYT{=tVn)|F?%e4z z_g_9$$rOG87O0g9U>l3VEnM8>wU@X*3jW6ikixNHBSLs}P0v=@z`*{&2b*hy_DonS zEtlv&>BPp?NonHFTtgn2(^+OH7GCZ78g!CKS4hvVY;HbUM}meR1L^mgKDh!>%%Ovt z38$xCPdnw!W#EH#+2p;UNrhzTq+PQLXCj+|3hTKO5+-{ns*@81_3nT%1e4S%^I+@} z7&UQO^{s(3K@Iv$zZEK1d1Ml}sVTj@hq}@(|3FaU>FD(oW?sB_74s~znd&>0Hx!dn zjJl*dJC7Gr-wAjqyp5N;OyLyuY$x$6dGBvdsviU+DV^=|yT@*P9k; zuU-Y%j8|`d$#Br8`1)?|4yF6rkZEh!-p~o->+yMxyJ;wmh+RKdbeH8-s}~m&9cS{8 zk?f2oEM0BY)XFENki1)acmpm2%oIW)FQ3bA?y_g2kJV(0M!mc1)UUiz&gR9y&!lY- z)yWE3uZKGppKxdx-?j8ua>q^1t(dSfetl;o83RQ+XoIUbirRt2r2>*+-;=U0YO+!~ zbT-=ahe&MDMX2n)4%l%0E|7NfmRjlVWBvSMMC9YgBg)%0bF)<8#~b4@Fa1Ve_rn1@ zZy>3x96uS30BG^0K#{DKpRScF20}pV~Mf# zmjy71%<7kruPdjFBY*g$wJIKHYezQ_@=Q)d-qNyQDJzU9F$Yqab^97sXlG9fj1z~$tYT$4 zl}cQj`?8BGw;}74(X1lmoyg`a17t0oko_wb>M~^aoTfJEKzVHrRSlpBbl)tZW{YtR zZ0>KY+ImBJ#)UZ@7(b)pr`S&3oU>-ys8X(8-v6ABu*&zMc^-_{&DJk(#FxYV~J1DuNZk zWpdZIuAZ8b`)#}i-;JV^tuGd#%NLSXj|UcMD=T^)B@t~JUqs=tl|BZ#=EE6mOw1(G zJ|j}ur40XS%I!|xzH50FKY@MXa|AA^{Z+OaBO{%e%}-xur)K*uD^%n2ecG^FZ&>`5 z#5`lf_DM7dl}Xp;Mg4`UB?v5wO}xWT`2~`7aga7x1J9~#t(seX@FfLQ%ufC0<#yY_ z<`VT_HJ=WT=Dq)ml6R1Mvm{Q`1^^dsJaMO z1+c0bj}UMxQxC<+9oh7H3i%FEGx7Ye&|+j8Jc~>+Y8-U;$&UgzF+Fi|{h_;J@n8-+3H)#Y}NTyl(wzjqXXk)4MZ}4e5J0h## zaAt2)U&LQ|ZaQA(V5^MPM}Utsd}|`eYqH+skvnB^epPP`Pz<&z2cgrHDfFrocz$WMpK)Gf|d&ptC&vx+RC(mp>%nQxnA*?y>^1Kp!P{L(P>E z{^)oVVM6eML#8vaZP}u(H7qQ!PSuQzkM)Oc15GbqI4e9$ZdE)& zmjMwR47_*hR_En`anddfOlaw+sZS#i$mnraMv zv#sbyBcfILt}GAm|4$ip%WKW0*h_!n?cOi*5flPdoz+|nTd1S@DcO0847cvNhJ5QW zf(p@-^8v%tHe(JBl?AVZs2e$lOLcV~@)0C)ktb1FqrZN69xjx)o{m^Diw${zAML^Z z{);cLtZCVy7xE*BX*&wQxDafhtmz;XtYx`lD1Rr7vKW=A(Yxdu0>0I zpMe2ZbnTGB?@t|ma0E1&>pp6BE2NBLy~LbBN<@S5M7)XOX; z1JV;*hM0>!Ln3q$y^^4P7zi0;q@>O%!BU4mhV#KaX5o#9FV+{ClRH0;SHh%jS>Xja zEQPpC;;*|{#nO(y*wRbc>m+L0UrT`HbgTv#iLi0 zmf|IKtPjz99_RCOe<46$-|k!?et={D@|rdMJ?l*)!$vx1M-3f}fi~GRL`%h9-8w$S zQ7-Mrcnn&>L}sMmC3(kdz|r30W=eqMhG1z@hweuX^)H`iy@&4oxCsr{GN5Jb$a8yW z%^S}2rb)1TC^u6i{~-u=?y?*FJR@^9R-hAt3AUCiZ=ttwsc z!$_uY2T49^^Yr$9T|b1W(m}X%gLeDd!dyeEhNAHGcOTulrg~%KzbYjCx>NiX_#&({ zb3hi7$=k8T#3P1h(nk{$Z6$A&-kv+KIGZbX?Y5-^su~L;@7e>n5@d4y;`KrTIs^Iz|(Bf8b^LU_-gp95#~}GMLx`4Rs)mi^gNF)`g)9OR!p8 zZ}Y3u(-QZV7foKKq5K$YJWR?uzJ+yFRhrW)zHaN*eon`0?1xsV-)8-6_rTkV?PvvR zLZz8=ujE^v>w*of^jTh(9pJ4iR2f@1?9ZVPr6Ndws4*6pgP~9j*)q% zoaa!guw6rjF1C| zATu$s6(d)o4(ajR;zwa0Gl22qh#*j1HaXPR+E3`$z%Wde$$2=DIWo}pTCgMP2~8FB zXuf0glI>%sU;|6L4jtpxm$8(y3UlF->7Lul;d*7&%JY$v7qol=^xTn0&E_4yZYcIn zusmwNCDj*d^_7{RJPB78L%7@ca-VEP9xAe|M>pvLPI8;F!WfnIYN*X@d=GQ2tfq@Z ztye$v)=)L698~?)!$%IB8zAc~4-Ea{CsMofF)@7BvMiz9V3Pl~qnEF@{HUrTfwmq&9ew*lot{sQijK#a z;@O?tlib#W^4jRypTf6d{19Z_4+ma0sgRphtQlsZxV|GC- zi%-Py_;6FlbguH~HTDxCx7=T+9!c!%*GI}3oM`=Mu@*`%dK5m|t+6WZ?o|@gr~{P^ z2#q__O}-mrwgX&_vyI;0ZO{U8%Ai~rMA1G2$(bDpni1kDfb-g#dL&8>9 zQqp&pCtY35{CBJMn?MvdlUV>GUhy z->_o7ZJ6Y}xwZPe`@x#0h|ghA1bRY$O6TXEopzX~bVYDtseD2^7mbU|h+ckTVCa}}jb5Oy9ktQ^e$5g8yw09>#`@`KB50l^a z$zed>C^c?2T4APc3^Z9WeN$J$lfT{405+kz3M1Dk%hFy2vz;SmSSY4JWo^+>@shmU zpvE*``>~@2^DZX&(6BCk5ZeJ+F_PU(g{5Mpn$6Z*3q=zPE33}-_Rqphp5N3-g$gIb zCHiJolZrFYOBoJs+S=M~2xAnh0Rxb$`}T8{mZz*o&k8DSO*;9`+(>HTe(@+Wec1o- zms}R|hcY1v3pOL@`{4XfU>-iF)=J+>YVZGX`q0L@@0J6mxn)`5AihoieYf4)jlTxu zwMyApSQf&FFSf(_sMUX@RX3Z3uO|{-E>tu(8@sz}FQ1}g%dJr6Kk)jD)$yd`p+IX# zjNbzpXJ^%fotdfXNBS;H5w&tXP-_+_86Y44gl_Q`UtuMnjS-50BF9W)H&c=oM^|r) z+rdZwv-z%`;v%J1utd^*fb7ft5M#<%OY7HlqiH$L53@?{?o}i9I%yK_U)eJcg3u6N zSo{vY$h>Z^^Y!`h&J?;X4O+@QtJnf_lPPHMxrp*Bbp!E)pZ@-ANlS|!nnGfc8#fKl zefEK^y$Oz~HcBG0FOycik@QOGJOorgFf{}-Fn5h2U1$SS&Fz;v`}_OyvLw3YRBo_FG}cX&w9N;y2}% zf1q2=QCu&3+pP$tb z7oI6Ml*9(k&>hTPpO; zlI7kTmI&czDY2$`fnM4s+zs9B%V|hSJ0~&H!=;xyV1>=x4%?63b->TR*_A6zl+wDK=#7I0UN^9@E{UC(j#^>jSC+iwa$Jc)NGre0h^*{BbM$=7S%KpXl;Ds;+ zUru8zSnP6p^Im*b>rO6glk@Wj%hAm{zZ9kt@Y`wySvEtsB zG|(kSBIxtyXAG}aBU?rM&v>+Nb`Y4qN}BoFwQwR=d-sd1dFSU0EXm~3sm1Hgr8XAR zEQ~B>rl*38x@j8m)C8-Q&;sy*A(vLx#nj2?8nKjY<1R^{784j<3%FKDejw1~LDgmFQW}+Y} zY+T$Fe-w^f9#I!s03MAjr8M+N0H}!+aYU8*|MSs_gJTFsz=1r*IqMe3_P0o4! z)-9PcHIR7$-$~GSs3lgg_flA^*3g2cy{xy!#nfQiKmB}hvR<3|UjpZ_;GA(hj61dR zunQ@;(ytQ|szB@I)rTozCnwN2J1XI5Z_DG;IoXZy$ItkkCU3}ai`ji+E1Cgp`wcGW zl>B_9(1cpm$H;$qIVtHo7Fk8IrNd+eiSf^$KYc+}@!-W>jE$qGpF&qfer|zVH|Q(c zCtFfVr>>)*p`$icc|9YS2`c;mEz67ysyIFR)=p|`g=)I%?M;b|7r1tods7SO4C~u} z_vp=sXna*Mr55sRgg}S-h8+PNT^ACn>hutO=x{`4*^0CwLS+2n@ZG!dkYAKE@P=CH zP{yQX{vPb0gK4)xv#Y|$w}b~(2L}iGU|DZNWsmgZ=*Y0lBw%%bK*2Wm)pt9qTfblb z>U%LdOl)?Jg1rJp;1bvowKk08b&ZXSQQcuynVhE7NoctOMC^D8-?}gr?LoU!F{7ywW2lUP=Up!*A7( zY-D21)^tk9!nO!Z$qO@|em59%868c~)YOFFeIr@h=&HZjrWrLg0l}4sv7n#;9(Nz| z2;Kh=VQ&GI<=Vab(j}pEg9_3m-3=-TC`dQbAdLvpNSD%GBB@f+-BN;pbW68%dM0aq z-+%9Y&KUcQAuh&n4c?dcd7k^8_ng=DyPocG3Jd6yJprYAZFPW1yBZ}L!IP&?kB+vx zMHB(09>Y+~&oFZIKw~P!M9VIoZ!*O(nf(eaN5z7lF&IWLL}Te|#8eH2*KbV`P;NW@ z4W|$$PQ(4CL#_7m3*{$aM$zqS~QNW2_ubcCo{GI{YeDDAF2K{VGW@R4&7=_pd(Mg(ey*- z!*sE>q8S*)ldhMTtI<99lu|MyTk{duOmk(Q_Q2%4*%Qf12;j~9b8iXw+7=(CDQIbt zg)K;!OZE`y4MS2%X^>MmXjr#q>JkWEUcjeNvVwo9-ws~WV;LVOY=H&Xl$@j_!byW^ z`#H@x~jf~`;K~noHg(bFAd=x(* zm5I1oOn^sCa?5kX0U9W*hgPuW_8dtnrHh1kFJL9Eig-?b%AE$|K)7mFFbvnV>N%uL zB0;zf%B!9U-9+>%C#e7wkrHQWnYMuxehxOah|wz0a4A3ZJa;Z&SO+Nl0Uem|Qm-==b4nWRP}1#Hxzrh=AUU#a>?ObNPgd) zqlYf*B0GQG*&xy^ktb{+laBISen^&0FuMT#YMt90$sZ{zZRx$3*WOyWY*@U${^fzG z$iQr+ExJP1UB^>iV^E*F({X%fM!OQ2!?xqwZp zdtBv~8P%K4zpIrf^TISYqeGLnTpm6i3h<)ksPQVFGc?M%O6(Kx+&ZB5Lc zisyR(fg_<^S0D!q*y?BBUC5CyQvbR_j;wLI{vo%(q*G$N)0CxLrMVVvhf>P}vx)2N zsn?JaFZSR8+E_$JXeh<@lt+1$ol!PRlODYeuSr|y(`fxnHpvv4l=wh}j<1|1l=ZIL zwW}E!48ngwW}@zWa#c~FYItVXxPRV|?D2YsujDOZ3oDI>Jt+f9G3EP@m=Fk|Ve?hf z&hG2umPX?T0qPHM)}IZA}{$_XH1SXGeLI!)`L>V_^KUW z9{ez=m#FI?(Gox+cRJb_{rUP6kjud$6^KptQv-o|+{a0xFfGn0npvocBu881q+wV(u~LAtFzh;@%;8$h zyp~#&6-Gxv$$Va7>qbOD;#G#Ch_T@)MI|7J_K;Gn=L&WsE(M`RiB2%6b29BYg(B3W zV1M&c(yDpRmj|(%XY%6bcbLbOeYV@*c|rn0)8#TpoVRC)>k;hu4t{!9M|R#z<1%m( z<{Y|c+H1Y_d8%N&;ECbAjSn^mrkr|iu8 z5)qr)E4)d45>K~5=v<<|T!r#Ot3#2bry&B-4+f_)TBIp^85Zmnc-lT`bQ~PJiydJ- zBX>a4%qbhr`(5NzhVtPlaoC;(VFAP0*$?kmGckS1yevFCgu{$mzShNZ(C1o!^Cg$u zBI>wT#loNE-v0A9EY*|Yf-OinIXOX?-Is#3a)+y_SCtJ$ZD_i0!5JxZ$B7;{u*>Ac z5^3-9BkO zcXSzEVbj>uM^A8EZ2u0Z;~lPCfbM~`DNK8*j~$SsaDhizLEq*&6Gyo8h9|vd3F)J< zo8Vm{lnacfzT8gi^XErpc;e@)rw%3_hika+!)5K}YtFLT*3oe(?Vn{u`uARgsC}PN zn6EM+FE7tq0>M?v;(K2oR4`R#1i*<1novv?0N#G|5#dI)`EDaJIxZ;Ki~n2#3SaE+ zVlN`o_rbGF>C=m`CwB&225Za*c<$+~F0XQE)!K}7-?@=@-Ytz?sV)RD!S-x<-q6t& znSj^S@pg@dqw0qkquj;bwQ9%lw#c-)zMe<7JsNedM|}{P{Lyd&sGGk~_Q)u`+6ly= zhzs*<{`2>2p8>XIh2Su|)uG4`{+m=MDi~gw9=8sp3PQjQkLBV=@EV?Cc?sO*M0Fgw zzCxGjrQvgCRAaFz)@jn8ykDIN`Yy3%v@G&$1E8tH)Ha~KyGcv`YSIZi3gc4>Oh7V<(gi36r zi~zB2CuE6c_491?m)E_XnoK$3aXH8FvDPnhUWc5e%s0|p#=T*`S69DY@6^po{q&-@ zZv1<#IjE3Yd?PzO^R4{r^UyMg zKCvPrC1tqD+glOQ7!+>vYHJThBFm8@RwQ+gNetd1HjMzcfbTGXv$6i0^;nNtG%PSi zcsf|Vn9Zc}kk9s5Ss5yZ`mG8God}{gK|n)63uQ@fwreiIqY|RiBkDcAFL)O}Qtxx3 zyL`wJ|EP@(F!-ek+oKcuuIUE{xEOjOyJX>wUx1freYA+7R|w%?w$gQm0a^4lF*T`b#J{v>H(pTZ&{;@pq6wzi;4(^FsTlwu6Wk^ChPQjFI=V+7YI z;Za-%9xv7>OrCC7V;ndp)qMyQok1s&raIp?V|42apNUd6iw}L9G(ZzHR(|9Tkz&`` zfTrPMRh&UFG5U7Y1rHt{Y9@|@S^4Ag8;~wbqCkw)6eg)@2xUrQ~E3kju4vD=&?5NcvrnkYR)sBSZLe4mUIiPV^N zH`cjn*e>tgg!t+A7@8*v6d6}Wsrg6>j?D$em!}TU6OwGZzS{kpKebxHyznQJ%%pn8 zdk>-E?N}wzuPvlB3>H6*%AtiKAK4P4)2ZhuwRoa_{uD>!*yBWnK0DmPmqw2EBPBvQ z^|c$H!1N;5TJcFuN$V||<>bFCHIEAcmII8l4yZIb zjraQeZEg1v;tkVL(s5){MDccvyv~O+j7`q|ER)#OwN$o8d0#Gp*6m*vvZL#wGk2hP zS09G=z%L`C>(tK&#=RMAt`mIT8NafSKiRw}rvMC$sxtTf-@{;aw#jd)W`&__$)anKCkY04MZeDup@G&wF@4 z^;pZOWp~F)P#G!};Hc-&(bY9xhE`HHIFxRyMnP`)TQzdR_NHHftKG(jXz5LRMWqPHiqlTi@bc%e~M}7|#^^&5Zl0vl6cvqnc=`H37 zih#MjM)Q}i5;fmB7)!3)Jo?8?<2iOIoh7xj1ib{NZj+Q$p$gyxJ5^Gyx6h7XzCfy^ ziK;9UUJCBdr?N1U;1}FfoAk-D>P5{7d4eK9(Y#koY`^$EoE&lB2&3G)Gy=U%fZ|9N z)}}ypEWNp{pCYWtG6nsrO?vZxdQb!s9OQkR=r2=^ogPv+XXx#;*6OtsO*KZRmM8mJ z?xk`!GdfxBD)^R8tkHioD0QH&kjhD?$SPXR2U>E)13EK7p zguA=Rwbrf$WKMh+PIu~TrbxYC-Egh``)3^EbFS2*q1OmOF*x7Ww`ccR_6`q8#b2@V z)i=~bgM;k~I40Kf&A#J|?~7}ZaBQZ>dBkFzqIPa9EaV5iX(j=58-sthe)8h@KJH+6 z6}rhH!yRg+SoW6!JuP?6KF5(KK<>+v=|y&-d-qZcTA^TNWnhp$e+GW&p`oGiXn?eF z-45W!ymhOs2^AGJVukh%JPiPJGBh-VICGEP28LT$WL(BFaJZ^L%ZUT?4egeX`aKmq zwvXB5F^C4CFK}+Yb2KQ-dZ(slBGRQ?iL!P@Gt41WypZNKZxXZ81B?Mga z`oranm4}E>#e@hJgXN z8=fi#Lq>>hg}s;34v`3w*X3yt^qFIS|DHFzrkZ}5QfaSz=fNmVEkrcLN%El?Qu|9zX7k+8X}ac&^=zBhe{wTM5nEEGQtr za0v?UN=k3I(0F)JTqd1x9q=gl6pz>8EDy<*%{Mgd0OV+lLLlMY;MX64glIJS;yHYq z4-nh@)CY{A$x0{77HBuXpUklAx(<7xfRD~ahKH`3#8f0Co5L+#bq%lkXOdH@UahPW zy)-iw^|~D2(ty!F#L;e!4SkgkIu^g&lYTMUzd81mreDDI27#71KKJ{jORZ4Dd#|i3j)QQuuJWDF-&U3JePa_47>)Hpi9;(B)|1PB;Le;&Is!OD5t5(e-b_Ppqs4_+? zKSZblJRRhG3@C^8>{(j(o9b|v{;iW6u4beOqGgGDTpVR*5KL{_TZGnObLU_H^=hKeopGez-1E z77({WA_;h5j-Z@RPX5_&6M8;AMECn*Ik1lNa|knqllRi$^kpa9E;k@}0aT!G;o#`U zsT=~Q=w=;S_?5VJtjv3}M>r6XKTu)V7%brm|F!eGQO)GxbUAjv-<^on(})wEQ6D8h zTft=ytdRPG)4{!X-|qg|TZf9B;pEmaz4)WJ`iMA52JfJRaQE;JIp3bWo9;fK!t`=YeiXbrtzfMS?M{!j=L zfQY>CD*-O?pH=FNZu-BErSUsm>L7W*D29jucUPX_cQvbpw6@>>`ne}>{$D>AGbD2Z z?CYb^Ly!M2kA_G;&k;T|d^$LTAFsMMoOOb^xI+GtVqx77*T)M3r1<~&Y~UCxy?G}; zC|>nhgzM8)v_VJa!n;sMYAea|!Us|DAee$s_G#XQBad`(wDs8Y1%O4)41I*q5P60T zE(gM(Oe`y2Px$3=0is!$W%R#&0O|N^dag$hCi~AjRipgp=iYoCOM+fK3=3$o&xY3< zDf{zrmImhoBjBSAe9+S@c~9sWUX4whO4~a1{cFL;Sro4jBDCUE*8X$9>{m$cmK6aY zGQV>OD=9oq+3)6ipXmfE;6hr3ubwhx1pLoC4qvc;!$_UGIEc>1l-Oy6P136PAdA{c z_#5fK*2t)Qe{B8{PvK||r4$ZNPe6aEeUo>z^inOJC~X zd4`62-%#dT&~Iw^l>~i4xHXZj)X8G@{b()e3WB8g|Bsg)kuaj%03iD)yfg~dlFAK^ z7@MrKMWg4@?lc8QxHL<6qKwZ^uYC6JJ`k~{O?Np+`Z>JC;ouVjt_RmAcBw3i!c281 zY@p?mA+SOEy8n#bpy6}iZLT%Z^=JD2izSv6zgv~}&OA1H7i){iI`BDMcEuelIXmCh}(X#)?%X|k0 z-2d!)NO%5$B?ChJhSB@a&PPW-r1#~Y?4MYHf$7Ztb^c1p9Dl+4uYJZY2?h=4NS z?BdMSH0_DFW56)YQ{HNQ?RpHU2NkK}m;1_<7D@l2OWOfm%KDmhP15uogK{z{z%*R9 zg$BF;K{i`t{sXKaM-+S|q8w~qrC-i>3!@$57)D4rIKKWvlLLQ|J`3COmTcQv1Oy*v z75vB$Z+g8eHegEnz+HN6f<&Ovd@NY>;6})Ab5`r17|$Lpc}u&vHuk!KKTYu)My02HnkX5JxF|u?Urq zAcaMr`?4*1?&VE0mCto++yM}X12gt%Y>>gL481ECqzKxMX2N)`Ygw z2yjvUFE^^Oz}CMvD&N5?Sf0PY9my%cxb{S!I!`>=N>lRl{^6g+02#vOc#J?edOt+X zmklFMC8sR3(zUUHk_fVBzxOE)c)j&}>#(2wGB^ADmvX-78tN}9+sVp>E!nAQ3R0iy zks=K}MsY^#o8;=*kdX1}=rlb&y?#MV97nrH?vU^Vemdy*LsPdTag&*7n=!TMkpvN?tN=P|{pg;ya&8w(39 zH5BaMP#w@LLv~bNe8^*%%X;dWAy;VF67R#KqzMh8yDA|7&8Zln-fLu#U_;o#YG;So^in^*P#lzi&&Li z9qmHJmuuOtV8ACjRpQzC-njmsPhNoP28#V}jZ3FO>-P`r``=8Un`|Jp$d3>-@lViL z1tmq_g{6+vvSl77`Ute*8qds3GiF0bIP@H^$nQn|BA8|1b;0I)7->4+BN%k2uqj)4 zcNYHj?MA%H5TCm=a^J7^-d?8-wd*+Dwf6&9^MkbR($*Ey(o+A@;PvJ%AcVev~cm@D1K+cxy z{T%bA4r-+D#bV5G6VRV#wt*QpL&_%UG4nfJ>^#J^HF z4AgsJUu!vGE$%A12^>SYtJ35+QmA|!@-83MXYpSYELrRl*@}k{)O~MJwcN!?4U5~fG^3qctR1dh zY#o_Vb+}pJr;M}RY(_1{9JS*He}~1p zJEP{G3!%>#Zi(|n&Q2LOEl|DFFqgEadBG8%03QvPlJxfcUs%OHfUzIIF?;iO9bQC2 z5+ti`HG*En>p8JVfCmx4`ZE1!67( zRn@Nc9f20e8G#GK0h%jD^ZHSciD(N^3Y-#)p#ux7!=f1{zGhOd-;XS!Ad9*ixIOr z?ki~f?6$XydAeVmoxOSUW?|ksH90vrlGoAeFP?tYjE5 z7EZOt3DghpTnN+Bcgz7@BS*f2UcI$dM6J=$2y^j|!H0{9s#+lOWj*Nr^ zQ(&C2TI(@(8_lyPy1Kd`+#7rXLbrhT0I{$`y46!@8DOv?87r?aW4sj#=`H+iM$d4twu&^rGi z`vYNNkFJ3t0jo<=ew)=gw{D#~Y z#f^dN<3BRl^*$b}`s}}2I#;*%;Z*CZzrOYYjG2U+kx}B z7wP4-mr*?rI&s5K#3}dKBJ32ywT?G$^i_P|4tnh3;`!R-IfYLPDIp!M+-W{+5Fw>^ z1RiT^lk+uR{^<=3#afPI)vmxW!Ku2@Ml6y0zcz4iINB-tNXN~sy3q)TR3I~j;7ohX z8;movB2tuO6S1g+6cYV2TfvC1jR}OH1KqM`(H`F{zO1}O5AFgzS59$y9Liwf3~0-l z@U*yW%{9@?PT9x?Xl05Qqq=sRAtvVMGJR)tIUasDf#+TV*rWTs#Wb%fQf!I~V74IE z`ZAz2+^l_U$N``U@n*Nni2!%f$vcKyw73YQ`x_AA&ypvP9!W&yWZ4^iYqOdYBkh9{ zX>YxY#@lAh-M;#$RS#Wz9!kAb(dNJ;yNL=*f&;ed_a=>~IdUsHCC@f>T(>xPb+&9! zW~=BP@a?f(9Bb8RC)tji#p`YS8rYmDUxn>aWdbm)8EYr7<>W+ut2&XDP3(srQEpdT zQq$p2g>iVUa&7KX*M+NNlG}nDbi7%F<;uFFbLbH0!)439gS-%3A8^nvgNI(`eq{Ae z7w~4^l5)bn=n;^-Eu$)-G%5@29&2ASaho-jeXt4C&gYp&QG*7ggW5=X_l^BuNehcd zO^u$hh?H)#Sb8+d+jmQKhQ{_8X&(Me6SmFVXt?}oERtk;fsBg3xWsQWO($noGQ8+! z=oY*qDZ^)D{p5)!j4`tI?-U*QfGMYL6)3ywUHvvb=n8elF1n;IX-K^RHWU zZUWR)Gei^~C%?qDj{h9W)I6-YW01jVr2E9NtQx&5oK%dp)>=aWi}KJPkT41y90CFu z;`!^qtsh8*Ldo=UKb#s}TzpqBwqpXJ)}P8!y%KQu&DEtQ9jb4zBzVa=X-&WX{LzPn z<}!NH+QB%gKFaXh4X2vw_kvPO3+G1|BI-oV?j0X%UoIN|I>HXtXT%U6(#rw%yKPR4 z?XTi9?jL>cq5;~-ASu5){jKO);QHkQ3=J4N6IidZAsnH82w|N@F$)=%Vtv2kizrPR z_!epT`Ee&->@KXEWx7|Xw_W{*rBY9!-e=RR*(O2GMae;swgfqhipmDdOI$Ov#jlI> z-wI-w1@(cO3WUH$&y5&~l7)%9*SlBfxLf0;szS|AiL?0wdUuy6p`O2p8Y`Xm2l= z&_@@Y?TJ;YA9^AMHTw~Q2qxwp!e(4AH3;sHchZ892XDbTvRIM(g-PV2@OJ6!WGDkY zeHNEaWpUcK{TbjyO7~wK4lDP^hdC{tf(G#MSCq_sm=srlXw9LC^Zp(hb`YoaFV^n8 zMY2rMrB;{>dnCCq`SJk6;%WPf1v(psK(v?Qv9RsXWJ_l~4?7qAf?{<~={o9UE`eNp z=NJvZ-|CC_!WiwXe~eACj=wvrQ1Usn2E@h^I^eOJvMeH>5ssheAMb>4s% zaCQ8zQWuK-4nSnbk{{`ew+1IP801svG29wnP8BTgz{aY+RzdQ`qp>HUj@a$ zq7X?tIsQ}lD&Im*R!*w7OAf-=b2=6rwz-{F=ii^-?C(?4b>1{ct0h^|S-S&?VxMcz zan-CQG|c)_BfDB)KV)WPb-!F2+TO5oO;0e;pPrq%0OUHTAa1JflUF!OycUBJ`Ay~T zwvfOMF^}@%f$+sWYEOptE+%9FFdFW0C^=rYqOv52y4o-gzb-E?S5ED&SJ&QH9pe&X z$F+j*NV!LC{y{&saxZ%Dp1{Z~E+NAkM2Ije9pTE^=XCE3U+9jsJAo^(E{2E3*~05(*CyIf{olh2Wp-l-@i+$z6Rl6fj2$`+s>)T z<5G%rPc#Q$E_n($gHZH7In8InJJjiXd^l*_Z%<2rjTsvkH%evE+I9t`D8o$vGJ<4d z@D#cpUF8?h7mz?F=YPfe`}uxF^owzmE0{=S2zowf%0wXAniYkh46d~a*Z|(1Ej#WT zk~X)#3(M@vIRSi|o{Gxe>3&X030S1%pJs?r3CAVaj&*Q?!Vh~CaguZ-7=|V^fA{<+ z&;&hpSOYQ9PFXn0`3;)b2xX>|lij&Ne5nKs!IJl0zq{Yvny|OeCs1ZZ;+S2YbQcL@Q0X`ThGvIcN2$lq@|?H6f z{=Hlk@3^03RlbJ9_*g)c8HotXOz>rQG=Lv2FJoa79(nPZ=(ke~^2i=F?biY1kCAso zn!^{3YtR{)7w?y5+Q6xxh2QaM1lHy|B|%WH*u-K^yn=24c)-SB5P36R;^5KgNfqR* zNl!F|W05o926}p)^r4cX`t{?zb5bn~u@Kdrg2x-Q^bWnKRP4OGMArn&D_~LWH*0y1 zSWpJ?Bxp>@iAyy|zitz;AAnQ5^gDpL2Cc_qN!u~bl{-jEN>d#G+1c0Kyd3wxxxo!nq{myD>Yc&)v==%XnwuZX)TY9SgKbQ5ZoOZ37WfjsE#5^N# zL6DG;GrYZKcfJ3jvkb*Yb3Q{+yp!g2Xo0p7yb=+zj&_GIA!7FLM$uP?aqwr~!2h(H zITpsmuS?bUh?o9?vXNmwK97qOX5J!29Wb42JYBq{r1=SjD(F7J;!+$UrHZ#8pFIBx zRwA3xeNj=W%J#ai(a|!#Ms0zZJJ$Eb-f02%$h$pouyuRra|z1jx&ORi)7c8ebOuHo zIfL3p)Yk9#Urf@b{pnB1Mg)MUTs;SmXb%4w6VpAU0W1UGUy;Y}6(85Tv?L1NtwE&} zPC?C_Gm(Kw?0hSEzYNmFo8>r-J+^9fBk)kbz5xL6?LJ}rLa;x*p+%Btei z`1fpn``>WDea5(BZXXDD+F}_<+@^)Co@4qiEF0$mr?mTd6GhT?3(E&;UxS zpGNQewGPp0Fa86f^fYdMzyRkvktAy@&WVgbkKMRpY}L zYmM4-_bj0XJ>1P;rpt(VwyHCEtI^av%?`8ZfQ|cEcI}~tzyTa5!+fDn#@Cm(C&=lD zEy}mtg5(fpe6?9g@fp8w$>tcual7n~qrZd>1w`2^y8x_zA${W*k9eoxKkafq+6vzU z3d?7lP<=8;z7a^pg@rykGgHEXVXj#7d-^je)c|HRCWl$4AAdOdPuAP?Q`-Em_Ln*( zhgt|$4~5=;K)m|kS(;f=GPJlCOpGU&z^bSH{5gfIJ;u>8bM-w@k8`N5k~3`Cc`ED%yDrp%zTw3g#?t8bUOL; zk{I&>9>=M1l=zK}Vyx~<7IK}P5h)YPS;f=SEMvVr02BUt*%O^5z1Q7X`wz2`u25^d znc%D=%s}R8-qW;mIA#C*I$yv~m#QqLhh!O4ju>Bo5E`r)b0;%l~=-`vS{%;b32Vh-&ctDQlHSow_%(f6fY_D?1}^s9x^Wk z5W&9Sr+TzG#V;f@*@=MbE#7o;eT_F{v$xiiUwz78*9c1fu#a9>e0CG9ySzy%b+BwW zwrKYg>>)+Q9+P6^VY26b!Sz5o-j91P6&xcd_=1lv^mwr~%%I_X5~0MoI4* zM@ei1UgNy^Gpw8te69<^7B$}leQj3H6fGO8DGoUlt4&DKY8!dV!u_KyD2iIFexm-3 z?KtY;Bdt)kwK`UJlm5wXT~T9=tRAJc=U@s1j;%b@?zHr%yr1=!nx!7mjF1yY?|)~= z&@JM|R6`v53sA`%KQNx+^OCxjaE2yiMcU+XhH{9VY?ZHBch8Pj>WM7~=fBoN{~Yf; z`0RkHy;;T2vBv%AwT-w9S~Bq$z!Xd@EEq-e$u5%?;$6$0c%ICh*!*~bh+%enBaUhC2AW}~R1KilDfrnihFa;`vu z-hqMVELzxxztRhwYN@)c6#M$>RwPz}E@?;qFM^go>9!I~ze!sE1@Rlf7^3fk(8nm} zjK#Nxhasz}y0f_hf>a2EXfB9^c(kdJxGza5JD>)o##AD``UVC94Pj>tJiJ|Mg8QMx zi67)5es`^dv=+t=oaHqx7p_J|cQ?fTz9X4$hf0Pegxz!HzrBD)he)O1J?G(-puypS>|S1?To2z&1?MVz0NA1mAbjju2LDOT}L!^tc`u7lST zW@h)z&sC>POiY$g$J#V;-U?p~U`5KtWlKU`p=V&o0KxN?JNvzQYuh<$w&VRSnlObs zSxhR6(04#arWSGIJZP(Yt(wF84!IqNTI^~i#SUw82d%X9?b#pxIXC3AFtIZH<`z7b ztVLKOi)qxWUX9`wogJ9AF*7m-o9?vazKX>uDtlOs`-Mq@H!?>Roq1@Up2<)7ozFQ|kdng}HGV9Gm1+LM)x zu<2BNr~7GxXakR3_;w4j=kXs(1+BY&3>S*L-=XW5Gh}XN7J8Qin-OuUa^E^19UZ-+ z-8qKJdvDMPc9Qh(xMU*1O9mC9_k@Y4uU}2KGhj9ojEi|%5m-z6n&BJ1eOrGB#QWQn zoxJhFD9RCc9Wh91q}AouCYG0p%9fyqD*O_o3F8DtlP|5`&re*f^uJw`fc}E!BtDZ) z<*RR8?*2tONYZbw+5NH|5UaIQpFU875Q@JLHKCQKRLZ+$D|3Xo&*SZTJHe^0_I#LL z!S+oaf%2&L(dOK|eE96`?~TnAbhUk*I`-WW56gYy5x9H^Pz)Uygh*DLLGonkN{oRg zC|IB0ClxEOK(zh$Vtd!?sK~$@s?a3*m*xlNfg6}FLbiRqe*C8IZu?nPBq(_wkPmoy z&0oUq)PUtoH8yaB>n(fI|#WCfDS_WFn7G11;(AW3G6w^ri&Ty#%*#Wun4VX*ZY5*o2<1>>8n)E z5Q_-x=-d;c>E4fTtGl&)jtZBaw-~96;Nry|g0w&wwU35XmT8X&0(++RBMv%tt?cBg zIj>FczbD?TS5Q?q3~}d|=qsWCQH(Mp(I|{n9Gy_0d}4Rv)*|$+j1L1bWT4ZzlNB@3!2p>?5Xzey^BaT1)P&@O(sMQIQzx5WqawFyl+4 zHxe8t<-WmlKAOQs;*Lq8|HSFK{9NW@qS_&bw4tTMSp0drYqU!{jIg|~Hs>z8yf1s6 z9HMEMuR@#t!we}fLnC*uSNQ*0_Tp2Y?!cS=dqVSfpT^+r{<~J4KLeEhcM4s%)5Az6 zDEnJ^WngwZK^fsys!v%?k5PK_<^!g*Pw3YPw|!@N9!TDGkF&!naLL--G($XN^v{4( z%mPZyK0Z>V?VKT?e3VH=bgm|MyPDU{rTcF-nppJG^a0Y za!se$U#Q2-$<;PB`h@YpgU+8M+YhzL92^{=RoyCo`o(#aO=;W0@s}vk|58of4zSLW z!MEblOf?vmLZoE>n3;*d?B^&>-~fYz$qLK1;}XW8y*>LaLcFl`UpP_nXUgT|2IB7= z7C-8KZ!p&UyWLIiDGEjF01A zJ;_=BOT(NBTGczGB{-3IxAsiw7czn|yy%m`wUwl&;`8{_x3zBT7Y80Qs<1y}=e&*$ zxq$0d^&hu#j@T&=F6@0A5BU{qMx(oT-|oafr<+E@mmXHfSKflalqov@Q z7>WA^z^Q|Gp^3aWU;8XIOQbqG+b@J=k+Jg5l<*@I)R|X|G2EJ6lCJo9PVp49e0+RN z1vGVT<;&StpLK3g(@qtlW5jwO9M5{!xl-$@nnWG_`jxyqzyyXfo`lfHOhvcCIs(ed znWDPRl8n$9FF!gCF)DGADg~b|*dQ!^u?Y@&ES7oLTO7njwzBYSj>)Li)TD>IE)tvj z`WVRu&zdaw>z})J_-40dE&L3{KcilJS5I%se^iknF@SWNb)s{mKo!6vmOhV+kgUKp z_CPU__Mj@lbLM4lOHWI%fpVHq2a_MGmI%56cBht{y}{j7q7(Yxrln&^0wm?C_$g$< z=jWjwoR*HQnViD6N>mt@GozpH22ngvFp)^o2XP^s^g6ygTj1_DE|fmILt*giM_a4# z4UiGGHTIuI9a8&BOhIw>^v01`5v7Ydg@k0?n(g|?bH?WPH4oWRh31}*ZKg7Lgu8`YlSQLw3is}28j-v=9~9@=7gQnaC$zGB&`gH*S=YaeR9mX!-E>Jb zNmhL}UughnMhN=Y^=nZun?M{1z?;Q~#12xAWV|FLUzik`1Z(!Rt2pG`qir@g!#c|8 zihAjRghGZ8)xpN55M{mwh9L(&hacbjiUBMLLDjXiKG;3bK&CiiQ1V|=sQ z*uPsP;6dW}GDtezYD;LV@YQuC3?VmRE} zd+Rmp^d2S#9^17ptTwIenaMDSX(6qzuLs3J3))4uT%NXPo_5W_!hQYU7BJ1K4to3q zeg)o(oyM=3MNFo7$J-6?w16rAohsLMs!~M;9`sw7zA+-Wh71ANP8xS!wqYjU3k+wa z2&fHvb2^AyUW%eLkeqvX?oy5(i)oD~NlWr`J;h)+KpT8}-OS2AxcH@ndhNzYkWC~e*4MN>+mHvdwvtYO1cOsNP2BsgxHt#@3D)q! zMiH^fD&i-dBLqZWFo*xVDxMJ$rNWuN$tV0Q@EcUSKVMh(XHUAbu=0HRxt;SO={pW^ z3jo&jlb119rW~y;m0@#H?pbJB_6yDATOZBZtgP0FydA{*qLvMeh@Lg#{7|WtH#;by zQK)%7E{kT2R$4NGt1T>g)eSD{TIcmYzjW**CHlXAuW_XRT2e9?FHfqal?U@)Lj0eY z=8u$iyg@5LDyWh$ieM6bRspSzkvF}3J?VZHqW1F=Zlo6DiW1AK7I)L{v#|7@n~v3S z+*0t5(@UuR%Al)Cdw`E^7Rp99WY||bf8G};T%y%ntMM{paxF;KX6uuj!`9iartEfWI%o$qhFmuXk$QNL6%geahGXL2o_V?0B z!SwM^cNLEfN^hgAR}22!!?rDrjkgM!wWjr~v$GoW$70P-0_j}jZvBl;O$|q5Wn!A3 zd%67~>lKfd!RL_w=sW)zw-}Jx=F&bjidqRLInJX8$*@#kDAvy_xAyNy0ypDU09rjb z*`=W|plBWfnprvsxBX_@CeU8;O{BbA9IRID?{ySw6pKg?2tllRHCO2@v7KZ4eQG=( zDcxa7l8ySb3?XuFkF!#(<#EwwUS=e#vyWc-hYhrgr;E#y6^|4ZOB`1s8~GYoUtvDr z7O9@2jq?N*@m)Qqm3Z}MLA3;E=wmE(mIVbGj$cJ81o?7R^X4H&Nau~}utW0bXOEsS zCAXMw zJ~a|g9_Y&Rr+@!x$k%eLrRSN=t+(j?B?bO6g>2ZiTq+XS^UX~VAJPIW(yTD)6vZbS zK{S0kx4U0&jWe00N4p_dvrvtg`Mqye|70jVb!Sjgy`pm}-u8LB8JrJ<)R>({nS2~xNQV!<3m4opXjFx_^b6U zDz%3w-?8eHLZkRbV^GA6@mn!*6B3q;^4}v#X}typNU6sO^`^CpD9m4=|DVKZ^cJl4 zL#7QV0+Qll_Vby`ql&*H7~Fc30HRf5$|>c?#=&w616%RYBSS=BHr)B<VA;Eq#p%0h^EGu(STDeHdGHa&HUkbLubs z98=(p_}B&n1|F{u%U7x-sLByQIg*9isS}<$DEg{wqh_cx(A7kxhFYBma3*A(2M7w*s9*t}l!}y8z@+n#h^6V#im2iKB8iqgX8X|e^t7L! zl=Df0$ECRV+4q#^h~Y^YdCRUbTCoiz_dE7OWED02j7-)2-wU9G-H-L8TX|wxEW?{K zF7y_?VZ6j2>m4q>&^okUEY-6rDj1^@C$4v9&aHjwG;0sJyPHMbor9XEf!{pB&&<4$ zn?U?k``dXRnODIlZ9MzBXLz*Uw#jVrrjL<_@jX5#a92_K5T4#nLSk;TZ1B2rC$2Fj zHvN$G2{i|g3Z-jmz;B`%Lkk1fT3R54qWZtRfbZD%1RGo08E80O?>@Y!i6{sySCo~l ziBsn4f-W(msHmt=tu+i;!1ubauuw7z(+x-jI{5- z?9kLRc(|b&=d&5_<_(-@BPox2%1H`rC1^(vt)HO0K7$pV34IA#70~4XB%U2mG9Sv5 z@R2*-#Tw@nFK_D_MmxzxZ`VSg(oj=-dG$zvW@0($g>wx3paEb&TMUG6Tosa%EkKeP za`4-17=!L1B+=Qc7pV#tvkKf^E|JRf09+9k@jfw?cDDB@_&2^0h#)rrl`SK~K-Xq^*7IWV-H|H$OLLv~bA54Z9y8Mqakiek8ZVhf z^QaC-KXFU7J#Jy~ur?sR>##AhUNORTst#xy7;ZU~Ub1vBP&AgB6RVN^35yE$nf78&oQf6o=TxVbC!tX} z7ruwG<2SNaAhzQ8bDHjI7&1bCdLP+^!2O`w!6b`BDe2)EKmh=o-|-#AZzV;ivI67PU5L`$wgmqvy%{ z$f>lA(3*O(cLd?BOF;zsjWoPgbRQ4F%LhqVU^=%u+_dk=Ln#5Y1Fx$Mq+kpD-d^fb z21QN(`D-pxElmW%U&X~}mS0$Lj5s;-_4IQx0`HtFr=*fA`Mo0R%f`3BNQ)!55< zP=CVIq?75_oglZE6pwdH3H^}}ebXLe+}#PhK2X|xFWq-2H8YTnhzjHNcmpDzHOf0J zHQ~wR`pYklsfu_%avW$-(9BiYfi~PvFy)zp)Og=bVsivwd?1Aklfu%nVDK+FTalA_ zV+Xr#x4IQVyfZnvQ5pr+%xJv77V%DDA@g#wq>AoK?F0)i8^-Tzzu0Qi?wtkbku2%b zCaJyHSCBf~Vf#@$uraFBU9CTBulW7;i`)P=Ze#+{@hUrmwXUszV$XnQHlN@x$~Gps zQkM-^Yq-(STHM;eZJvG%<$6&0o6KVEV6Ai-1G&y~=VBj*otiIu*-Ly`)xa#Sfi z5HUOBKEYXdlX$R0{?s!rDrT2n40U9<)n?H4t^yzLo+Hf62?-bd5}WtZf;S@Oo!bvKb`9aS6^8|Tc%Q8@4W`=_o2lMr+-JTJqYA^Bquqd zmzRL8xPSUr?vwjl-q!(HJd0<*P92ok0RW-g$TuMy%k<1O=*MLbmwznK`&RE)o(*t4 zQ5o?Gv@4}HIpeSXN}_Ve8aiAd9s>S-(PZ>Z4Ue8dn0u1( zZ7%9H6|Y4E?|W2(0q(Q@r7q17@}l<78RWkS0Brz6!V$EM4#G#m5-zI|XF-?o`3eow zWDJU0YEPrzkrh-CoFvA?C?>K`;l?G)-3MUz1YGZyZ?h2V6aU-*dq<~{o$ZnIMiZE7 zAsfHvwXE97PfwrLbSy9qII9r2N!juKge8B(ZjG4YNYBZMW5&^kdT3bTESIZynw(Zd z$&z;|Vj)A!JYErI?-u)#P=GlvFE5Elxs#n6!La=5apXogERk}Lx(>5_9D1Y4`uqEV z0kiR-`eA6TB;m=*7Zs1@wzdG^12ptX^_-iISJF#9B{%cX*#%mTb`aZpVb>2w7h_6J z%@V`}^Ba^t6o@mK8~Wt;(_09IPDB=X6|)>Gaju$o9Pq9!z$VbZyzbP4tSkY%xrZ_Y zU45@!y4l*6)GzFh-72JPFhE$E4$VUtC#WGJZdu^}BmUu0CmbRf?lSBWs)b_e4_C0-)AUHHP(y9E^w zV&2(&K3dItXznA~em9ane-TN0k_&H$2gvR`4wMQ+piB5l7LPAX@suAW_=iZer?scY z&EIE}S^q|wr_e>smbuVr@`qw*{ zQc}7-FLFBTQ@Snc?(oPom)?4^!ueiJ5fr-SldV~lvXw#7r>;o=U^#!}t2{b|=igeK*9=`17aFeWrn;czRS^at5O$g)Gx0ReJfy z2luXk5w3`{vW%XK>v#GOH#9WVsbcfThxGAZ%Xtnf3t#M~rN}g?m-CaY+r&x~g}GOfGX2v6$~_ zgv(x748@v}!D+3wF~>rb!h`;O;

HE2SDBx6wF9AvYhBVx2L~3AW+4n^fUJS?+W` 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

S=6^q0CTE1Fd8>ATM7#J_U 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

mQq>`<`}n%HJ=tu2%+ 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�Ix^U89xWxRkC;c*}2 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�(qP~n1*8T+2fo(>>(q_q!mm+b8{jiC1p7HYLY7D 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/` ; le tableau de bord -ne les répertorie pas et les omet s’il enregistre la liste. Configurez au maximum cinq identifiants. Avec +conserve ces identifiants déjà enregistrés, même indisponibles. Configurez au maximum cinq identifiants. Avec des sélecteurs de compte, un choix natif non qualifié peut se décliner en plusieurs lignes de catalogue qualifiées par sélecteur ; les choix configurés et les lignes annoncées ne correspondent donc pas nécessairement un à un. @@ -178,3 +178,11 @@ l’outil natif. `disabledModels` et `selectedModels` de chaque fournisseur restent des champs de visibilité, pas des contrôles d’ordre. Il n’existe aucun paramètre distinct `modelOrder`, `providerOrder` ou de carte de priorité. + +## Préréglages du sélecteur + +Dans **Models**, choisissez Par défaut, A–Z par modèle, Par fournisseur ou Instantané des usages, puis appliquez l’ordre. Les identifiants routés actuellement disponibles et `modelPickerOrderMode` (`alphabetical`, `provider`, `most-used`) sont enregistrés. Les usages conservés sont lus une seule fois lors de l’application ; un rechargement ou un changement de modèles ne recalcule rien. Un ordre personnalisé ou natif complet reste intact jusqu’à une application explicite. Par défaut efface les deux champs même sans modèle disponible. + +`GET/PUT /api/subagent-models` conserve les choix enregistrés désactivés ou absents dans `chosen` et `available` ; `pickerAvailable` contient les identifiants routés admissibles. Models envoie `pickerOrder` et `pickerOrderMode`, jamais `models`. Une sauvegarde du roster seul conserve l’ordre. Entrée invalide ou échec de sauvegarde préserve l’état précédent. + +Les plages prioritaires et natives restent en place. Les préréglages affectent le catalogue Codex et les groupes routés de la découverte Claude, sans modifier le préfixe natif Claude ni les profils Desktop explicites ou la propriété des alias. Les rangs de guidage OpenCodex et les réglages de repli sont conservés ; les cinq choix annoncés nativement par Codex et le défaut recommandé peuvent changer. Aucun client n’est redémarré ; une actualisation peut rester en attente et nécessiter de rouvrir le client. diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md index 79e74e8e2d..2d33b409d8 100644 --- a/docs-site/src/content/docs/guides/model-ordering.md +++ b/docs-site/src/content/docs/guides/model-ordering.md @@ -112,8 +112,7 @@ have expanded into selector-qualified groups. Use `subagentModels` to choose and order the leading models that Codex also advertises to `spawn_agent`. The dashboard's **Sub-agents** page can reorder bare native and routed ids. Use `ocx agent subagents set` or edit the opencodex configuration for exact -`/` choices; the dashboard does not list those choices and omits them -if it saves the roster. Use at most five configured ids. With account selectors, one bare native +`/` choices; the dashboard retains those ids once saved, including choices currently unavailable. Use at most five configured ids. With account selectors, one bare native choice can expand into multiple selector-qualified catalog rows, so configured choices and advertised rows are not necessarily one-to-one. @@ -171,3 +170,11 @@ not reorder the native tool's advertised list. `disabledModels` and each provider's `selectedModels` remain visibility fields, not ordering controls. There is no separate `modelOrder`, `providerOrder`, or priority-map setting. + +## Dashboard picker presets + +On **Models**, choose **Default**, **A–Z by model**, **Group by provider**, or **Most used snapshot**, then **Apply order**. This saves the currently visible routed ids and `modelPickerOrderMode` (`alphabetical`, `provider`, or `most-used`). Most used reads all retained usage once when applied; reloads restore the snapshot without fetching usage again. New or removed models do not automatically recompute it. A manually saved order, including a complete/native order, remains untouched until you explicitly apply a replacement. Default clears both picker fields, even when no routed models are available. + +The controls use `GET/PUT /api/subagent-models`: `chosen` and `available` retain saved roster choices, including disabled or missing models; `pickerAvailable` contains only eligible routed catalog ids. The Models page sends `pickerOrder` and `pickerOrderMode`, never `models`. Roster-only saves preserve picker settings. Invalid combined updates and failed persistence leave the previous picker/roster state intact. + +Routed-only presets keep the existing featured/native priority bands. They affect the Codex catalog and Claude discovery's routed groups; Claude's native prefix and explicit Desktop profile/alias ownership remain unchanged. OpenCodex guidance ranks and configured fallback settings are preserved, but native Codex's advertised five and recommended default can change with display priority. Saving does not restart clients; a catalog refresh may remain pending, and clients holding an old catalog may need reopening. diff --git a/docs-site/src/content/docs/ja/guides/model-ordering.md b/docs-site/src/content/docs/ja/guides/model-ordering.md index d9c782b3fb..74febb1e78 100644 --- a/docs-site/src/content/docs/ja/guides/model-ordering.md +++ b/docs-site/src/content/docs/ja/guides/model-ordering.md @@ -109,8 +109,7 @@ account selector がある場合、5 項目の制限は bare native の選択が 先頭モデルの順序を変更するサポート手段は、`subagentModels` を並べ替えることです。 ダッシュボードの **Sub-agents** ページでは bare native id と routed id を並べ替えられます。 設定と `ocx agent subagents set` は exact account-qualified -`/` id も受け付けますが、ダッシュボードはこれらの id を表示せず、 -リストの保存時にも保持しません。設定する id は最大 5 つにしてください。account selector がある +`/` id も受け付けます。ダッシュボードは保存済みの id を現在利用できなくても保持します。設定する id は最大 5 つにしてください。account selector がある 場合は 1 つの bare native が複数の selector-qualified 行に展開されるため、設定した選択肢と公開 される行は必ずしも一対一ではありません。 @@ -155,3 +154,11 @@ V1 には OpenCodex の推奨候補リストを注入しません。V2 にはク `disabledModels` と各プロバイダーの `selectedModels` は 表示の有無を制御するフィールドです。別の `modelOrder`、`providerOrder`、priority map 設定はありません。 + +## ダッシュボードの並び順プリセット + +**Models**でデフォルト、モデル名A–Z、プロバイダー別、使用量スナップショットを選び、順序を適用します。現在選択可能なルーティングIDと `modelPickerOrderMode`(`alphabetical`、`provider`、`most-used`)を保存します。使用量は適用時に保持された全履歴を一度だけ読み、再読込やモデルの増減では再計算しません。既存のカスタム・ネイティブ全体順は明示的な適用まで保持されます。デフォルトは候補が空でも両フィールドを削除できます。 + +`GET/PUT /api/subagent-models` の `chosen` と `available` は無効・欠落した保存済みrosterも保持します。`pickerAvailable` は選択可能なルーティングIDのみです。Modelsは `pickerOrder` と `pickerOrderMode` を送り、`models` は送りません。Rosterだけの保存は順序設定を維持し、不正入力・保存失敗は以前の状態を保ちます。 + +featured・ネイティブの優先順位帯を保ち、CodexカタログとClaude検出のルーティンググループに適用します。Claudeのネイティブ先頭グループと明示的なDesktopプロファイル・alias所有権は維持されます。OpenCodexのガイド順位とfallback設定は変わりませんが、ネイティブCodexの上位5候補や推奨デフォルトは変わる場合があります。保存による再起動は行いません。更新が保留中、または古いカタログを保持するクライアントでは開き直しが必要な場合があります。 diff --git a/docs-site/src/content/docs/ko/guides/model-ordering.md b/docs-site/src/content/docs/ko/guides/model-ordering.md index 365ea476fc..e921bfdfbc 100644 --- a/docs-site/src/content/docs/ko/guides/model-ordering.md +++ b/docs-site/src/content/docs/ko/guides/model-ordering.md @@ -109,7 +109,7 @@ account selector가 있으면 bare native 선택이 selector-qualified 그룹으 선두 모델 순서를 바꾸는 지원 수단은 `subagentModels`를 재정렬하는 것입니다. 대시보드의 **Sub-agents** 페이지에서는 bare native와 routed id의 순서를 바꿀 수 있습니다. 설정과 `ocx agent subagents set`은 exact account-qualified `/` id도 -지원하지만, 대시보드는 이러한 id를 제공하지 않으며 목록을 저장할 때도 보존하지 않습니다. 설정 id는 +지원합니다. 대시보드는 이미 저장된 id를 현재 사용할 수 없어도 보존합니다. 설정 id는 최대 5개만 사용하세요. account selector가 있으면 bare native 하나가 여러 selector-qualified 행으로 확장될 수 있으므로 설정 항목과 노출 행이 항상 일대일로 대응하지는 않습니다. @@ -153,3 +153,11 @@ V1에는 OpenCodex의 선호 후보 목록을 주입하지 않습니다. V2는 `disabledModels`와 각 공급자의 `selectedModels`는 노출 여부를 정하는 필드입니다. 별도의 `modelOrder`, `providerOrder`, priority map 설정은 없습니다. + +## 대시보드 정렬 프리셋 + +**Models**에서 기본값·모델 이름순·프로바이더별·사용량순 스냅샷을 선택한 뒤 **순서 적용**을 누르세요. 현재 표시 가능한 라우팅 ID와 `modelPickerOrderMode`(`alphabetical`, `provider`, `most-used`)를 저장합니다. 사용량순은 적용 시 보관된 전체 사용량을 한 번 읽습니다. 다시 열거나 모델이 추가·삭제되어도 자동 재계산하지 않습니다. 기존 사용자 지정·네이티브 전체 순서는 명시적으로 적용하기 전까지 유지됩니다. 기본값은 사용 가능한 모델이 없어도 두 피커 필드를 지웁니다. + +`GET/PUT /api/subagent-models`의 `chosen`·`available`은 비활성·누락된 저장 roster도 보존하고, `pickerAvailable`은 선택 가능한 라우팅 ID만 제공합니다. Models는 `pickerOrder`·`pickerOrderMode`만 보내며 `models`를 보내지 않습니다. Roster만 저장하면 피커 설정은 유지됩니다. 잘못된 요청이나 저장 실패는 이전 상태를 보존합니다. + +라우팅 전용 프리셋은 featured·네이티브 우선순위 구간을 유지하며 Codex 카탈로그와 Claude 검색 목록의 라우팅 그룹에 적용됩니다. Claude 네이티브 선두 그룹과 명시적 Desktop 프로필·alias 소유권은 유지됩니다. OpenCodex 가이드 순위와 fallback 설정은 유지되지만 네이티브 Codex에 표시되는 상위 5개·권장 기본 모델은 달라질 수 있습니다. 저장은 클라이언트를 재시작하지 않으며 카탈로그 갱신이 미완료이면 나중에 다시 열어야 할 수 있습니다. diff --git a/docs-site/src/content/docs/ru/guides/model-ordering.md b/docs-site/src/content/docs/ru/guides/model-ordering.md index 290194fcd2..5ff04e4892 100644 --- a/docs-site/src/content/docs/ru/guides/model-ordering.md +++ b/docs-site/src/content/docs/ru/guides/model-ordering.md @@ -118,7 +118,7 @@ native-выбора в selector-qualified группы. Поддерживаемый способ настроить порядок ведущих моделей — переставить элементы `subagentModels`. Страница **Sub-agents** в дашборде позволяет менять порядок bare native- и routed-id. Конфигурация и `ocx agent subagents set` также принимают точные account-qualified id -`/`, но дашборд не предлагает и не сохраняет их при записи списка. +`/`, а дашборд сохраняет уже записанные ID, даже если они недоступны. Используйте не более пяти настроенных id. При активных селекторах одна bare native-модель может развернуться в несколько selector-qualified строк, поэтому число настроенных вариантов и объявляемых строк не обязательно совпадает. @@ -170,3 +170,11 @@ native-выбора в selector-qualified группы. `disabledModels` и `selectedModels` каждого провайдера по-прежнему управляют видимостью. Отдельных настроек `modelOrder`, `providerOrder` или карты приоритетов нет. + +## Пресеты в дашборде + +На странице **Models** выберите порядок по умолчанию, по имени A–Z, по провайдеру или снимок использования и примените его. Сохраняются доступные маршрутизируемые ID и `modelPickerOrderMode` (`alphabetical`, `provider`, `most-used`). Сохранённая статистика читается один раз при применении; перезагрузка и изменения каталога не пересчитывают снимок. Пользовательский порядок, включая полный нативный, сохраняется до явного применения. Сброс удаляет оба поля даже при пустом списке моделей. + +`GET/PUT /api/subagent-models` сохраняет отключённые и отсутствующие выбранные ID в `chosen` и `available`; `pickerAvailable` содержит допустимые маршрутизируемые ID. Models отправляет `pickerOrder` и `pickerOrderMode`, но не `models`. Сохранение только roster не меняет порядок; неверный ввод и ошибка записи сохраняют предыдущее состояние. + +Диапазоны приоритетных и нативных моделей сохраняются. Порядок применяется к каталогу Codex и маршрутизируемым группам обнаружения Claude, сохраняя нативный префикс Claude, явные профили Desktop и владельцев alias. Ранги подсказок OpenCodex и настройки fallback не меняются, но пять вариантов, объявляемых нативным Codex, и рекомендуемая модель могут измениться. Сохранение не перезапускает клиенты; обновление может ожидать завершения, а старый каталог потребовать повторного открытия клиента. diff --git a/docs-site/src/content/docs/tr/guides/model-ordering.md b/docs-site/src/content/docs/tr/guides/model-ordering.md index 336513225a..ea42630154 100644 --- a/docs-site/src/content/docs/tr/guides/model-ordering.md +++ b/docs-site/src/content/docs/tr/guides/model-ordering.md @@ -131,7 +131,7 @@ yeniden sıralamaktır. Kontrol panelinin **Alt Ajanlar** sayfası yalın yerel yönlendirilen kimlikleri yeniden sıralayabilir. Tam `/` seçimleri için `ocx agent subagents set` kullanın veya opencodex yapılandırmasını düzenleyin; kontrol paneli bu seçimleri -listelemez ve kadroyu kaydederse bunları atlar. En fazla beş yapılandırılmış +önceden kaydedilen kimlikleri, kullanılamasalar bile korur. En fazla beş yapılandırılmış kimlik kullanın. Hesap seçicileriyle tek bir yalın yerel seçenek birden çok seçici nitelikli katalog satırına genişleyebilir, bu nedenle yapılandırılmış seçimler ve tanıtılan satırlar birebir olmak zorunda değildir. @@ -181,3 +181,11 @@ tanıttığı listeyi yeniden sıralamaz. `disabledModels` ve her sağlayıcının `selectedModels` alanı görünürlüğü denetler. Ayrı bir `modelOrder`, `providerOrder` veya öncelik haritası ayarı yoktur. + +## Kontrol paneli sıra önayarları + +**Models** sayfasında Varsayılan, Model adına göre A–Z, Sağlayıcıya göre veya Kullanım anlık görüntüsünü seçip sırayı uygulayın. Kullanılabilir yönlendirilmiş kimlikler ve `modelPickerOrderMode` (`alphabetical`, `provider`, `most-used`) kaydedilir. Saklanan tüm kullanım yalnızca uygulamada bir kez okunur; yeniden yükleme veya model değişiklikleri yeniden hesaplamaz. Özel ve tam yerel sıra açıkça değiştirilene kadar korunur. Varsayılan, kullanılabilir model yokken bile iki alanı temizler. + +`GET/PUT /api/subagent-models`, devre dışı veya eksik kayıtlı seçimleri `chosen` ve `available` içinde korur; `pickerAvailable` uygun yönlendirilmiş kimliklerdir. Models yalnızca `pickerOrder` ve `pickerOrderMode` gönderir, `models` göndermez. Yalnız kadroyu kaydetmek sırayı değiştirmez. Geçersiz giriş veya kayıt hatası önceki durumu korur. + +Öne çıkan ve yerel öncelik aralıkları korunur. Sıra Codex kataloğu ve Claude keşfinin yönlendirilmiş gruplarına uygulanır; Claude yerel öneki, açık Desktop profilleri ve alias sahipliği korunur. OpenCodex rehberlik sıraları ve fallback ayarları değişmez; yerel Codex aracının sunduğu ilk beş seçenek ve önerilen varsayılan değişebilir. Kayıt istemcileri yeniden başlatmaz; katalog yenilemesi bekleyebilir ve eski kataloğu gösteren istemcinin yeniden açılması gerekebilir. diff --git a/docs-site/src/content/docs/zh-cn/guides/model-ordering.md b/docs-site/src/content/docs/zh-cn/guides/model-ordering.md index a07d4cf01f..ffe4ffda87 100644 --- a/docs-site/src/content/docs/zh-cn/guides/model-ordering.md +++ b/docs-site/src/content/docs/zh-cn/guides/model-ordering.md @@ -104,7 +104,7 @@ subagentModels = [ 自定义开头模型顺序的受支持方式是重新排列 `subagentModels`。仪表盘的 **Sub-agents** 页面可以调整 裸原生和路由 id 的顺序。配置和 `ocx agent subagents set` 也接受精确的账户限定 -`/` id,但仪表盘不会提供这些 id,保存列表时也不会保留它们。配置的 +`/` id,仪表盘会保留已保存的 id,即使当前不可用。配置的 id 请勿超过五个。存在账户 selector 时,一个裸原生选项可能展开为多个 selector-qualified 行,因此 已配置的选项与公布的行不一定一一对应。 @@ -145,3 +145,11 @@ V2 在客户端目录状态允许时,可以额外接收基于原有优先级 `disabledModels` 和各提供商的 `selectedModels` 仍是可见性字段。没有独立的 `modelOrder`、 `providerOrder` 或优先级映射设置。 + +## 仪表盘排序预设 + +在 **Models** 中选择默认、按模型名 A–Z、按提供商或使用量快照,再应用顺序。保存当前可用的路由 ID 和 `modelPickerOrderMode`(`alphabetical`、`provider`、`most-used`)。使用量排序仅在应用时读取一次保留的全部历史;重新打开或模型增减不会重新计算。已有自定义、原生完整顺序会保留,直到明确应用替换。即使没有可用模型,默认也能清除两个字段。 + +`GET/PUT /api/subagent-models` 的 `chosen`、`available` 保留禁用或缺失的已存 roster;`pickerAvailable` 只包含可选路由 ID。Models 只发送 `pickerOrder`、`pickerOrderMode`,不发送 `models`。只保存 roster 不影响排序,非法输入或保存失败会保留原状态。 + +预设保留精选、原生优先级区间,应用于 Codex 目录与 Claude 发现列表的路由分组。Claude 原生前缀、明确的 Desktop 配置及 alias 归属不变。OpenCodex 指导排序与 fallback 设置不变,但原生 Codex 工具显示的前五候选及推荐默认模型可能改变。保存不会重启客户端;目录刷新可能尚未完成,旧列表可能需要重新打开客户端。 diff --git a/docs-site/src/content/docs/zh-tw/guides/model-ordering.md b/docs-site/src/content/docs/zh-tw/guides/model-ordering.md index e0946db620..d944aef199 100644 --- a/docs-site/src/content/docs/zh-tw/guides/model-ordering.md +++ b/docs-site/src/content/docs/zh-tw/guides/model-ordering.md @@ -134,3 +134,11 @@ V2 在用戶端目錄狀態允許時,可以額外接收基於原有優先級 `disabledModels` 和各供應商的 `selectedModels` 仍是可見性欄位。沒有獨立的 `modelOrder`、 `providerOrder` 或優先級對應表設定。 + +## 儀表板排序預設 + +在 **Models** 選擇預設、依模型名稱 A–Z、依供應商或使用量快照,再套用順序。儲存目前可用的路由 ID 和 `modelPickerOrderMode`(`alphabetical`、`provider`、`most-used`)。使用量排序僅在套用時讀取一次保留的全部歷史;重新開啟或模型增減不會重新計算。現有自訂與原生完整順序會保留,直到明確套用替換。即使沒有可用模型,預設也能清除兩個欄位。 + +`GET/PUT /api/subagent-models` 的 `chosen`、`available` 保留停用或缺少的已存 roster;`pickerAvailable` 僅包含可選路由 ID。Models 只傳送 `pickerOrder`、`pickerOrderMode`,不傳送 `models`。只儲存 roster 不影響排序;無效輸入或儲存失敗會保留原狀態。 + +預設保留精選與原生優先級區間,套用於 Codex 目錄和 Claude 探索清單的路由群組。Claude 原生前綴、明確的 Desktop 設定及 alias 歸屬不變。OpenCodex 指引排序和 fallback 設定不變,但原生 Codex 工具顯示的前五個候選與建議預設模型可能改變。儲存不會重新啟動用戶端;目錄更新可能尚未完成,舊清單可能需要重新開啟用戶端。 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 429379396f..9086c9bf42 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -5,6 +5,20 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { + "models.pickerOrder.label": "Modellreihenfolge", + "models.pickerOrder.default": "Standard", + "models.pickerOrder.alphabetical": "A–Z nach Modell", + "models.pickerOrder.provider": "Nach Anbieter", + "models.pickerOrder.mostUsed": "Nutzungsschnappschuss", + "models.pickerOrder.custom": "Eigene Reihenfolge", + "models.pickerOrder.apply": "Reihenfolge anwenden", + "models.pickerOrder.applying": "Wird angewendet…", + "models.pickerOrder.saved": "Modellreihenfolge gespeichert. Clients mit altem Katalog erneut öffnen.", + "models.pickerOrder.pending": "Reihenfolge gespeichert; Katalogaktualisierung ausstehend.", + "models.pickerOrder.usageFailed": "Modellnutzung konnte nicht geladen werden.", + "models.pickerOrder.loadFailed": "Auswahleinstellungen konnten nicht geladen werden.", + "models.pickerOrder.retry": "Erneut versuchen", + "models.pickerOrder.hint": "Speichert geroutete Modelle für Codex- und Claude-Listen. Prioritätsbereiche bevorzugter/nativer Modelle bleiben erhalten. Nutzung ist eine Momentaufnahme; nativ angebotene Optionen können sich ändern.", "codexAuth.quotaAutoRefreshAllHint": "Schaltet die unterstützten 5-Stunden- und Wochenfenster aller aktuellen Konten gemeinsam um. Im Pool-Modus wird nach jedem Reset eine kleine Anfrage gesendet, die Kontingent verbraucht.", "codexAuth.quotaAutoRefreshMixed": "Einige Fenster sind aktiviert.", "codexAuth.quotaAutoRefreshEmpty": "Keine unterstützten Kontingentfenster. Aktualisieren Sie die Kontingente der Konten.", @@ -560,7 +574,7 @@ export const de: Record = { "models.setAllHint": "Schaltet das Standardfenster {value} für alle gerouteten Anbieter ein. Fehlen context_window / context_length, wird dieser Wert das tatsächliche Codex-Fenster. Für ein einzelnes Modell nutzen Sie «Eigene Fenster» in derselben Zeile. Native Anbieter bleiben unberührt.", "models.collapseAll": "Alle einklappen", "models.expandAll": "Alle ausklappen", - "models.orderHint": "Reihenfolge in der Modellauswahl: Subagents-Auswahl (in der festgelegten Reihenfolge) → übrige geroutete Modelle alphabetisch nach Anbieter, dann Modell-ID → native Modelle. Sichtbarkeitsschalter filtern nur; sie ändern diese Reihenfolge nicht.", + "models.orderHint": "Die Standardreihenfolge folgt bevorzugten Modellen und der Katalogpriorität; geroutete Modelle mit gleichem Rang folgen Anbieter und Modell. Eine gespeicherte Reihenfolge kann die Anzeige ändern. Sichtbarkeitsschalter filtern die Zeilen. Clients können bis zum erneuten Öffnen einen älteren Katalog anzeigen.", "models.custom": "Benutzerdefiniert…", "models.customApply": "Anwenden", "models.customPlaceholder": "Tokens (z. B. 420000)", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9cbf8699fd..0d2f1d05d4 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -6,6 +6,20 @@ * `{var}` are plain interpolations. */ export const en = { + "models.pickerOrder.label": "Picker order", + "models.pickerOrder.default": "Default", + "models.pickerOrder.alphabetical": "A–Z by model", + "models.pickerOrder.provider": "Group by provider", + "models.pickerOrder.mostUsed": "Most used snapshot", + "models.pickerOrder.custom": "Custom order", + "models.pickerOrder.apply": "Apply order", + "models.pickerOrder.applying": "Applying…", + "models.pickerOrder.saved": "Picker order saved. Reopen clients that still show the old catalog.", + "models.pickerOrder.pending": "Order saved; catalog refresh is pending.", + "models.pickerOrder.usageFailed": "Could not load model usage.", + "models.pickerOrder.loadFailed": "Could not load picker settings.", + "models.pickerOrder.retry": "Retry", + "models.pickerOrder.hint": "Saves routed order for Codex and Claude discovery. Featured/native bands stay in place; Most used is a snapshot. Native advertised choices may change.", "codexAuth.quotaAutoRefreshAllHint": "Controls the supported 5-hour and weekly windows for all current accounts together. In Pool mode, a small request is sent after each reset and uses quota.", "codexAuth.quotaAutoRefreshMixed": "Some windows are enabled.", "codexAuth.quotaAutoRefreshEmpty": "No supported quota windows. Refresh account quotas to check again.", @@ -585,7 +599,7 @@ export const en = { "models.setAllHint": "Turn on the {value} default window for every routed provider. Relays that omit context_window / context_length get this as the actual Codex window. Use Custom windows on a provider row to set one model by hand. Native providers are unaffected.", "models.collapseAll": "Collapse all", "models.expandAll": "Expand all", - "models.orderHint": "Picker order: Subagents picks (in the selected order) → remaining routed models alphabetically by provider, then model ID → native models. Visibility switches only filter models; they do not change this order.", + "models.orderHint": "Default order follows featured selections and catalog priority, with provider/model ordering for tied routed rows. A saved picker order can override display order; visibility switches filter which rows appear. Clients may keep an older catalog until reopened.", "models.custom": "Custom…", "models.customApply": "Apply", "models.customPlaceholder": "Tokens (e.g. 420000)", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index ec171e627c..b6eb03f0b0 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4,6 +4,20 @@ import type { TKey } from "./en"; * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { + "models.pickerOrder.label": "Ordre des modèles", + "models.pickerOrder.default": "Par défaut", + "models.pickerOrder.alphabetical": "A–Z par modèle", + "models.pickerOrder.provider": "Par fournisseur", + "models.pickerOrder.mostUsed": "Instantané des usages", + "models.pickerOrder.custom": "Ordre personnalisé", + "models.pickerOrder.apply": "Appliquer l’ordre", + "models.pickerOrder.applying": "Application…", + "models.pickerOrder.saved": "Ordre enregistré. Rouvrez les clients affichant encore l’ancien catalogue.", + "models.pickerOrder.pending": "Ordre enregistré ; actualisation du catalogue en attente.", + "models.pickerOrder.usageFailed": "Impossible de charger les usages.", + "models.pickerOrder.loadFailed": "Impossible de charger les réglages du sélecteur.", + "models.pickerOrder.retry": "Réessayer", + "models.pickerOrder.hint": "Enregistre l’ordre des modèles routés pour Codex et la découverte Claude. Les plages prioritaires et natives sont conservées. Les usages sont un instantané ; les choix annoncés nativement peuvent changer.", "codexAuth.quotaAutoRefreshAllHint": "Active ou désactive ensemble, pour tous les comptes actuels, les fenêtres de quota prises en charge par chaque compte : 5 heures et hebdomadaire. En mode Groupe, une petite requête consommant du quota est envoyée après chaque réinitialisation.", "codexAuth.quotaAutoRefreshMixed": "Certaines fenêtres sont activées.", "codexAuth.quotaAutoRefreshEmpty": "Aucune fenêtre de quota prise en charge. Actualisez les quotas des comptes.", @@ -570,7 +584,7 @@ export const fr: Record = { "models.setAllHint": "Active la fenêtre par défaut {value} pour chaque fournisseur routé. Si un relais omet context_window / context_length, cette valeur devient la fenêtre Codex réelle. Pour un seul modèle, utilisez « Fenêtres perso » sur la même ligne. Les fournisseurs natifs ne sont pas affectés.", "models.collapseAll": "Tout réduire", "models.expandAll": "Tout développer", - "models.orderHint": "Ordre du sélecteur : choix des sous-agents (dans l’ordre sélectionné) → autres modèles routés, classés par ordre alphabétique du fournisseur puis par ID de modèle → modèles natifs. Les options de visibilité ne font que filtrer les modèles ; elles ne modifient pas cet ordre.", + "models.orderHint": "L’ordre par défaut suit les modèles prioritaires et la priorité du catalogue ; les modèles routés à égalité suivent le fournisseur puis le modèle. Un ordre enregistré peut modifier l’affichage. Les options de visibilité filtrent les lignes. Les clients peuvent conserver un ancien catalogue jusqu’à leur réouverture.", "models.custom": "Personnalisé…", "models.customApply": "Appliquer", "models.customPlaceholder": "Jetons (p. ex. 420000)", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c71bd7a045..4b16912324 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,6 +4,20 @@ import type { TKey } from "./en"; * Japanese i18n catalog; must match the `TKey` set (compile-checked). */ export const ja: Record = { + "models.pickerOrder.label": "モデル選択順", + "models.pickerOrder.default": "デフォルト", + "models.pickerOrder.alphabetical": "モデル名のA–Z順", + "models.pickerOrder.provider": "プロバイダー別", + "models.pickerOrder.mostUsed": "使用量のスナップショット", + "models.pickerOrder.custom": "カスタム順", + "models.pickerOrder.apply": "順序を適用", + "models.pickerOrder.applying": "適用中…", + "models.pickerOrder.saved": "選択順を保存しました。古い一覧が表示される場合はクライアントを開き直してください。", + "models.pickerOrder.pending": "順序を保存しました。カタログの更新は保留中です。", + "models.pickerOrder.usageFailed": "モデル使用量を読み込めませんでした。", + "models.pickerOrder.loadFailed": "モデル選択設定を読み込めませんでした。", + "models.pickerOrder.retry": "再試行", + "models.pickerOrder.hint": "CodexとClaudeの検出一覧のルーティングモデル順を保存します。優先・ネイティブの順位帯は維持されます。使用量順はスナップショットで、ネイティブツールの候補表示は変わる場合があります。", "codexAuth.quotaAutoRefreshAllHint": "現在の全アカウントで、対応する5時間・週間枠をまとめて切り替えます。プールモードではリセット後に少量の利用枠を消費するリクエストを送信します。", "codexAuth.quotaAutoRefreshMixed": "一部の枠が有効です。", "codexAuth.quotaAutoRefreshEmpty": "対応する利用枠がありません。アカウントの利用枠を更新してください。", @@ -568,7 +582,7 @@ export const ja: Record = { "models.setAllHint": "すべてのルーティング済みプロバイダーに {value} のデフォルトウィンドウをオンにします。中継が context_window / context_length を返さない場合、この値が実際の Codex ウィンドウになります。1 モデルだけ手で書くときは同じ行の「カスタムウィンドウ」を使います。ネイティブプロバイダーには影響しません。", "models.collapseAll": "すべて折りたたむ", "models.expandAll": "すべて展開", - "models.orderHint": "ピッカーの順序: サブエージェントの選択(選択順) → 残りのルーティングモデルはプロバイダー別、次にモデル ID 別のアルファベット順 → ネイティブモデル。表示切り替えはモデルをフィルタするだけで、この順序は変更しません。", + "models.orderHint": "デフォルト順は優先モデルとカタログの優先順位に従い、同順位のルーティングモデルはプロバイダー・モデル順になります。保存した順序で表示順を変更でき、表示スイッチは表示する行を絞り込みます。クライアントを開き直すまで古いカタログが表示される場合があります。", "models.custom": "カスタム…", "models.customApply": "適用", "models.customPlaceholder": "トークン (例: 420000)", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 63ac304426..a87bf608e5 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,6 +4,20 @@ import type { TKey } from "./en"; * Korean i18n catalog; must match the `TKey` set (compile-checked). */ export const ko: Record = { + "models.pickerOrder.label": "모델 선택 순서", + "models.pickerOrder.default": "기본값", + "models.pickerOrder.alphabetical": "모델 이름순", + "models.pickerOrder.provider": "프로바이더별", + "models.pickerOrder.mostUsed": "사용량순 스냅샷", + "models.pickerOrder.custom": "사용자 지정 순서", + "models.pickerOrder.apply": "순서 적용", + "models.pickerOrder.applying": "적용 중…", + "models.pickerOrder.saved": "모델 선택 순서를 저장했습니다. 이전 목록이 보이면 클라이언트를 다시 열어 주세요.", + "models.pickerOrder.pending": "순서를 저장했습니다. 카탈로그 갱신은 아직 완료되지 않았습니다.", + "models.pickerOrder.usageFailed": "모델 사용량을 불러오지 못했습니다.", + "models.pickerOrder.loadFailed": "모델 선택 설정을 불러오지 못했습니다.", + "models.pickerOrder.retry": "다시 시도", + "models.pickerOrder.hint": "Codex·Claude 검색 목록의 라우팅 모델 순서를 저장합니다. 지정 모델·네이티브 모델의 우선순위 구간은 유지됩니다. 사용량순은 스냅샷이며, 네이티브 도구에 표시되는 후보는 달라질 수 있습니다.", "codexAuth.quotaAutoRefreshAllHint": "현재 등록된 모든 계정의 5시간·주간 할당량을 한 번에 켜거나 끕니다. 지원하는 창에만 적용하며, 풀 모드에서 리셋 후 소량의 할당량을 쓰는 요청을 보냅니다.", "codexAuth.quotaAutoRefreshMixed": "일부만 켜져 있습니다.", "codexAuth.quotaAutoRefreshEmpty": "지원하는 할당량 창이 없습니다. 계정 할당량을 새로고침해 주세요.", @@ -571,7 +585,7 @@ export const ko: Record = { "models.setAllHint": "라우팅된 모든 프로바이더에 {value} 기본 창을 켭니다. 중계가 context_window / context_length 를 주지 않으면 이 값이 실제 Codex 창이 됩니다. 모델 하나만 손으로 쓰려면 같은 줄의 「사용자 지정 창」을 쓰세요. 네이티브 프로바이더는 영향을 받지 않습니다.", "models.collapseAll": "모두 접기", "models.expandAll": "모두 펼치기", - "models.orderHint": "피커 순서: Subagents에서 지정한 순서 → 나머지 라우팅 모델(프로바이더, 모델 ID 순 알파벳 정렬) → 네이티브 모델. 노출 토글은 모델을 필터링할 뿐 이 순서를 바꾸지 않습니다.", + "models.orderHint": "기본 순서는 지정 모델과 카탈로그 우선순위를 따르며, 우선순위가 같은 라우팅 모델은 프로바이더·모델 순으로 정렬됩니다. 저장된 피커 순서는 표시 순서를 바꿀 수 있고, 노출 토글은 표시할 행을 필터링합니다. 클라이언트를 다시 열기 전까지 이전 카탈로그가 보일 수 있습니다.", "models.custom": "직접 입력…", "models.customApply": "적용", "models.customPlaceholder": "토큰 (예: 420000)", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 9f220ba2b1..70eb364002 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,6 +4,20 @@ import type { TKey } from "./en"; * Russian i18n catalog; must match the `TKey` set (compile-checked). */ export const ru: Record = { + "models.pickerOrder.label": "Порядок моделей", + "models.pickerOrder.default": "По умолчанию", + "models.pickerOrder.alphabetical": "По имени A–Z", + "models.pickerOrder.provider": "По провайдеру", + "models.pickerOrder.mostUsed": "Снимок использования", + "models.pickerOrder.custom": "Свой порядок", + "models.pickerOrder.apply": "Применить порядок", + "models.pickerOrder.applying": "Применение…", + "models.pickerOrder.saved": "Порядок сохранён. Перезапустите клиенты, показывающие старый каталог.", + "models.pickerOrder.pending": "Порядок сохранён; обновление каталога ожидается.", + "models.pickerOrder.usageFailed": "Не удалось загрузить статистику моделей.", + "models.pickerOrder.loadFailed": "Не удалось загрузить настройки выбора.", + "models.pickerOrder.retry": "Повторить", + "models.pickerOrder.hint": "Сохраняет порядок маршрутизируемых моделей для Codex и обнаружения Claude. Диапазоны приоритетных и нативных моделей сохраняются. Использование — снимок; нативно объявляемые варианты могут измениться.", "codexAuth.quotaAutoRefreshAllHint": "Общее переключение поддерживаемых 5-часовых и недельных окон всех текущих аккаунтов. В режиме пула после сброса отправляется небольшой запрос, расходующий квоту.", "codexAuth.quotaAutoRefreshMixed": "Включены некоторые окна.", "codexAuth.quotaAutoRefreshEmpty": "Нет поддерживаемых окон квоты. Обновите квоты аккаунтов.", @@ -573,7 +587,7 @@ export const ru: Record = { "models.setAllHint": "Включает окно по умолчанию {value} для всех маршрутизируемых провайдеров. Если релей не отдаёт context_window / context_length, это значение становится реальным окном Codex. Чтобы задать одну модель вручную, используйте «Пользовательские окна» в той же строке. Нативные провайдеры не затрагиваются.", "models.collapseAll": "Свернуть все", "models.expandAll": "Развернуть все", - "models.orderHint": "Порядок в селекторе: модели, выбранные на странице «Подагенты» (в заданном порядке) → остальные маршрутизируемые модели по алфавиту — сначала по провайдеру, затем по ID модели → нативные модели. Переключатели видимости лишь фильтруют модели и не меняют этот порядок.", + "models.orderHint": "Порядок по умолчанию следует избранным моделям и приоритету каталога; равные по рангу маршрутизируемые строки сортируются по провайдеру и модели. Сохранённый порядок может менять отображение, а переключатели видимости фильтруют строки. До повторного открытия клиент может показывать старый каталог.", "models.custom": "Другое…", "models.customApply": "Применить", "models.customPlaceholder": "Токены (напр. 420000)", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index aee152cd39..ca233f452e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -5,6 +5,20 @@ import type { TKey } from "./en"; * Turkish i18n catalog. Must match the `TKey` set (compile-checked). */ export const tr: Record = { + "models.pickerOrder.label": "Model sırası", + "models.pickerOrder.default": "Varsayılan", + "models.pickerOrder.alphabetical": "Model adına göre A–Z", + "models.pickerOrder.provider": "Sağlayıcıya göre", + "models.pickerOrder.mostUsed": "Kullanım anlık görüntüsü", + "models.pickerOrder.custom": "Özel sıra", + "models.pickerOrder.apply": "Sırayı uygula", + "models.pickerOrder.applying": "Uygulanıyor…", + "models.pickerOrder.saved": "Model sırası kaydedildi. Eski kataloğu gösteren istemcileri yeniden açın.", + "models.pickerOrder.pending": "Sıra kaydedildi; katalog yenilemesi bekleniyor.", + "models.pickerOrder.usageFailed": "Model kullanımı yüklenemedi.", + "models.pickerOrder.loadFailed": "Seçici ayarları yüklenemedi.", + "models.pickerOrder.retry": "Yeniden dene", + "models.pickerOrder.hint": "Codex ve Claude keşfi için yönlendirilen model sırasını kaydeder. Öne çıkan/yerel öncelik aralıkları korunur. Kullanım bir anlık görüntüdür; yerel araçta sunulan seçenekler değişebilir.", "codexAuth.quotaAutoRefreshAllHint": "Mevcut tüm hesapların desteklenen 5 saatlik ve haftalık pencerelerini birlikte açıp kapatır. Havuz modunda her sıfırlamadan sonra az miktarda kota kullanan bir istek gönderilir.", "codexAuth.quotaAutoRefreshMixed": "Bazı pencereler etkin.", "codexAuth.quotaAutoRefreshEmpty": "Desteklenen kota penceresi yok. Hesap kotalarını yenileyin.", @@ -576,7 +590,7 @@ export const tr: Record = { "models.setAllHint": "Her yönlendirilen sağlayıcıda {value} varsayılan pencereyi açar. Röle context_window / context_length vermezse bu değer gerçek Codex penceresi olur. Tek bir modeli elle yazmak için aynı satırdaki «Özel pencereler»i kullanın.", "models.collapseAll": "Tümünü daralt", "models.expandAll": "Tümünü genişlet", - "models.orderHint": "Seçici sırası: Alt ajan seçimleri → kalan modeller.", + "models.orderHint": "Varsayılan sıra öne çıkan seçimleri ve katalog önceliğini izler; eşit öncelikli yönlendirilmiş satırlar sağlayıcı ve modele göre sıralanır. Kaydedilmiş sıra görünümü değiştirebilir; görünürlük anahtarları satırları filtreler. İstemci yeniden açılana kadar eski kataloğu gösterebilir.", "models.custom": "Özel…", "models.customApply": "Uygula", "models.customPlaceholder": "Jetonlar (örn. 420000)", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 39c9e2f0b3..2b7e6ac6ba 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2,6 +2,20 @@ import type { TKey } from "./en"; /** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */ export const zhTW: Record = { + "models.pickerOrder.label": "模型選擇順序", + "models.pickerOrder.default": "預設", + "models.pickerOrder.alphabetical": "依模型名稱 A–Z", + "models.pickerOrder.provider": "依供應商分組", + "models.pickerOrder.mostUsed": "使用量快照", + "models.pickerOrder.custom": "自訂順序", + "models.pickerOrder.apply": "套用順序", + "models.pickerOrder.applying": "正在套用…", + "models.pickerOrder.saved": "選擇順序已儲存。若仍顯示舊目錄,請重新開啟用戶端。", + "models.pickerOrder.pending": "順序已儲存,目錄更新尚未完成。", + "models.pickerOrder.usageFailed": "無法載入模型使用量。", + "models.pickerOrder.loadFailed": "無法載入模型選擇設定。", + "models.pickerOrder.retry": "重試", + "models.pickerOrder.hint": "儲存 Codex 與 Claude 探索清單中的路由模型順序。保留精選與原生模型的優先級區間;使用量排序是快照,原生工具顯示的候選可能改變。", "codexAuth.quotaAutoRefreshAllHint": "統一切換目前所有帳戶各自支援的 5 小時與每週額度視窗。在帳戶池模式下,重設後會傳送消耗少量額度的請求。", "codexAuth.quotaAutoRefreshMixed": "部分視窗已啟用。", "codexAuth.quotaAutoRefreshEmpty": "沒有支援的額度視窗。請重新整理帳戶額度。", @@ -439,7 +453,7 @@ export const zhTW: Record = { "models.setAllHint": "為所有已路由供應商打開 {value} 預設視窗。中繼站沒回報 context_window / context_length 時,這個值就是 Codex 實際視窗。要幫單一模型手寫,用同一列上的「自訂視窗」。原生供應商不受影響。", "models.collapseAll": "全部摺疊", "models.expandAll": "全部展開", - "models.orderHint": "選擇器順序:Subagents 中的選擇(按所選順序)→ 其餘已路由模型(依次按供應商、模型 ID 字母排序)→ 原生模型。可見性開關僅用於篩選,不會改變此順序。", + "models.orderHint": "預設順序遵循精選項目與目錄優先級;同優先級的路由列依供應商、模型排序。儲存的選擇順序可改變顯示順序,可見性開關用於篩選顯示的列。用戶端重新開啟前可能仍顯示舊目錄。", "models.custom": "自訂…", "models.customApply": "套用", "models.customPlaceholder": "tokens (例如 420000)", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 1ba4cabfa8..42ac3941d4 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,6 +4,20 @@ import type { TKey } from "./en"; * Chinese i18n catalog; must match the `TKey` set (compile-checked). */ export const zh: Record = { + "models.pickerOrder.label": "模型选择顺序", + "models.pickerOrder.default": "默认", + "models.pickerOrder.alphabetical": "按模型名 A–Z", + "models.pickerOrder.provider": "按提供商分组", + "models.pickerOrder.mostUsed": "使用量快照", + "models.pickerOrder.custom": "自定义顺序", + "models.pickerOrder.apply": "应用顺序", + "models.pickerOrder.applying": "正在应用…", + "models.pickerOrder.saved": "选择顺序已保存。若仍显示旧目录,请重新打开客户端。", + "models.pickerOrder.pending": "顺序已保存,目录刷新尚未完成。", + "models.pickerOrder.usageFailed": "无法加载模型使用量。", + "models.pickerOrder.loadFailed": "无法加载模型选择设置。", + "models.pickerOrder.retry": "重试", + "models.pickerOrder.hint": "保存 Codex 和 Claude 发现列表中的路由模型顺序。保留精选与原生模型的优先级区间;使用量排序是快照,原生工具显示的候选可能变化。", "codexAuth.quotaAutoRefreshAllHint": "统一开关当前所有账户各自支持的 5 小时和每周额度窗口。在账户池模式下,重置后会发送消耗少量额度的请求。", "codexAuth.quotaAutoRefreshMixed": "部分窗口已启用。", "codexAuth.quotaAutoRefreshEmpty": "没有支持的额度窗口。请刷新账户额度。", @@ -568,7 +582,7 @@ export const zh: Record = { "models.setAllHint": "给所有已路由提供方打开 {value} 默认窗口。中转站没报 context_window / context_length 时,这个值就是 Codex 实际窗口。要给单个模型手写,用同一行上的「自定义窗口」。原生提供方不受影响。", "models.collapseAll": "全部折叠", "models.expandAll": "全部展开", - "models.orderHint": "选择器顺序:Subagents 中的选择(按所选顺序)→ 其余已路由模型(依次按提供方、模型 ID 字母排序)→ 原生模型。可见性开关仅用于筛选,不会改变此顺序。", + "models.orderHint": "默认顺序遵循精选项和目录优先级;同优先级的路由行按提供商、模型排序。保存的选择顺序可改变显示顺序,可见性开关用于筛选显示的行。客户端重新打开前可能仍显示旧目录。", "models.custom": "自定义…", "models.customApply": "应用", "models.customPlaceholder": "令牌 (例如 420000)", diff --git a/gui/src/model-picker-order.ts b/gui/src/model-picker-order.ts new file mode 100644 index 0000000000..9878b3dbd1 --- /dev/null +++ b/gui/src/model-picker-order.ts @@ -0,0 +1,103 @@ +export type ModelPickerOrderMode = "default" | "alphabetical" | "provider" | "most-used" | "custom"; +export type SavedModelPickerOrderMode = Exclude; +export interface ModelPickerUsage { + provider: string; + model: string; + resolvedModel?: string; + requests: number; +} +export interface PickerModelIdentity { provider: string; id: string; namespaced: string } +export interface PickerOrderSaved { + pickerOrder: string[]; + pickerOrderMode: SavedModelPickerOrderMode | null; +} +export interface PickerOrderSettings extends PickerOrderSaved { pickerAvailable: string[] } + +function stringList(value: unknown): value is string[] { + return Array.isArray(value) && value.every(id => typeof id === "string" && id.trim().length > 0); +} +function savedMode(value: unknown): value is SavedModelPickerOrderMode | null { + return value === null || value === "alphabetical" || value === "provider" || value === "most-used"; +} +export function isPickerOrderSaved(value: unknown): value is PickerOrderSaved { + if (value === null || typeof value !== "object") return false; + const row = value as Record; + return stringList(row.pickerOrder) && savedMode(row.pickerOrderMode); +} +export function isPickerOrderSettings(value: unknown): value is PickerOrderSettings { + return isPickerOrderSaved(value) && stringList((value as PickerOrderSettings).pickerAvailable); +} +export function isModelPickerUsage(value: unknown): value is ModelPickerUsage[] { + return Array.isArray(value) && value.every(row => row !== null && typeof row === "object" + && typeof row.provider === "string" && typeof row.model === "string" + && (row.resolvedModel === undefined || typeof row.resolvedModel === "string") + && typeof row.requests === "number" && Number.isFinite(row.requests) && row.requests >= 0); +} +function parts(slug: string): [string, string] { + const slash = slug.indexOf("/"); + return slash < 0 ? ["", slug] : [slug.slice(0, slash), slug.slice(slash + 1)]; +} +// Fixed locale makes snapshots independent of the user's display language/OS locale. +const compare = (a: string, b: string) => a.localeCompare(b, "en"); +function byProvider(a: string, b: string): number { + const [ap, am] = parts(a), [bp, bm] = parts(b); + return compare(ap, bp) || compare(am, bm); +} + +export function modelPickerOrder( + mode: Exclude, + models: readonly string[], + usage: readonly ModelPickerUsage[] = [], + identities: readonly PickerModelIdentity[] = [], +): string[] | null { + if (mode === "default") return null; + const unique = [...new Set(models)]; + if (mode === "alphabetical") return unique.sort((a, b) => compare(parts(a)[1], parts(b)[1]) || byProvider(a, b)); + if (mode === "provider") return unique.sort(byProvider); + const candidates = new Set(unique); + const raw = new Map>(); + const owners = new Map>(); + for (const row of identities) { + if (!candidates.has(row.namespaced)) continue; + const key = JSON.stringify([row.provider, row.id]); + const values = raw.get(key) ?? new Set(); + values.add(row.namespaced); + raw.set(key, values); + const sources = owners.get(row.namespaced) ?? new Set(); + sources.add(key); + owners.set(row.namespaced, sources); + } + const unambiguous = (slug: string): string | null => (owners.get(slug)?.size ?? 0) > 1 ? null : slug; + const resolve = (provider: string, id: string): string | null | undefined => { + const exact = raw.get(JSON.stringify([provider, id])); + if (exact) return exact.size === 1 ? unambiguous([...exact][0]!) : null; + // A raw upstream slash is not a namespace. Use the observed identity table above; + // only fall back to an exact same-provider catalog id or an ordinary bare model id. + if (id.startsWith(`${provider}/`) && candidates.has(id)) return unambiguous(id); + const slug = `${provider}/${id}`; + return !id.includes("/") && candidates.has(slug) ? unambiguous(slug) : undefined; + }; + const counts = new Map(); + for (const row of usage) { + const requested = resolve(row.provider, row.model); + const target = requested === undefined && row.resolvedModel !== undefined + ? resolve(row.provider, row.resolvedModel) : requested; + if (target) counts.set(target, (counts.get(target) ?? 0) + row.requests); + } + return unique.sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0) || byProvider(a, b)); +} + +export function modelPickerOrderMode( + models: readonly string[], saved: readonly string[], mode?: SavedModelPickerOrderMode | null, +): ModelPickerOrderMode { + if (saved.length === 0) return "default"; + // Existing complete/native orders are never silently replaced by a routed preset. + if (saved.some(id => !id.includes("/"))) return "custom"; + if (mode === "alphabetical" || mode === "provider" || mode === "most-used") return mode; + if (new Set(saved).size !== saved.length || saved.length !== new Set(models).size + || saved.some(id => !models.includes(id))) return "custom"; + for (const preset of ["alphabetical", "provider"] as const) { + if (modelPickerOrder(preset, models)?.every((id, index) => id === saved[index])) return preset; + } + return "custom"; +} diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index a78a57e6a8..cd6a034cee 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -13,7 +13,11 @@ import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { describeIntegrationRefusalParts } from "./integrations/refusal-copy"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; import { setClientResourceData } from "../client-resource"; -import { createBoundedFetch } from "../bounded-fetch"; +import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch"; +import { + isModelPickerUsage, isPickerOrderSaved, isPickerOrderSettings, modelPickerOrder, modelPickerOrderMode, + type ModelPickerOrderMode, type PickerOrderSettings, type ModelPickerUsage, +} from "../model-picker-order"; import { startVisibilityPoll } from "../visibility-poll"; import { useDataSurface } from "../data-surface"; import { DataSurfaceSkeleton } from "../components/data-surface"; @@ -217,6 +221,39 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const [contextCaps, setContextCaps] = useState>(() => cached?.contextCaps ?? {}); const [contextCapValues, setContextCapValues] = useState>(() => cached?.contextCapValues ?? {}); const [contextCapValue, setContextCapValue] = useState(() => cached?.contextCapValue ?? 350_000); + const pickerCacheKey = `${cacheKey}:picker-order`; + const cachedPicker = useMemo(() => { + const value = readSessionListCache(pickerCacheKey); + return isPickerOrderSettings(value) ? value : undefined; + }, [pickerCacheKey]); + const [pickerDraft, setPickerDraft] = useState(null); + const [pickerBusy, setPickerBusy] = useState(false); + const pickerFlight = useRef(null); + const pickerResource = useDataSurface( + pickerCacheKey, [apiBase], + useCallback(async (signal: AbortSignal) => { + const response = await fetch(`${apiBase}/api/subagent-models`, { signal }); + const data = await readJsonOrThrow(response); + if (!isPickerOrderSettings(data)) throw new Error("picker settings payload missing"); + if (signal.aborted) throw new Error("picker settings request aborted"); + writeSessionListCache(pickerCacheKey, data); + return data; + }, [apiBase, pickerCacheKey]), + { isEmpty: () => false, enabled: catalogActive, deadlineMs: 15_000, initialData: cachedPicker }, + ); + const pickerSettings = pickerResource.state.data; + const pickerMode = pickerDraft ?? modelPickerOrderMode( + pickerSettings?.pickerAvailable ?? [], pickerSettings?.pickerOrder ?? [], pickerSettings?.pickerOrderMode, + ); + useEffect(() => { + setPickerDraft(null); + setPickerBusy(false); + return () => { + pickerFlight.current?.controller.abort(); + pickerFlight.current?.clear(); + pickerFlight.current = null; + }; + }, [apiBase, catalogActive]); const [customCap, setCustomCap] = useState(""); const [showCustom, setShowCustom] = useState(false); const [providerCapCustomOpen, setProviderCapCustomOpen] = useState>({}); @@ -486,6 +523,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; // Follow-up mutation refreshes retain their existing awaitable contract while publishing // the result through the same shared store used by the initial catalog subscription. setClientResourceData(cacheKey, next); + pickerResource.refresh(); return true; } catch { return false; @@ -494,7 +532,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; loadPendingRef.current = false; } } - }, [applyCatalog, cacheKey, fetchCatalog]); + }, [applyCatalog, cacheKey, fetchCatalog, pickerResource.refresh]); // Shadow/v2 controls must not wait on the models catalog (live discovery can be slow). useEffect(() => { @@ -1593,6 +1631,50 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; ? groups.filter(group => group.provider === selectedProvider) : groups; + const savePickerOrder = async () => { + if (pickerFlight.current || !pickerSettings || pickerResource.state.showError || pickerMode === "custom") return; + const mode = pickerMode; + const available = pickerSettings.pickerAvailable; + const bounded = createBoundedFetch(15_000); + pickerFlight.current = bounded; + setPickerBusy(true); + try { + let usage: ModelPickerUsage[] = []; + if (mode === "most-used") { + const response = await fetch(`${apiBase}/api/usage?range=all&surface=all`, { signal: bounded.signal }); + const payload = await readJsonOrThrow<{ models?: unknown }>(response, t("models.pickerOrder.usageFailed")); + if (!isModelPickerUsage(payload?.models)) throw new Error(t("models.pickerOrder.usageFailed")); + usage = payload.models; + } + const order = modelPickerOrder(mode, available, usage, models); + const response = await fetch(`${apiBase}/api/subagent-models`, { + method: "PUT", headers: { "Content-Type": "application/json" }, signal: bounded.signal, + body: JSON.stringify({ pickerOrder: order, pickerOrderMode: mode === "default" ? null : mode }), + }); + const data = await readJsonOrThrow(response, t("models.saveFailed")); + if (!isPickerOrderSaved(data) || !("ok" in data) || data.ok !== true) throw new Error(t("models.saveFailed")); + if (bounded.signal.aborted || pickerFlight.current !== bounded) return; + const next = { ...pickerSettings, pickerOrder: data.pickerOrder, pickerOrderMode: data.pickerOrderMode }; + // This aborts an older GET and advances the shared resource generation. + setClientResourceData(pickerCacheKey, next); + writeSessionListCache(pickerCacheKey, next); + setPickerDraft(null); + reloadAppServerState(bounded.signal); + const refresh = "catalogRefresh" in data ? data.catalogRefresh : undefined; + const converged = refresh !== null && typeof refresh === "object" + && "status" in refresh && refresh.status === "committed" + && "degraded" in refresh && refresh.degraded === false; + publishFeedback(converged, t(converged ? "models.pickerOrder.saved" : "models.pickerOrder.pending")); + } catch (error) { + if (pickerFlight.current === bounded) { + publishFeedback(false, error instanceof Error ? error.message : t("models.networkError")); + } + } finally { + bounded.clear(); + if (pickerFlight.current === bounded) { pickerFlight.current = null; setPickerBusy(false); } + } + }; + const controlsBlock = ( <>

@@ -1752,6 +1834,35 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; {t("models.setAllHint", { value: fmtK(contextCapValue) })}
+
+ {t("models.pickerOrder.label")} + { + onEdit?.(); + setDraft(event.target.value); + setValidationKey(null); + }} + /> +

+ {t("models.displayNameHelp", { model: model.namespaced })} +

+ {visibleError && ( + + )} + +
+ + + +
+ + + ); +} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 9086c9bf42..817034bed6 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -2548,4 +2548,27 @@ export const de: Record = { "integrations.cursor.colReasoning": "Reasoning-Aufwand", "integrations.cursor.colContext": "Kontext", "integrations.cursor.guide": "Anleitung zu Cursor Private Inference öffnen", + "models.displayNameSavedRefreshFailed": "Die Änderung wurde gespeichert, aber die Modellliste konnte nicht aktualisiert werden. Versuchen Sie es erneut.", + "models.displayNameOutcomeUnknown": "Die Anfrage wurde nicht abgeschlossen. Die Änderung wurde möglicherweise gespeichert. Prüfen Sie den aktuellen Namen durch erneutes Versuchen, bevor Sie ihn weiter ändern.", + "models.displayNameCurrentUnavailable": "Aktueller Name erst nach Aktualisierung verfügbar", + "models.displayNameReloaded": "Modellliste aktualisiert", + "models.displayNameAction": "Name", + "models.displayNameActionLabel": "Anzeigenamen für {model} bearbeiten", + "models.displayNameTitle": "Anzeigename", + "models.displayNameModelId": "Modell-ID", + "models.displayNameCurrent": "Aktueller Name", + "models.displayNameSourceOperator": "Ihr Name", + "models.displayNameSourceProvider": "Anbietername", + "models.displayNameSourceFallback": "Modell-ID als Ersatz", + "models.displayNameField": "Anzeigename", + "models.displayNamePlaceholder": "z. B. Grok 4.6", + "models.displayNameHelp": "Ändert nur die Anzeige. Das Routing bleibt {model}.", + "models.displayNameReset": "Name zurücksetzen", + "models.displayNameSaved": "Anzeigename gespeichert", + "models.displayNameResetDone": "Anzeigename zurückgesetzt", + "models.displayNameSaveFailed": "Anzeigename konnte nicht gespeichert werden", + "models.displayNameRequired": "Geben Sie einen Anzeigenamen ein oder verwenden Sie Name zurücksetzen.", + "models.displayNameTooLong": "Der Anzeigename darf höchstens 128 Zeichen lang sein.", + "models.displayNameNoSlash": "Der Anzeigename darf kein / enthalten.", + "models.displayNameNoControl": "Der Anzeigename darf keine Steuerzeichen enthalten.", }; diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0d2f1d05d4..a5ba86e2dd 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2582,6 +2582,29 @@ export const en = { "usage.scope.machine": "This machine", "usage.scope.hub": "Hub-wide", "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", + "models.displayNameSavedRefreshFailed": "The change was saved, but the model list could not be refreshed. Retry to refresh it.", + "models.displayNameOutcomeUnknown": "The request did not finish. The change may have been saved. Retry to check the current name before making another change.", + "models.displayNameCurrentUnavailable": "Current name unavailable until refresh", + "models.displayNameReloaded": "Model list refreshed", + "models.displayNameAction": "Name", + "models.displayNameActionLabel": "Edit friendly name for {model}", + "models.displayNameTitle": "Friendly name", + "models.displayNameModelId": "Model ID", + "models.displayNameCurrent": "Current name", + "models.displayNameSourceOperator": "Your name", + "models.displayNameSourceProvider": "Provider name", + "models.displayNameSourceFallback": "Model ID fallback", + "models.displayNameField": "Friendly name", + "models.displayNamePlaceholder": "e.g. Grok 4.6", + "models.displayNameHelp": "Changes presentation only. Routing remains {model}.", + "models.displayNameReset": "Reset name", + "models.displayNameSaved": "Display name saved", + "models.displayNameResetDone": "Display name reset", + "models.displayNameSaveFailed": "Failed to save display name", + "models.displayNameRequired": "Enter a friendly name, or use Reset name.", + "models.displayNameTooLong": "Friendly name must be 128 characters or fewer.", + "models.displayNameNoSlash": "Friendly name cannot contain /.", + "models.displayNameNoControl": "Friendly name cannot contain control characters.", } as const; export type TKey = keyof typeof en; diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b6eb03f0b0..fecc7ca243 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2535,4 +2535,27 @@ export const fr: Record = { "integrations.cursor.colReasoning": "Raisonnement", "integrations.cursor.colContext": "Contexte", "integrations.cursor.guide": "Ouvrir le guide de Cursor Private Inference", + "models.displayNameSavedRefreshFailed": "La modification a été enregistrée, mais la liste des modèles n’a pas pu être actualisée. Réessayez.", + "models.displayNameOutcomeUnknown": "La requête n’a pas abouti. La modification a peut-être été enregistrée. Réessayez pour vérifier le nom actuel avant toute autre modification.", + "models.displayNameCurrentUnavailable": "Nom actuel indisponible avant actualisation", + "models.displayNameReloaded": "Liste des modèles actualisée", + "models.displayNameAction": "Nom", + "models.displayNameActionLabel": "Modifier le nom d’affichage de {model}", + "models.displayNameTitle": "Nom d’affichage", + "models.displayNameModelId": "ID du modèle", + "models.displayNameCurrent": "Nom actuel", + "models.displayNameSourceOperator": "Votre nom d’affichage", + "models.displayNameSourceProvider": "Nom du fournisseur", + "models.displayNameSourceFallback": "ID du modèle par défaut", + "models.displayNameField": "Nom d’affichage", + "models.displayNamePlaceholder": "p. ex. Grok 4.6", + "models.displayNameHelp": "Modifie uniquement l’affichage. Le routage reste {model}.", + "models.displayNameReset": "Réinitialiser le nom", + "models.displayNameSaved": "Nom d’affichage enregistré", + "models.displayNameResetDone": "Nom d’affichage réinitialisé", + "models.displayNameSaveFailed": "Impossible d’enregistrer le nom d’affichage", + "models.displayNameRequired": "Saisissez un nom d’affichage ou utilisez Réinitialiser le nom.", + "models.displayNameTooLong": "Le nom d’affichage doit contenir au maximum 128 caractères.", + "models.displayNameNoSlash": "Le nom d’affichage ne peut pas contenir /.", + "models.displayNameNoControl": "Le nom d’affichage ne peut pas contenir de caractères de contrôle.", }; diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 4b16912324..cf2a3df5a8 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2569,4 +2569,27 @@ export const ja: Record = { "integrations.cursor.colReasoning": "推論", "integrations.cursor.colContext": "コンテキスト", "integrations.cursor.guide": "Cursor Private Inference のガイドを開く", + "models.displayNameSavedRefreshFailed": "変更は保存されましたが、モデル一覧を更新できませんでした。再試行してください。", + "models.displayNameOutcomeUnknown": "リクエストが完了しませんでした。変更が保存されている可能性があります。再度変更する前に再試行して現在の名前を確認してください。", + "models.displayNameCurrentUnavailable": "更新するまで現在の名前を確認できません", + "models.displayNameReloaded": "モデル一覧を更新しました", + "models.displayNameAction": "名前", + "models.displayNameActionLabel": "{model} の表示名を編集", + "models.displayNameTitle": "表示名", + "models.displayNameModelId": "モデル ID", + "models.displayNameCurrent": "現在の名前", + "models.displayNameSourceOperator": "設定した名前", + "models.displayNameSourceProvider": "プロバイダー名", + "models.displayNameSourceFallback": "モデル ID の既定値", + "models.displayNameField": "表示名", + "models.displayNamePlaceholder": "例: Grok 4.6", + "models.displayNameHelp": "表示だけを変更します。ルーティングは {model} のままです。", + "models.displayNameReset": "名前をリセット", + "models.displayNameSaved": "表示名を保存しました", + "models.displayNameResetDone": "表示名をリセットしました", + "models.displayNameSaveFailed": "表示名を保存できませんでした", + "models.displayNameRequired": "表示名を入力するか、名前をリセットしてください。", + "models.displayNameTooLong": "表示名は 128 文字以内にしてください。", + "models.displayNameNoSlash": "表示名に / は使用できません。", + "models.displayNameNoControl": "表示名に制御文字は使用できません。", }; diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a87bf608e5..3c1d0b1497 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2570,4 +2570,27 @@ export const ko: Record = { "integrations.cursor.colReasoning": "추론", "integrations.cursor.colContext": "컨텍스트", "integrations.cursor.guide": "Cursor Private Inference 가이드 열기", + "models.displayNameSavedRefreshFailed": "변경 사항은 저장되었지만 모델 목록을 새로 고치지 못했습니다. 다시 시도해 주세요.", + "models.displayNameOutcomeUnknown": "요청이 완료되지 않았습니다. 변경 사항이 저장되었을 수 있습니다. 다시 변경하기 전에 재시도하여 현재 이름을 확인하세요.", + "models.displayNameCurrentUnavailable": "새로 고침 전까지 현재 이름을 확인할 수 없음", + "models.displayNameReloaded": "모델 목록을 새로 고쳤습니다", + "models.displayNameAction": "이름", + "models.displayNameActionLabel": "{model}의 표시 이름 편집", + "models.displayNameTitle": "표시 이름", + "models.displayNameModelId": "모델 ID", + "models.displayNameCurrent": "현재 이름", + "models.displayNameSourceOperator": "운영자 지정 이름", + "models.displayNameSourceProvider": "프로바이더 제공 이름", + "models.displayNameSourceFallback": "모델 ID 기본값", + "models.displayNameField": "표시 이름", + "models.displayNamePlaceholder": "예: Grok 4.6", + "models.displayNameHelp": "표시 방식만 변경합니다. 라우팅은 {model}로 유지됩니다.", + "models.displayNameReset": "이름 초기화", + "models.displayNameSaved": "표시 이름이 저장되었습니다", + "models.displayNameResetDone": "표시 이름이 초기화되었습니다", + "models.displayNameSaveFailed": "표시 이름을 저장하지 못했습니다", + "models.displayNameRequired": "표시 이름을 입력하거나 이름 초기화를 사용하세요.", + "models.displayNameTooLong": "표시 이름은 128자 이하여야 합니다.", + "models.displayNameNoSlash": "표시 이름에 /를 사용할 수 없습니다.", + "models.displayNameNoControl": "표시 이름에 제어 문자를 사용할 수 없습니다.", }; diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 70eb364002..b7f02bdf50 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2571,4 +2571,27 @@ export const ru: Record = { "integrations.cursor.colReasoning": "Рассуждения", "integrations.cursor.colContext": "Контекст", "integrations.cursor.guide": "Открыть руководство по Cursor Private Inference", + "models.displayNameSavedRefreshFailed": "Изменение сохранено, но список моделей не удалось обновить. Повторите попытку.", + "models.displayNameOutcomeUnknown": "Запрос не завершён. Изменение могло сохраниться. Повторите попытку, чтобы проверить текущее имя перед следующим изменением.", + "models.displayNameCurrentUnavailable": "Текущее имя недоступно до обновления", + "models.displayNameReloaded": "Список моделей обновлён", + "models.displayNameAction": "Имя", + "models.displayNameActionLabel": "Изменить понятное имя для {model}", + "models.displayNameTitle": "Понятное имя", + "models.displayNameModelId": "ID модели", + "models.displayNameCurrent": "Текущее имя", + "models.displayNameSourceOperator": "Ваше имя", + "models.displayNameSourceProvider": "Имя провайдера", + "models.displayNameSourceFallback": "ID модели по умолчанию", + "models.displayNameField": "Понятное имя", + "models.displayNamePlaceholder": "например, Grok 4.6", + "models.displayNameHelp": "Меняет только отображение. Маршрут остаётся {model}.", + "models.displayNameReset": "Сбросить имя", + "models.displayNameSaved": "Понятное имя сохранено", + "models.displayNameResetDone": "Понятное имя сброшено", + "models.displayNameSaveFailed": "Не удалось сохранить понятное имя", + "models.displayNameRequired": "Введите понятное имя или используйте Сбросить имя.", + "models.displayNameTooLong": "Понятное имя должно содержать не более 128 символов.", + "models.displayNameNoSlash": "Понятное имя не может содержать /.", + "models.displayNameNoControl": "Понятное имя не может содержать управляющие символы.", }; diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ca233f452e..b18d1cb360 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2571,4 +2571,27 @@ export const tr: Record = { "integrations.cursor.colReasoning": "Akıl yürütme", "integrations.cursor.colContext": "Bağlam", "integrations.cursor.guide": "Cursor Private Inference kılavuzunu aç", + "models.displayNameSavedRefreshFailed": "Değişiklik kaydedildi ancak model listesi yenilenemedi. Yenilemek için tekrar deneyin.", + "models.displayNameOutcomeUnknown": "İstek tamamlanmadı. Değişiklik kaydedilmiş olabilir. Başka bir değişiklik yapmadan önce geçerli adı kontrol etmek için tekrar deneyin.", + "models.displayNameCurrentUnavailable": "Geçerli ad yenilemeye kadar kullanılamıyor", + "models.displayNameReloaded": "Model listesi yenilendi", + "models.displayNameAction": "Ad", + "models.displayNameActionLabel": "{model} için görünen adı düzenle", + "models.displayNameTitle": "Görünen ad", + "models.displayNameModelId": "Model kimliği", + "models.displayNameCurrent": "Geçerli ad", + "models.displayNameSourceOperator": "Sizin adınız", + "models.displayNameSourceProvider": "Sağlayıcı adı", + "models.displayNameSourceFallback": "Model kimliği varsayılanı", + "models.displayNameField": "Görünen ad", + "models.displayNamePlaceholder": "örn. Grok 4.6", + "models.displayNameHelp": "Yalnızca görünümü değiştirir. Yönlendirme {model} olarak kalır.", + "models.displayNameReset": "Adı sıfırla", + "models.displayNameSaved": "Görünen ad kaydedildi", + "models.displayNameResetDone": "Görünen ad sıfırlandı", + "models.displayNameSaveFailed": "Görünen ad kaydedilemedi", + "models.displayNameRequired": "Bir görünen ad girin veya Adı sıfırla seçeneğini kullanın.", + "models.displayNameTooLong": "Görünen ad en fazla 128 karakter olabilir.", + "models.displayNameNoSlash": "Görünen ad / içeremez.", + "models.displayNameNoControl": "Görünen ad denetim karakterleri içeremez.", }; diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 2b7e6ac6ba..463c2c88e5 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2533,4 +2533,27 @@ export const zhTW: Record = { "integrations.cursor.colReasoning": "推理", "integrations.cursor.colContext": "上下文", "integrations.cursor.guide": "開啟 Cursor Private Inference 指南", + "models.displayNameSavedRefreshFailed": "變更已儲存,但無法重新整理模型清單。請重試。", + "models.displayNameOutcomeUnknown": "請求未完成。變更可能已儲存。再次變更之前,請重試以檢查目前名稱。", + "models.displayNameCurrentUnavailable": "重新整理之前無法取得目前名稱", + "models.displayNameReloaded": "模型清單已重新整理", + "models.displayNameAction": "名稱", + "models.displayNameActionLabel": "編輯 {model} 的友善名稱", + "models.displayNameTitle": "友善名稱", + "models.displayNameModelId": "模型 ID", + "models.displayNameCurrent": "目前名稱", + "models.displayNameSourceOperator": "你的名稱", + "models.displayNameSourceProvider": "供應商名稱", + "models.displayNameSourceFallback": "模型 ID 預設值", + "models.displayNameField": "友善名稱", + "models.displayNamePlaceholder": "例如 Grok 4.6", + "models.displayNameHelp": "只變更顯示方式。路由仍為 {model}。", + "models.displayNameReset": "重設名稱", + "models.displayNameSaved": "友善名稱已儲存", + "models.displayNameResetDone": "友善名稱已重設", + "models.displayNameSaveFailed": "無法儲存友善名稱", + "models.displayNameRequired": "請輸入友善名稱,或使用重設名稱。", + "models.displayNameTooLong": "友善名稱不能超過 128 個字元。", + "models.displayNameNoSlash": "友善名稱不能包含 /。", + "models.displayNameNoControl": "友善名稱不能包含控制字元。", }; diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 42ac3941d4..1c6694fc8b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2569,4 +2569,27 @@ export const zh: Record = { "integrations.cursor.colReasoning": "推理", "integrations.cursor.colContext": "上下文", "integrations.cursor.guide": "打开 Cursor Private Inference 指南", + "models.displayNameSavedRefreshFailed": "更改已保存,但无法刷新模型列表。请重试以刷新。", + "models.displayNameOutcomeUnknown": "请求未完成。更改可能已保存。再次更改之前,请重试以检查当前名称。", + "models.displayNameCurrentUnavailable": "刷新之前无法获取当前名称", + "models.displayNameReloaded": "模型列表已刷新", + "models.displayNameAction": "名称", + "models.displayNameActionLabel": "编辑 {model} 的友好名称", + "models.displayNameTitle": "友好名称", + "models.displayNameModelId": "模型 ID", + "models.displayNameCurrent": "当前名称", + "models.displayNameSourceOperator": "你的名称", + "models.displayNameSourceProvider": "提供商名称", + "models.displayNameSourceFallback": "模型 ID 默认值", + "models.displayNameField": "友好名称", + "models.displayNamePlaceholder": "例如 Grok 4.6", + "models.displayNameHelp": "仅更改显示方式。路由仍为 {model}。", + "models.displayNameReset": "重置名称", + "models.displayNameSaved": "友好名称已保存", + "models.displayNameResetDone": "友好名称已重置", + "models.displayNameSaveFailed": "无法保存友好名称", + "models.displayNameRequired": "请输入友好名称,或使用重置名称。", + "models.displayNameTooLong": "友好名称不能超过 128 个字符。", + "models.displayNameNoSlash": "友好名称不能包含 /。", + "models.displayNameNoControl": "友好名称不能包含控制字符。", }; diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 22ea51bc20..b7b3a0e285 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -1,4 +1,5 @@ import { CodexStaleBanner } from "../components/codex-stale-banner"; +import ModelDisplayNameDialog from "../components/ModelDisplayNameDialog"; import { fetchCodexAppServerState } from "../codex-app-server-state"; import type { AppServerStateOutcome } from "../codex-app-server-state"; import { useCodexRestart } from "../use-codex-restart"; @@ -8,7 +9,7 @@ import { IconChevron, IconBoxes, IconInfo, IconCheck, IconAlert, IconRefresh, Ic import { useT } from "../i18n/shared"; import type { TFn, TKey } from "../i18n/shared"; import { modelLabel } from "../model-display"; -import { formatNamespacedModelId, formatProviderDisplayName, providerDisplaySlug } from "../provider-icons"; +import { formatProviderDisplayName, providerDisplaySlug } from "../provider-icons"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { describeIntegrationRefusalParts } from "./integrations/refusal-copy"; import { readSessionListCache, writeSessionListCache } from "../session-list-cache"; @@ -328,6 +329,22 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; const [showThreadsCustom, setShowThreadsCustom] = useState(false); const [v2HelpOpen, setV2HelpOpen] = useState(false); const [customModalOpen, setCustomModalOpen] = useState(false); + const [displayNameModel, setDisplayNameModel] = useState(null); + const [displayNameSaving, setDisplayNameSaving] = useState(false); + const [displayNameRequestError, setDisplayNameRequestError] = useState(null); + const [displayNameRecovery, setDisplayNameRecovery] = useState<{ + value: string | null | undefined; + confirmed: boolean; + } | null>(null); + const [displayNameCurrentPending, setDisplayNameCurrentPending] = useState(false); + const displayNameRequestRef = useRef(null); + const displayNameSavingRef = useRef(false); + useEffect(() => () => { + displayNameRequestRef.current?.controller.abort(); + displayNameRequestRef.current?.clear(); + displayNameRequestRef.current = null; + }, []); + const displayNameTriggerRef = useRef(null); const reloadAliases = useCallback(async (signal?: AbortSignal) => { const response = await fetch(`${apiBase}/api/aliases`, { signal }); @@ -535,12 +552,12 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; ); const catalogState = catalogResource.state; - const load = useCallback(async (force = false): Promise => { + const load = useCallback(async (force = false, signal?: AbortSignal): Promise => { if (loadPendingRef.current && !force) return false; loadPendingRef.current = true; const generation = ++loadGenerationRef.current; try { - const next = await fetchCatalog(new AbortController().signal); + const next = await fetchCatalog(signal ?? new AbortController().signal); if (!shouldApplyLoadGeneration(generation, loadGenerationRef.current)) return false; applyCatalog(next); // Follow-up mutation refreshes retain their existing awaitable contract while publishing @@ -557,6 +574,106 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; } }, [applyCatalog, cacheKey, fetchCatalog, pickerResource.refresh]); + const finishDisplayNameEdit = useCallback(() => { + const trigger = displayNameTriggerRef.current; + setDisplayNameModel(null); + setDisplayNameRequestError(null); + setDisplayNameRecovery(null); + setDisplayNameCurrentPending(false); + window.setTimeout(() => { + if (trigger?.isConnected) trigger.focus(); + }, 0); + }, []); + + const closeDisplayNameEdit = useCallback(() => { + if (!displayNameSavingRef.current) finishDisplayNameEdit(); + }, [finishDisplayNameEdit]); + + // undefined retries only the read after a confirmed write or an unknown outcome. + const saveDisplayName = useCallback(async (displayName: string | null | undefined) => { + const model = displayNameModel; + if (!model || displayNameSavingRef.current) return; + const bounded = createBoundedFetch(60_000); + displayNameRequestRef.current = bounded; + displayNameSavingRef.current = true; + setDisplayNameSaving(true); + setDisplayNameRequestError(null); + let confirmed = displayName === undefined && displayNameRecovery?.confirmed === true; + let refreshOnly = displayName === undefined; + try { + if (displayName !== undefined) { + const response = await fetch( + `${apiBase}/api/providers/${encodeURIComponent(model.provider)}/model-display-names`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ modelId: model.id, displayName }), + signal: bounded.signal, + }, + ); + // The route can persist the value and return 503 when catalog convergence fails. + // Keep that receipt instead of throwing away saved:true with the error body. + type DisplayNameReceipt = { + saved?: boolean; + error?: string; + displayName?: string; + displayNameOverride?: string | null; + displayNameSource?: ModelRow["displayNameSource"]; + }; + const result: DisplayNameReceipt | undefined = response.ok + ? await readJsonOrThrow(response, t("models.displayNameSaveFailed")) + : await response.json(); + bounded.signal.throwIfAborted(); + if (!result) throw new Error(t("models.displayNameSaveFailed")); + confirmed = response.ok || result.saved === true; + if (confirmed) { + const override = result.displayNameOverride === null ? undefined + : result.displayNameOverride ?? displayName ?? undefined; + const fields: Pick = { + displayName: result.displayName ?? override, + displayNameOverride: override, + displayNameSource: result.displayNameSource ?? (override ? "operator" : undefined), + }; + setModels(current => current.map(row => row.namespaced === model.namespaced ? { ...row, ...fields } : row)); + setDisplayNameModel({ ...model, ...fields }); + // A saved:true reset receipt omits the provider's effective fallback label. + setDisplayNameCurrentPending(fields.displayName === undefined); + } + if (!response.ok) { + throw new Error(result.error || t("models.displayNameSaveFailed")); + } + refreshOnly = true; + } + if (!await load(true, bounded.signal)) throw new Error(t("models.loadFail")); + bounded.signal.throwIfAborted(); + publishFeedback(true, confirmed + ? t(displayName === null || (displayName === undefined && displayNameRecovery?.value === null) + ? "models.displayNameResetDone" : "models.displayNameSaved") + : t("models.displayNameReloaded")); + finishDisplayNameEdit(); + } catch (error) { + if (displayNameRequestRef.current !== bounded) return; + if (bounded.signal.aborted && !confirmed) setDisplayNameCurrentPending(true); + setDisplayNameRecovery(confirmed || bounded.signal.aborted || refreshOnly + ? { value: refreshOnly || bounded.signal.aborted ? undefined : displayName, confirmed } + : null); + setDisplayNameRequestError(confirmed + ? t("models.displayNameSavedRefreshFailed") + : bounded.signal.aborted || refreshOnly + ? t("models.displayNameOutcomeUnknown") + : error instanceof Error && error.message + ? error.message + : t("models.displayNameSaveFailed")); + } finally { + bounded.clear(); + if (displayNameRequestRef.current === bounded) { + displayNameRequestRef.current = null; + displayNameSavingRef.current = false; + setDisplayNameSaving(false); + } + } + }, [apiBase, displayNameModel, displayNameRecovery, finishDisplayNameEdit, load, t]); + // Shadow/v2 controls must not wait on the models catalog (live discovery can be slow). useEffect(() => { // Both belong to the catalog tab; a hidden panel polling /api/v2 every ten seconds @@ -1537,9 +1654,31 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; void applyVisibility("models", provider, [{ id: m.id, native: m.native === true }], off)} disabled={busy || m.initialSelectionPending} label={m.native ? m.id : m.namespaced} /> {m.initialSelectionPending && {t("models.initialSelectionPending")}} {aliases.models[provider]?.[m.id] && {aliases.models[provider][m.id].alias}} - {m.native ? modelLabel(m.id) : formatNamespacedModelId(m.namespaced, t)} + + {m.native ? modelLabel(m.id) : m.namespaced} + {!m.native && m.displayName?.trim() && m.displayName.trim() !== m.namespaced && ( + {m.displayName.trim()} + )} + {aliases.models[provider]?.[m.id]?.source === "builtin" && {t("models.aliasAuto")}} + {!m.native && !m.custom && ( + + )} {m.custom && ( {t("models.customBadge")} @@ -2485,6 +2624,20 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; )}
+ + {displayNameModel && ( + void saveDisplayName(displayNameRecovery.value) : undefined} + onEdit={() => setDisplayNameRecovery(null)} + onSave={value => void saveDisplayName(value)} + onReset={() => void saveDisplayName(null)} + onClose={closeDisplayNameEdit} + /> + )} ); diff --git a/gui/src/pages/models-shared.ts b/gui/src/pages/models-shared.ts index fdc487301c..1f5ef7786b 100644 --- a/gui/src/pages/models-shared.ts +++ b/gui/src/pages/models-shared.ts @@ -1,4 +1,4 @@ -import type { TFn } from "../i18n/shared"; +import type { TFn, TKey } from "../i18n/shared"; import type { ProviderDiscoverySummary } from "../models-groups"; import { modelVisible, type ProviderModelMap } from "../model-visibility"; import { formatNamespacedModelId } from "../provider-icons"; @@ -35,6 +35,8 @@ export interface ModelRow { custom?: boolean; customId?: string; displayName?: string; + displayNameOverride?: string; + displayNameSource?: "operator" | "provider" | "fallback"; inputModalities?: string[]; contextWindow?: number; contextCap?: number; @@ -43,6 +45,26 @@ export interface ModelRow { reasoningEfforts?: string[]; } +function containsDisplayNameControlCharacter(value: string): boolean { + return [...value].some(character => { + const codePoint = character.codePointAt(0)!; + return codePoint <= 0x1f + || (codePoint >= 0x7f && codePoint <= 0x9f) + || codePoint === 0x2028 + || codePoint === 0x2029; + }); +} + +/** Mirror the server display-name contract for immediate form feedback. */ +export function modelDisplayNameValidationKey(value: string): TKey | null { + const trimmed = value.trim(); + if (!trimmed) return "models.displayNameRequired"; + if (trimmed.length > 128) return "models.displayNameTooLong"; + if (trimmed.includes("/")) return "models.displayNameNoSlash"; + if (containsDisplayNameControlCharacter(trimmed)) return "models.displayNameNoControl"; + return null; +} + /** * Reasoning-effort labels offered in the custom-model dialog. The full set of real * `reasoning_effort` values (none, minimal, low, medium, high, xhigh, max). Deliberately diff --git a/gui/src/styles.css b/gui/src/styles.css index b0bbc0a6ff..630882006f 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2639,6 +2639,52 @@ button.prov-account-row.active { cursor: default; } /* ---- model row hover tooltip ---- */ .model-row-wrap { position: relative; } +.models-model-identity { + display: inline-flex; + min-width: 0; + flex-direction: column; + align-items: flex-start; + gap: 1px; +} +.models-model-friendly { + max-width: min(42vw, 420px); + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; +} +.models-display-name-trigger { flex-shrink: 0; } +.model-display-name-dialog { max-width: 460px; } +.model-display-name-identity, +.model-display-name-current { + display: grid; + gap: 5px; + margin-bottom: 16px; +} +.model-display-name-identity code { + overflow-wrap: anywhere; + color: var(--text); +} +.model-display-name-current { + grid-template-columns: 1fr auto; + align-items: center; +} +.model-display-name-current > .text-label { grid-column: 1 / -1; } +.model-display-name-current strong { min-width: 0; overflow-wrap: anywhere; } +.model-display-name-dialog > .input { margin-bottom: 6px; } +.model-display-name-error { + margin-top: 8px; + color: var(--red); + font-size: var(--text-label); + line-height: var(--leading-body); +} +@media (max-width: 560px) { + .models-model-friendly { max-width: 58vw; } + .model-display-name-current { grid-template-columns: 1fr; } + .model-display-name-current > .text-label { grid-column: auto; } + .model-display-name-dialog .modal-actions { align-items: stretch; flex-direction: column; } + .model-display-name-dialog .modal-actions .btn { width: 100%; } +} .model-tip { z-index: 10; background: var(--surface); diff --git a/gui/tests/models-display-name-editor.test.tsx b/gui/tests/models-display-name-editor.test.tsx new file mode 100644 index 0000000000..be64333944 --- /dev/null +++ b/gui/tests/models-display-name-editor.test.tsx @@ -0,0 +1,657 @@ +import { afterEach, beforeEach, describe, expect, jest, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import ModelDisplayNameDialog from "../src/components/ModelDisplayNameDialog"; +import { clearClientResourceStoresForTests } from "../src/client-resource"; +import { LanguageProvider } from "../src/i18n/provider"; +import { installApiAuthFetch, resetApiAuthFetchForTests } from "../src/api"; +import Models from "../src/pages/Models"; +import type { ModelRow } from "../src/pages/models-shared"; +import { modelDisplayNameValidationKey } from "../src/pages/models-shared"; + +describe("discovered model display name validation", () => { + test("accepts a safe label at both ordinary and maximum length", () => { + expect(modelDisplayNameValidationKey("Grok 4.6")).toBeNull(); + expect(modelDisplayNameValidationKey("A".repeat(128))).toBeNull(); + expect(modelDisplayNameValidationKey("모델 이름")).toBeNull(); + expect(modelDisplayNameValidationKey("🚀".repeat(64))).toBeNull(); + expect(modelDisplayNameValidationKey("🚀".repeat(65))).toBe("models.displayNameTooLong"); + }); + + test("rejects values that the management API cannot persist", () => { + expect(modelDisplayNameValidationKey(" ")).toBe("models.displayNameRequired"); + expect(modelDisplayNameValidationKey("Grok/4.6")).toBe("models.displayNameNoSlash"); + for (const control of ["\n", "\u0000", "\u007f", "\u0085", "\u2028", "\u2029"]) { + expect(modelDisplayNameValidationKey(`Grok${control}4.6`)).toBe("models.displayNameNoControl"); + } + expect(modelDisplayNameValidationKey("A".repeat(129))).toBe("models.displayNameTooLong"); + }); +}); + +describe("discovered model display name responsive styles", () => { + test("keeps the narrow action order aligned with keyboard navigation", async () => { + const styles = await Bun.file(new URL("../src/styles.css", import.meta.url)).text(); + + expect(styles).toContain( + ".model-display-name-dialog .modal-actions { align-items: stretch; flex-direction: column; }", + ); + expect(styles).not.toContain( + ".model-display-name-dialog .modal-actions { align-items: stretch; flex-direction: column-reverse; }", + ); + }); +}); + +describe("Models dashboard discovered display name integration", () => { + const globals = [ + "document", "window", "navigator", "localStorage", "sessionStorage", + "IS_REACT_ACT_ENVIRONMENT", "fetch", "setInterval", "clearInterval", + ] as const; + let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; + let testWindow: Window; + let container: HTMLElement; + let root: Root | null; + let mutationBodies: Array<{ modelId: string; displayName: string | null }>; + let mutationFailure: string | null; + let savedFailure: boolean; + let mutationGate: Promise | null; + let modelFetches: number; + let modelFetchFailure: string | null; + let currentModels: ModelRow[]; + + const routedModel = (): ModelRow => ({ + provider: "xai-demo", + id: "grok-4.6", + namespaced: "xai-demo/grok-4.6", + disabled: false, + displayName: "Grok 4.6", + displayNameOverride: "Grok 4.6", + displayNameSource: "operator", + }); + + beforeEach(() => { + clearClientResourceStoresForTests(); + previousGlobals = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#models" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + setInterval: { configurable: true, value: () => 1 }, + clearInterval: { configurable: true, value: () => {} }, + }); + currentModels = [ + routedModel(), + { + provider: "command-code", + id: "deepseek-deepseek-v4-flash", + namespaced: "command-code/deepseek-deepseek-v4-flash", + disabled: false, + displayName: "DeepSeek V4 Flash", + displayNameSource: "provider", + }, + { provider: "openai", id: "gpt-5.5", namespaced: "openai/gpt-5.5", disabled: false, native: true }, + { + provider: "xai-demo", id: "custom-one", namespaced: "xai-demo/custom-one", + disabled: false, custom: true, customId: "custom-1", displayName: "Custom One", + }, + ]; + mutationBodies = []; + mutationFailure = null; + savedFailure = false; + resetApiAuthFetchForTests(); + mutationGate = null; + modelFetches = 0; + modelFetchFailure = null; + testWindow.localStorage.setItem("ocx-models-collapsed:v2", JSON.stringify([])); + testWindow.sessionStorage.setItem("ocx.models.catalog.v1:http://localhost", JSON.stringify({ + models: currentModels, + providers: [ + { name: "xai-demo", liveModels: false, models: ["grok-4.6", "custom-one"] }, + { name: "command-code", liveModels: false, models: ["deepseek-deepseek-v4-flash"] }, + { name: "openai", liveModels: false, models: ["gpt-5.5"] }, + ], + selectedModels: {}, + disabled: [], + contextCaps: {}, + contextCapValue: 350_000, + })); + + globalThis.fetch = (async (input, init) => { + const url = String(input); + if (url.endsWith("/api/models")) { + modelFetches += 1; + if (modelFetchFailure) { + return Response.json({ error: modelFetchFailure }, { status: 500 }); + } + return Response.json(currentModels); + } + if (url.endsWith("/api/providers")) return Response.json([ + { name: "xai-demo", liveModels: false, models: ["grok-4.6", "custom-one"] }, + { name: "command-code", liveModels: false, models: ["deepseek-deepseek-v4-flash"] }, + { name: "openai", liveModels: false, models: ["gpt-5.5"] }, + ]); + if (url.endsWith("/api/selected-models")) return Response.json({ selected: {} }); + if (url.endsWith("/api/provider-context-caps")) return Response.json({ caps: {} }); + if (url.endsWith("/api/aliases")) return Response.json({ providers: {}, models: {}, defaults: { global: false, providers: {} } }); + if (url.endsWith("/api/combos")) return Response.json({ combos: [] }); + if (url.endsWith("/api/shadow-call-settings")) return Response.json({ enabled: false, model: "" }); + if (url.endsWith("/api/v2")) return Response.json({ enabled: false, agentsMaxThreadsConflict: false, multiAgentMode: "default" }); + if (url.includes("/api/providers/xai-demo/model-display-names") && init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as { modelId: string; displayName: string | null }; + mutationBodies.push(body); + if (mutationGate) await mutationGate; + if (mutationFailure && !savedFailure) return Response.json({ error: mutationFailure }, { status: 500 }); + currentModels = currentModels.map(row => row.namespaced !== "xai-demo/grok-4.6" ? row : { + ...row, + displayName: body.displayName ?? "xai-demo/grok-4.6", + displayNameOverride: body.displayName ?? undefined, + displayNameSource: body.displayName ? "operator" : "fallback", + }); + if (savedFailure) return Response.json({ + error: "model display name saved but catalog refresh failed", + saved: true, + displayNameOverride: body.displayName, + }, { status: 503 }); + const row = currentModels.find(model => model.namespaced === "xai-demo/grok-4.6")!; + return Response.json({ + ok: true, + displayName: row.displayName, + displayNameOverride: row.displayNameOverride ?? null, + displayNameSource: row.displayNameSource, + }); + } + return new Response(null, { status: 404 }); + }) as typeof fetch; + + container = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(container as never); + root = null; + }); + + afterEach(async () => { + resetApiAuthFetchForTests(); + clearClientResourceStoresForTests(); + if (root) { + const mounted = root; + await act(async () => mounted.unmount()); + } + testWindow.close(); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }); + + async function flush() { + await act(async () => { + await new Promise(resolve => testWindow.setTimeout(resolve, 0)); + await Promise.resolve(); + }); + } + + async function mountModels() { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(container); + root.render(); + }); + await flush(); + } + + function nameTrigger(): HTMLButtonElement { + return container.querySelector( + '[aria-label="Edit friendly name for xai-demo/grok-4.6"]', + )!; + } + + function dialogInput(): HTMLInputElement { + return container.querySelector("dialog")! + .querySelector("input")!; + } + + function setInputValue(input: HTMLInputElement, value: string) { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")! + .set!.call(input, value); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + } + + function dialogButton(label: string): HTMLButtonElement { + return [...container.querySelectorAll("dialog button")] + .find(button => button.textContent === label)!; + } + + test("only discovered rows expose Name while showing friendly and exact identities", async () => { + await mountModels(); + + expect(nameTrigger()).not.toBeNull(); + expect(container.querySelectorAll('[aria-label^="Edit friendly name for "]')).toHaveLength(2); + expect(container.querySelector('[aria-label="Edit friendly name for openai/gpt-5.5"]')).toBeNull(); + expect(container.querySelector('[aria-label="Edit friendly name for xai-demo/custom-one"]')).toBeNull(); + expect(container.textContent).toContain("Grok 4.6"); + expect(container.textContent).toContain("xai-demo/grok-4.6"); + expect([...container.querySelectorAll("code")].some(code => + code.textContent === "command-code/deepseek-deepseek-v4-flash" + )).toBe(true); + expect(container.textContent).toContain("Custom One"); + }); + + test("save and reset send exact payloads, reload the catalog, and restore trigger focus", async () => { + await mountModels(); + const trigger = nameTrigger(); + const fetchesBeforeSave = modelFetches; + + await act(async () => trigger.click()); + await act(async () => { + setInputValue(dialogInput(), " Grok Fast "); + dialogButton("Save").click(); + }); + await flush(); + + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: "Grok Fast" }]); + expect(modelFetches).toBeGreaterThan(fetchesBeforeSave); + expect(container.querySelector("dialog")).toBeNull(); + expect(container.textContent).toContain("Grok Fast"); + expect(testWindow.document.activeElement).toBe(trigger); + + await act(async () => nameTrigger().click()); + await act(async () => dialogButton("Reset name").click()); + await flush(); + + expect(mutationBodies[1]).toEqual({ modelId: "grok-4.6", displayName: null }); + expect(container.querySelector("dialog")).toBeNull(); + expect(container.textContent).toContain("xai-demo/grok-4.6"); + }); + + test("a server failure keeps the dialog and edited draft available for retry", async () => { + mutationFailure = "Catalog refresh failed"; + await mountModels(); + await act(async () => nameTrigger().click()); + await act(async () => { + setInputValue(dialogInput(), "Retry Name"); + dialogButton("Save").click(); + }); + await flush(); + + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: "Retry Name" }]); + expect(container.querySelector("dialog")).not.toBeNull(); + expect(dialogInput().value).toBe("Retry Name"); + expect(container.textContent).toContain("Catalog refresh failed"); + expect(testWindow.document.activeElement).toBe(dialogInput()); + mutationFailure = null; + await act(async () => dialogButton("Save").click()); + await flush(); + expect(mutationBodies).toHaveLength(2); + expect(currentModels[0]!.displayNameOverride).toBe("Retry Name"); + expect(container.querySelector("dialog")).toBeNull(); + }); + + test("a failed catalog reload after save keeps the dialog available for retry", async () => { + await mountModels(); + modelFetchFailure = "Catalog reload failed"; + await act(async () => nameTrigger().click()); + await act(async () => { + setInputValue(dialogInput(), "Retry Reload"); + dialogButton("Save").click(); + }); + await flush(); + + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: "Retry Reload" }]); + expect(container.querySelector("dialog")).not.toBeNull(); + expect(dialogInput().value).toBe("Retry Reload"); + expect(container.textContent).toContain("The change was saved, but the model list could not be refreshed."); + expect(testWindow.document.activeElement).toBe(dialogInput()); + }); + + function currentNameText(): string { + return container.querySelector(".model-display-name-current")!.textContent ?? ""; + } + + test("first save followed by failed reload updates the snapshot and enables Reset", async () => { + await mountModels(); + await act(async () => nameTrigger().click()); + await act(async () => dialogButton("Reset name").click()); + await flush(); + mutationBodies = []; + await act(async () => nameTrigger().click()); + expect(dialogButton("Reset name").disabled).toBe(true); + modelFetchFailure = "reload failed"; + await act(async () => { + setInputValue(dialogInput(), " First Name "); + dialogButton("Save").click(); + }); + await flush(); + expect(dialogInput().value).toBe("First Name"); + expect(currentNameText()).toContain("First Name"); + expect(currentNameText()).toContain("Your name"); + expect(dialogButton("Reset name").disabled).toBe(false); + + modelFetchFailure = null; + await act(async () => dialogButton("Retry").click()); + await flush(); + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: "First Name" }]); + expect(container.querySelector("dialog")).toBeNull(); + }); + + test("reset followed by failed reload clears the draft and Enter retries only the read", async () => { + await mountModels(); + await act(async () => nameTrigger().click()); + modelFetchFailure = "reload failed"; + await act(async () => dialogButton("Reset name").click()); + await flush(); + expect(dialogInput().value).toBe(""); + expect(currentNameText()).toContain("xai-demo/grok-4.6"); + expect(currentNameText()).not.toContain("Your name"); + expect(dialogButton("Reset name").disabled).toBe(true); + + modelFetchFailure = null; + await act(async () => container.querySelector("dialog form")!.dispatchEvent( + new testWindow.Event("submit", { bubbles: true, cancelable: true }), + )); + await flush(); + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: null }]); + expect(container.querySelector("dialog")).toBeNull(); + expect(currentModels[0]!.displayNameOverride).toBeUndefined(); + }); + + for (const value of ["Saved Name", null]) { + test(`saved:true failure reconciles ${value === null ? "reset" : "save"} and retries the same operation`, async () => { + await mountModels(); + await act(async () => nameTrigger().click()); + savedFailure = true; + await act(async () => { + if (value === null) dialogButton("Reset name").click(); + else { + setInputValue(dialogInput(), value); + dialogButton("Save").click(); + } + }); + await flush(); + expect(dialogInput().value).toBe(value ?? ""); + expect(dialogButton("Reset name").disabled).toBe(value === null); + expect(currentNameText()).toContain(value ?? "Current name unavailable until refresh"); + expect(currentNameText()).not.toContain(value === null ? "Your name" : "Model ID fallback"); + expect(container.textContent).toContain("The change was saved"); + savedFailure = false; + await act(async () => dialogButton("Retry").click()); + await flush(); + expect(mutationBodies).toEqual([ + { modelId: "grok-4.6", displayName: value }, + { modelId: "grok-4.6", displayName: value }, + ]); + expect(container.querySelector("dialog")).toBeNull(); + }); + } + + test("editing after a saved receipt explicitly starts a new save", async () => { + await mountModels(); + await act(async () => nameTrigger().click()); + savedFailure = true; + await act(async () => dialogButton("Reset name").click()); + await flush(); + savedFailure = false; + await act(async () => setInputValue(dialogInput(), "New intention")); + await act(async () => dialogButton("Save").click()); + await flush(); + expect(mutationBodies.map(body => body.displayName)).toEqual([null, "New intention"]); + }); + + // Exercise the real global auth wrapper over an abort-aware transport. Only + // the deadline clock is controlled; the operation must supply its own signal. + for (const stage of ["mutation", "reload"] as const) { + test(`stalled ${stage} through installed API fetch releases the editor and retries a read`, async () => { + await mountModels(); + const descriptor = Object.getOwnPropertyDescriptor(AbortSignal, "timeout"); + const deadline = new AbortController(); + const budgets: number[] = []; + const seenSignals: Array = []; + let stall = true; + const transport = globalThis.fetch; + Object.defineProperty(AbortSignal, "timeout", { + configurable: true, + value: (ms: number) => { budgets.push(ms); return deadline.signal; }, + }); + const boundedTransport = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (String(input).includes("model-display-names") || String(input).endsWith("/api/models")) { + seenSignals.push(init?.signal); + } + if (stall && (stage === "mutation" + ? init?.method === "PUT" && String(input).includes("model-display-names") + : String(input).endsWith("/api/models"))) { + // Persist the write before losing its response: abort is not rollback. + if (stage === "mutation") await transport(input, init); + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted) reject(signal.reason); + else signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + } + return transport(input, init); + }) as typeof fetch; + Object.defineProperty(window, "fetch", { configurable: true, value: boundedTransport }); + installApiAuthFetch(); + globalThis.fetch = window.fetch; + try { + const trigger = nameTrigger(); + await act(async () => trigger.click()); + await act(async () => { + setInputValue(dialogInput(), "Possibly saved"); + dialogButton("Save").click(); + }); + await flush(); + expect(budgets).toEqual([60_000]); + expect(seenSignals.every(signal => signal != null)).toBe(true); + if (stage === "reload") expect(seenSignals[1]).toBe(seenSignals[0]); + await act(async () => deadline.abort(new DOMException("Timed out", "TimeoutError"))); + await flush(); + expect(dialogInput().disabled).toBe(false); + expect(dialogButton("Cancel").disabled).toBe(false); + expect(dialogInput().value).toBe("Possibly saved"); + expect(container.textContent).toContain(stage === "mutation" + ? "The change may have been saved" : "The change was saved"); + expect(testWindow.document.activeElement).toBe(dialogInput()); + stall = false; + if (descriptor) Object.defineProperty(AbortSignal, "timeout", descriptor); + await act(async () => dialogButton("Retry").click()); + await flush(); + expect(mutationBodies).toHaveLength(1); + expect(currentModels[0]!.displayNameOverride).toBe("Possibly saved"); + expect(container.querySelector("dialog")).toBeNull(); + expect(testWindow.document.activeElement).toBe(trigger); + } finally { + if (descriptor) Object.defineProperty(AbortSignal, "timeout", descriptor); + else Reflect.deleteProperty(AbortSignal, "timeout"); + } + }); + } + + test("a pending save blocks duplicate mutations", async () => { + let releaseMutation!: () => void; + mutationGate = new Promise(resolve => { releaseMutation = resolve; }); + await mountModels(); + await act(async () => nameTrigger().click()); + const save = dialogButton("Save"); + + await act(async () => { + setInputValue(dialogInput(), "Grok Once"); + save.click(); + save.click(); + await Promise.resolve(); + }); + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: "Grok Once" }]); + expect(save.disabled).toBe(true); + + releaseMutation(); + await flush(); + expect(container.querySelector("dialog")).toBeNull(); + }); + + test("Cancel closes without mutation and restores focus to Name", async () => { + await mountModels(); + const trigger = nameTrigger(); + await act(async () => trigger.click()); + await act(async () => dialogButton("Cancel").click()); + await flush(); + + expect(mutationBodies).toHaveLength(0); + expect(container.querySelector("dialog")).toBeNull(); + expect(testWindow.document.activeElement).toBe(trigger); + }); +}); + +describe("discovered model display name dialog", () => { + const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; + let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; + let testWindow: Window; + let container: HTMLElement; + let root: Root | null; + + const model: ModelRow = { + provider: "xai-demo", + id: "grok-4.6", + namespaced: "xai-demo/grok-4.6", + disabled: false, + displayName: "Grok 4.6", + displayNameOverride: "Grok 4.6", + displayNameSource: "operator", + }; + + beforeEach(() => { + previousGlobals = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, value: true }, + }); + container = testWindow.document.createElement("div"); + testWindow.document.body.appendChild(container as never); + root = null; + }); + + afterEach(async () => { + if (root) { + const mounted = root; + await act(async () => mounted.unmount()); + } + testWindow.close(); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }); + + async function renderDialog(options: { + saving?: boolean; + requestError?: string | null; + onSave?: (value: string) => void; + onReset?: () => void; + onClose?: () => void; + } = {}) { + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root ??= createRoot(container); + root.render( + + {})} + onReset={options.onReset ?? (() => {})} + onClose={options.onClose ?? (() => {})} + /> + , + ); + }); + } + + function setInputValue(input: HTMLInputElement, value: string) { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")! + .set!.call(input, value); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + } + + test("opens with immutable identity and only the operator override in the input", async () => { + await renderDialog(); + + const dialog = container.querySelector("dialog")!; + const input = container.querySelector("input")!; + expect(dialog.open).toBe(true); + expect(dialog.textContent).toContain("xai-demo/grok-4.6"); + expect(dialog.textContent).toContain("Grok 4.6"); + expect(dialog.textContent).toContain("Your name"); + expect(input.value).toBe("Grok 4.6"); + expect(testWindow.document.activeElement).toBe(input); + }); + + test("validates before save and sends the trimmed safe draft", async () => { + const onSave = jest.fn(); + await renderDialog({ onSave }); + const input = container.querySelector("input")!; + const save = [...container.querySelectorAll("button")] + .find(button => button.textContent === "Save")!; + + await act(async () => { + setInputValue(input, "Bad/Name"); + save.click(); + }); + expect(container.textContent).toContain("Friendly name cannot contain /."); + expect(onSave).not.toHaveBeenCalled(); + + await act(async () => { + setInputValue(input, " Grok Fast "); + save.click(); + }); + expect(onSave).toHaveBeenCalledTimes(1); + expect(onSave).toHaveBeenCalledWith("Grok Fast"); + }); + + test("keeps request errors visible and locks every closing action while saving", async () => { + const onClose = jest.fn(); + const onReset = jest.fn(); + await renderDialog({ saving: true, requestError: "Catalog refresh failed", onClose, onReset }); + + expect(container.textContent).toContain("Catalog refresh failed"); + const actionButtons = [...container.querySelectorAll("button")]; + expect(actionButtons.filter(button => button.tabIndex !== -1).every(button => button.disabled)).toBe(true); + + const dialog = container.querySelector("dialog")!; + await act(async () => { + dialog.dispatchEvent(new testWindow.Event("cancel", { bubbles: false, cancelable: true })); + container.querySelector(".modal-backdrop-dismiss")!.click(); + }); + expect(onClose).not.toHaveBeenCalled(); + expect(onReset).not.toHaveBeenCalled(); + }); + + test("a request failure does not mark a valid display name as invalid", async () => { + await renderDialog({ requestError: "Catalog refresh failed" }); + + const input = container.querySelector("input")!; + expect(input.getAttribute("aria-invalid")).toBeNull(); + expect(testWindow.document.activeElement).toBe(input); + }); + + test("focus returns to the editable name after a pending save fails", async () => { + await renderDialog({ saving: true }); + testWindow.document.body.tabIndex = -1; + testWindow.document.body.focus(); + expect(testWindow.document.activeElement).toBe(testWindow.document.body); + + await renderDialog({ requestError: "Catalog refresh failed" }); + + expect(testWindow.document.activeElement).toBe(container.querySelector("input")); + }); +}); From f215f79b4562735029ad5672a68bc6104e534b98 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:41:57 +0900 Subject: [PATCH 64/99] fix(anthropic): attribute quota headers and honor measured reset deadlines [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry and refine #3809: observe each request-bound physical response, preserve probe clocks and model-specific windows, and retain valid multi-day upstream reset deadlines. Preserve credential ownership and skip unprovable observations. Runtime checks are deferred to the final cumulative hosted CI at owner request; no local suite was run. Co-authored-by: Éverton Toffanetto --- .../src/content/docs/guides/claude-code.md | 13 +- .../docs/reference/configuration/providers.md | 20 +- scripts/test-layout/layout.json | 2 + src/images/loop.ts | 12 +- src/oauth/anthropic-routing.ts | 73 ++- src/oauth/health.ts | 3 + src/providers/quota.ts | 77 ++- src/server/responses/core.ts | 35 +- src/web-search/loop.ts | 12 +- .../anthropic-quota-dispatch.test.ts | 283 ++++++++++ .../anthropic-ratelimit-headers.test.ts | 528 ++++++++++++++++++ ...anthropic-sidecar-account-failover.test.ts | 55 +- tests/fixtures/test-layout-expected.json | 2 + 13 files changed, 1074 insertions(+), 41 deletions(-) create mode 100644 tests/adapters/anthropic/anthropic-quota-dispatch.test.ts create mode 100644 tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 5ed946c72a..2e841e205c 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -27,9 +27,16 @@ rotation does not protect against provider enforcement. Operational contract when enabled: -- Upstream **429** cools that account using `Retry-After` when present (else a default backoff), - clears its affinities, and may rotate to another eligible account within the same request - (bounded). +- Upstream **429** cools that account, clears its affinities, and may rotate to another eligible + account within the same request (bounded). The cooldown uses a usable `Retry-After` when present, + otherwise the latest valid reset time among windows Anthropic marks `rejected`, including + weekly windows. Valid upstream deadlines are not shortened to a fixed cooldown ceiling. + A refusal with no usable deadline falls back to a default backoff. +- Responses report the serving account's 5-hour and weekly utilization, and whichever of those + two the response carries is recorded for that account — each window independently, and a + refusal counts as well as a success. Usage-aware selection works from ordinary traffic, + without waiting for a dashboard poll. Headers preserve model-specific quota windows and do + not postpone usage probes or clear a failed usage probe's unavailable status. - Affinity is **process-local** (lost on proxy restart). - **401/403** credential failures quarantine the account (`needsReauth`) so it is excluded from selection until re-authenticated. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 02d5ba4323..c80176868e 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -430,14 +430,26 @@ rotation may trigger provider restrictions. | `anthropicAccountPool.enabled?` | `boolean` | `false` | Enable sticky session affinity and quota-ranked new-session selection. **429 failover is not gated here**: it activates whenever two or more usable accounts are stored, exactly like every other multi-credential provider, and cannot be switched off. | | `anthropicAccountPool.autoSwitchThreshold?` | `number` | `80` | For new sessions, when the active account reaches this threshold, choose the lowest known cached usage in the configured window; the account chosen does not itself have to be at or above the threshold. `0` disables **proactive** usage-based switching only — new-session selection and routing recovery after an eligible 429 still consult `quotaWindow`. | | `anthropicAccountPool.strategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | New-session strategy; `quota` ranks accounts by the window set by `quotaWindow`, and `fill-first` evaluates its drain threshold in that same window. | -| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | The cached provider-reported utilization bar used for usage-aware account selection. `five-hour` keeps the original behavior. `weekly` scores the weekly bar and skips accounts whose 5-hour bar is exhausted while another eligible account remains, but falls back to exhausted candidates when none do. `max-utilization` scores the highest known bar, so it can use 5-hour usage before weekly usage is available; if neither is known, the account follows unknown-usage ordering. Known usage ranks before unknown usage under the opt-in `weekly` and `max-utilization` windows only; an omitted or explicit `five-hour` preserves the legacy ordering. If every eligible account is unknown, selection still returns one in eligible order. After the documented lower-5-hour tie-break, exact ties preserve eligible order. A healthy affinity-bound session is not proactively rebalanced. For new-session assignment and routing recovery after an eligible 429 replacement, `quota` ranks eligible candidates directly with this window; `fill-first` advances in stable order using this window's threshold and exhaustion rules; `round-robin` ignores it. Cooldown, failover limits, and reauthentication eligibility remain separate local state. Per-account weekly bars are only known once the dashboard Providers page has polled them. | +| `anthropicAccountPool.quotaWindow?` | `"five-hour" \| "weekly" \| "max-utilization"` | `"five-hour"` | The cached provider-reported utilization bar used for usage-aware account selection. `five-hour` keeps the original behavior. `weekly` scores the weekly bar and skips accounts whose 5-hour bar is exhausted while another eligible account remains, but falls back to exhausted candidates when none do. `max-utilization` scores the highest known bar, so it can use 5-hour usage before weekly usage is available; if neither is known, the account follows unknown-usage ordering. Known usage ranks before unknown usage under the opt-in `weekly` and `max-utilization` windows only; an omitted or explicit `five-hour` preserves the legacy ordering. If every eligible account is unknown, selection still returns one in eligible order. After the documented lower-5-hour tie-break, exact ties preserve eligible order. A healthy affinity-bound session is not proactively rebalanced. For new-session assignment and routing recovery after an eligible 429 replacement, `quota` ranks eligible candidates directly with this window; `fill-first` advances in stable order using this window's threshold and exhaustion rules; `round-robin` ignores it. Cooldown, failover limits, and reauthentication eligibility remain separate local state. Per-account weekly bars come from usage probes or observed response headers. | | `anthropicAccountPool.stickyLimit?` | `number` | `1` | Successful new-session binds retained on one round-robin selection. Range 1–100. | -When enabled, 429 records bounded cooldown from `Retry-After` or a default backoff and may rotate -within the request. Affinity is process-local and size-bounded. Credential 401/403 marks the account -as needing reauthentication. If all eligible accounts are cooling, clients receive 429 with +When enabled, 429 records a cooldown and may rotate within the request. The cooldown length comes +from a usable `Retry-After`, otherwise from the latest valid reset time among rate-limit windows +Anthropic reports as `rejected`, including weekly windows. Valid upstream deadlines are not +shortened to a fixed cooldown ceiling; non-finite or unrepresentable deadlines are ignored. +A refusal with no usable deadline falls back to a short default backoff. Affinity is process-local +and size-bounded. Credential 401/403 marks the account as needing reauthentication. If all eligible accounts are cooling, clients receive 429 with `Retry-After` when known, not an authentication error. +Anthropic responses also report the serving account's 5-hour and weekly utilization, and whichever +of those two a given response carries is recorded against that account — each window independently, +on refusals as well as successes. Usage-aware selection therefore works from the accounts you +actually use, without waiting for the dashboard Providers page to poll them. These readings refresh +the existing row rather than replacing it, so the model-scoped weekly bars that only the usage +endpoint reports are preserved. Header observations do not postpone usage probes or clear a failed +probe's unavailable status. After restart, cached Anthropic observations remain available while +the next quota read probes again, because the saved observations do not include the probe clock. + :::caution[Experimental] Leave this disabled unless you understand Anthropic account policy risk. Prefer manual `ocx account use anthropic ` switching when unsure. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d309073a3f..f560dffeb5 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -204,6 +204,8 @@ "anthropic-image-retry.test.ts": "adapters/anthropic", "anthropic-pool-toggle-copy.test.ts": "adapters/anthropic", "anthropic-quorum-cache.test.ts": "routing", + "anthropic-quota-dispatch.test.ts": "adapters/anthropic", + "anthropic-ratelimit-headers.test.ts": "adapters/anthropic", "anthropic-reasoning.test.ts": "adapters/anthropic", "anthropic-sidecar-account-failover.test.ts": "adapters/anthropic", "anthropic-stream-hardening.test.ts": "adapters/anthropic", diff --git a/src/images/loop.ts b/src/images/loop.ts index e3a7f8252f..7d4855f91b 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -263,8 +263,16 @@ export interface ImageBridgeDeps { * Optional 429 failover for the routed (non-xAI) model. Return a rebuilt adapter for the * rotated credential, or null when the pool is exhausted. Async hooks support OAuth refresh; * existing synchronous key-pool hooks remain valid. + * + * `responseHeaders` carries the whole refusal, not just Retry-After, because an Anthropic + * 429 states the window's reset epoch even when it omits Retry-After -- and a rotation that + * cannot see it cools the drained account for the short default instead of until the window + * actually reopens. Optional so existing callers keep compiling. */ - on429?: (retryAfterHeader: string | null) => ProviderAdapter | null | Promise; + on429?: ( + retryAfterHeader: string | null, + responseHeaders?: Headers, + ) => ProviderAdapter | null | Promise; /** Opt-in same-target 429 policy (key-auth providers). When present, 429 replays on the SAME key before on429 rotation. */ retryOn429Policy?: Required | null; /** Called when the bridged Responses stream completes (parity with runTurn / routed paths). */ @@ -579,7 +587,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise {}); } catch { /* already closed */ } adapter = rotated; diff --git a/src/oauth/anthropic-routing.ts b/src/oauth/anthropic-routing.ts index a029207be5..6b2eea5a3b 100644 --- a/src/oauth/anthropic-routing.ts +++ b/src/oauth/anthropic-routing.ts @@ -10,9 +10,10 @@ * Intentionally narrower than the Codex pool: no mid-session quota rotation, * soft-avoid ladders, or probe leases. Anthropic OAuth is ToS-sensitive. * - * Affinity is process-local (lost on restart). Cooldown uses Retry-After when present, - * otherwise a default backoff. 401/403 credential failures should set needsReauth on the - * store (existing OAuth path) so the account is excluded from eligibility. + * Affinity is process-local (lost on restart). Cooldown uses Retry-After when present, else + * the reset time of whichever rate-limit window upstream reports as rejected, else a default + * backoff. 401/403 credential failures should set needsReauth on the store (existing OAuth + * path) so the account is excluded from eligibility. */ import { createHash } from "node:crypto"; import { captureOAuthAccountSelection, commitOAuthAccountSelection, credentialGeneration, getAccountSet, getAccountCredential, getAccountCredentialWithStatus } from "./store"; @@ -33,9 +34,16 @@ import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConf import { sweepExpiredOnWrite } from "../lib/state-store-sweeper"; import { retainedUtf8Bytes } from "../lib/admission"; +/** + * The read side of a `Headers` object, so a caller can pass the live upstream response's + * headers without this module importing anything from the server layer -- and so a test can + * hand it a plain `new Headers({...})`. + */ +export type AnthropicRateLimitHeaders = Pick; + const PROVIDER = "anthropic"; +/** Backoff only when upstream supplies no usable deadline. */ const DEFAULT_COOLDOWN_MS = 60_000; -const MAX_COOLDOWN_MS = 15 * 60_000; const AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000; const MAX_AFFINITY_ENTRIES = 2_000; const MAX_AFFINITY_COMPONENT_BYTES = 512; @@ -58,9 +66,19 @@ export interface AnthropicAccountPoolConfig { quotaWindow?: OcxAccountPoolQuotaWindow; } +/** + * Where a cooldown's length came from. Same vocabulary as `CodexCooldownSource`, because it + * answers the same question for the same reason: `retry-after` is upstream answering THIS + * refusal, `reset-derived` is upstream stating when the spent window reopens, and `default` + * is our own guess. The dashboard renders the first as a rate limit and the rest as quota, + * which is exactly the distinction a reset-derived cooldown carries -- collapsing it into + * `retry-after` would report a drained five-hour window as request-rate throttling. + */ +type AnthropicCooldownSource = "retry-after" | "reset-derived" | "default"; + interface AccountHealth { cooldownUntil: number; - cooldownSource: "retry-after" | "default"; + cooldownSource: AnthropicCooldownSource; } interface AffinityEntry { @@ -112,19 +130,38 @@ export function anthropicQuotaWindow(config: AnthropicAccountPoolConfig): OcxAcc return normalizeAccountPoolQuotaWindow(config.quotaWindow); } +/** Accept upstream deadlines within the runtime's date range, without a policy ceiling. */ +function delayUntil(timestamp: number, now: number): number | undefined { + const delay = timestamp - now; + return Number.isFinite(new Date(timestamp).getTime()) && Number.isFinite(delay) && delay > 0 + ? delay : undefined; +} + function parseRetryAfterMs(value: string | null | undefined, now: number): number | undefined { const text = value?.trim(); if (!text) return undefined; if (/^\d+(?:\.\d+)?$/.test(text)) { const seconds = Number(text); - if (Number.isFinite(seconds) && seconds > 0) { - return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS); - } + if (!Number.isFinite(seconds) || seconds <= 0) return undefined; + return delayUntil(now + Math.max(Math.ceil(seconds * 1000), 1), now); } - const timestamp = Date.parse(text); - if (!Number.isFinite(timestamp)) return undefined; - const delay = timestamp - now; - return delay > 0 ? Math.min(delay, MAX_COOLDOWN_MS) : undefined; + return delayUntil(Date.parse(text), now); +} + +/** Only rejected windows constrain recovery; all must reopen, so take the latest reset. */ +function parseRateLimitResetMs(headers: AnthropicRateLimitHeaders | null | undefined, now: number): number | undefined { + if (!headers) return undefined; + let latest: number | undefined; + for (const window of ["5h", "7d"] as const) { + if (headers.get(`anthropic-ratelimit-unified-${window}-status`)?.trim() !== "rejected") continue; + const resetSeconds = Number(headers.get(`anthropic-ratelimit-unified-${window}-reset`)?.trim()); + if (!Number.isFinite(resetSeconds) || resetSeconds <= 0) continue; + const resetAt = resetSeconds * 1000; + if (delayUntil(resetAt, now) === undefined) continue; + if (latest === undefined || resetAt > latest) latest = resetAt; + } + if (latest === undefined) return undefined; + return latest - now; } export function getAnthropicAccountHealthSnapshot( @@ -669,6 +706,7 @@ export function rotateAnthropicAccountOn429( retryAfterHeader: string | null | undefined, sessionKey?: string | null, now = Date.now(), + rateLimitHeaders?: AnthropicRateLimitHeaders | null, ): string | null { // Reactive 429 failover is NOT gated on the pool flag. That flag buys PROACTIVE routing -- // session affinity, quota-ranked new-session selection, autoSwitchThreshold, strategy -- all @@ -678,11 +716,18 @@ export function rotateAnthropicAccountOn429( // Presence is the activation rule, the same one an apiKeyPool of two keys already uses. if (!isAnthropicAccountPoolEnabled(config) && !hasAnthropicFailoverQuorum(now)) return null; + // Retry-After first: it is the header written FOR this decision. The rejected window's + // reset is the fallback, because a 429 that omits Retry-After still carries it -- and + // without that fallback such a refusal cools for the 60s default and the exhausted + // account is back in the rotation a minute later. const parsedRetry = parseRetryAfterMs(retryAfterHeader, now); - const cooldownMs = parsedRetry ?? DEFAULT_COOLDOWN_MS; + const resetDerived = parsedRetry === undefined ? parseRateLimitResetMs(rateLimitHeaders, now) : undefined; + const cooldownMs = parsedRetry ?? resetDerived ?? DEFAULT_COOLDOWN_MS; upstreamHealth.set(failedAccountId, { cooldownUntil: now + cooldownMs, - cooldownSource: parsedRetry ? "retry-after" : "default", + cooldownSource: parsedRetry !== undefined + ? "retry-after" + : resetDerived !== undefined ? "reset-derived" : "default", }); sweepExpiredOnWrite(now); clearAnthropicSessionAffinityForAccount(failedAccountId); diff --git a/src/oauth/health.ts b/src/oauth/health.ts index 4c997c47cc..011ebd8f41 100644 --- a/src/oauth/health.ts +++ b/src/oauth/health.ts @@ -184,6 +184,9 @@ export function projectStoredOAuthAccountHealth( needsReauth: account.needsReauth === true, reauthReason: account.needsReauth === true ? "refresh_failed" : undefined, cooldownUntilMs: anthropicSnap?.cooldownUntil, + // Same mapping as the Codex pool's `cooldownReasonFromSource`: only a Retry-After is + // request-rate throttling. A reset-derived cooldown means a usage window is spent, which + // is quota, and reporting it as a rate limit would tell the operator to retry shortly. cooldownReason: anthropicSnap?.cooldownSource === "retry-after" ? "rate_limit" : anthropicSnap ? "quota" : undefined, warningReason: detectOAuthWarning(provider, account, opts.observeOnly === true, now), now, diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 7136cd3c70..dbdb6699f7 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1568,7 +1568,10 @@ function hydrateAccountQuotaCache(): void { if (diskHydrated) return; diskHydrated = true; for (const [key, quota] of readPersistedAccountQuotas()) { - if (!accountQuotaCache.has(key)) accountQuotaCache.set(key, { ts: quota.updatedAt, quota }); + // Disk stores observation time, not the Anthropic usage probe's clock. + if (!accountQuotaCache.has(key)) { + accountQuotaCache.set(key, { ts: key.startsWith("anthropic\u0000") ? 0 : quota.updatedAt, quota }); + } } } @@ -1642,6 +1645,66 @@ export function setCachedProviderAccountQuotaForTests( accountQuotaCache.set(key, { ts: Date.now(), quota }); } +/** Unified headers report utilization fractions and epoch-second reset times. */ +function anthropicHeaderResetAt(value: string | null): number | undefined { + const seconds = toFiniteNumber(value); + if (seconds === undefined || seconds <= 0) return undefined; + const timestamp = seconds * 1000; + return Number.isFinite(new Date(timestamp).getTime()) ? timestamp : undefined; +} + +export function parseAnthropicRateLimitHeaders(headers: Headers): ProviderQuota | null { + const fiveHourPercent = normalizeUtilizationFraction(headers.get("anthropic-ratelimit-unified-5h-utilization")); + const weeklyPercent = normalizeUtilizationFraction(headers.get("anthropic-ratelimit-unified-7d-utilization")); + if (fiveHourPercent === undefined && weeklyPercent === undefined) return null; + const fiveHourResetAt = anthropicHeaderResetAt(headers.get("anthropic-ratelimit-unified-5h-reset")); + const weeklyResetAt = anthropicHeaderResetAt(headers.get("anthropic-ratelimit-unified-7d-reset")); + return { + ...(fiveHourPercent !== undefined ? { fiveHourPercent } : {}), + ...(fiveHourResetAt !== undefined ? { fiveHourResetAt } : {}), + ...(weeklyPercent !== undefined ? { weeklyPercent } : {}), + ...(weeklyResetAt !== undefined ? { weeklyResetAt } : {}), + updatedAt: Date.now(), + }; +} + +/** Reject unknown scales; round fraction conversion for persisted/displayed percentages. */ +function normalizeUtilizationFraction(value: string | null): number | undefined { + const numeric = toFiniteNumber(value); + if (numeric === undefined || numeric < 0 || numeric > 1) return undefined; + return Math.round(numeric * 10_000) / 100; +} + +/** + * Merge serving-account observations without advancing the usage probe's clock or + * erasing model-specific windows. The caller owns credential attribution; this guard + * prevents a retired account key from being revived by an older config generation. + */ +export function recordAnthropicAccountQuotaFromHeaders( + accountId: string, + headers: Headers, + writerGeneration: number, +): void { + if (!accountId) return; + const observed = parseAnthropicRateLimitHeaders(headers); + if (!observed) return; + const key = accountCacheKey("anthropic", accountId); + if (!mayCommitAccountQuotaKey(key, writerGeneration)) return; + // Hydrate before writing, for the same reason `recordPassiveAccountQuota` does: this write + // arrives unprompted from the request path, and `persistAccountQuotaCache` serializes the + // whole map. Landing before any reader has hydrated would persist this single row and erase + // every other provider's saved row. + hydrateAccountQuotaCache(); + const previous = accountQuotaCache.get(key); + accountQuotaCache.set(key, { + ...previous, + // Headers do not prove that the last usage probe succeeded. + ts: previous?.ts ?? 0, + quota: { ...(previous?.quota ?? {}), ...observed }, + }); + persistAccountQuotaCache(); +} + /** * Providers whose per-account quota is OBSERVED in-band, never probed. * @@ -1714,7 +1777,11 @@ export function readPassiveProviderAccountQuotas(provider: string): ProviderAcco export function sweepExpiredProviderAccountQuotaRows(now = Date.now()): number { let removed = 0; for (const [key, entry] of accountQuotaCache) { - if (entry.ts + ACCOUNT_QUOTA_TTL_MS > now) continue; + // Anthropic observations extend retention, never the usage probe's eligibility clock. + const retainedAt = key.startsWith("anthropic\u0000") + ? Math.max(entry.ts, entry.quota?.updatedAt ?? 0) + : entry.ts; + if (retainedAt + ACCOUNT_QUOTA_TTL_MS > now) continue; accountQuotaCache.delete(key); removed += 1; } @@ -1907,6 +1974,7 @@ async function fetchAccountQuota( ): Promise { if (!supportsPerAccountQuota(provider)) return { ts: Date.now(), quota: null, unavailable: true }; if (explicitAccountReader(provider)) return fetchExplicitAccountQuota(provider, accountId, forceRefresh, providerConfig); + if (provider === "anthropic") hydrateAccountQuotaCache(); const key = accountCacheKey(provider, accountId); const writerGeneration = captureConfigGeneration(); const cached = accountQuotaCache.get(key); @@ -1947,7 +2015,8 @@ async function fetchAccountQuota( // negative-cache instead of re-probing on every GUI poll. const entry: AccountQuotaCacheEntry = { ts: Date.now(), - quota: cached?.quota ?? null, + // Settle once for all joiners against observations committed during the probe. + quota: (provider === "anthropic" ? accountQuotaCache.get(key)?.quota : cached?.quota) ?? null, unavailable: true, }; if (mayCommitAccountQuotaKey(key, writerGeneration)) { @@ -1969,7 +2038,7 @@ async function fetchAccountQuota( } catch { const entry: AccountQuotaCacheEntry = { ts: Date.now(), - quota: cached?.quota ?? null, + quota: (provider === "anthropic" ? accountQuotaCache.get(key)?.quota : cached?.quota) ?? null, unavailable: true, }; if (mayCommitAccountQuotaKey(key, writerGeneration)) { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3c539c6d8e..fc2195f427 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -230,7 +230,7 @@ import { } from "../../providers/request-pacing"; import { slugsEquivalent } from "../../providers/slug-codec"; import { isMuseSubscriptionUsagePayload, parseMuseSubscriptionUsage } from "../../providers/muse-subscription-usage"; -import { hasPassiveAccountQuota, recordPassiveAccountQuota } from "../../providers/quota"; +import { hasPassiveAccountQuota, recordAnthropicAccountQuotaFromHeaders, recordPassiveAccountQuota } from "../../providers/quota"; import { captureConfigGeneration } from "../../lib/state-store-sweeper"; import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models"; import { isUsageDebugEnabled } from "../../usage/debug"; @@ -3946,7 +3946,27 @@ async function handleResponsesInner( for (let attempt = 0; attempt < 3; attempt++) { if (selectionIsCurrent(requestBindings.get(wireRequest))) { const fetchImpl = (route.provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? execute; - return fetchImpl(destination, dispatchInit); + const binding = requestBindings.get(wireRequest); + const snapshot = route.providerName === "anthropic" && anthropicPoolAccountId && binding?.kind === "oauth" + ? binding.snapshot : undefined; + const writerGeneration = snapshot ? captureConfigGeneration() : 0; + const sentHeaders = snapshot ? new Headers(dispatchInit.headers) : undefined; + const ownsBearer = snapshot !== undefined + && sentHeaders?.get("authorization") === `Bearer ${snapshot.accessToken}` + && !sentHeaders?.has("x-api-key"); + const response = await fetchImpl(destination, dispatchInit); + // Observe each physical response before retries replace it. The binding belongs to + // this dispatch, so a manual switch cannot file A's headers against B. Header + // overrides and credential replacement make ownership unprovable: skip those writes. + if (ownsBearer && snapshot) { + try { + const current = getAccountCredentialWithStatus("anthropic", snapshot.accountId); + if (current && !current.needsReauth && credentialGeneration(current.credential) === snapshot.generation) { + recordAnthropicAccountQuotaFromHeaders(snapshot.accountId, response.headers, writerGeneration); + } + } catch { /* best-effort observation cannot fail the response */ } + } + return response; } const nextAdapter = await refreshDispatchAdapter(requestParsed); const rebuilt = await nextAdapter.buildRequest(requestParsed, { @@ -5956,7 +5976,10 @@ async function handleResponsesInner( const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined; const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined; const canRunWebSearch = !!wsPlan && !adapter.runTurn; - const rotateSidecarProviderOn429 = async (retryAfter: string | null): Promise => { + const rotateSidecarProviderOn429 = async ( + retryAfter: string | null, + responseHeaders?: Headers, + ): Promise => { const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, { retryAfter, now: Date.now(), @@ -6000,6 +6023,8 @@ async function handleResponsesInner( anthropicPoolAccountId, retryAfter, anthropicSessionKey, + Date.now(), + responseHeaders, ); if (!nextAccountId) return null; try { @@ -7032,6 +7057,8 @@ async function handleResponsesInner( anthropicPoolAccountId, upstreamResponse.headers.get("retry-after"), anthropicSessionKey, + Date.now(), + upstreamResponse.headers, ); if (!nextAccountId) break; try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ } @@ -7445,6 +7472,8 @@ async function handleResponsesInner( anthropicPoolAccountId, response.headers.get("retry-after"), anthropicSessionKey, + Date.now(), + response.headers, ); if (nextAccountId) { try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 3a2c5e99b4..0c957e1c17 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -309,8 +309,16 @@ export interface WebSearchLoopDeps { * 429 failover hook: rotate the provider's active credential and return a rebuilt adapter, * or null when the pool is exhausted. Async hooks support OAuth refresh; existing synchronous * key-pool hooks remain valid. + * + * `responseHeaders` carries the whole refusal, not just Retry-After, because an Anthropic + * 429 states the window's reset epoch even when it omits Retry-After -- and a rotation that + * cannot see it cools the drained account for the short default instead of until the window + * actually reopens. Optional so existing callers keep compiling. */ - on429?: (retryAfterHeader: string | null) => ProviderAdapter | null | Promise; + on429?: ( + retryAfterHeader: string | null, + responseHeaders?: Headers, + ) => ProviderAdapter | null | Promise; /** Opt-in same-target 429 policy (key-auth providers). When present, 429 replays on the SAME key before on429 rotation. */ retryOn429Policy?: Required | null; /** Called only when the final bridged Responses stream reaches completed or incomplete. */ @@ -521,7 +529,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise }[]; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-anthropic-quota-dispatch-")); + process.env.OPENCODEX_HOME = home; + sent = []; + clearAnthropicAccountPoolState(); + forgetAnthropicFailoverQuorum(); + clearGenericFailoverHealth(); + clearAccountQuotaCache(); + resetProviderQuotaReconcileStateForTests(); + clearResponseStateForTests(); +}); + +afterEach(() => { + clearAnthropicAccountPoolState(); + forgetAnthropicFailoverQuorum(); + clearGenericFailoverHealth(); + // Cancel the debounced persistence before restoring the real home. + clearAccountQuotaCache(); + resetProviderQuotaReconcileStateForTests(); + clearResponseStateForTests(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(home); +}); + +function credential(index: number) { + return { + access: `synthetic-anthropic-access-${index}`, + refresh: `synthetic-anthropic-refresh-${index}`, + expires: Date.now() + 3_600_000, + accountId: `synthetic-account-${index}`, + }; +} + +async function seed(count = 2): Promise { + for (let index = 0; index < count; index++) { + await saveCredential("anthropic", credential(index)); + } + const ids = getAccountSet("anthropic")!.accounts.map(account => account.id); + await setActiveAccount("anthropic", ids[0]!); + return ids; +} + +function quotaHeaders(fiveHour: string, weekly: string): Record { + return { + "anthropic-ratelimit-unified-5h-utilization": fiveHour, + "anthropic-ratelimit-unified-7d-utilization": weekly, + }; +} + +function limited(fiveHour = "1", weekly = "0.61"): Response { + return Response.json({ type: "error", error: { type: "rate_limit_error", message: "synthetic quota exhausted" } }, { + status: 429, + headers: { ...quotaHeaders(fiveHour, weekly), "retry-after": "30" }, + }); +} + +function answer(stream: boolean, fiveHour = "0.23", weekly = "0.47", text = "The answer is complete."): Response { + const usage = { input_tokens: 8, output_tokens: 6 }; + const message = { id: "msg_synthetic", type: "message", role: "assistant", model: "claude-sonnet-4-5", content: [{ type: "text", text }], stop_reason: "end_turn", usage }; + if (!stream) return Response.json(message, { headers: quotaHeaders(fiveHour, weekly) }); + const frames = [ + { type: "message_start", message: { ...message, content: [], stop_reason: null } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn" }, usage }, + { type: "message_stop" }, + ]; + return new Response(frames.map(frame => `event: ${frame.type}\ndata: ${JSON.stringify(frame)}\n\n`).join(""), { + headers: { ...quotaHeaders(fiveHour, weekly), "content-type": "text/event-stream" }, + }); +} + +function configFor(reply: (body: Record) => Response | Promise, headers?: Record): OcxConfig { + const transport = (async (_input, init) => { + const wireHeaders = new Headers(init?.headers); + const body = JSON.parse(String(init?.body)) as Record; + sent.push({ authorization: wireHeaders.get("authorization"), apiKey: wireHeaders.get("x-api-key"), body }); + return reply(body); + }) as typeof fetch; + const provider: OcxProviderConfig & { fetch: typeof fetch } = { + adapter: "anthropic", baseUrl: "https://anthropic-quota.test", authMode: "oauth", + models: ["claude-sonnet-4-5"], fetch: transport, ...(headers ? { headers } : {}), + }; + return { + port: 0, defaultProvider: "anthropic", + anthropicAccountPool: { enabled: false, strategy: "round-robin" }, + providers: { anthropic: provider }, + }; +} + +function post(config: OcxConfig, body: Record = {}) { + return handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "anthropic/claude-sonnet-4-5", input: "Answer briefly", stream: false, ...body }), + }), config, { model: "", provider: "" }); +} + +function expectQuota(id: string, fiveHourPercent: number, weeklyPercent: number) { + expect(getCachedProviderAccountQuota("anthropic", id)).toMatchObject({ fiveHourPercent, weeklyPercent }); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +test("main A429 -> B200 records both physical responses against their sending accounts", async () => { + const [a, b] = await seed(); + const config = configFor(body => { + if (sent.length === 1) return limited(); + expect(sent.length).toBe(2); + // A must already be measured before the replacement response exists. + expectQuota(a!, 100, 61); + expect(getCachedProviderAccountQuota("anthropic", b!)).toBeNull(); + return answer(body.stream === true); + }); + const response = await post(config); + expect(response.status).toBe(200); + expect(await response.text()).toContain("The answer is complete."); + expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); + expectQuota(a!, 100, 61); + expectQuota(b!, 23, 47); +}); + +test("terminal 429 after both accounts are exhausted records both refused physical responses", async () => { + const [a, b] = await seed(); + const response = await post(configFor(() => { + if (sent.length === 1) return limited(); + expect(sent.length).toBe(2); + expectQuota(a!, 100, 61); + return limited("0.89", "1"); + })); + expect(response.status).toBe(429); + await response.text(); + expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); + expectQuota(a!, 100, 61); + expectQuota(b!, 89, 100); +}); + +test("manual active switch while A is pending keeps A's measurement off B", async () => { + const [a, b] = await seed(); + const entered = deferred(); + const returned = deferred(); + const config = configFor(() => { entered.resolve(); return returned.promise; }); + const pending = post(config); + await entered.promise; + try { + expect(sent[0]!.authorization).toBe(`Bearer ${credential(0).access}`); + expect(await setActiveAccount("anthropic", b!)).toBe(true); + } finally { + returned.resolve(answer(false, "0.37", "0.53")); + } + const response = await pending; + expect(response.status).toBe(200); + await response.text(); + expect(sent).toHaveLength(1); + expect(getAccountSet("anthropic")!.activeAccountId).toBe(b!); + expectQuota(a!, 37, 53); + expect(getCachedProviderAccountQuota("anthropic", b!)).toBeNull(); +}); + +test("credential replacement while A is pending skips its old-generation response", async () => { + const [a, b] = await seed(); + const entered = deferred(); + const returned = deferred(); + const pending = post(configFor(() => { entered.resolve(); return returned.promise; })); + await entered.promise; + try { + expect(sent[0]!.authorization).toBe(`Bearer ${credential(0).access}`); + await saveAccountCredential("anthropic", a!, { ...credential(0), access: "synthetic-replacement-access", refresh: "synthetic-replacement-refresh" }); + } finally { + returned.resolve(answer(false)); + } + const response = await pending; + expect(response.status).toBe(200); + await response.text(); + expect(sent).toHaveLength(1); + expect(getAccountSet("anthropic")!.accounts.find(row => row.id === a)!.credential.access).toBe("synthetic-replacement-access"); + expect(getCachedProviderAccountQuota("anthropic", a!)).toBeNull(); + expect(getCachedProviderAccountQuota("anthropic", b!)).toBeNull(); +}); + +const overriddenHeaders: { label: string; headers: Record; authorization: string; apiKey: string | null }[] = [ + { label: "overridden bearer", headers: { Authorization: "Bearer synthetic-override" }, authorization: "Bearer synthetic-override", apiKey: null }, + { label: "additional x-api-key", headers: { "x-api-key": "synthetic-api-key" }, authorization: `Bearer ${credential(0).access}`, apiKey: "synthetic-api-key" }, +]; +test.each(overriddenHeaders)("$label skips quota attribution even when a selected OAuth account exists", async ({ headers, authorization, apiKey }) => { + const ids = await seed(); + const response = await post(configFor(body => answer(body.stream === true), headers)); + expect(response.status).toBe(200); + await response.text(); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ authorization, apiKey }); + for (const id of ids) expect(getCachedProviderAccountQuota("anthropic", id)).toBeNull(); +}); + +test("real web-search routed loop records A429 and B200 through fetchForRequest", async () => { + const [a, b] = await seed(); + const config = configFor(body => { + // The search loop forces upstream streaming although the client asks for JSON. + expect(body.stream).toBe(true); + if (sent.length === 1) return limited(); + expect(sent.length).toBe(2); + expectQuota(a!, 100, 61); + return answer(true); + }); + config.webSearchSidecar = { backend: "anthropic", enabled: true }; + const response = await post(config, { tools: [{ type: "web_search" }] }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("The answer is complete."); + expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); + expectQuota(a!, 100, 61); + expectQuota(b!, 23, 47); +}); + +test("real terminal continuation records A429 before retrying the continuation on B", async () => { + const [a, b] = await seed(); + const config = configFor(body => { + // The real guard recognizes an actionable request plus a short execution announcement, + // with available tools and no tool call. A normal completed answer does not trigger it. + if (sent.length === 1) return answer(body.stream === true, "0.11", "0.31", "I will modify the file now."); + if (sent.length === 2) { + expectQuota(a!, 11, 31); + return limited(); + } + expect(sent.length).toBe(3); + expectQuota(a!, 100, 61); + return answer(body.stream === true); + }); + const response = await post(config, { + input: "Please modify the file now", + tools: [{ type: "function", name: "read_file", description: "read a file", parameters: { type: "object" } }], + }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("The answer is complete."); + expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); + expectQuota(a!, 100, 61); + expectQuota(b!, 23, 47); +}); + +test("real image bridge routed loop records A429 and B200 through fetchForRequest", async () => { + const [a, b] = await seed(); + const config = configFor(body => { + expect(body.stream).toBe(true); + // Only the bridge installs this synthetic tool for the hosted image_generation input. + expect(body.tools).toEqual(expect.arrayContaining([expect.objectContaining({ name: "custom_image_gen" })])); + if (sent.length === 1) return limited(); + expect(sent.length).toBe(2); + expectQuota(a!, 100, 61); + return answer(true); + }); + config.images = { bridgeEnabled: true }; + config.providers.xai = { + adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "key", apiKey: "synthetic-image-key", + }; + const response = await post(config, { stream: true, tools: [{ type: "image_generation" }] }); + expect(response.status).toBe(200); + expect(await response.text()).toContain("The answer is complete."); + expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); + expectQuota(a!, 100, 61); + expectQuota(b!, 23, 47); +}); diff --git a/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts b/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts new file mode 100644 index 0000000000..21ae2d8489 --- /dev/null +++ b/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts @@ -0,0 +1,528 @@ +/** Anthropic response observations must preserve account usage and probe semantics. */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + clearAnthropicAccountCooldown, + clearAnthropicAccountPoolState, + forgetAnthropicFailoverQuorum, + getAnthropicAccountHealthSnapshot, + rotateAnthropicAccountOn429, +} from "../../../src/oauth/anthropic-routing"; +import { projectStoredOAuthAccountHealth } from "../../../src/oauth/health"; +import { + clearAccountQuotaCache, + fetchProviderAccountQuotas, + getCachedProviderAccountQuota, + parseAnthropicRateLimitHeaders, + recordAnthropicAccountQuotaFromHeaders, + reconcileProviderAccountQuotaRows, + resetProviderQuotaReconcileStateForTests, + setCachedProviderAccountQuotaForTests, + sweepExpiredProviderAccountQuotaRows, +} from "../../../src/providers/quota"; +import { getAccountSet, saveCredential } from "../../../src/oauth/store"; +import { clearPoolRotationState } from "../../../src/codex/pool-rotation"; +import { removeTreeWithRetry } from "../../helpers/remove-tree"; +import type { OcxConfig } from "../../../src/types"; + +const originalHome = process.env.OPENCODEX_HOME; +const originalFetch = globalThis.fetch; +const originalNow = Date.now; +let home: string; + +beforeEach(() => { + globalThis.fetch = (async () => { throw new Error("Unexpected network request in quota test"); }) as typeof fetch; + home = mkdtempSync(join(tmpdir(), "ocx-anthropic-ratelimit-")); + process.env.OPENCODEX_HOME = home; + clearAnthropicAccountPoolState(); + clearPoolRotationState(); + clearAccountQuotaCache(); + // `lastReconciledGeneration` is module-global and survives a cache clear, so the fence case + // below would otherwise raise the floor for every test that runs after it in this file. + resetProviderQuotaReconcileStateForTests(); + forgetAnthropicFailoverQuorum(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + Date.now = originalNow; + clearAnthropicAccountPoolState(); + clearPoolRotationState(); + // The argument-less form, deliberately: only it calls cancelPendingAccountQuotaPersist. + // The observer ends in a 250ms-debounced write that resolves OPENCODEX_HOME at fire time, + // so a provider-scoped clear would leave that write to land in whatever home is current a + // quarter second later — the next test's sandbox, or the developer's real one. + clearAccountQuotaCache(); + resetProviderQuotaReconcileStateForTests(); + forgetAnthropicFailoverQuorum(); + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + removeTreeWithRetry(home); +}); + +/** The store assigns its own slot ids, so the seeded `accountId` is never the cache key. */ +async function seed(count: number): Promise { + for (let i = 0; i < count; i++) { + await saveCredential("anthropic", { + access: `access-${i}`, + refresh: `refresh-${i}`, + expires: Date.now() + 3_600_000, + accountId: `uuid-${i}`, + email: `user${i}@example.test`, + } as never); + } + return getAccountSet("anthropic")?.accounts.map(a => a.id) ?? []; +} + +function poolEnabled(): OcxConfig { + return { + port: 0, + defaultProvider: "anthropic", + providers: { + anthropic: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "oauth" }, + }, + anthropicAccountPool: { enabled: true }, + } as OcxConfig; +} + +/** A real 429 from a drained five-hour window, captured from api.anthropic.com. */ +function drainedFiveHour(resetEpochSeconds: number): Headers { + return new Headers({ + "anthropic-ratelimit-unified-status": "rejected", + "anthropic-ratelimit-unified-5h-status": "rejected", + "anthropic-ratelimit-unified-5h-reset": String(resetEpochSeconds), + "anthropic-ratelimit-unified-5h-utilization": "1.0", + "anthropic-ratelimit-unified-7d-status": "allowed", + "anthropic-ratelimit-unified-7d-reset": String(resetEpochSeconds + 86_400), + "anthropic-ratelimit-unified-7d-utilization": "0.36", + }); +} + +describe("Anthropic cooldown honours the stated window", () => { + test("a multi-hour Retry-After is not truncated to the guessed-backoff ceiling", async () => { + const start = Date.now(); + const ids = await seed(2); + // 7999s is what a drained five-hour window actually answers; the old 15-minute clamp + // turned a single refusal into sixteen wasted retries before the window reopened. + rotateAnthropicAccountOn429(poolEnabled(), ids[0]!, "7999", null, start); + const health = getAnthropicAccountHealthSnapshot(ids[0]!, start); + expect(health?.cooldownUntil).toBe(start + 7_999_000); + expect(health?.cooldownSource).toBe("retry-after"); + }); + + test("a week-long Retry-After retains its stated deadline", async () => { + const start = Date.now(); + const ids = await seed(2); + rotateAnthropicAccountOn429(poolEnabled(), ids[0]!, "604800", null, start); + expect(getAnthropicAccountHealthSnapshot(ids[0]!, start)?.cooldownUntil) + .toBe(start + 604_800_000); + }); + + test("an HTTP-date Retry-After is honoured beyond six hours", async () => { + const start = Date.now(); + const ids = await seed(2); + // RFC 9110 allows either form, and both are upstream STATING when it will serve again -- + // the date branch had its own clamp and would have kept the 15-minute truncation. + rotateAnthropicAccountOn429(poolEnabled(), ids[0]!, new Date(start + 2 * 60 * 60_000).toUTCString(), null, start); + const cooldown = getAnthropicAccountHealthSnapshot(ids[0]!, start)?.cooldownUntil; + // toUTCString drops sub-second precision, so the deadline lands within a second of target. + expect(cooldown).toBeGreaterThan(start + 2 * 60 * 60_000 - 1_000); + expect(cooldown).toBeLessThanOrEqual(start + 2 * 60 * 60_000); + + const reset = Math.floor(start / 1000) * 1000 + 48 * 60 * 60_000; + rotateAnthropicAccountOn429(poolEnabled(), ids[1]!, new Date(reset).toUTCString(), null, start); + expect(getAnthropicAccountHealthSnapshot(ids[1]!, start)?.cooldownUntil).toBe(reset); + }); + + test("a 429 without Retry-After cools until the rejected window reopens", async () => { + const start = Date.now(); + const ids = await seed(2); + // The wire carries whole seconds, so the reset is built from an epoch second and the + // expectation is derived from the same value rather than from `start + 90min` — an + // assertion on the un-truncated millisecond would be testing the fixture, not the code. + const resetEpochSeconds = Math.floor((start + 90 * 60_000) / 1000); + // Retry-After is not guaranteed on an Anthropic 429; the rejected window's reset is. + // Without reading it this refusal cooled for the 60s default and the drained account + // was back in the rotation a minute later. + rotateAnthropicAccountOn429(poolEnabled(), ids[0]!, null, null, start, drainedFiveHour(resetEpochSeconds)); + const health = getAnthropicAccountHealthSnapshot(ids[0]!, start); + expect(health?.cooldownUntil).toBe(resetEpochSeconds * 1000); + // Its own source, not "retry-after": the dashboard renders that one as request-rate + // throttling, and a spent five-hour window is quota. Same vocabulary the Codex pool uses. + expect(health?.cooldownSource).toBe("reset-derived"); + }); + + test("an ALLOWED window's reset never cools the account", async () => { + const start = Date.now(); + const ids = await seed(2); + // Every response names when the current period ends, including a healthy one. Treating + // that as a cooldown would bench an account with 4% used for the rest of its window. + const healthy = new Headers({ + "anthropic-ratelimit-unified-status": "allowed", + "anthropic-ratelimit-unified-5h-status": "allowed", + "anthropic-ratelimit-unified-5h-reset": String(Math.floor((start + 3 * 60 * 60_000) / 1000)), + "anthropic-ratelimit-unified-5h-utilization": "0.04", + }); + rotateAnthropicAccountOn429(poolEnabled(), ids[0]!, null, null, start, healthy); + const health = getAnthropicAccountHealthSnapshot(ids[0]!, start); + expect(health?.cooldownUntil).toBe(start + 60_000); + expect(health?.cooldownSource).toBe("default"); + }); + + test("both windows rejected cools until the LAST one reopens", async () => { + const start = Date.now(); + const ids = await seed(2); + // The limiter is AND-composed: upstream refuses while ANY window rejects. An account whose + // 5-hour bucket rolls in three minutes is still refused for the days its weekly window + // needs, so cooling to the earliest reset would re-offer it every three minutes until the + // weekly window finally reopens -- the exact loop this path exists to end. + const fiveHourReset = Math.floor((start + 3 * 60_000) / 1000); + const weeklyReset = Math.floor((start + 5 * 24 * 60 * 60_000) / 1000); + const bothDrained = new Headers({ + "anthropic-ratelimit-unified-status": "rejected", + "anthropic-ratelimit-unified-5h-status": "rejected", + "anthropic-ratelimit-unified-5h-reset": String(fiveHourReset), + "anthropic-ratelimit-unified-7d-status": "rejected", + "anthropic-ratelimit-unified-7d-reset": String(weeklyReset), + }); + rotateAnthropicAccountOn429(poolEnabled(), ids[0]!, null, null, start, bothDrained); + expect(getAnthropicAccountHealthSnapshot(ids[0]!, start)?.cooldownUntil).toBe(weeklyReset * 1000); + }); + + test("a reset-derived cooldown surfaces as quota, a Retry-After as a rate limit", async () => { + const start = Date.now(); + const ids = await seed(2); + const account = getAccountSet("anthropic")!.accounts.find(a => a.id === ids[0]!)!; + // The distinction is not cosmetic: the dashboard tells an operator to wait out a rate + // limit and to switch accounts on spent quota. A drained five-hour window is the second. + rotateAnthropicAccountOn429( + poolEnabled(), + ids[0]!, + null, + null, + start, + drainedFiveHour(Math.floor((start + 90 * 60_000) / 1000)), + ); + expect(projectStoredOAuthAccountHealth("anthropic", account, start)).toMatchObject({ + status: "cooldown", + reason: "quota", + }); + + clearAnthropicAccountCooldown(ids[0]!); + rotateAnthropicAccountOn429(poolEnabled(), ids[0]!, "300", null, start); + expect(projectStoredOAuthAccountHealth("anthropic", account, start)).toMatchObject({ + status: "cooldown", + reason: "rate_limit", + }); + }); + + test("Retry-After wins over the header reset", async () => { + const start = Date.now(); + const ids = await seed(2); + // Retry-After is written for this decision; the reset epoch is a fallback for the + // refusals that omit it. A disagreement must not silently prefer the fallback. + rotateAnthropicAccountOn429( + poolEnabled(), + ids[0]!, + "120", + null, + start, + drainedFiveHour(Math.floor((start + 4 * 60 * 60_000) / 1000)), + ); + expect(getAnthropicAccountHealthSnapshot(ids[0]!, start)?.cooldownUntil).toBe(start + 120_000); + }); +}); + +describe("Anthropic rate-limit headers feed the routing cache", () => { + test("utilization is read as a fraction, not as a percent", () => { + // The header sends 0.74 for a 74%-spent window while the probe endpoint sends 74.0 for + // the same account. Passing the header value through unscaled would file the emptiest + // account as the freshest and route every new session straight at it. + const quota = parseAnthropicRateLimitHeaders(new Headers({ + "anthropic-ratelimit-unified-5h-utilization": "0.42", + "anthropic-ratelimit-unified-7d-utilization": "0.74", + })); + expect(quota?.fiveHourPercent).toBe(42); + expect(quota?.weeklyPercent).toBe(74); + }); + + test("reset epochs are promoted from seconds to milliseconds", () => { + const quota = parseAnthropicRateLimitHeaders(new Headers({ + "anthropic-ratelimit-unified-5h-utilization": "0.5", + "anthropic-ratelimit-unified-5h-reset": "1788717000", + })); + expect(quota?.fiveHourResetAt).toBe(1_788_717_000_000); + }); + + test("a header set with no utilization yields no measurement", () => { + // A renamed or dropped header must degrade to "unmeasured", which the router already + // has a defined behaviour for -- never to a fabricated zero, which reads as a fresh + // account and would pull traffic toward whichever account stopped reporting. + expect(parseAnthropicRateLimitHeaders(new Headers({ + "anthropic-ratelimit-unified-5h-reset": "1788717000", + }))).toBeNull(); + }); + + test("a utilization above 1 is rejected rather than clamped", () => { + // Above one is a wire change, not a full window. Inventing 100 from it would cool a + // healthy account on a misread. + expect(parseAnthropicRateLimitHeaders(new Headers({ + "anthropic-ratelimit-unified-5h-utilization": "42", + }))).toBeNull(); + }); + + test("an observed turn makes the serving account's usage known to the router", async () => { + const ids = await seed(2); + // Before the observation the account has no reading at all, which is what left a + // two-account pool scoring both at UNKNOWN_USAGE_SCORE and picking between them blind. + expect(getCachedProviderAccountQuota("anthropic", ids[0]!)).toBeNull(); + recordAnthropicAccountQuotaFromHeaders(ids[0]!, drainedFiveHour(Math.floor(Date.now() / 1000) + 3600), 0); + expect(getCachedProviderAccountQuota("anthropic", ids[0]!)?.fiveHourPercent).toBe(100); + // The other account stays unmeasured: an observation is attributed to the account that + // served the turn, never spread across the roster. + expect(getCachedProviderAccountQuota("anthropic", ids[1]!)).toBeNull(); + }); + + test("headers with nothing parseable leave the previous reading intact", async () => { + const ids = await seed(1); + recordAnthropicAccountQuotaFromHeaders(ids[0]!, new Headers({ + "anthropic-ratelimit-unified-5h-utilization": "0.25", + }), 0); + recordAnthropicAccountQuotaFromHeaders(ids[0]!, new Headers({ "content-type": "application/json" }), 0); + // A response that says nothing about quota is not evidence that the quota is gone. + expect(getCachedProviderAccountQuota("anthropic", ids[0]!)?.fiveHourPercent).toBe(25); + }); + + test("an empty account id writes nothing", () => { + // API-key providers and single-account installs below failover quorum reach the observer + // with no account to attribute; that is an ordinary state, not an error. Asserting only + // that it does not throw would pass with the guard deleted -- an empty-string cache key + // is perfectly writable -- so this asserts the absence of the row instead. + recordAnthropicAccountQuotaFromHeaders("", drainedFiveHour(Math.floor(Date.now() / 1000) + 3600), 0); + expect(getCachedProviderAccountQuota("anthropic", "")).toBeNull(); + }); + + test("a stale writer generation is refused", async () => { + const ids = await seed(1); + // The fence exists because a turn is a long await: an account or config change that lands + // mid-turn must not be overwritten by a measurement taken before it. Every other test here + // passes 0, which a fresh worker always accepts, so without this case the parameter is + // carried but never actually exercised as a fence. + reconcileProviderAccountQuotaRows({ + generation: 5, + providerNames: new Set(), + comboIds: new Set(), + comboTargets: new Set(), + codexAccountIds: new Set(), + oauthAccountKeys: new Set(), + configRoots: new Set(), + }); + recordAnthropicAccountQuotaFromHeaders(ids[0]!, new Headers({ + "anthropic-ratelimit-unified-5h-utilization": "0.5", + }), 1); + expect(getCachedProviderAccountQuota("anthropic", ids[0]!)).toBeNull(); + }); + + test("an observation keeps the model-scoped bars the probe filled", async () => { + const ids = await seed(1); + // The probe reports per-model weekly limits (Opus, Sonnet, Fable) that no header carries. + // They are read by the manual-preference exhaustion check and by `headroomOf`, so a + // wholesale replace would not merely blank the dashboard: it would route an Opus request + // to an account whose Opus allowance is spent. + setCachedProviderAccountQuotaForTests("anthropic", ids[0]!, { + fiveHourPercent: 10, + weeklyPercent: 20, + customWindows: [{ label: "Opus", percent: 96 }], + updatedAt: Date.now(), + }); + recordAnthropicAccountQuotaFromHeaders(ids[0]!, new Headers({ + "anthropic-ratelimit-unified-5h-utilization": "0.41", + }), 0); + const quota = getCachedProviderAccountQuota("anthropic", ids[0]!); + expect(quota?.fiveHourPercent).toBe(41); + // Untouched by this observation, not erased by it. + expect(quota?.weeklyPercent).toBe(20); + expect(quota?.customWindows).toEqual([{ label: "Opus", percent: 96 }]); + }); + + test("a percent that is not exactly representable is rounded, not left as an artifact", () => { + // `0.29 * 100` is 28.999999999999996 in binary floating point, and the CLI interpolates the + // percent raw. A user reading `5h 28.999999999999996%` would reasonably file a bug. + expect(parseAnthropicRateLimitHeaders(new Headers({ + "anthropic-ratelimit-unified-5h-utilization": "0.29", + }))?.fiveHourPercent).toBe(29); + }); +}); + +describe("Anthropic observation and probe clocks", () => { + function observe(accountId: string, percent = "0.41"): void { + recordAnthropicAccountQuotaFromHeaders(accountId, new Headers({ + "anthropic-ratelimit-unified-5h-utilization": percent, + }), 0); + } + + function usageResponse(): Response { + return Response.json({ five_hour: { utilization: 12 }, seven_day_opus: { utilization: 63 } }); + } + + test("a cold header-only row does not defer the first usage probe", async () => { + const [id] = await seed(1); + let calls = 0; + globalThis.fetch = (async () => { calls++; return usageResponse(); }) as typeof fetch; + observe(id!); + expect(getCachedProviderAccountQuota("anthropic", id!)?.fiveHourPercent).toBe(41); + const [row] = await fetchProviderAccountQuotas("anthropic"); + expect(calls).toBe(1); + expect(row?.quota).toMatchObject({ fiveHourPercent: 12, customWindows: [{ label: "Opus", percent: 63 }] }); + expect(row?.unavailable).toBeUndefined(); + }); + + test("fresh header observations survive sweeping until their own TTL expires", async () => { + const [id] = await seed(1); + const observedAt = originalNow(); + Date.now = () => observedAt; + observe(id!); + expect(sweepExpiredProviderAccountQuotaRows(observedAt + 1)).toBe(0); + expect(getCachedProviderAccountQuota("anthropic", id!)?.fiveHourPercent).toBe(41); + expect(sweepExpiredProviderAccountQuotaRows(observedAt + 10 * 60_000 - 1)).toBe(0); + expect(sweepExpiredProviderAccountQuotaRows(observedAt + 10 * 60_000)).toBe(1); + expect(getCachedProviderAccountQuota("anthropic", id!)).toBeNull(); + }); + + test("headers preserve the probe TTL instead of renewing it", async () => { + const [id] = await seed(1); + let now = originalNow(); + Date.now = () => now; + let calls = 0; + globalThis.fetch = (async () => { calls++; return usageResponse(); }) as typeof fetch; + await fetchProviderAccountQuotas("anthropic"); + now += 9 * 60_000; + observe(id!); + expect((await fetchProviderAccountQuotas("anthropic"))[0]?.quota?.fiveHourPercent).toBe(41); + expect(calls).toBe(1); + now += 60_001; + await fetchProviderAccountQuotas("anthropic"); + expect(calls).toBe(2); + }); + + for (const observeAfterRestart of [false, true]) { + test(`restart keeps Anthropic probes due with new headers: ${observeAfterRestart}`, async () => { + const [id] = await seed(1); + const updatedAt = Date.now(); + const saved = { fiveHourPercent: 41, customWindows: [{ label: "Opus", percent: 63 }], updatedAt }; + writeFileSync(join(home, "provider-account-quota-cache.json"), JSON.stringify({ + version: 1, + rows: { [`anthropic\u0000${id}`]: saved, "kiro\u0000other": { monthlyPercent: 17, updatedAt } }, + })); + clearAccountQuotaCache(); + // Cover both dashboard-first and response-first hydration after restart. + if (observeAfterRestart) observe(id!, "0.52"); + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response("busy", { status: 429 }); }) as typeof fetch; + const [row] = await fetchProviderAccountQuotas("anthropic"); + expect(calls).toBe(1); + expect(row?.quota).toMatchObject({ fiveHourPercent: observeAfterRestart ? 52 : 41, customWindows: saved.customWindows }); + expect(getCachedProviderAccountQuota("kiro", "other")?.monthlyPercent).toBe(17); + expect(row?.unavailable).toBe(true); + }); + } + + for (const [failure, warm] of [["http", true], ["network", true], ["http", false]] as const) { + test(`joined ${failure} probe failures preserve in-flight headers (warm cache: ${warm})`, async () => { + const [id] = await seed(1); + if (warm) setCachedProviderAccountQuotaForTests("anthropic", id!, { + fiveHourPercent: 10, weeklyPercent: 20, customWindows: [{ label: "Opus", percent: 63 }], updatedAt: Date.now(), + }); + let started!: () => void; + const dispatched = new Promise(resolve => { started = resolve; }); + let finish!: (response: Response) => void; + let fail!: (error: Error) => void; + const response = new Promise((resolve, reject) => { finish = resolve; fail = reject; }); + let calls = 0; + globalThis.fetch = (async () => { calls++; started(); return response; }) as typeof fetch; + const first = fetchProviderAccountQuotas("anthropic", true); + await dispatched; + const second = fetchProviderAccountQuotas("anthropic", true); + observe(id!); + const latest = getCachedProviderAccountQuota("anthropic", id!); + if (failure === "http") finish(new Response("busy", { status: 429 })); + else fail(new Error("offline")); + const [a, b] = await Promise.all([first, second]); + expect(calls).toBe(1); + expect(a).toEqual(b); + expect(a[0]?.quota).toEqual(latest); + expect(a[0]?.quota?.fiveHourPercent).toBe(41); + if (warm) expect(a[0]?.quota).toMatchObject({ weeklyPercent: 20, customWindows: [{ label: "Opus", percent: 63 }] }); + expect(a[0]?.unavailable).toBe(true); + expect(getCachedProviderAccountQuota("anthropic", id!)).toEqual(latest); + // A later partial observation cannot claim that the failed usage probe succeeded. + observe(id!, "0.53"); + const [cached] = await fetchProviderAccountQuotas("anthropic"); + expect(cached?.unavailable).toBe(true); + expect(cached?.quota?.fiveHourPercent).toBe(53); + expect(calls).toBe(1); + globalThis.fetch = (async () => usageResponse()) as typeof fetch; + expect((await fetchProviderAccountQuotas("anthropic", true))[0]?.unavailable).toBeUndefined(); + }); + } +}); + +describe("Anthropic malformed deadlines and partial windows", () => { + for (const invalid of ["NaN", "Infinity", "1e309", "1e308", "8640000000001", "not-a-date", "-1", "0"]) { + test(`invalid reset ${invalid} cannot establish a cooldown deadline`, async () => { + const start = Date.now(); + const [id] = await seed(1); + const headers = new Headers({ + "anthropic-ratelimit-unified-7d-status": "rejected", + "anthropic-ratelimit-unified-7d-reset": invalid, + "anthropic-ratelimit-unified-7d-utilization": "0.74", + }); + rotateAnthropicAccountOn429(poolEnabled(), id!, null, null, start, headers); + expect(getAnthropicAccountHealthSnapshot(id!, start)).toMatchObject({ + cooldownUntil: start + 60_000, cooldownSource: "default", + }); + expect(parseAnthropicRateLimitHeaders(headers)?.weeklyResetAt).toBeUndefined(); + }); + } + + test("overflowing Retry-After falls back to a valid rejected reset", async () => { + const start = Date.now(); + const [id] = await seed(1); + const reset = Math.floor(start / 1000) + 432_000; + for (const invalid of ["9".repeat(400), "8640000000001", "invalid-date"]) { + rotateAnthropicAccountOn429(poolEnabled(), id!, invalid, null, start, drainedFiveHour(reset)); + expect(getAnthropicAccountHealthSnapshot(id!, start)).toMatchObject({ + cooldownUntil: reset * 1000, cooldownSource: "reset-derived", + }); + } + }); + + test("a malformed weekly deadline cannot hide a valid five-hour reset", async () => { + const start = Date.now(); + const [id] = await seed(1); + const reset = Math.floor(start / 1000) + 180; + const headers = drainedFiveHour(reset); + headers.set("anthropic-ratelimit-unified-7d-status", "rejected"); + headers.set("anthropic-ratelimit-unified-7d-reset", "1e308"); + rotateAnthropicAccountOn429(poolEnabled(), id!, null, null, start, headers); + expect(getAnthropicAccountHealthSnapshot(id!, start)?.cooldownUntil).toBe(reset * 1000); + }); + + test("partial zero utilization preserves other and model-specific windows", async () => { + const [id] = await seed(1); + const customWindows = [{ label: "Opus", percent: 63 }]; + setCachedProviderAccountQuotaForTests("anthropic", id!, { + fiveHourPercent: 10, weeklyPercent: 20, weeklyResetAt: 1_800_000_000_000, customWindows, updatedAt: Date.now(), + }); + recordAnthropicAccountQuotaFromHeaders(id!, new Headers({ + "anthropic-ratelimit-unified-5h-utilization": "0", + "anthropic-ratelimit-unified-7d-utilization": "NaN", + "anthropic-ratelimit-unified-7d-reset": "1e308", + }), 0); + expect(getCachedProviderAccountQuota("anthropic", id!)).toMatchObject({ + fiveHourPercent: 0, weeklyPercent: 20, weeklyResetAt: 1_800_000_000_000, customWindows, + }); + }); +}); diff --git a/tests/adapters/anthropic/anthropic-sidecar-account-failover.test.ts b/tests/adapters/anthropic/anthropic-sidecar-account-failover.test.ts index 31b2389fb8..8d094db631 100644 --- a/tests/adapters/anthropic/anthropic-sidecar-account-failover.test.ts +++ b/tests/adapters/anthropic/anthropic-sidecar-account-failover.test.ts @@ -8,10 +8,11 @@ import { afterAll, afterEach, beforeAll, beforeEach, expect, mock, test } from " import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import type { ProviderAdapter } from "../../../src/adapters/base"; +import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../../../src/adapters/base"; import { clearAnthropicAccountPoolState } from "../../../src/oauth/anthropic-routing"; import { clearGenericFailoverHealth } from "../../../src/oauth/generic-account-failover"; import { getAccountSet, saveCredential, setActiveAccount } from "../../../src/oauth/store"; +import { clearAccountQuotaCache, getCachedProviderAccountQuota, resetProviderQuotaReconcileStateForTests } from "../../../src/providers/quota"; import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; @@ -66,15 +67,24 @@ beforeAll(async () => { runWithWebSearch: async (args: { parsed: OcxParsedRequest; adapter: ProviderAdapter; + incomingMeta: IncomingMeta; + fetchForRequest: (request: AdapterRequest, parsed: OcxParsedRequest) => typeof fetch; on429?: (retryAfter: string | null) => Promise; }) => { - const first = await args.adapter.buildRequest(args.parsed); - observedKeys.push(new Headers(first.headers).get("authorization") ?? ""); - const rotated = await args.on429?.("30"); + // This is a dispatch seam test. The real loop is covered in anthropic-quota-dispatch. + const first = await args.adapter.buildRequest(args.parsed, args.incomingMeta); + const refused = await args.fetchForRequest(first, args.parsed)(first.url, { + method: first.method, headers: first.headers, body: first.body, + }); + expect(refused.status).toBe(429); + const retryAfter = refused.headers.get("retry-after"); + await refused.body?.cancel(); + const rotated = await args.on429?.(retryAfter); if (!rotated) throw new Error("Anthropic sidecar did not rotate after 429"); - const second = await rotated.buildRequest(args.parsed); - observedKeys.push(new Headers(second.headers).get("authorization") ?? ""); - return new Response("sidecar-ok", { status: 200 }); + const second = await rotated.buildRequest(args.parsed, args.incomingMeta); + return args.fetchForRequest(second, args.parsed)(second.url, { + method: second.method, headers: second.headers, body: second.body, + }); }, })); @@ -88,11 +98,15 @@ beforeEach(() => { sidecarMode = false; clearAnthropicAccountPoolState(); clearGenericFailoverHealth(); + clearAccountQuotaCache(); + resetProviderQuotaReconcileStateForTests(); }); afterEach(() => { clearAnthropicAccountPoolState(); clearGenericFailoverHealth(); + clearAccountQuotaCache(); + resetProviderQuotaReconcileStateForTests(); removeTreeWithRetry(testHome); }); @@ -102,7 +116,7 @@ afterAll(() => { mock.restore(); }); -test("Anthropic web-search sidecar rotates on 429 when proactive pooling is disabled", async () => { +test("Anthropic sidecar dispatch seam records A429 and B200 when proactive pooling is disabled", async () => { sidecarMode = true; for (let index = 0; index < 2; index += 1) { await saveCredential("anthropic", { @@ -110,7 +124,7 @@ test("Anthropic web-search sidecar rotates on 429 when proactive pooling is disa refresh: `anthropic-refresh-${index}`, expires: Date.now() + 3_600_000, accountId: `anthropic-account-${index}`, - } as never, { addAccount: true }); + }); } const ids = getAccountSet("anthropic")!.accounts.map(account => account.id); await setActiveAccount("anthropic", ids[0]!); @@ -125,6 +139,27 @@ test("Anthropic web-search sidecar rotates on 429 when proactive pooling is disa baseUrl: "https://anthropic-sidecar.test/v1", authMode: "oauth", models: ["model"], + fetch: (async (_input, init) => { + observedKeys.push(new Headers(init?.headers).get("authorization") ?? ""); + if (observedKeys.length === 1) { + return new Response("rate limited", { + status: 429, + headers: { + "retry-after": "30", + "anthropic-ratelimit-unified-5h-utilization": "1", + "anthropic-ratelimit-unified-7d-utilization": "0.61", + }, + }); + } + expect(observedKeys).toHaveLength(2); + expect(getCachedProviderAccountQuota("anthropic", ids[0]!)).toMatchObject({ fiveHourPercent: 100, weeklyPercent: 61 }); + return new Response("sidecar-ok", { + headers: { + "anthropic-ratelimit-unified-5h-utilization": "0.23", + "anthropic-ratelimit-unified-7d-utilization": "0.47", + }, + }); + }) as typeof fetch, }, }, } as unknown as OcxConfig; @@ -146,4 +181,6 @@ test("Anthropic web-search sidecar rotates on 429 when proactive pooling is disa "Bearer anthropic-access-0", "Bearer anthropic-access-1", ]); + expect(getCachedProviderAccountQuota("anthropic", ids[0]!)).toMatchObject({ fiveHourPercent: 100, weeklyPercent: 61 }); + expect(getCachedProviderAccountQuota("anthropic", ids[1]!)).toMatchObject({ fiveHourPercent: 23, weeklyPercent: 47 }); }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6565f12821..2a1cac7ec3 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -39,6 +39,8 @@ "anthropic-image-retry.test.ts": "adapters/anthropic", "anthropic-pool-toggle-copy.test.ts": "adapters/anthropic", "anthropic-quorum-cache.test.ts": "routing", + "anthropic-quota-dispatch.test.ts": "adapters/anthropic", + "anthropic-ratelimit-headers.test.ts": "adapters/anthropic", "anthropic-reasoning.test.ts": "adapters/anthropic", "anthropic-sidecar-account-failover.test.ts": "adapters/anthropic", "anthropic-stream-hardening.test.ts": "adapters/anthropic", From 12b174ebc90e980970024445825f6b49a374154c Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:42:22 +0900 Subject: [PATCH 65/99] fix(claude): preserve signed and opaque replay block boundaries Preserve empty signed and redacted-only replay items, keep signature updates within their source thinking block, and emit opaque blocks in order. Retain hidden-summary policy and document deferred Claude hidden-text replay and live/cache claims. Add exact-array synthetic round-trip coverage; local tests and typecheck intentionally not run, pending parent combined remote CI. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> Co-authored-by: Yumi --- .../src/content/docs/fr/guides/claude-code.md | 2 + .../src/content/docs/guides/claude-code.md | 2 + .../src/content/docs/ja/guides/claude-code.md | 2 + .../src/content/docs/ko/guides/claude-code.md | 2 + .../src/content/docs/ru/guides/claude-code.md | 2 + .../src/content/docs/tr/guides/claude-code.md | 2 + .../content/docs/zh-cn/guides/claude-code.md | 2 + .../content/docs/zh-tw/guides/claude-code.md | 2 + src/adapters/anthropic.ts | 11 +- src/bridge.ts | 34 +++- src/claude/outbound.ts | 11 +- src/responses/parser.ts | 19 +- .../anthropic-thinking-signature.test.ts | 188 +++++++++++++++++- .../claude-outbound.test.ts | 17 ++ tests/responses/reasoning-envelope.test.ts | 4 +- 15 files changed, 277 insertions(+), 23 deletions(-) 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 ffc3ad6e88..7f1c0a54aa 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -506,6 +506,8 @@ Le proxy traduit chaque requête Anthropic Messages API au format Codex Response | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +Sur l’adaptateur Anthropic prévu, les blocs signés non masqués (y compris thinking vide) et les blocs redacted opaques sont préservés. `hideThinkingSummary` reste inchangé : le texte signé masqué localement n’est pas exposé aux clients Claude ; sa relecture sans perte via cette frontière reste non établie. Les anciennes enveloppes combinées ne permettent pas de rétablir l’ordre après émission du texte en streaming. `claudeCode.compatibility: "enforce"` refuse toujours la relecture thinking. Cela ne prouve ni l’acceptation réelle par Anthropic ni une amélioration du cache ; [#3719](https://github.com/lidge-jun/opencodex/issues/3719) reste ouvert. + **Cas d'erreur (400) :** JSON mal formé ; `model` absent ou vide ; `messages` absent ou vide ; rôle non pris en charge ; `tool_result` sans `tool_use_id` ; `tool_use` sans identifiant ni nom ; `tool_choice` nommé sans nom. diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index b237955adb..f1c1bfbfe1 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -519,6 +519,8 @@ The proxy translates every Anthropic Messages API request into the Codex Respons | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +Replay preserves non-hidden signed blocks (including empty thinking) and opaque redacted blocks on the intended Anthropic adapter. `hideThinkingSummary` remains unchanged: locally hidden signed text is not exposed to Claude clients, and lossless replay through that hidden Claude boundary is not established. Older combined reasoning envelopes cannot recover original block order once streaming text has been emitted. `claudeCode.compatibility: "enforce"` still rejects thinking replay. This does not establish live Anthropic acceptance or cache-hit improvements; [#3719](https://github.com/lidge-jun/opencodex/issues/3719) remains open. + **Error cases (400):** malformed JSON; missing/empty `model`; missing/empty `messages`; unsupported role; `tool_result` without `tool_use_id`; `tool_use` without id/name; named `tool_choice` without name. 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 43b8bcee24..2445e7fa3a 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -374,6 +374,8 @@ Claude Code の `/effort` 設定はアダプターでも維持されます。 | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +意図した Anthropic アダプターでは、非表示でない署名付きブロック(空の thinking を含む)と不透明な redacted ブロックを保持します。`hideThinkingSummary` は変更しません。ローカルで隠した署名付きテキストは Claude クライアントに公開せず、この非表示境界での無損失再生は未確認です。旧形式の結合エンベロープは、テキスト送信後に元のブロック順を復元できません。`claudeCode.compatibility: "enforce"` は引き続き thinking 再生を拒否します。実際の Anthropic 受理やキャッシュ改善の証明ではなく、[#3719](https://github.com/lidge-jun/opencodex/issues/3719) は未解決です。 + **エラー条件(400):** 不正な JSON、欠落または空の `model`、欠落または空の `messages`、未サポートの role、`tool_use_id` のない `tool_result`、id/name のない `tool_use`、name のない名前指定 `tool_choice` です。 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 0964f2ff49..4e3535d821 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -412,6 +412,8 @@ Claude Code의 `/effort` 설정은 어댑터에서도 유지돼요. | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +의도한 Anthropic 어댑터에서는 숨기지 않은 서명 블록(빈 thinking 포함)과 불투명 redacted 블록을 보존해요. `hideThinkingSummary` 정책은 유지돼요. 로컬에서 숨긴 서명 텍스트를 Claude 클라이언트에 노출하지 않으며, 이 숨김 경계를 통한 무손실 재생은 아직 보장하지 않아요. 이전 결합 봉투는 스트리밍 텍스트가 이미 전송됐다면 원래 블록 순서를 복원할 수 없어요. `claudeCode.compatibility: "enforce"`는 여전히 thinking 재생을 거절해요. 실제 Anthropic 수락이나 캐시 적중 개선을 증명한 것은 아니며 [#3719](https://github.com/lidge-jun/opencodex/issues/3719)는 열어 둬요. + **오류 조건(400):** 잘못된 JSON, 누락되거나 빈 `model`, 누락되거나 빈 `messages`, 지원하지 않는 role, `tool_use_id` 없는 `tool_result`, id/name 없는 `tool_use`, name 없는 이름 지정 `tool_choice`예요. 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 1769642bd7..847e79964a 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -399,6 +399,8 @@ Claude Code — это лишь учётные данные для доступ | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +На выбранном адаптере Anthropic сохраняются нескрытые подписанные блоки (включая пустой thinking) и непрозрачные блоки redacted. Политика `hideThinkingSummary` не меняется: локально скрытый подписанный текст не раскрывается клиентам Claude, а воспроизведение без потерь через эту границу пока не подтверждено. Старые объединённые конверты не восстанавливают порядок после отправки потокового текста. `claudeCode.compatibility: "enforce"` по-прежнему отклоняет thinking replay. Приём реальным Anthropic и улучшение кеша не доказаны; [#3719](https://github.com/lidge-jun/opencodex/issues/3719) остаётся открытым. + **Случаи ошибок (400):** некорректный JSON; отсутствующий или пустой `model`; отсутствующий или пустой `messages`; неподдерживаемая роль; `tool_result` без `tool_use_id`; `tool_use` без id/name; именованный `tool_choice` без имени. 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 29d96506ac..f1c7fca438 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -588,6 +588,8 @@ dönüştürür: | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +Hedeflenen Anthropic adaptöründe gizlenmemiş imzalı bloklar (boş thinking dahil) ve opak redacted blokları korunur. `hideThinkingSummary` değişmez: yerel olarak gizlenen imzalı metin Claude istemcilerine gösterilmez; bu sınır üzerinden kayıpsız yeniden oynatma doğrulanmamıştır. Eski birleşik zarflarda metin akışla gönderildikten sonra özgün blok sırası geri getirilemez. `claudeCode.compatibility: "enforce"` thinking yeniden oynatmasını hâlâ reddeder. Bu, gerçek Anthropic kabulünü veya önbellek iyileşmesini kanıtlamaz; [#3719](https://github.com/lidge-jun/opencodex/issues/3719) açık kalır. + **Hata durumları (400):** hatalı biçimlendirilmiş JSON; eksik/boş `model`; eksik/boş `messages`; desteklenmeyen rol; `tool_use_id` içermeyen `tool_result`; kimlik/ad içermeyen `tool_use`; ad içermeyen adlandırılmış `tool_choice`. 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 3e2824d3e9..2b8c0bfa98 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 @@ -350,6 +350,8 @@ Claude Code 的 `/effort` 设置会完整保留并传递给适配器: | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +在预期的 Anthropic 适配器上,保留未隐藏的签名块(包括空 thinking)和不透明的 redacted 块。`hideThinkingSummary` 策略不变:不会向 Claude 客户端公开本地隐藏的签名文本,尚未证明经过此隐藏边界的无损重放。旧版组合信封在流式文本发出后无法恢复原始块顺序。`claudeCode.compatibility: "enforce"` 仍拒绝 thinking 重放。这不证明真实 Anthropic 接受请求或缓存命中改善;[#3719](https://github.com/lidge-jun/opencodex/issues/3719) 仍未关闭。 + **错误情况(400):**JSON 格式错误;缺少/空的 `model`;缺少/空的 `messages`;不支持的 role;`tool_result` 缺少 `tool_use_id`;`tool_use` 缺少 id/name;指定名称的 `tool_choice` 缺少 name。 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 5a6fbf3a86..db1c779145 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 @@ -426,6 +426,8 @@ Claude Code 的 `/effort` 設定會完整保留並傳遞給適配器: | `max_tokens` | `max_output_tokens` | | `stop_sequences` | `stop` | +在預期的 Anthropic 適配器上,保留未隱藏的簽名區塊(包括空 thinking)和不透明的 redacted 區塊。`hideThinkingSummary` 政策不變:不會向 Claude 用戶端公開本地隱藏的簽名文字,尚未證明經過此隱藏邊界的無損重播。舊版組合信封在串流文字發出後無法恢復原始區塊順序。`claudeCode.compatibility: "enforce"` 仍拒絕 thinking 重播。這不證明真實 Anthropic 接受請求或快取命中改善;[#3719](https://github.com/lidge-jun/opencodex/issues/3719) 仍未關閉。 + **錯誤情況(400):**JSON 格式錯誤;缺少/空的 `model`;缺少/空的 `messages`;不支援的 role;`tool_result` 缺少 `tool_use_id`;`tool_use` 缺少 id/name;指定名稱的 `tool_choice` 缺少 name。 diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 6eea4764a1..a9a8279198 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -1118,9 +1118,14 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti break; } case "content_block_start": { - const block = data.content_block as { type: string; id?: string; name?: string; data?: string } | undefined; + const block = data.content_block as { type: string; id?: string; name?: string; data?: string; thinking?: string } | undefined; if (!block) break; currentBlockType = block.type; + if (block.type === "thinking") { + // Preserve even a display:omitted block boundary. The bridge can then + // distinguish consecutive empty signed blocks from signature updates. + yield { type: "thinking_delta", thinking: typeof block.thinking === "string" ? block.thinking : "" }; + } if (block.type === "tool_use") { currentToolCallId = usableToolUseId(block.id); currentToolCallName = toolNames.fromWire(block.name ?? ""); @@ -1151,8 +1156,8 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // later text blocks independent. yield { type: "thinking_delta", thinking: delta.reasoning }; } else if (delta.type === "signature_delta" && typeof delta.signature === "string" && (currentBlockType === "thinking" || currentBlockType === "reasoning")) { - // Arrives once, just before the thinking block's content_block_stop; block-scoped - // so a stray signature on a non-thinking block can never be captured. + // Anthropic SDKs replace the signature with this value. Forward updates + // within the block; the bridge closes on the next semantic boundary. yield { type: "thinking_signature", signature: delta.signature }; } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && currentBlockType === "tool_use") { // Forwarded immediately: the bridge maps each delta to a client-visible diff --git a/src/bridge.ts b/src/bridge.ts index 645dfff8e7..ff044a5e52 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -945,6 +945,13 @@ export function bridgeToResponsesSSE( } if (event.type !== "done" && event.type !== "incomplete" && event.type !== "error") continue; } + // Anthropic signature_delta supplies the latest signature, not an append-only + // fragment (anthropic-sdk-typescript MessageStream). Keep consecutive updates + // together; the next semantic event belongs to the following block. + if (pendingSignature !== undefined && event.type !== "thinking_signature" && event.type !== "heartbeat") { + if (currentReasoning) closeCurrentReasoning(); + else flushHiddenReasoningEnvelope(); + } switch (event.type) { case "assistant_boundary": { // A guarded continuation starts a fresh assistant output item while keeping the @@ -1054,15 +1061,21 @@ export function bridgeToResponsesSSE( case "thinking_signature": { pendingSignatureBytes = replaceRetainedString(pendingSignatureBytes, event.signature, "reasoning"); pendingSignature = event.signature; - // Signature arrives at the end of the thinking block. With a visible reasoning item - // open, closeCurrentReasoning attaches the envelope; hidden/suppressed blocks flush - // an envelope-only reasoning item now. - if (!currentReasoning) flushHiddenReasoningEnvelope(); + // Delay closing until the next semantic event so a signature update cannot + // create another block or become attached to the following thinking text. break; } case "redacted_thinking": { + if (currentMsg) closeCurrentMessage("commentary"); + if (currentReasoning) closeCurrentReasoning(); + if (currentRawReasoning) closeCurrentRawReasoning(); + flushHiddenRawReasoning(); + if (currentToolCall) closeCurrentToolCall(); budget?.chargeRetained(bytesOf(event.data), { kind: "reasoning" }); pendingRedacted.push(event.data); + // A redacted block is complete at content_block_start. Emit it here, + // not with a later thinking block or after a tool call at turn end. + flushHiddenReasoningEnvelope(); break; } case "kiro_redacted_reasoning": { @@ -1816,6 +1829,9 @@ function buildResponseJSONWithBudget( if (budget) releaseTranslatedEvent(e, budget); continue; } + if (batchSignature !== undefined && e.type !== "thinking_signature" && e.type !== "heartbeat") { + flushSummaryReasoning(); + } switch (e.type) { case "assistant_boundary": flushText("commentary"); @@ -1860,19 +1876,23 @@ function buildResponseJSONWithBudget( } break; case "thinking_signature": - // End of the current thinking block — flush it WITH the signature envelope so the - // block/signature pairing survives multi-block turns. + // Like streaming, retain the latest signature update until the next semantic + // event. Flushing every update would manufacture signature-only siblings. batchSignatureBytes = replaceBatchRetainedString(batchSignatureBytes, e.signature, "reasoning"); batchSignature = e.signature; - flushSummaryReasoning(); break; case "redacted_thinking": + flushText("commentary"); + flushSummaryReasoning(); + flushRawReasoning(); + flushToolCall(); { const dataBytes = bytesOf(e.data); budget?.chargeRetained(dataBytes, { kind: "reasoning" }); batchRedactedBytes += dataBytes; } batchRedacted.push(e.data); + flushSummaryReasoning(); break; case "kiro_redacted_reasoning": // Stash only — pushed after the trailing flushes. One blob per turn, so last wins. diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 8c0db5b7b8..4dcdaa0eb7 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -559,7 +559,10 @@ export function responsesSseToAnthropicSse( if (env?.sig) open.reasoningSig = env.sig; closeOpenBlock(); } - if (red.length > 0) ensureStarted(); + if (red.length > 0) { + ensureStarted(); + closeOpenBlock(); + } for (const data of red) { const idx = blockIndex++; emit("content_block_start", { type: "content_block_start", index: idx, content_block: { type: "redacted_thinking", data } }); @@ -806,10 +809,14 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R } const encrypted = typeof raw.encrypted_content === "string" ? raw.encrypted_content : ""; const env = encrypted ? decodeReasoningEnvelope(encrypted) : null; + // Legacy combined envelopes place redacted blocks before the signed block, + // matching the Anthropic adapter. New bridge output uses separate items. + for (const data of env?.red ?? []) content.push({ type: "redacted_thinking", data }); + // env.txt may be locally hidden text. Do not expose it here or manufacture + // a new signed continuity carrier; hidden-summary replay remains limited. if (parts.length > 0 || env?.sig) { content.push({ type: "thinking", thinking: parts.join("\n\n"), signature: env?.sig ?? encodeReasoningEnvelope({ txt: parts.join("\n\n") }) }); } - for (const data of env?.red ?? []) content.push({ type: "redacted_thinking", data }); break; } case "function_call": { diff --git a/src/responses/parser.ts b/src/responses/parser.ts index a81a693a4a..396f2170b2 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -126,6 +126,12 @@ export function parseRequest( } return holder; }; + const preservePendingReplay = () => { + const replay = pendingReasoning.filter(entry => entry.envelopeSigned || entry.part.redacted?.length); + if (replay.length > 0) { + ensureAssistantPlaceholder(messages, data.model, now).content.push(...replay.map(entry => entry.part)); + } + }; // Tool specs surfaced by a prior tool_search (deferred tools, e.g. subagents). Codex does not // re-list these in `tools`, but chat models can only call listed tools — so we re-inject them. const loadedToolSpecs: unknown[] = []; @@ -148,6 +154,12 @@ export function parseRequest( const effectiveType = (item as { type?: string }).type ?? ("role" in item ? "message" : undefined); const itemRole = (item as { role?: string }).role; const externalTaskInput = effectiveType === "function_call_output" ? externalTaskInputContent(item) : undefined; + // A signed/opaque assistant-only turn still owns its replay blocks, even + // without a following assistant text or tool call to drain the pending list. + if (effectiveType === "agent_message" || externalTaskInput !== undefined + || (effectiveType === "message" && ["user", "developer", "system"].includes(itemRole ?? ""))) { + preservePendingReplay(); + } // Raw protocol items do not map one-to-one onto context messages. Capture the boundary while // both representations are available so later metadata can stay before conversation in both. if ( @@ -269,7 +281,7 @@ export function parseRequest( const envelope = typeof reasoning.encrypted_content === "string" ? decodeReasoningEnvelope(reasoning.encrypted_content) : null; - const thinkingText = envelope?.txt || text; + const thinkingText = envelope?.txt ?? text; // Kiro reasoning round-trip: a krc-only item carries nothing renderable — it is provider // state for the assistant turn that ALREADY closed, because Kiro emits its @@ -285,7 +297,7 @@ export function parseRequest( // Native/non-ocxr1 encrypted-only reasoning is opaque here. Do not create a detached // assistant turn or invent replayable plaintext/signatures from the encrypted payload. - if (thinkingText.length > 0) { + if (thinkingText.length > 0 || envelope?.sig || envelope?.red?.length) { const part: OcxThinkingContent = { type: "thinking", thinking: thinkingText, @@ -296,7 +308,7 @@ export function parseRequest( const envelopeSigned = typeof envelope?.sig === "string"; const previous = pendingReasoning[pendingReasoning.length - 1]; - if (!envelopeSigned && previous && !previous.envelopeSigned) { + if (!envelopeSigned && !part.redacted && previous && !previous.envelopeSigned && !previous.part.redacted) { previous.part = { ...part, thinking: `${previous.part.thinking}\n${part.thinking}`, @@ -466,6 +478,7 @@ export function parseRequest( } } } + preservePendingReplay(); if (data.previous_response_id && continuationConversationMessageIndex === undefined) { continuationConversationMessageIndex = messages.length; } diff --git a/tests/adapters/anthropic/anthropic-thinking-signature.test.ts b/tests/adapters/anthropic/anthropic-thinking-signature.test.ts index 68c972a742..db8f489c4f 100644 --- a/tests/adapters/anthropic/anthropic-thinking-signature.test.ts +++ b/tests/adapters/anthropic/anthropic-thinking-signature.test.ts @@ -4,7 +4,12 @@ import { createAnthropicAdapter as createAnthropicAdapterProduction } from "../. import { parseRequest } from "../../../src/responses/parser"; import { encodeReasoningEnvelope, decodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../../../src/responses/reasoning-envelope"; import type { AdapterEvent, OcxProviderConfig, OcxThinkingContent } from "../../../src/types"; -import { withTestTranslatorBudget } from "../../helpers/translator-budget"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "../../helpers/translator-budget"; + +import { anthropicToResponsesBody } from "../../../src/claude/inbound"; +import { collectAnthropicMessage, responsesSseToAnthropicSse, responsesJsonToAnthropicMessage } from "../../../src/claude/outbound"; +import { createGoogleAdapter } from "../../../src/adapters/google"; +import { sanitizeReasoningInputContent } from "../../../src/adapters/openai-responses"; const createAnthropicAdapter = (...args: Parameters) => withTestTranslatorBudget(createAnthropicAdapterProduction(...args)); @@ -127,11 +132,11 @@ describe("bridge ocxr1 envelope emission", () => { ...baseEvents, ], "claude-x"); const output = response.output as Record[]; - const reasoning = output.find(i => i.type === "reasoning"); - expect(reasoning).toBeDefined(); - const env = decodeReasoningEnvelope(reasoning!.encrypted_content as string); - expect(env?.sig).toBe("RealSig1234567890=="); - expect(env?.red).toEqual(["RED1"]); + const reasoning = output.filter(i => i.type === "reasoning"); + expect(reasoning.map(item => decodeReasoningEnvelope(item.encrypted_content as string))).toEqual([ + { red: ["RED1"] }, + { sig: "RealSig1234567890==" }, + ]); }); test("redacted-only turn still emits an envelope reasoning item (SSE)", async () => { @@ -326,3 +331,174 @@ describe("passthrough scrub of ocxr1 envelopes", () => { expect(req.body ?? "").toContain('"rs_1"'); // reasoning item itself survives }); }); + + +describe("Claude / Responses / intended Anthropic replay fidelity", () => { + // Synthetic fixtures prove transport fidelity only, never upstream signature validity. + const first = { type: "thinking", thinking: "first\nexact", signature: "FirstSyntheticSignature123456==" }; + const second = { type: "thinking", thinking: "second", signature: "SecondSyntheticSignature123456==" }; + const empty = { type: "thinking", thinking: "", signature: "EmptySyntheticSignature123456==" }; + const before = { type: "redacted_thinking", data: "opaque-before" }; + const middle = { type: "redacted_thinking", data: "opaque-middle" }; + const after = { type: "redacted_thinking", data: "opaque-after" }; + const tool = { type: "tool_use", id: "toolu_replay", name: "lookup", input: { q: "x" } }; + const cases = [ + { name: "consecutive signed blocks", blocks: [first, second, tool] }, + { name: "opaque blocks in source order", blocks: [before, first, middle, second, after, tool] }, + { name: "empty signed block", blocks: [empty, tool] }, + { name: "consecutive empty signed blocks", blocks: [empty, { ...empty, signature: "OtherEmptySyntheticSignature123456==" }, tool] }, + { name: "redacted-only tool turn", blocks: [before, after, tool] }, + ]; + + for (const fixture of cases) { + for (const streaming of [true, false]) { + test(`${fixture.name}: ${streaming ? "SSE" : "JSON"} full chain preserves exact blocks`, async () => { + const adapter = createAnthropicAdapter(provider, "none"); + let events: AdapterEvent[]; + if (streaming) { + const frames = [frame("message_start", { message: { usage: { input_tokens: 1, output_tokens: 0 } } })]; + fixture.blocks.forEach((block, index) => { + frames.push(frame("content_block_start", { index, content_block: block.type === "thinking" + ? { type: "thinking", thinking: "", signature: "" } + : block.type === "tool_use" ? { ...tool, input: {} } : block })); + if ("thinking" in block) { + // Omitted thinking has no thinking_delta on the actual wire. + if (block.thinking) frames.push(frame("content_block_delta", { index, delta: { type: "thinking_delta", thinking: block.thinking } })); + frames.push(frame("content_block_delta", { index, delta: { type: "signature_delta", signature: block.signature } })); + } else if (block.type === "tool_use") { + frames.push(frame("content_block_delta", { index, delta: { type: "input_json_delta", partial_json: JSON.stringify(tool.input) } })); + } + frames.push(frame("content_block_stop", { index })); + }); + frames.push(frame("message_delta", { delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } }), frame("message_stop", {})); + events = await collect(adapter.parseStream(sseResponse(frames))); + } else { + events = await adapter.parseResponse!(new Response(JSON.stringify({ + id: "msg_fixture", type: "message", role: "assistant", model: "claude-x", + content: fixture.blocks, stop_reason: "tool_use", usage: { input_tokens: 1, output_tokens: 1 }, + }))); + } + let message: Record; + if (streaming) { + async function* upstream() { yield* events; } + const budget = createTestTranslatorBudget(); + message = await collectAnthropicMessage(responsesSseToAnthropicSse( + bridgeToResponsesSSE(upstream(), "claude-x"), "claude-x", { translatorBudget: budget }, + ), "claude-x", budget); + } else { + message = responsesJsonToAnthropicMessage(buildResponseJSON(events, "claude-x"), "claude-x"); + } + expect(message.content).toEqual(fixture.blocks); + const parsed = parseRequest(anthropicToResponsesBody({ + model: "anthropic/claude-x", messages: [ + { role: "user", content: "question" }, + { role: "assistant", content: message.content }, + { role: "user", content: [{ type: "tool_result", tool_use_id: tool.id, content: "result" }] }, + ], + })); + const request = await adapter.buildRequest(parsed); + const replay = JSON.parse(request.body as string) as { messages: Array<{ role: string; content: unknown }> }; + expect(replay.messages).toEqual([ + { role: "user", content: [{ type: "text", text: "question" }] }, + { role: "assistant", content: fixture.blocks }, + { role: "user", content: [{ type: "tool_result", tool_use_id: tool.id, content: "result" }] }, + ]); + }); + } + } + + test("signature updates replace rather than concatenate, across heartbeats", async () => { + // Both official SDKs assign signature_delta.signature instead of appending it: + // anthropic-sdk-typescript/src/lib/MessageStream.ts and + // anthropic-sdk-python/src/anthropic/lib/streaming/_messages.py. + const adapter = createAnthropicAdapter(provider); + const events = await collect(adapter.parseStream(sseResponse([ + frame("content_block_start", { index: 0, content_block: { type: "thinking", thinking: "", signature: "" } }), + frame("content_block_delta", { index: 0, delta: { type: "thinking_delta", thinking: "first" } }), + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature: "old" } }), + ": heartbeat\n\n", + frame("content_block_delta", { index: 0, delta: { type: "signature_delta", signature: "FirstSyntheticSignature123456==" } }), + frame("content_block_stop", { index: 0 }), + frame("content_block_start", { index: 1, content_block: { type: "thinking", thinking: "", signature: "" } }), + frame("content_block_delta", { index: 1, delta: { type: "thinking_delta", thinking: "second" } }), + frame("content_block_delta", { index: 1, delta: { type: "signature_delta", signature: "SecondSyntheticSignature123456==" } }), + frame("content_block_stop", { index: 1 }), + frame("message_stop", {}), + ]))); + async function* upstream() { yield* events; } + const streamed = sseItems(await drainSse(bridgeToResponsesSSE(upstream(), "claude-x"))); + const buffered = buildResponseJSON(events, "claude-x").output as Record[]; + for (const items of [streamed, buffered]) { + expect(items.map(item => ({ summary: item.summary, envelope: decodeReasoningEnvelope(item.encrypted_content as string) }))).toEqual([ + { summary: [{ type: "summary_text", text: "first" }], envelope: { sig: "FirstSyntheticSignature123456==" } }, + { summary: [{ type: "summary_text", text: "second" }], envelope: { sig: "SecondSyntheticSignature123456==" } }, + ]); + } + }); + + test("signed/opaque-only assistant turns survive a user boundary and end of input", () => { + for (const continuation of [[], [{ role: "user", content: "next" }]]) { + const parsed = parseRequest(anthropicToResponsesBody({ model: "anthropic/claude-x", messages: [ + { role: "assistant", content: [empty, before, after] }, ...continuation, + ] })); + const assistant = parsed.context.messages.find(message => message.role === "assistant"); + expect(assistant?.content).toEqual([ + expect.objectContaining({ type: "thinking", thinking: "", signature: empty.signature }), + expect.objectContaining({ type: "thinking", thinking: "", redacted: [before.data] }), + expect.objectContaining({ type: "thinking", thinking: "", redacted: [after.data] }), + ]); + } + }); + + test("locally hidden signed text remains exact on Responses replay without being exposed to Claude", async () => { + const events: AdapterEvent[] = [ + { type: "thinking_delta", thinking: "hidden exact\ntext" }, + { type: "thinking_signature", signature: first.signature }, + { type: "text_delta", text: "answer" }, + { type: "done", usage: { inputTokens: 1, outputTokens: 1 } }, + ]; + async function* upstream() { yield* events; } + const items = sseItems(await drainSse(bridgeToResponsesSSE(upstream(), "claude-x", undefined, undefined, undefined, undefined, 2000, { hideThinkingSummary: true }))); + const response = buildResponseJSON(events, "claude-x", { hideThinkingSummary: true }); + for (const output of [items, response.output as Record[]]) { + const reasoning = output.find(item => item.type === "reasoning")!; + expect(reasoning.summary).toEqual([]); + expect(decodeReasoningEnvelope(reasoning.encrypted_content as string)).toEqual({ sig: first.signature, txt: "hidden exact\ntext" }); + const request = await createAnthropicAdapter(provider, "none").buildRequest(parseRequest({ model: "anthropic/claude-x", input: output })); + const replay = JSON.parse(request.body as string) as { messages: Array<{ content: unknown }> }; + expect(replay.messages[0].content).toEqual([ + { type: "thinking", thinking: "hidden exact\ntext", signature: first.signature }, + { type: "text", text: "answer" }, + ]); + // Deliberate existing limitation: no new signed carrier and no hidden-text disclosure. + expect(JSON.stringify(responsesJsonToAnthropicMessage({ output }, "claude-x"))).not.toContain("hidden exact"); + } + expect(() => anthropicToResponsesBody({ model: "m", messages: [{ role: "assistant", content: [ + { type: "thinking", thinking: "", signature: encodeReasoningEnvelope({ sig: first.signature, txt: "hidden exact" }) }, + ] }] })).toThrow(/continuity/); + }); + + test("explicitly empty signed envelope text does not fall back to a different summary", () => { + const parsed = parseRequest({ model: "m", input: [ + { type: "reasoning", summary: [{ type: "summary_text", text: "different summary" }], encrypted_content: encodeReasoningEnvelope({ sig: empty.signature, txt: "" }) }, + ] }); + expect(parsed.context.messages[0]?.content).toEqual([ + { type: "thinking", thinking: "", signature: empty.signature }, + ]); + }); + + test("opaque Anthropic payloads do not become Google signatures or native Responses encryption", async () => { + const body = anthropicToResponsesBody({ model: "google/gemini-test", messages: [ + { role: "assistant", content: [empty, before, tool] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: tool.id, content: "result" }] }, + ] }); + const google = withTestTranslatorBudget(createGoogleAdapter({ adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "synthetic" })); + const request = await google.buildRequest(parseRequest(body)); + for (const output of [request.body as string, JSON.stringify(sanitizeReasoningInputContent(body))]) { + expect(output).not.toContain(empty.signature); + expect(output).not.toContain(before.data); + expect(output).not.toContain("ocxr1:"); + } + expect(parseRequest({ model: "m", input: [{ type: "reasoning", summary: [], encrypted_content: "native-opaque" }] }).context.messages).toEqual([]); + }); +}); diff --git a/tests/claude-integration/claude-outbound.test.ts b/tests/claude-integration/claude-outbound.test.ts index e78cc529d3..33faa2e839 100644 --- a/tests/claude-integration/claude-outbound.test.ts +++ b/tests/claude-integration/claude-outbound.test.ts @@ -1179,6 +1179,23 @@ describe("sanitizeWebSearchInput (#381)", () => { expect(events[2].data.content_block).toEqual({ type: "redacted_thinking", data: "opaque" }); }); + test("redacted reasoning closes an open text block before opening its opaque block", async () => { + const events = await collectEvents(responsesSseToAnthropicSse(streamFrom([ + sse("response.output_text.delta", { delta: "text" }), + sse("response.output_item.done", { + item: { type: "reasoning", id: "rs_red", encrypted_content: encodeReasoningEnvelope({ red: ["opaque"] }) }, + }), + sse("response.completed", { response: { status: "completed", usage: {} } }), + ].join("")), "m")); + expect(events.filter(event => event.name === "content_block_start" || event.name === "content_block_stop") + .map(event => ({ name: event.name, index: event.data.index }))).toEqual([ + { name: "content_block_start", index: 0 }, + { name: "content_block_stop", index: 0 }, + { name: "content_block_start", index: 1 }, + { name: "content_block_stop", index: 1 }, + ]); + }); + test("signature-only reasoning emits an empty thinking block with the genuine signature", async () => { const events = await collectEvents(responsesSseToAnthropicSse(streamFrom([ sse("response.output_item.done", { diff --git a/tests/responses/reasoning-envelope.test.ts b/tests/responses/reasoning-envelope.test.ts index 75c51994f2..2469b8e46b 100644 --- a/tests/responses/reasoning-envelope.test.ts +++ b/tests/responses/reasoning-envelope.test.ts @@ -34,8 +34,8 @@ describe("reasoning and tool/result envelopes", () => { const message = responsesJsonToAnthropicMessage({ output: [{ type: "reasoning", summary: [{ type: "summary_text", text: "visible" }], encrypted_content: encoded }], }, "m") as any; - expect(message.content[0]).toMatchObject({ type: "thinking", signature: "sig" }); - expect(message.content.slice(1)).toEqual([ + expect(message.content[2]).toMatchObject({ type: "thinking", signature: "sig" }); + expect(message.content.slice(0, 2)).toEqual([ { type: "redacted_thinking", data: "red-a" }, { type: "redacted_thinking", data: "red-b" }, ]); From 8f8790e31cdaf6d9c0083324aa01d6f778410e98 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:43:23 +0900 Subject: [PATCH 66/99] docs(devlog): record axis three protocol delivery plan --- .../_plan/260907_axis3_protocol/000_plan.md | 13 ++++++ .../260907_axis3_protocol/001_roadmap_lock.md | 3 ++ .../010_prepare_and_verify.md | 42 +++++++++++++++++++ .../260907_axis3_protocol/011_candidate.md | 9 ++++ .../260907_axis3_protocol/020_delivery.md | 7 ++++ 5 files changed, 74 insertions(+) create mode 100644 devlog/_plan/260907_axis3_protocol/000_plan.md create mode 100644 devlog/_plan/260907_axis3_protocol/001_roadmap_lock.md create mode 100644 devlog/_plan/260907_axis3_protocol/010_prepare_and_verify.md create mode 100644 devlog/_plan/260907_axis3_protocol/011_candidate.md create mode 100644 devlog/_plan/260907_axis3_protocol/020_delivery.md diff --git a/devlog/_plan/260907_axis3_protocol/000_plan.md b/devlog/_plan/260907_axis3_protocol/000_plan.md new file mode 100644 index 0000000000..36cfe95772 --- /dev/null +++ b/devlog/_plan/260907_axis3_protocol/000_plan.md @@ -0,0 +1,13 @@ +# Axis 3 protocol fidelity roadmap + +Mode: satisfy-spec HOTL, requested by the maintainer on 2026-09-07. Deliver source-grounded dispositions for #3815, #3816, #3807, #3719 and land accepted fixes with original authors credited in commits. No local suites or typecheck; verification is remote exact final-head CI, with lower-layer CI only on final failure. Ordinary manual PR chain only; admin merge authorized. No explicit token or wall-time limit was requested; agents use bounded tasks and waits. Do not invoke private provider accounts or spend inference credits. Tools: local Git/files, GitHub gh, Astra high leaf agents. Writes confined to task worktrees and this axis's GitHub branches/PRs. Preserve unrelated dirty work. + +Scope: ordered Claude thinking/redacted/tool-result envelope fidelity; Grok strict-client control frame projection; valid task-seed diagnosis. Exclude new auth/routing/default policies, fabricated provider signatures or tool pairing IDs (new Responses reasoning item IDs are permitted transport identities), cache savings claims, unrelated axes, deployment/release. Unknown field/runtime reports receive explicit deferred dispositions per user direction. + +Work phases: wp0 roadmap audit and lock; wp1 prepare two independently reviewable source layers and any justified contract regressions, then remote final combined verification; wp2 publish/merge ordinary PRs bottom-up and record final ancestry/dispositions. The two source fixes are independent; the manual chain is the user's requested integration/CI grouping, not a runtime dependency. + +Success: roadmap verified, accepted changes reviewed and remotely validated, commits credit SB Yoon (yansigit) and Yumi for #3815 and Danh Thanh (dt418) for #3816, landed SHA proven ancestor of refreshed dev; uncertain #3807/#3719 runtime or cache claims remain open. Stop only after accepted delivery and explicit dispositions. Escalate only an unavoidable owner-policy choice; defer that portion and continue the rest. + +Acceptance: (1) thinking then text/tool then result retains order and genuine signatures; opaque blocks remain bounded and malformed/nested signatures fail closed. (2) Grok user agent receives ordinary Responses data without codex.rate_limits/codex.response.metadata, while proxy inspection and normal clients retain metadata. (3) valid external task seeds preserve text/order; absent metadata invalid tool outputs still reject. (4) no credential, admission, cache-retention default, provider/routing policy mutation. (5) final CI must really run relevant tests/typecheck, not skip/cancel or fabricate success. No local suite was run. Final failure permits lower-layer CI for localization; unrelated failures may defer delivery, never count as success. + +Sources: PRs https://github.com/lidge-jun/opencodex/pull/3815 and /pull/3816; issues /issues/3807 and /issues/3719. Current dev 137d6a727. Evidence snapshots under .tmp/axis3. Public notes contain no unreleased vulnerability detail; any new security investigation stays in scratch. diff --git a/devlog/_plan/260907_axis3_protocol/001_roadmap_lock.md b/devlog/_plan/260907_axis3_protocol/001_roadmap_lock.md new file mode 100644 index 0000000000..115f8c3dfa --- /dev/null +++ b/devlog/_plan/260907_axis3_protocol/001_roadmap_lock.md @@ -0,0 +1,3 @@ +# Roadmap lock + +Independent Astra high reviewer Pauli passed the amended wp0 roadmap. Transport reasoning IDs are permitted; fabricated tool pairing IDs remain prohibited. Claude fallback retention must be bounded or removed and checked remotely. Grok parser must follow SSE last-field/reset semantics. No runtime was changed in wp0. Next: wp1 carries source layers, adds justified regression coverage and verifies the final combined head remotely. diff --git a/devlog/_plan/260907_axis3_protocol/010_prepare_and_verify.md b/devlog/_plan/260907_axis3_protocol/010_prepare_and_verify.md new file mode 100644 index 0000000000..c278f094bf --- /dev/null +++ b/devlog/_plan/260907_axis3_protocol/010_prepare_and_verify.md @@ -0,0 +1,42 @@ +# Prepare and verify combined protocol candidate + +Reverify base/source heads before build. Carry exact source deltas from the scratch diff snapshots, fold independently confirmed review fixes only. Each commit contains verified contributor trailers. Do not include upstream planning notes or unrelated changes. + +Layer 1 MODIFY: +scripts/test-layout/layout.json +src/claude/inbound.ts +src/claude/outbound.ts +src/responses/reasoning-envelope.ts +tests/claude-integration/claude-code-thought-signature-scope.test.ts +tests/claude-integration/claude-inbound.test.ts +tests/claude-integration/claude-outbound.test.ts +tests/claude-integration/claude-source-envelope.test.ts +tests/fixtures/test-layout-expected.json +tests/responses/reasoning-envelope.test.ts + +Preserve genuine signatures; encode bounded unsigned/redacted fallback; keep structured tool results. Layer 2 NEW src/server/grok-responses-control-frame.ts and MODIFY: +src/server/grok-responses-control-frame.ts +src/server/responses/core.ts +tests/responses/responses-snapshot-repair-server.test.ts + +Separate strict-client filtering from internal inspection. On a Grok metadata frame, forward no incompatible client frame; on ordinary delta, preserve unchanged; ordinary clients remain unchanged. No shared account/routing changes. + +Potential follow-up tests belong only in existing responses/Claude test files after diagnosis, with independent expected values. If no valid unhandled #3807 input is established, leave production guards unchanged. #3719 cache-hit and true Anthropic signed replay cannot be certified by codec fixtures. + +SoT: update docs-site/src/content/docs/guides/claude-code.md and existing translated counterparts only if #3815 makes their drop-policy statements stale. Read docs-site/AGENTS.md first. No global retention change. + +Verification: user prohibits local suites/typecheck (NOT RUN). Inspect source and diff-check locally. Push task branches with --no-verify. Dispatch existing Cross-platform CI workflow on final combined head, lane all. Confirm workflow head SHA, jobs, conclusion, test/typecheck execution from logs. Final CI failure permits lower-layer CI. Keep workflow/protection configuration unchanged; suppress only task-owned redundant automatic runs when needed for requested top-first scheduling, reporting cancelled runs honestly. No real accounts are used. + +## Audit amendments + +New rs_ reasoning IDs are normal transport identity, not fabricated tool call pairing. Do not synthesize tool-call IDs to bypass #3807 validation. + +Before acceptance, remove unbounded thinkingBuf retention introduced by #3815 or charge it to the existing TranslatorBudget retained bytes with normal fail-closed overflow. Use the established budget and error event; no silent truncation or new policy default. Cover multi-part text exactness, empty continuity fallback, and overflow with a small injected existing budget in remote regression tests. Decoder/consumer traces must prove any compact continuity marker still replays the original summary. + +#3816 must use SSE last-event-field-wins semantics, including colonless/empty resets and removal of only one optional leading space. Test event-only, data-only, repeated event fields in both orders, and preservation of ordinary completion data. Keep downstream Grok WebSocket support deferred because the existing surface marker is absent there; do not claim this HTTP/SSE patch solves it. + +## WP1 source refresh and scoped hardening + +Previous D: roadmap locked; execute reviewed source preparation. PR #3815 advanced to 76e07d181c48dca8c80167878381e1edb5642395 during investigation, including budget fixes and translated guide changes; carry fresh source, not old snapshots. Add a third dependent hardening layer only for source-proven preservation faults. MODIFY src/responses/parser.ts: retain recognized redacted-only and empty signed envelopes even when text is empty, preserving real boundary grouping. MODIFY src/bridge.ts: preserve signed block boundaries and redacted block positions identically in streaming/buffered output; signature fragments must be assembled at owning adapter boundary. MODIFY src/claude/outbound.ts only for exact block order/text restoration where current contract permits; do not invent a new signed continuity carrier or change hide-thinking policy. If hidden signed replay needs a new policy/carrier, explicitly defer that part rather than widening scope. Existing budget/guard contracts remain. + +Tests: existing tests/responses/anthropic-thinking-signature.test.ts or matching current domain file and Claude envelope tests get exact block-array roundtrip oracles; no fixture claims a live genuine signature. tests/responses/responses-compaction-routing.test.ts gets an established-history complete send_message_to_thread envelope across normal response, stored-ID continuation, v2 compaction_trigger and v1 compact endpoint, preserving real pairing and task content. If current fixture support makes a case impractical, record exact gap; no runtime seed repair. diff --git a/devlog/_plan/260907_axis3_protocol/011_candidate.md b/devlog/_plan/260907_axis3_protocol/011_candidate.md new file mode 100644 index 0000000000..7edc9aa1ce --- /dev/null +++ b/devlog/_plan/260907_axis3_protocol/011_candidate.md @@ -0,0 +1,9 @@ +# Combined candidate + +Source baseline: dev 137d6a727. Foundation carries #3815 through 76e07d181 with SB Yoon/Yumi commit trailers. Grok carries #3816 d5e0a9a2 and corrects SSE event overwrite/reset semantics, with Danh Thanh trailers. Added established-history external-task HTTP/continuation/compact fixtures without changing the missing-ID guard. Replay hardening preserves signed/opaque-only inputs and block ordering; signature updates replace previous values according to the SDK accumulator contract, and block closure waits for the next semantic event. + +Independent source reviews: Pauli scoped foundation PASS (18/18 files); Faraday Grok/seed PASS. Final Claude combined source audit and remote CI pending. Local suites/typecheck/build not run under user instruction. No live accounts invoked. + +Deferred: #3807 lacks raw failing current-version input; #3719 still needs live intended-Anthropic acceptance and controlled cache comparisons. Locally hidden text through Claude and legacy combined-envelope streaming order recovery are not claimed supported. Existing compatibility enforcement, hidden presentation, credential/admission and retention policies remain. + +Ordinary PR chain is an integration grouping requested by owner, with final combined CI first. Lower-layer runs only if it fails. Admin merge is authorized after accepted evidence. No GitHub native stack or fabricated check status. diff --git a/devlog/_plan/260907_axis3_protocol/020_delivery.md b/devlog/_plan/260907_axis3_protocol/020_delivery.md new file mode 100644 index 0000000000..050cc76d54 --- /dev/null +++ b/devlog/_plan/260907_axis3_protocol/020_delivery.md @@ -0,0 +1,7 @@ +# Publish and deliver verified manual chain + +Prerequisite: wp1 accepted-source review and successful final-head remote validation, or source-grounded defer outcome. Publish ordinary PRs targeting dev then the parent branch, using every repository template section. Bodies name source PRs, own layer-only diff, exact final combined CI evidence and explicit lower-layer CI deferral per owner instruction. Do not attest local CI. Preserve original contributor trailers in commits; admin merge with merge commits preserves their identity. + +Read live native-stack membership and head/base identity before merge. Never register a native stack. Parent merges to dev first; retain its branch, retarget child to dev, verify current head and ancestry. If integration tree changes materially, refresh final combined CI before landing. Use --admin and --match-head-commit exact guard. Do not merge into the parent branch by mistake. Refresh origin/dev and prove each merge SHA ancestor. Close superseded source PRs only after equivalent fix is actually landed, with credit and replacement link. Keep #3807 and #3719 open if real reproduction/cache acceptance remains unmet. No release or deployment. + +Record final PR URLs, source-to-delivery mapping, commit authors/trailers, CI run and exact SHA, review verdicts, remaining limitations and preserved dirty-work evidence. No fabricated status checks. Completion: every candidate has an honest disposition, accepted work is landed, unresolved diagnostics explicitly deferred under user direction. From 9d775faccce38d9e16e5d544bdba0b451d170333 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:44:48 +0900 Subject: [PATCH 67/99] fix(gui): preserve display name receipts across recovery failures Keep an earlier confirmed save/reset receipt when retrying the same value and convergence fails with an ordinary HTTP error. Do not replace the saved snapshot from an unconfirmed response; retain the pending reset intent for another retry. Treat transport and response-body failures without a usable receipt as unknown outcomes, hide the stale current name, and make Retry/Enter read-only. Keep known unpersisted HTTP failures on the existing editable-draft path. Add regressions for reset -> saved:true -> HTTP failure -> success and persisted save/reset followed by rejected transport or response-body reads without abort. Local tests/typecheck/build NOT RUN by owner mandate; static diff check only. Co-authored-by: Zig Zag --- gui/src/pages/Models.tsx | 28 +++++--- gui/tests/models-display-name-editor.test.tsx | 72 +++++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index b7b3a0e285..c342866d7e 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -598,7 +598,11 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; displayNameSavingRef.current = true; setDisplayNameSaving(true); setDisplayNameRequestError(null); - let confirmed = displayName === undefined && displayNameRecovery?.confirmed === true; + // A failed convergence retry cannot invalidate an earlier persistence receipt + // for the same value. Editing the draft clears recovery and starts a new intent. + let confirmed = displayNameRecovery?.confirmed === true + && (displayName === undefined || displayName === displayNameRecovery.value); + let receivedReceipt = displayName === undefined; let refreshOnly = displayName === undefined; try { if (displayName !== undefined) { @@ -624,9 +628,14 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; ? await readJsonOrThrow(response, t("models.displayNameSaveFailed")) : await response.json(); bounded.signal.throwIfAborted(); - if (!result) throw new Error(t("models.displayNameSaveFailed")); - confirmed = response.ok || result.saved === true; - if (confirmed) { + if (!result || typeof result !== "object" || Array.isArray(result) + || (!response.ok && result.saved !== true && typeof result.error !== "string")) { + throw new Error(t("models.displayNameSaveFailed")); + } + receivedReceipt = true; + const receiptConfirmed = response.ok || result.saved === true; + confirmed = confirmed || receiptConfirmed; + if (receiptConfirmed) { const override = result.displayNameOverride === null ? undefined : result.displayNameOverride ?? displayName ?? undefined; const fields: Pick = { @@ -653,13 +662,16 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string; finishDisplayNameEdit(); } catch (error) { if (displayNameRequestRef.current !== bounded) return; - if (bounded.signal.aborted && !confirmed) setDisplayNameCurrentPending(true); - setDisplayNameRecovery(confirmed || bounded.signal.aborted || refreshOnly - ? { value: refreshOnly || bounded.signal.aborted ? undefined : displayName, confirmed } + // A dropped connection or unreadable body can hide a committed write just + // like a timeout. Reconcile by reading; never replay an unchanged old draft. + const unknownOutcome = !receivedReceipt || bounded.signal.aborted; + if (unknownOutcome && !confirmed) setDisplayNameCurrentPending(true); + setDisplayNameRecovery(confirmed || unknownOutcome || refreshOnly + ? { value: refreshOnly || unknownOutcome ? undefined : displayName, confirmed } : null); setDisplayNameRequestError(confirmed ? t("models.displayNameSavedRefreshFailed") - : bounded.signal.aborted || refreshOnly + : unknownOutcome || refreshOnly ? t("models.displayNameOutcomeUnknown") : error instanceof Error && error.message ? error.message diff --git a/gui/tests/models-display-name-editor.test.tsx b/gui/tests/models-display-name-editor.test.tsx index be64333944..b0656391ed 100644 --- a/gui/tests/models-display-name-editor.test.tsx +++ b/gui/tests/models-display-name-editor.test.tsx @@ -389,6 +389,78 @@ describe("Models dashboard discovered display name integration", () => { }); } + test("confirmed reset survives an ordinary convergence retry error before success", async () => { + await mountModels(); + await act(async () => nameTrigger().click()); + savedFailure = true; + await act(async () => dialogButton("Reset name").click()); + await flush(); + savedFailure = false; + mutationFailure = "Temporary server failure"; + await act(async () => dialogButton("Retry").click()); + await flush(); + expect(dialogInput().value).toBe(""); + expect(dialogButton("Reset name").disabled).toBe(true); + expect(dialogButton("Retry").disabled).toBe(false); + expect(container.textContent).toContain("The change was saved"); + expect(currentNameText()).not.toContain("Your name"); + expect(currentModels[0]!.displayNameOverride).toBeUndefined(); + + mutationFailure = null; + await act(async () => container.querySelector("dialog form")!.dispatchEvent( + new testWindow.Event("submit", { bubbles: true, cancelable: true }), + )); + await flush(); + expect(mutationBodies.map(body => body.displayName)).toEqual([null, null, null]); + expect(container.querySelector("dialog")).toBeNull(); + expect(currentModels[0]!.displayNameOverride).toBeUndefined(); + }); + + for (const failure of ["transport", "body"] as const) { + for (const value of ["Saved despite disconnect", null]) { + test(`persisted ${value === null ? "reset" : "save"} with ${failure} failure retries only a read`, async () => { + await mountModels(); + const transport = globalThis.fetch; + let failedSignal: AbortSignal | null | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const response = await transport(input, init); + if (init?.method === "PUT" && String(input).includes("model-display-names")) { + failedSignal = init.signal; + if (failure === "transport") throw new TypeError("Connection closed"); + Object.defineProperty(response, "text", { + value: async () => { throw new TypeError("Response body interrupted"); }, + }); + } + return response; + }) as typeof fetch; + await act(async () => nameTrigger().click()); + await act(async () => { + if (value === null) dialogButton("Reset name").click(); + else { + setInputValue(dialogInput(), value); + dialogButton("Save").click(); + } + }); + await flush(); + expect(failedSignal?.aborted).toBe(false); + expect(currentModels[0]!.displayNameOverride).toBe(value ?? undefined); + expect(dialogInput().value).toBe(value ?? "Grok 4.6"); + expect(currentNameText()).toContain("Current name unavailable until refresh"); + expect(currentNameText()).not.toContain("Your name"); + expect(container.textContent).toContain("The change may have been saved"); + expect(dialogButton("Retry").disabled).toBe(false); + expect(dialogButton("Cancel").disabled).toBe(false); + await act(async () => container.querySelector("dialog form")!.dispatchEvent( + new testWindow.Event("submit", { bubbles: true, cancelable: true }), + )); + await flush(); + expect(mutationBodies).toEqual([{ modelId: "grok-4.6", displayName: value }]); + expect(currentModels[0]!.displayNameOverride).toBe(value ?? undefined); + expect(container.querySelector("dialog")).toBeNull(); + }); + } + } + test("editing after a saved receipt explicitly starts a new save", async () => { await mountModels(); await act(async () => nameTrigger().click()); From d52399070f098865394500398862b05ef95261c6 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:46:19 +0900 Subject: [PATCH 68/99] fix(providers): repair static BigModel login and Responses effort Skip undocumented HTTP model-list validation through the existing unknown key-validation policy. Add a zero-fetch regression. For explicitly empty model/provider effort ladders, omit only the Responses effort field and retain reasoning summaries. Preserve omitted requests, nonempty model override precedence, unknown/non-rankable ladders, and forward behavior. Keep the documented Turbo max metadata without adding a selectable tier or injecting a wire default. Existing __omit__ mappings and noReasoningModels policy are unchanged. Add outbound serialization cases for omitted, max and ultra effort, summary preservation, input immutability, and unchanged consumer paths. Regenerate model metadata; the generated delta is only the BigModel Responses to zai alias. Validation: bun run generate:model-metadata and git diff --check passed. No tests, typecheck, lint or builds run; commit hooks skipped to honor that restriction. Main final CI and separate independent review remain required. Co-authored-by: jamespan --- .../src/content/docs/guides/providers.md | 5 ++ src/adapters/openai-responses.ts | 7 +++ src/generated/model-metadata.ts | 1 + src/providers/registry.ts | 2 + .../provider-registry-parity.test.ts | 17 ++++-- .../openai-responses-passthrough.test.ts | 52 +++++++++++++++++++ 6 files changed, 81 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index f5c4ddc190..081dced988 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -605,11 +605,16 @@ Both entries declare text input. The default model is `glm-5.3`; Responses reaso content is preserved on replay. The existing Codex export adds its compatibility `ultra` tier to GLM-5.3 and omits Turbo's default-effort field because Turbo has no selectable ladder; the provider metadata still records `max` for both models. +For Turbo, outgoing Responses requests omit `reasoning.effort`, including a caller's +`max` or `ultra`, while preserving requested reasoning summaries. This leaves effort +selection to the upstream default; opencodex does not inject a selectable or wire `max`. The example's `models.json` is a local catalog file, not a documented HTTP model-list response. This preset does not perform live model discovery. `glm-5.3-flash` is not seeded here because its exact Responses metadata is not verified. An existing custom provider with the same name keeps its configured destination and metadata. +CLI key login also skips the undocumented `/models` probe and reports validation as +unknown; successful key authentication is established by a subsequent inference request. ### Multiple API keys diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 07c0556d5e..1b8c1b076e 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -630,6 +630,13 @@ function mapRoutedResponsesReasoningEffort( if (provider.authMode === "forward") return body; if (configuredReasoningEfforts(provider, modelId) === undefined) return body; if (!isPlainObject(body) || !isPlainObject(body.reasoning)) return body; + const declaredEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts; + // An explicitly empty ladder means no effort control, not no reasoning output. + // Omit only effort so the upstream default applies; unknown/non-rankable ladders stay untouched. + if (declaredEfforts?.length === 0 && Object.hasOwn(body.reasoning, "effort")) { + const { effort: _effort, ...reasoning } = body.reasoning; + return { ...body, reasoning: Object.keys(reasoning).length > 0 ? reasoning : undefined }; + } const requested = body.reasoning.effort; if (typeof requested !== "string") return body; diff --git a/src/generated/model-metadata.ts b/src/generated/model-metadata.ts index 662cf6f1a7..7220aa7509 100644 --- a/src/generated/model-metadata.ts +++ b/src/generated/model-metadata.ts @@ -31,6 +31,7 @@ const PROVIDER_ALIASES: Record = { "moonshot": "moonshot", "zhipu-bigmodel": "zai", "zhipu-bigmodel-coding": "zai", + "zhipu-bigmodel-responses": "zai", "minimax": "minimax", "minimax-cn": "minimax" } as const; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 1c3396a48a..ef7cb59e00 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2557,6 +2557,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ defaultModel: "glm-5.3", models: ["glm-5.3", "glm-5-turbo"], liveModels: false, + // The local Codex catalog does not establish an authenticated HTTP /models contract. + apiKeyValidation: "unknown", jawcodeBundle: "zai", // A pre-existing same-named custom provider must retain its destination and key boundary. preserveCustomDestination: true, diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 0d935d12a2..e2083646a5 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -1,10 +1,10 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { buildCatalogEntries } from "../../src/codex/catalog"; import { CURSOR_NO_VISION_MODELS } from "../../src/adapters/cursor/discovery"; import { getModelMetadata, resolveMetadataProvider } from "../../src/generated/model-metadata"; import { buildInitProviders } from "../../src/cli/init"; import { OAUTH_PROVIDERS } from "../../src/oauth"; -import { enrichProviderFromCatalog, KEY_LOGIN_PROVIDERS } from "../../src/oauth/key-providers"; +import { enrichProviderFromCatalog, KEY_LOGIN_PROVIDERS, validateApiKey } from "../../src/oauth/key-providers"; import { deriveFeaturedProviderIds, deriveInitProviders, @@ -459,7 +459,7 @@ describe("provider registry parity", () => { expect(registry.modelDiscovery).toBeUndefined(); expect(registry.preserveReasoningContentModels).toBeUndefined(); expect(KEY_LOGIN_PROVIDERS[id]).toMatchObject({ - models: ["glm-5.3", "glm-5-turbo"], liveModels: false, + models: ["glm-5.3", "glm-5-turbo"], liveModels: false, apiKeyValidation: "unknown", }); const provider = providerConfigSeed(registry); enrichProviderFromRegistry(id, provider); @@ -492,6 +492,17 @@ describe("provider registry parity", () => { expect(entries.some(entry => String(entry.slug).includes("glm-5.3-flash"))).toBe(false); }); + test("BigModel Responses key login does not probe an undocumented models endpoint", async () => { + const fetchSpy = spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status: 403 })); + try { + const id = "zhipu-bigmodel-responses"; + expect(await validateApiKey(id, KEY_LOGIN_PROVIDERS[id], "test-bigmodel-key")).toBe("unknown"); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + test("BigModel Responses name collisions preserve custom transport and metadata", () => { const id = "zhipu-bigmodel-responses"; // Exercise both a different destination on the same wire and the canonical URL on diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index 0277da71d4..7e97217dc2 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -569,6 +569,58 @@ describe("DeepSeek Responses endpoint contract", () => { } }); + test.each([undefined, "max", "ultra"])("BigModel Turbo omits outbound effort %s and preserves summary requests", (effort) => { + const id = "zhipu-bigmodel-responses"; + const config: OcxConfig = { + port: 10100, + defaultProvider: id, + providers: { [id]: providerConfigSeed(getProviderRegistryEntry(id)!) }, + }; + const route = routeModel(config, `${id}/glm-5-turbo`); + for (const withSummary of [false, true]) { + const raw = { + model: route.modelId, + input: "ping", + ...(effort !== undefined || withSummary ? { + reasoning: { + ...(effort !== undefined ? { effort } : {}), + ...(withSummary ? { summary: "auto" } : {}), + }, + } : {}), + }; + const before = structuredClone(raw); + const request = createResponsesPassthroughAdapter(route.provider).buildRequest(parseRequest(raw)); + const wire = JSON.parse(request.body); + expect(request.url).toBe("https://open.bigmodel.cn/api/v1/responses"); + if (withSummary) expect(wire.reasoning).toEqual({ summary: "auto" }); + else expect(wire).not.toHaveProperty("reasoning"); + expect(raw).toEqual(before); + } + }); + + test("a provider-wide empty ladder removes even non-string raw effort", () => { + const keyed = { adapter: "openai-responses", baseUrl: "https://example.test/v1", authMode: "key" as const }; + const raw = { model: "model", input: "ping", reasoning: { effort: 123, summary: "auto" } }; + const wire = JSON.parse(createResponsesPassthroughAdapter({ ...keyed, reasoningEfforts: [] }) + .buildRequest(parseRequest(raw)).body); + expect(wire.reasoning).toEqual({ summary: "auto" }); + expect(raw.reasoning.effort).toBe(123); + }); + + test("empty-ladder repair preserves unknown, non-rankable and native forward effort behavior", () => { + const keyed = { adapter: "openai-responses", baseUrl: "https://example.test/v1", authMode: "key" as const }; + for (const unchanged of [keyed, { ...keyed, reasoningEfforts: ["enabled"] }, { ...provider, reasoningEfforts: [] }]) { + const raw = { model: "gpt-5.6-sol", input: "ping", reasoning: { effort: "ultra" } }; + const wire = JSON.parse(createResponsesPassthroughAdapter(unchanged).buildRequest(parseRequest(raw)).body); + expect(wire.reasoning.effort).toBe("ultra"); + } + // A model-specific nonempty ladder overrides a provider-wide empty declaration. + const wire = JSON.parse(createResponsesPassthroughAdapter({ + ...keyed, reasoningEfforts: [], modelReasoningEfforts: { model: ["low", "high", "max"] }, + }).buildRequest(parseRequest({ model: "model", input: "ping", reasoning: { effort: "ultra" } })).body); + expect(wire.reasoning.effort).toBe("max"); + }); + test("a config saved before the fix is backfilled, and a hand-set path is preserved", () => { const saved = { adapter: "openai-chat", baseUrl: "https://api.deepseek.com", apiKey: "sk-test" } as Parameters[1]; enrichProviderFromRegistry("deepseek", saved); From 9336a27ffeed3023500de914654b85b1e889fb81 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:47:39 +0900 Subject: [PATCH 69/99] test(providers): distinguish BigModel upstream and bridge modalities First CI run 34064726690 reports text,image rather than text for both BigModel models at provider-registry-parity.test.ts:471; the Turbo effort array remains empty. Source tracing confirms applyProviderConfigHints uses isModelVisionSidecarConsumer, also used by the runtime vision planner, to expose image attachment support for configured text-only models. Keep the official text-only registry declarations and assert them independently before checking the bridge-enriched model and final catalog modalities. Preserve exact Turbo empty effort expectations and all generic modality behavior. Clarify that image handling requires the existing available, enabled vision sidecar rather than native BigModel image support. Validation: supplied CI failure log and source contract reviewed; git diff --check passed. No local tests, typecheck, lint or builds run. Commit hooks skipped per execution restriction. Main final CI and independent review remain required. Co-authored-by: jamespan --- docs-site/src/content/docs/guides/providers.md | 9 +++++++-- tests/providers/provider-registry-parity.test.ts | 10 ++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 081dced988..5e46979816 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -601,8 +601,13 @@ The preset uses a **static roster** (`liveModels: false`) taken from the | `glm-5.3` | 1,048,576 | `low`, `high`, `max` | `max` | Supported | | `glm-5-turbo` | 204,800 | None (empty list) | `max` | Supported | -Both entries declare text input. The default model is `glm-5.3`; Responses reasoning -content is preserved on replay. The existing Codex export adds its compatibility +Both entries declare upstream text-only input. The Codex catalog advertises text and +image because opencodex's existing vision sidecar can describe images for text-only +models. Image handling requires an available, enabled vision sidecar; this does not +declare native BigModel image support. + +The default model is `glm-5.3`; Responses reasoning content is preserved on replay. +The existing Codex export adds its compatibility `ultra` tier to GLM-5.3 and omits Turbo's default-effort field because Turbo has no selectable ladder; the provider metadata still records `max` for both models. For Turbo, outgoing Responses requests omit `reasoning.effort`, including a caller's diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index e2083646a5..238a5808ea 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -458,6 +458,8 @@ describe("provider registry parity", () => { }); expect(registry.modelDiscovery).toBeUndefined(); expect(registry.preserveReasoningContentModels).toBeUndefined(); + const upstreamModalities = { "glm-5.3": ["text"], "glm-5-turbo": ["text"] }; + expect(registry.modelInputModalities).toEqual(upstreamModalities); expect(KEY_LOGIN_PROVIDERS[id]).toMatchObject({ models: ["glm-5.3", "glm-5-turbo"], liveModels: false, apiKeyValidation: "unknown", }); @@ -468,11 +470,14 @@ describe("provider registry parity", () => { const models = provider.models!.map(modelId => applyProviderConfigHints(id, provider, { provider: id, id: modelId, })); + // The official upstream declaration stays text-only. Catalog hints add image for the + // existing vision sidecar (vision/eligibility.ts), not native BigModel image support. + expect(provider.modelInputModalities).toEqual(upstreamModalities); expect(models).toMatchObject([ { id: "glm-5.3", contextWindow: 1_048_576, reasoningEfforts: ["low", "high", "max"], - defaultReasoningEffort: "max", supportsReasoningSummaries: true, inputModalities: ["text"] }, + defaultReasoningEffort: "max", supportsReasoningSummaries: true, inputModalities: ["text", "image"] }, { id: "glm-5-turbo", contextWindow: 204_800, reasoningEfforts: [], - defaultReasoningEffort: "max", supportsReasoningSummaries: true, inputModalities: ["text"] }, + defaultReasoningEffort: "max", supportsReasoningSummaries: true, inputModalities: ["text", "image"] }, ]); const entries = buildCatalogEntries(nativeTemplate(), [], models); for (const [modelId, window, efforts] of [ @@ -482,6 +487,7 @@ describe("provider registry parity", () => { const entry = entries.find(row => row.slug === `${id}/${modelId}`); expect(entry).toMatchObject({ context_window: window, supports_reasoning_summaries: true, + input_modalities: ["text", "image"], }); // Existing export policy adds a compatibility ultra tier and omits the default // for empty ladders. The provider/CatalogModel defaults above remain official max. From e352ede9f6ad2e2a5fa58d982315968c5f4520e6 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:48:54 +0900 Subject: [PATCH 70/99] fix(gui): balance name editor helper text on narrow screens Browser review found an isolated Korean ending at 390px. Balance the short helper sentence without fixed line breaks or changing the existing layout. Co-authored-by: Zig Zag --- gui/src/styles.css | 1 + 1 file changed, 1 insertion(+) diff --git a/gui/src/styles.css b/gui/src/styles.css index 630882006f..a5838be7e4 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2672,6 +2672,7 @@ button.prov-account-row.active { cursor: default; } .model-display-name-current > .text-label { grid-column: 1 / -1; } .model-display-name-current strong { min-width: 0; overflow-wrap: anywhere; } .model-display-name-dialog > .input { margin-bottom: 6px; } +.model-display-name-dialog > .small { text-wrap: balance; } .model-display-name-error { margin-top: 8px; color: var(--red); From c721b94494a9c6f12e059aec9f9c77f0a0ce0380 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:48:43 +0900 Subject: [PATCH 71/99] fix(claude): report terminal closure buffer overflow once Distinguish termination entry from terminal delivery so closure-time overflow can release thinking and emit the bounded error without retrying closure. Prioritize collected errors over unfinished block serialization. Add eight real-budget closure-only overflow cases for EOF, failure, completion and incomplete terminals, including shared-budget collection. Tests and typecheck intentionally not run; parent owns final combined remote CI. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> Co-authored-by: Yumi --- src/claude/outbound.ts | 16 +++- .../claude-outbound.test.ts | 84 +++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 4dcdaa0eb7..ac06afac2d 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -234,6 +234,9 @@ export function responsesSseToAnthropicSse( let bufferBytes = 0; let started = false; let terminated = false; + // Starting termination can still throw while closing a block or emitting its + // terminal frame. Only a delivered terminal forbids the bounded overflow error. + let terminalDelivered = false; let cancelled = false; let blockIndex = 0; let open: OpenBlock | null = null; @@ -338,6 +341,7 @@ export function responsesSseToAnthropicSse( usage: anthropicUsage(usage, webSearchRequests), }); emit("message_stop", { type: "message_stop" }); + terminalDelivered = true; }; // upstreamDerived: transient upstream statuses become overloaded_error so the // Anthropic-SDK client retries with backoff; proxy-internal exceptions stay @@ -346,12 +350,15 @@ export function responsesSseToAnthropicSse( // resets reach the reader catch (no failed-tail relay) and stay api_error — // same as today, deliberate residual. const fail = (status: number, message: string, upstreamDerived = false, code?: string) => { - if (terminated) return; + // finish/fail sets terminated before closeOpenBlock. A closure-time + // allocation failure must still emit one error, without retrying closure. + if (terminated && (code !== "translation_buffer_limit" || terminalDelivered)) return; terminated = true; if (code === "translation_buffer_limit") { releaseThinkingBuffer(open); if (open?.callId) translatorBudget.closeCall(open.callId); open = null; + terminalDelivered = true; // No normal close frames are valid after overflow. Emit exactly one bounded // typed terminal without consulting the exhausted budget. controller.enqueue(encoder.encode(sseFrame("error", anthropicErrorBody( @@ -368,10 +375,12 @@ export function responsesSseToAnthropicSse( // Do not manufacture message_start before the terminal error. Earlier transport-only // pings remain valid and do not turn the failure into a partial message. emit("error", anthropicErrorBody(status, message, type, code)); + terminalDelivered = true; return; } closeOpenBlock(); emit("error", anthropicErrorBody(status, message, type, code)); + terminalDelivered = true; }; const handleFrame = (eventName: string, data: Rec) => { @@ -981,9 +990,10 @@ export async function collectAnthropicMessage( } finally { reader.releaseLock(); } - closeBlock(); - + // Error is authoritative. In particular, do not allocate another copy of an + // unfinished thinking block after the translator reported closure overflow. if (error) return error; + closeBlock(); return { id: `msg_${uuid()}`, type: "message", diff --git a/tests/claude-integration/claude-outbound.test.ts b/tests/claude-integration/claude-outbound.test.ts index 33faa2e839..78fcf0879c 100644 --- a/tests/claude-integration/claude-outbound.test.ts +++ b/tests/claude-integration/claude-outbound.test.ts @@ -342,6 +342,90 @@ describe("claude outbound SSE", () => { expect(reasoningReleased).toBe(reasoningCommitted); }); + for (const terminal of ["eof", "failed", "completed", "incomplete"] as const) { + for (const buffered of [false, true]) { + test(`closure-only reasoning overflow: ${terminal}, ${buffered ? "collector" : "stream"}`, async () => { + // All small deltas fit, including replacement reservations. Closing needs + // the retained 32 KiB text PLUS its base64 signature frame. For the + // collector allow its additional retained text in the same real budget. + const budget = createTestTranslatorBudget({ maxTurnBytes: (buffered ? 102 : 70) * 1024 }); + let reasoningBytes = 0; + let maxReasoningBytes = 0; + let reasoningBytesAtOverflow = -1; + const trackedBudget: TranslatorBudget = { + openCall: id => budget.openCall(id), + closeCall: id => budget.closeCall(id), + reserveTransient(bytes, scope) { + let reservation: ReturnType; + try { reservation = budget.reserveTransient(bytes, scope); } + catch (error) { reasoningBytesAtOverflow = reasoningBytes; throw error; } + return { + commitRetained() { + reservation.commitRetained(); + if (scope.kind === "reasoning") { + reasoningBytes += bytes; + maxReasoningBytes = Math.max(maxReasoningBytes, reasoningBytes); + } + }, + release: () => reservation.release(), + }; + }, + chargeRetained: (bytes, scope) => budget.chargeRetained(bytes, scope), + releaseRetained(bytes, scope) { + if (scope.kind === "reasoning") reasoningBytes -= bytes; + budget.releaseRetained(bytes, scope); + }, + observeAcceptedRequestCopy: bytes => budget.observeAcceptedRequestCopy(bytes), + observeExternallyCapped: (kind, bytes) => budget.observeExternallyCapped(kind, bytes), + snapshot: () => budget.snapshot(), + dispose: () => budget.dispose(), + }; + const text = "x".repeat(32 * 1024); + const frames = Array.from({ length: 128 }, () => sse("response.reasoning_text.delta", { + item_id: "rs_closure", content_index: 0, delta: text.slice(0, 256), + })); + if (terminal !== "eof") { + frames.push(sse(`response.${terminal}`, { response: terminal === "failed" + ? { error: { message: "upstream failure", status: 502 } } + : terminal === "incomplete" + ? { status: "incomplete", incomplete_details: { reason: "max_output_tokens" }, usage: {} } + : { status: "completed", usage: {} } })); + // Neither a repeated completion nor a later failure may add a terminal. + frames.push(sse("response.completed", { response: { status: "completed", usage: {} } })); + frames.push(sse("response.failed", { response: { error: { message: "late failure" } } })); + } + const stream = responsesSseToAnthropicSse(streamFromChunks(frames), "m", { + translatorBudget: trackedBudget, pingIntervalMs: 0, + }); + if (buffered) { + const message = await collectAnthropicMessage(stream, "m", trackedBudget); + expect(message).toMatchObject({ type: "error", error: { + type: "request_too_large", code: "translation_buffer_limit", + } }); + expect(message).not.toHaveProperty("content"); + expect(message).not.toHaveProperty("stop_reason"); + } else { + const events = await collectEvents(stream); + const deltas = events.filter(event => event.data.delta?.type === "thinking_delta"); + expect(deltas.map(event => event.data.delta.thinking).join("")).toBe(text); + expect(events.filter(event => event.name === "error")).toHaveLength(1); + expect(events.at(-1)).toMatchObject({ name: "error", data: { type: "error", error: { + type: "request_too_large", code: "translation_buffer_limit", + } } }); + expect(JSON.stringify(events.at(-1)).length).toBeLessThan(1024); + expect(events.some(event => event.name === "message_stop" || event.name === "message_delta" || event.name === "content_block_stop")).toBe(false); + expect(events.some(event => event.data.delta?.type === "signature_delta")).toBe(false); + } + // These prove failure happened after all text was retained, not while + // ingesting a delta, and the error path released the thinking reservation. + expect(reasoningBytesAtOverflow).toBe(text.length); + expect(maxReasoningBytes).toBeGreaterThanOrEqual(text.length); + expect(reasoningBytes).toBe(0); + expect(budget.snapshot().overflows).toBe(1); + }); + } + } + test("same-part deltas and index-free reasoning frames never get a separator", async () => { const samePart = [ sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), From 513391e8fa94716867ac127821beaa003ddbf48f Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:49:57 +0900 Subject: [PATCH 72/99] test(container): isolate synthetic inference without internal networking [skip ci] CI run 34064754947 built and started the image, then proved internal-only networking suppressed host port publication. Keep actual Compose networking and seed a checked loopback-only provider into disposable state before startup. Verify loaded configuration and hashes through recreation. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- scripts/ci/docker-smoke.ts | 46 +++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/scripts/ci/docker-smoke.ts b/scripts/ci/docker-smoke.ts index 0745f6bd9e..6bcf56f164 100644 --- a/scripts/ci/docker-smoke.ts +++ b/scripts/ci/docker-smoke.ts @@ -126,6 +126,20 @@ const fixture = JSON.stringify({ models: [{ const token = randomBytes(32).toString("hex"); const replacement = randomBytes(32).toString("hex"); const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); +let seededConfigHash = ""; + +// Check the loader, including its schema-repair/default-provider fallback, before server startup +// and again in each running container. This isolates synthetic inference, not all process egress. +const fixtureConfigCheck = ` + const { loadConfig } = await import('./src/config.ts'); + const effective = loadConfig(); + const provider = effective.providers.smoke; + if (Object.keys(effective.providers).join(',') !== 'smoke' || effective.defaultProvider !== 'smoke' + || provider?.adapter !== 'openai-responses' || provider?.authMode !== 'local' + || provider?.baseUrl !== 'http://127.0.0.1:9/v1' || provider?.codexAccountMode !== undefined || provider?.apiKey + || effective.runtimeRole !== 'hub' || effective.hostname !== '0.0.0.0' || effective.port !== 10100 + || effective.codexAutoStart !== false || effective.codexShimAutoRestore !== false) throw new Error('unsafe effective fixture config'); +`; interface Container { Id: string; @@ -169,6 +183,7 @@ async function inspect() { const stateProbe = ` import { readFileSync, statSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; + ${fixtureConfigCheck} const homes = ['/home/bun/.opencodex', '/home/bun/.codex']; const uid = process.getuid(); if (uid === 0) throw new Error('root user'); @@ -192,6 +207,7 @@ const stateProbe = ` async function state() { const hashes = JSON.parse(await compose(["exec", "-T", "hub", "bun", "-e", stateProbe])) as string[]; check(hashes.length === 3 && hashes.every(hash => /^[a-f0-9]{64}$/.test(hash)), "invalid state evidence"); + check(hashes[0] === seededConfigHash, "seeded config changed"); check(hashes[1] === sha256(`${token}\n`) && hashes[2] === sha256(fixture), "token/catalog changed"); return JSON.stringify(hashes); } @@ -280,8 +296,6 @@ async function main() { writeFileSync(join(scratch, "empty.env"), "", { mode: 0o600 }); writeFileSync(join(scratch, "override.json"), JSON.stringify({ services: { hub: { image, restart: "no" } }, - // Block upstream egress even if an admission regression reaches a provider path. - networks: { default: { internal: true } }, }), { mode: 0o600 }); env = { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", TMPDIR: scratch, @@ -293,10 +307,32 @@ async function main() { progress("validate and build"); await compose(["config", "--quiet"]); await build(); - progress("bootstrap and seed synthetic catalog"); + progress("verify shipped config and seed loopback-only fixture"); + seededConfigHash = await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "-e", + ` + import { readFileSync, writeFileSync } from 'node:fs'; + import { createHash } from 'node:crypto'; + import { atomicWriteFile } from './src/config/atomic-write.ts'; + const { shipped, catalog } = JSON.parse(await Bun.stdin.text()); + const path = '/home/bun/.opencodex/config.json'; + if (readFileSync(path, 'utf8') !== shipped || readFileSync('docker/config.json', 'utf8') !== shipped) { + throw new Error('shipped config mismatch'); + } + const config = JSON.parse(shipped); + if (config.runtimeRole !== 'hub' || config.hostname !== '0.0.0.0' || config.port !== 10100 + || config.codexAutoStart !== false || config.codexShimAutoRestore !== false) throw new Error('shipped runtime contract'); + // Port 9 has no listener in this image. Replace all provider routes before any server starts; + // even an admission regression cannot send these synthetic requests to a real provider. + config.providers = { smoke: { adapter: 'openai-responses', baseUrl: 'http://127.0.0.1:9/v1', authMode: 'local' } }; + config.defaultProvider = 'smoke'; + atomicWriteFile(path, JSON.stringify(config) + '\\n'); + ${fixtureConfigCheck} + writeFileSync('/home/bun/.codex/opencodex-catalog.json', catalog, { mode: 0o600, flag: 'wx' }); + console.log(createHash('sha256').update(readFileSync(path)).digest('hex')); + `], JSON.stringify({ shipped: readFileSync(join(root, "docker/config.json"), "utf8"), catalog: fixture })); + check(/^[a-f0-9]{64}$/.test(seededConfigHash), "invalid seeded config evidence"); + progress("bootstrap throwaway token"); await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "run", "docker/bootstrap-token.ts"], `${token}\n`); - await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "-e", - "import { writeFileSync } from 'node:fs'; writeFileSync('/home/bun/.codex/opencodex-catalog.json', await Bun.stdin.text(), { mode: 0o600, flag: 'wx' });"], fixture); progress("start and check admission"); await compose(["up", "--no-build", "--wait", "--wait-timeout", "120", "hub"], undefined, 150_000); const first = await inspect(); From 76e667fbb6e06b58d84a92e45481b9f0db27a298 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:59:20 +0900 Subject: [PATCH 73/99] test(responses): account for ordinary tool catalog guidance Combined Linux CI at c721b94494a9c6f12e059aec9f9c77f0a0ce0380 reported six messages where the seed fixtures expected five. Non-OpenAI chat translation prepends system tool-catalog guidance while compaction removes context.tools first. Explicitly require one system prefix advertising read_value on ordinary and stored-ID turns, and none on compact turns. Keep exact total length, ordered history content, original tool pairing and compact output assertions. Follow-up to synthetic #3807 coverage motivated by @DaveW001 and @stephen-drew; no original source patch copied. Source-only review and git diff --check passed. Local tests, typecheck and build NOT RUN by instruction. --- .../responses-compaction-routing.test.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index 8faca32bef..a787024d95 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -1817,10 +1817,20 @@ describe("established-history external task input (#3807)", () => { return captured; } - function expectHistory(sent: Record, tail: Array> = []) { + function expectHistory( + sent: Record, + tail: Array> = [], + withToolCatalog = true, + ) { const messages = sent.messages as Array>; - expect(messages).toHaveLength(wireHistory.length + tail.length); - expect(messages).toMatchObject([...wireHistory, ...tail]); + // Ordinary non-OpenAI chat turns prepend catalog guidance; compaction removes + // context.tools before translation. Require that exact prefix, not arbitrary extras. + const prefix = withToolCatalog ? [{ + role: "system", + content: expect.stringContaining("Valid tool names for this turn are exactly `read_value`."), + }] : []; + expect(messages).toHaveLength(prefix.length + wireHistory.length + tail.length); + expect(messages).toMatchObject([...prefix, ...wireHistory, ...tail]); // Exactly one original pair: delivery must not acquire a synthesized tool identity. expect(messages.flatMap(message => message.tool_calls ?? [])).toEqual(wireHistory[1]!.tool_calls); expect(messages.filter(message => message.role === "tool")).toEqual([wireHistory[2]]); @@ -1882,7 +1892,7 @@ describe("established-history external task input (#3807)", () => { expect(captured).toHaveLength(1); expectHistory(captured[0]!, [ { role: "user", content: expect.stringContaining("CONTEXT CHECKPOINT COMPACTION") }, - ]); + ], false); expect(captured[0]!.tools).toBeUndefined(); expect(JSON.stringify(captured)).not.toContain("compaction_trigger"); if (version === "v2 trigger") { From 9b5b670db3e24ae5522c5d61e74c071c71257a26 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:00:29 +0900 Subject: [PATCH 74/99] test(claude): correct replay and closure overflow oracles Match the canonical user string observed at parent combined head c721b9449 while preserving exact assistant block arrays. Capture closure-overflow output before collecting under the same unreset budget, separating concurrent ingestion pressure from closure-only failure. Assert all text, one bounded error, no success terminal, exact 32768-byte overflow boundary and no second overflow. No local tests or typecheck run. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> Co-authored-by: Yumi --- .../anthropic-thinking-signature.test.ts | 2 +- .../claude-outbound.test.ts | 41 +++++++++++-------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/tests/adapters/anthropic/anthropic-thinking-signature.test.ts b/tests/adapters/anthropic/anthropic-thinking-signature.test.ts index db8f489c4f..86ca82b412 100644 --- a/tests/adapters/anthropic/anthropic-thinking-signature.test.ts +++ b/tests/adapters/anthropic/anthropic-thinking-signature.test.ts @@ -399,7 +399,7 @@ describe("Claude / Responses / intended Anthropic replay fidelity", () => { const request = await adapter.buildRequest(parsed); const replay = JSON.parse(request.body as string) as { messages: Array<{ role: string; content: unknown }> }; expect(replay.messages).toEqual([ - { role: "user", content: [{ type: "text", text: "question" }] }, + { role: "user", content: "question" }, { role: "assistant", content: fixture.blocks }, { role: "user", content: [{ type: "tool_result", tool_use_id: tool.id, content: "result" }] }, ]); diff --git a/tests/claude-integration/claude-outbound.test.ts b/tests/claude-integration/claude-outbound.test.ts index 78fcf0879c..67380bb44a 100644 --- a/tests/claude-integration/claude-outbound.test.ts +++ b/tests/claude-integration/claude-outbound.test.ts @@ -346,9 +346,11 @@ describe("claude outbound SSE", () => { for (const buffered of [false, true]) { test(`closure-only reasoning overflow: ${terminal}, ${buffered ? "collector" : "stream"}`, async () => { // All small deltas fit, including replacement reservations. Closing needs - // the retained 32 KiB text PLUS its base64 signature frame. For the - // collector allow its additional retained text in the same real budget. - const budget = createTestTranslatorBudget({ maxTurnBytes: (buffered ? 102 : 70) * 1024 }); + // the retained 32 KiB text PLUS its base64 signature frame. Capture the + // generated stream before collection: concurrent collector retention can + // exceed a shared budget during ingestion instead of exercising closure. + // Collection below reuses this SAME budget, without resetting it. + const budget = createTestTranslatorBudget({ maxTurnBytes: 70 * 1024 }); let reasoningBytes = 0; let maxReasoningBytes = 0; let reasoningBytesAtOverflow = -1; @@ -397,24 +399,31 @@ describe("claude outbound SSE", () => { const stream = responsesSseToAnthropicSse(streamFromChunks(frames), "m", { translatorBudget: trackedBudget, pingIntervalMs: 0, }); - if (buffered) { - const message = await collectAnthropicMessage(stream, "m", trackedBudget); + const captured = buffered ? await new Response(stream).text() : undefined; + const capturedFrames = captured?.split("\n\n").filter(Boolean).map(frame => `${frame}\n\n`); + const events = await collectEvents(capturedFrames ? streamFromChunks(capturedFrames) : stream); + const deltas = events.filter(event => event.data.delta?.type === "thinking_delta"); + expect(deltas.map(event => event.data.delta.thinking).join("")).toBe(text); + expect(events.filter(event => event.name === "error")).toHaveLength(1); + expect(events.at(-1)).toMatchObject({ name: "error", data: { type: "error", error: { + type: "request_too_large", code: "translation_buffer_limit", + } } }); + expect(JSON.stringify(events.at(-1)).length).toBeLessThan(1024); + expect(events.some(event => event.name === "message_stop" || event.name === "message_delta" || event.name === "content_block_stop")).toBe(false); + expect(events.some(event => event.data.delta?.type === "signature_delta")).toBe(false); + if (capturedFrames) { + expect(capturedFrames.join("")).toBe(captured); + expect(reasoningBytesAtOverflow).toBe(text.length); + expect(reasoningBytes).toBe(0); + expect(budget.snapshot().overflows).toBe(1); + // Feed the actual generated frames, without inventing an error event or + // collecting one huge chunk that introduces a different buffer limit. + const message = await collectAnthropicMessage(streamFromChunks(capturedFrames), "m", trackedBudget); expect(message).toMatchObject({ type: "error", error: { type: "request_too_large", code: "translation_buffer_limit", } }); expect(message).not.toHaveProperty("content"); expect(message).not.toHaveProperty("stop_reason"); - } else { - const events = await collectEvents(stream); - const deltas = events.filter(event => event.data.delta?.type === "thinking_delta"); - expect(deltas.map(event => event.data.delta.thinking).join("")).toBe(text); - expect(events.filter(event => event.name === "error")).toHaveLength(1); - expect(events.at(-1)).toMatchObject({ name: "error", data: { type: "error", error: { - type: "request_too_large", code: "translation_buffer_limit", - } } }); - expect(JSON.stringify(events.at(-1)).length).toBeLessThan(1024); - expect(events.some(event => event.name === "message_stop" || event.name === "message_delta" || event.name === "content_block_stop")).toBe(false); - expect(events.some(event => event.data.delta?.type === "signature_delta")).toBe(false); } // These prove failure happened after all text was retained, not while // ingesting a delta, and the error path released the thinking reservation. From 619f7a7b0ecfdc3dd32cf8b399dd2851e3002f10 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 08:01:39 +0900 Subject: [PATCH 75/99] test(container): declare the synthetic loopback destination [skip ci] The Docker diagnostic failed because loadConfig correctly rejects private destinations unless explicitly allowed. A bounded remote fixture reproduced the fallback and passed with the test-only flag. Validate the fixture before startup and report fixed seed-phase codes without exposing child errors. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- scripts/ci/docker-smoke.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/scripts/ci/docker-smoke.ts b/scripts/ci/docker-smoke.ts index 6bcf56f164..eb488cf3b1 100644 --- a/scripts/ci/docker-smoke.ts +++ b/scripts/ci/docker-smoke.ts @@ -136,6 +136,7 @@ const fixtureConfigCheck = ` const provider = effective.providers.smoke; if (Object.keys(effective.providers).join(',') !== 'smoke' || effective.defaultProvider !== 'smoke' || provider?.adapter !== 'openai-responses' || provider?.authMode !== 'local' + || provider?.allowPrivateNetwork !== true || provider?.baseUrl !== 'http://127.0.0.1:9/v1' || provider?.codexAccountMode !== undefined || provider?.apiKey || effective.runtimeRole !== 'hub' || effective.hostname !== '0.0.0.0' || effective.port !== 10100 || effective.codexAutoStart !== false || effective.codexShimAutoRestore !== false) throw new Error('unsafe effective fixture config'); @@ -308,13 +309,18 @@ async function main() { await compose(["config", "--quiet"]); await build(); progress("verify shipped config and seed loopback-only fixture"); - seededConfigHash = await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "-e", + const seeded = await run(["docker", ...composeArgs, "run", "--rm", "-T", "--no-deps", "hub", "bun", "-e", ` import { readFileSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; - import { atomicWriteFile } from './src/config/atomic-write.ts'; + // Exit codes are fixed diagnostic markers; never serialize the caught exception. + let seedStage = 70; + try { + const { atomicWriteFile } = await import('./src/config/atomic-write.ts'); + seedStage = 71; const { shipped, catalog } = JSON.parse(await Bun.stdin.text()); const path = '/home/bun/.opencodex/config.json'; + seedStage = 72; if (readFileSync(path, 'utf8') !== shipped || readFileSync('docker/config.json', 'utf8') !== shipped) { throw new Error('shipped config mismatch'); } @@ -323,13 +329,27 @@ async function main() { || config.codexAutoStart !== false || config.codexShimAutoRestore !== false) throw new Error('shipped runtime contract'); // Port 9 has no listener in this image. Replace all provider routes before any server starts; // even an admission regression cannot send these synthetic requests to a real provider. - config.providers = { smoke: { adapter: 'openai-responses', baseUrl: 'http://127.0.0.1:9/v1', authMode: 'local' } }; + config.providers = { smoke: { adapter: 'openai-responses', baseUrl: 'http://127.0.0.1:9/v1', authMode: 'local', allowPrivateNetwork: true } }; config.defaultProvider = 'smoke'; + seedStage = 73; + const { validateConfigCandidate } = await import('./src/config.ts'); + if (!validateConfigCandidate(config).ok) throw new Error('invalid fixture'); + seedStage = 74; atomicWriteFile(path, JSON.stringify(config) + '\\n'); + seedStage = 75; ${fixtureConfigCheck} + seedStage = 76; writeFileSync('/home/bun/.codex/opencodex-catalog.json', catalog, { mode: 0o600, flag: 'wx' }); + seedStage = 77; console.log(createHash('sha256').update(readFileSync(path)).digest('hex')); + } catch { process.exitCode = seedStage; } `], JSON.stringify({ shipped: readFileSync(join(root, "docker/config.json"), "utf8"), catalog: fixture })); + const seedFailures: Record = { + 70: "imports", 71: "input", 72: "shipped config contract", 73: "fixture validation", + 74: "atomic config write", 75: "effective config", 76: "catalog write", 77: "config hash", + }; + check(seeded.code === 0, `seed failed: ${seedFailures[seeded.code ?? -1] ?? "unclassified child failure"} (exit ${seeded.code ?? "signal"})`); + seededConfigHash = seeded.out.trim(); check(/^[a-f0-9]{64}$/.test(seededConfigHash), "invalid seeded config evidence"); progress("bootstrap throwaway token"); await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "run", "docker/bootstrap-token.ts"], `${token}\n`); From 4c1d9afaa6152b6a84c5f5a929aefbae52adc595 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 08:05:11 +0900 Subject: [PATCH 76/99] fix(gui): reconcile display name draft during snapshot render Replace effect-driven draft synchronization with a guarded render-state adjustment when the parent supplies a new confirmed model snapshot. Keep the dialog mounted, retaining focus refs, pending state and request errors. Ordinary typing and catalog polling do not replace the editor snapshot. Local tests, lint, typecheck and build NOT RUN by owner mandate. Static diff inspection only; final CI and independent review remain parent-owned. Co-authored-by: Zig Zag --- gui/src/components/ModelDisplayNameDialog.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/gui/src/components/ModelDisplayNameDialog.tsx b/gui/src/components/ModelDisplayNameDialog.tsx index c24b6612d7..2a57ff8279 100644 --- a/gui/src/components/ModelDisplayNameDialog.tsx +++ b/gui/src/components/ModelDisplayNameDialog.tsx @@ -41,6 +41,7 @@ export default function ModelDisplayNameDialog({ const titleId = useId(); const helpId = useId(); const errorId = useId(); + const [draftSnapshot, setDraftSnapshot] = useState(model); const [draft, setDraft] = useState(model.displayNameOverride ?? ""); const [validationKey, setValidationKey] = useState(null); @@ -57,11 +58,13 @@ export default function ModelDisplayNameDialog({ if (saveFailed) inputRef.current?.focus(); }, [requestError, saving]); - // Parent replaces this snapshot only after a confirmed mutation, not catalog polling. - useEffect(() => { + // Parent replaces this snapshot only after a confirmed mutation, not typing or polling. + // Adjust before committing children, preserving the mounted dialog and its focus refs. + if (draftSnapshot !== model) { + setDraftSnapshot(model); setDraft(model.displayNameOverride ?? ""); setValidationKey(null); - }, [model]); + } const validationError = validationKey ? t(validationKey) : null; const visibleError = validationError ?? requestError; From d6cf8764ff2758c9ab0aca63cb0dda07a0ccae2c Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:36:59 +0900 Subject: [PATCH 77/99] feat(integrations): carry Raycast client from #3733 Carry source fa8b488dce0aa661653b0ef4c61d33b6a76249ff onto current dev. Local test, typecheck, lint and build execution deferred to combined remote CI per task scope. Co-authored-by: Chanhee Lee --- .../260904_raycast_integration/000_plan.md | 121 ++++++++ .../pr-screenshots/raycast-integration.png | Bin 0 -> 282997 bytes .../content/docs/fr/guides/integrations.md | 36 ++- .../content/docs/fr/reference/cli/agents.md | 15 +- .../src/content/docs/guides/integrations.md | 39 ++- .../content/docs/ja/reference/cli/agents.md | 7 +- .../content/docs/ko/reference/cli/agents.md | 7 +- .../src/content/docs/reference/cli/agents.md | 14 +- .../content/docs/ru/reference/cli/agents.md | 15 +- .../content/docs/tr/guides/integrations.md | 40 ++- .../content/docs/tr/reference/cli/agents.md | 16 +- .../docs/zh-cn/reference/cli/agents.md | 7 +- .../content/docs/zh-tw/guides/integrations.md | 31 +- .../docs/zh-tw/reference/cli/agents.md | 7 +- gui/public/provider-icons/README.md | 13 + gui/public/provider-icons/raycast.svg | 3 + gui/src/app-routing.ts | 1 + .../client-config-clients.ts | 5 +- gui/src/components/integration-marks.ts | 1 + gui/src/i18n/de.ts | 6 + gui/src/i18n/en.ts | 6 + gui/src/i18n/fr.ts | 6 + gui/src/i18n/ja.ts | 6 + gui/src/i18n/ko.ts | 6 + gui/src/i18n/ru.ts | 6 + gui/src/i18n/tr.ts | 6 + gui/src/i18n/zh-TW.ts | 6 + gui/src/i18n/zh.ts | 6 + .../integrations/FileIntegrationPage.tsx | 5 + .../pages/integrations/RaycastPlanNotice.tsx | 32 +++ gui/src/pages/integrations/integration-api.ts | 15 + .../pages/integrations/integration-tabs.ts | 2 + .../pages/integrations/overview-clients.ts | 1 + gui/tests/client-config-panel.test.tsx | 4 +- gui/tests/fr-localization.test.ts | 2 + gui/tests/integration-marks.test.ts | 9 +- gui/tests/integrations-api.test.ts | 4 +- gui/tests/integrations-overview-rows.test.ts | 7 +- gui/tests/locale-parity.test.ts | 2 + gui/tests/raycast-plan-notice.test.tsx | 51 ++++ scripts/test-layout/layout.json | 3 + src/cli/dispatch.ts | 2 +- src/cli/help.ts | 2 +- src/cli/index.ts | 38 ++- src/cli/integrations.ts | 35 ++- src/cli/registry.ts | 4 +- src/clients/config-export.ts | 36 +++ src/clients/config-export/contracts.ts | 3 +- src/clients/config-export/raycast.ts | 86 ++++++ src/clients/model-presentation.ts | 61 ++++ src/integrations/catalog-refresh.ts | 2 +- src/integrations/merge.ts | 183 ++++++++++-- src/integrations/raycast-detect.ts | 110 +++++++ src/integrations/registry.ts | 18 ++ src/integrations/state.ts | 74 ++++- src/integrations/writer.ts | 69 +++-- src/server/management/config-routes.ts | 8 +- src/server/management/integration-routes.ts | 31 +- tests/clients/integrations-merge.test.ts | 268 +++++++++++++++++ tests/clients/integrations-state.test.ts | 4 +- tests/clients/raycast-client.test.ts | 271 ++++++++++++++++++ tests/clients/raycast-detect.test.ts | 82 ++++++ .../clients/sync-client-integrations.test.ts | 18 +- .../client-config-export-new-clients.test.ts | 12 +- tests/config/client-config-export.test.ts | 6 +- .../config/client-config-new-clients.test.ts | 12 +- tests/fixtures/test-layout-expected.json | 3 + tests/gui/integrations-invariants.test.ts | 39 ++- .../management-integration-routes.test.ts | 26 ++ 69 files changed, 1904 insertions(+), 168 deletions(-) create mode 100644 devlog/_plan/260904_raycast_integration/000_plan.md create mode 100644 docs-site/public/pr-screenshots/raycast-integration.png create mode 100644 gui/public/provider-icons/raycast.svg create mode 100644 gui/src/pages/integrations/RaycastPlanNotice.tsx create mode 100644 gui/tests/raycast-plan-notice.test.tsx create mode 100644 src/clients/config-export/raycast.ts create mode 100644 src/clients/model-presentation.ts create mode 100644 src/integrations/raycast-detect.ts create mode 100644 tests/clients/integrations-merge.test.ts create mode 100644 tests/clients/raycast-client.test.ts create mode 100644 tests/clients/raycast-detect.test.ts diff --git a/devlog/_plan/260904_raycast_integration/000_plan.md b/devlog/_plan/260904_raycast_integration/000_plan.md new file mode 100644 index 0000000000..c98701a2cd --- /dev/null +++ b/devlog/_plan/260904_raycast_integration/000_plan.md @@ -0,0 +1,121 @@ +# Raycast Custom Providers integration — plan + +Raycast (Pro-only) reads `~/.config/raycast/ai/providers.yaml` and watches it, so a +file-toggle client is the right shape. Spec: https://manual.raycast.com/ai/custom-providers. + +Decisions taken with the maintainer: + +1. Install signal is `~/.config/raycast/ai` (the directory Raycast creates on + "Reveal Providers Config"), not `Raycast.app`. +2. A non-Pro plan is a warning in status/GUI, never a refusal. +3. Every exported model declares `tools: supported: true` (same stance as Hermes: + every routed model is tool-capable). +4. Array ownership goes into the shared merge/classifier layer as a path-segment + selector rather than a Raycast-only patcher. `structure/09_client-integrations.md` + forbids a special case that lives only in the writer or only in status; a + selector segment that `readPath`/`setPath`/`deletePath` all understand is the + one way both keep agreeing. + +## Raycast file shape + +```yaml +providers: + - id: opencodex # <- our one owned sequence item + name: OpenCodex + base_url: http://127.0.0.1:10100/v1 + models: + - id: anthropic/claude-opus-5 + name: Claude Opus 5 + context: 200000 + abilities: + temperature: { supported: true } + vision: { supported: true } + system_message: { supported: true } + tools: { supported: true } + reasoning_effort: { supported: false } +``` + +No `api_keys`: loopback is unauthenticated and the file has no env interpolation, +so the client is `loopbackOnly: true`. + +## Pro signal (macOS) + +`defaults read com.raycast.macos.v1 subscriptions_active` → `1` / `0`. Read via +`Bun.spawnSync`, not by parsing the binary plist (cfprefsd caches). Windows: `unknown`. + +## Work packages (disjoint files, run in parallel) + +| WP | Files | +|---|---| +| 1 merge selector | `src/integrations/merge.ts`, `src/integrations/state.ts`, `tests/integrations-merge.test.ts` | +| 2 client | `src/clients/config-export.ts`, `src/integrations/registry.ts`, `src/cli/registry.ts`, `src/cli/help.ts`, `tests/raycast-client.test.ts`, list-assertion tests | +| 3 sync fan-out | `src/integrations/owned-refresh.ts`, `src/cli/dispatch.ts`, `src/server/management/config-routes.ts`, `src/cli/index.ts`, `tests/sync-client-integrations.test.ts` | +| 4 detect + API + GUI | `src/integrations/raycast-detect.ts`, `src/server/management/integration-routes.ts`, `src/cli/integrations.ts`, `gui/**`, i18n | +| 5 docs | `docs-site/**` | + +### WP1 — `[field=value]` path segment + +```ts +// merge.ts +const ARRAY_SELECTOR = /^\[([A-Za-z_][A-Za-z0-9_]*)=([^\]]+)\]$/u; +export type PathSegment = { kind: "key"; key: string } | { kind: "select"; field: string; value: string }; +export function parseSegment(raw: string): PathSegment; +export class AmbiguousSelectorError extends Error {} +``` + +- `setPath`: a `select` segment addresses the element of an array whose + `item[field] === value`. Missing parent → `[]` is created (recorded by + `createdContainerPaths`). Match found → replace in place; none → push; ≥2 → + throw `AmbiguousSelectorError` (writer maps it to `unsafe` alongside + `UnserializableValueError`). +- `deletePath`: splice the match; an emptied array we created is pruned by the + existing `createdContainers` walk. +- `state.ts readPath`: `select` → `Array.prototype.find`. Because the classifier + and the writer share this one function, status and mutation cannot disagree. +- `blockedContainerPath`: a non-array, non-undefined value where a `select` + segment expects an array is blocked (`providers: {}` written by the user). +- `createdContainerPaths`: unchanged join rule; a `select` segment is never a + container prefix on its own. +- A key-only path is byte-for-byte the old behaviour; the twelve existing clients + do not change. + +### WP2 — client registration + +`config-export.ts`: `"raycast"` in `ExportClientId`; `raycastAiDir(env, home)` = +`join(home, ".config", "raycast", "ai")` (Raycast ignores XDG; same path on Windows); +`raycastConfigPath` = `…/providers.yaml`; types `RaycastAbility`, +`RaycastModelEntry`, `RaycastProviderEntry`, `RaycastGeneratedConfig`; +`buildRaycastClientConfig(ctx)` over `normalizeExportModels(ctx.models)` with +`exportModelLabel(model)` as `name`, `contextWindow` → `context`, abilities: +`temperature: !(reasoningEfforts?.length)`, `vision: inputModalities?.includes("image") ?? false`, +`system_message: true`, `tools: true`, `reasoning_effort: (reasoningEfforts?.length ?? 0) > 0`. +`buildRaycastContribution` = `singleFragment("raycast", ["providers", "[id=opencodex]"], providers[0])`. +`summarizeRaycast` finds the `opencodex` item. `EXPORT_CLIENTS.raycast`: +`filename: "raycast-providers.yaml"`, `format: "yaml"`, `apiKeyEnv: ""`, `loopbackOnly: true`. + +`registry.ts`: `configPath: raycastConfigPath`, `detectDir: raycastAiDir`, no +`sourcePreservingYaml` (that patcher handles block-map leaves only), no `writerLock`. + +### WP3 — sync fan-out + +Raycast joins the shared `refreshOwnedCatalogIntegrations` coordinator. Model +selection changes use its default `["pi", "aside", "raycast"]` set; +`POST /api/sync` uses `["mcode", "pi", "aside", "raycast"]`; direct CLI sync +updates `["mcode", "pi", "raycast"]` locally and keeps Aside behind its +server-owned multi-profile route. Startup and ensure refresh the owned Raycast +catalog after the Codex catalog publishes, using the live port. + +### WP4 — detection, API, GUI + +`raycast-detect.ts` mirrors `cursor-detect.ts` (injectable deps, read-only): +`RaycastPlan = "pro" | "free" | "unknown"`, `detectRaycast(deps)` → +`{ appPath, aiDirPresent, plan }`. `GET /api/client-integrations/raycast` +adds `raycast: { plan, appPath, aiDirPresent }` to the envelope (only for this +client). `ocx integration client status --client raycast` prints `plan`. GUI: +every surface in `devlog/_fin/260831_aside_client_and_integrations_ux/002_registration_checklist.md` +plus one `RaycastPlanNotice` shown when `plan !== "pro"` or `!aiDirPresent`. + +### WP5 — docs + +`guides/integrations.md` row + paragraph (Pro, reveal-first), `reference/cli/agents.md`, +translated locales, `bun run build` in `docs-site`. diff --git a/docs-site/public/pr-screenshots/raycast-integration.png b/docs-site/public/pr-screenshots/raycast-integration.png new file mode 100644 index 0000000000000000000000000000000000000000..e17261c158d64cab5ce5f8112000ffbb3cb3500a GIT binary patch literal 282997 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

A0+6Mo1P(PAj7!Qi250`_gDOyCJRWxN-CLFYa6 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%0A7UdBS})4ht_ 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 z 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 BVSoSt literal 0 HcmV?d00001 diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index c65531a4d3..c70d471f39 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Intégrations -description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness et MiniMax Code depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture. +description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code et Raycast depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture. --- L'onglet **Intégrations** écrit le bloc fournisseur d'opencodex dans le fichier de configuration du client, -puis peut le retirer. Neuf clients fonctionnent ainsi, chacun avec son propre commutateur : +puis peut le retirer. Dix clients fonctionnent ainsi, chacun avec son propre commutateur : | Client | Fichier de configuration | Format | Prise d'effet de la modification | Identifiant | |---|---|---|---|---| @@ -17,6 +17,7 @@ puis peut le retirer. Neuf clients fonctionnent ainsi, chacun avec son propre co | Gajae Code | `~/.gjc/agent/models.yml` | YAML | dans les nouvelles sessions ou à l'ouverture de `/model` |`OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (`~/.dsh/settings.yaml` par défaut) | YAML | rechargement à chaud | jeton porteur fictif et non secret pour le bouclage | | MiniMax Code | `~/.minimax/config.yaml` | YAML | dans les nouvelles sessions ou après l’ouverture du sélecteur de modèles | valeur fictive de bouclage | +| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immédiatement à l'enregistrement — Raycast surveille le fichier | aucun — bouclage uniquement | La prise en charge gérée de DSH exige au minimum **DSH 0.1.0-rc.6**. OpenCodex ne possède que le fragment `llm-pi-ai.providers.opencodex` : **Appliquer** et **Actualiser** remplacent ce fragment, **Désactiver** ne @@ -33,6 +34,27 @@ L’actualisation de l’intégration met également à jour les fenêtres de co d’effort de raisonnement faisant autorité ; les capacités inconnues sont omises et l’effort courant, qui appartient à la session MCode, est préservé. +Raycast a deux prérequis. Les fournisseurs personnalisés (Custom Providers) sont une fonctionnalité +**Raycast Pro** : avec un forfait gratuit, le fichier est tout de même écrit, mais +`ocx integration client status --client raycast` et la page Intégrations signalent un avertissement, +car Raycast ne le lira pas. Et Raycast ne crée son dossier `ai` que lorsque vous ouvrez une fois +Raycast → Settings → AI → **Reveal Providers Config** ; opencodex utilise ce dossier comme signal +d'installation et indique que le client n'est pas installé tant qu'il n'existe pas. Raycast lit +`~/.config/raycast/ai/providers.yaml` aussi bien sur macOS que sur Windows et n'honore pas +`XDG_CONFIG_HOME` ; ce chemin ne peut donc pas être déplacé. + +Le bloc géré est un seul élément, `id: opencodex`, dans la séquence `providers` du fichier : +`name: OpenCodex`, `base_url: http://:/v1`, et chaque modèle routé avec ses `abilities` — +`tools` et `system_message` sont toujours pris en charge, `vision` suit les modalités d'entrée du +catalogue, `reasoning_effort` est défini lorsque le modèle dispose d'une échelle d'effort, et +`temperature` est désactivé pour les modèles de raisonnement. Les autres fournisseurs du fichier sont +préservés, et la désactivation ne retire que l'élément OpenCodex. Raycast prend en compte la +modification dès l'enregistrement du fichier, sans redémarrage ; les modèles apparaissent dans le +sélecteur de modèles de Raycast regroupés sous **OpenCodex**. Le fichier n'a aucun emplacement pour +un identifiant, ce client est donc limité au bouclage : aucune entrée `api_keys` n'est écrite et une +liaison hors bouclage est refusée. Le format est documenté sur +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). + Les chemins respectent les variables de remplacement propres à chaque client, lorsqu'elles existent. Pour OMP, la présence de `OMP_PROFILE` l'emporte sur `PI_PROFILE`, même si sa valeur est explicitement vide. Un profil nommé emploie `PI_CONFIG_DIR` comme nom de répertoire relatif au dossier personnel de l'utilisateur @@ -93,7 +115,7 @@ niveaux. Dans ces cas, le commutateur est verrouillé afin que rien ne soit modi **OMP** n'est pas affecté non plus par les modifications voisines, mais pour une autre raison : son outil d'écriture ne modifie, octet par octet, que sa propre plage `providers.opencodex` ; le reste du fichier n'est jamais réécrit. Pour les autres formats susceptibles de contenir des commentaires (Hermes, OpenClaw, -Kimi Code, Gajae Code et MiniMax Code — documents YAML, JSON5 et TOML réécrits en entier), ou lorsque les propres entrées +Kimi Code, Gajae Code, MiniMax Code et Raycast — documents YAML, JSON5 et TOML réécrits en entier), ou lorsque les propres entrées d'opencodex ont été modifiées, le commutateur se verrouille et la désactivation est refusée plutôt que de deviner quelles modifications vous appartiennent. @@ -169,9 +191,11 @@ ocx integration client enable --client mcode ocx mcode ``` -Une fois l’intégration connectée, `ocx sync` actualise également le bloc MCode géré avec les fenêtres de -contexte et les niveaux d’effort de raisonnement actuels. Les blocs absents, modifiés par un tiers, non sûrs -ou jamais gérés restent intacts ; réactivez explicitement l’intégration lorsque vous souhaitez la reconnecter. +Une fois l’intégration connectée, `ocx sync` et `POST /api/sync` actualisent les catalogues MCode, +Pi, Aside et Raycast gérés. Le démarrage du proxy actualise aussi le catalogue Raycast géré. +Les changements de visibilité, de fournisseur ou de préréglage actualisent Pi, Aside et Raycast. +Les blocs absents, modifiés par un tiers, non sûrs ou supprimés manuellement restent intacts ; +réactivez explicitement l’intégration lorsque vous souhaitez la reconnecter. Le CLI distinct de la plateforme MiniMax (`mmx`) n’est pas une intégration à commutateur de fichier. Ses commandes textuelles utilisent le point de terminaison compatible avec Anthropic de MiniMax ; OpenCodex diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index e1501048c6..749119f70a 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -164,7 +164,7 @@ Gérez et appliquez la clôture du modèle Grok Build. ## Exportation de la configuration client -### `ocx export --client ` +### `ocx export --client ` Imprimez une configuration client connectée au proxy en cours d'exécution. La commande sérialise le bloc fournisseur `opencodex` — URL de base, liste de modèles et référence d’identifiant du client @@ -175,7 +175,7 @@ les modèles Codex peuvent actuellement voir. | Option | Actions | | --- | --- | -| `--client ` | Requis. Sélectionne le dialecte de configuration client. | +| `--client ` | Requis. Sélectionne le dialecte de configuration client. | | `--json` | Imprimez le document généré en tant que JSON sur la sortie standard pour les scripts. Il s'agit de JSON même lorsque le format natif du client sélectionné est YAML, TOML ou JSON5. | | `--out ` | Écrivez le format de configuration natif du client dans ``. Refuse de remplacer un fichier existant. | | `--force` | Autoriser `--out` à remplacer un fichier existant. | @@ -205,6 +205,17 @@ propres valeurs par défaut à ces lignes. | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, puis l'ancien `MAVIS_DATA_DIR`, l'emportent une fois définis ; une valeur relative est refusée) | `mcode-config.yaml` | aucun — espace réservé de bouclage | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` l'emporte une fois défini ; une valeur relative est refusée) | `config.json` | aucun — espace réservé de bouclage | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` l'emporte une fois défini ; une valeur relative est refusée) | `prime-models.json` | aucun — espace réservé de bouclage | +| `raycast` | `~/.config/raycast/ai/providers.yaml`, sur macOS comme sur Windows (Raycast n'honore pas `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | aucun — bouclage uniquement, aucune entrée `api_keys` n'est écrite | + +L'exportation Raycast est un document `providers.yaml` autonome contenant un seul élément `id: opencodex` +dans la séquence `providers` : `name: OpenCodex`, l'URL de base `/v1` du proxy et chaque modèle routé avec +ses `abilities` (`tools` et `system_message` toujours pris en charge, `vision` d'après les modalités d'entrée +du catalogue, `reasoning_effort` lorsque le modèle dispose d'une échelle d'effort, `temperature` désactivé +pour les modèles de raisonnement). Les fournisseurs personnalisés sont une fonctionnalité Raycast Pro, et +Raycast surveille le fichier : une modification enregistrée prend effet sans redémarrage. Le format est +documenté sur [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). +Aucune entrée `api_keys` n'est écrite ; cette exportation est donc limitée au bouclage et une liaison hors +bouclage est refusée. L'exportation DSH gérée nécessite DSH 0.1.0-rc.6 ou plus récent et ne possède que `llm-pi-ai.providers.opencodex`. DSH recharge à chaud ce fournisseur ; le modèle par défaut de l'utilisateur et diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 0c95908206..bb8cea7001 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Integrations -description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent and Aside from the dashboard — one switch per client, with a backup taken before every write. +description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside and Raycast from the dashboard — one switch per client, with a backup taken before every write. --- The **Integrations** tab writes opencodex's provider block into a client's own config -file, and removes it again. Twelve clients work this way, each with a switch: +file, and removes it again. Thirteen clients work this way, each with a switch: | Client | Config file | Format | When the change takes effect | Credential | |---|---|---|---|---| @@ -20,6 +20,7 @@ file, and removes it again. Twelve clients work this way, each with a switch: | Prime Agent | `~/.prime/agent/models.json` | JSON | new sessions | loopback placeholder | | ZCode | `~/.zcode/v2/config.json` | JSON | on restart | loopback placeholder | | Aside | `~/.aside/u//models.json` | JSON | after fully quitting and reopening Aside | loopback placeholder | +| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immediately on save — Raycast watches the file | none — loopback only | Generated catalogs include only enabled models from each provider selection. This applies to both downloads and managed integrations, including Pi and Aside. The management model list still shows @@ -61,6 +62,28 @@ One caveat specific to Aside: the running app rewrites `models.json` itself, so fully quit and reopen Aside after applying, the same way Claude Desktop needs a restart. Aside's block is loopback-only and never carries a real credential. +Raycast has two prerequisites. Custom Providers is a **Raycast Pro** feature: on a +free plan the file is still written, but `ocx integration client status --client +raycast` and the Integrations page report a warning, because Raycast will not +read it. And Raycast only creates its `ai` folder when you open Raycast → +Settings → AI → **Reveal Providers Config** once; opencodex uses that folder as +the install signal and reports the client as not installed until then. Raycast +reads `~/.config/raycast/ai/providers.yaml` on macOS and Windows alike and does +not honor `XDG_CONFIG_HOME`, so that path is not relocatable. + +The managed block is one element, `id: opencodex`, in the file's `providers` +sequence: `name: OpenCodex`, `base_url: http://:/v1`, and every +routed model with its `abilities` — `tools` and `system_message` are always +supported, `vision` follows the catalog's input modalities, `reasoning_effort` +is set when the model has an effort ladder, and `temperature` is turned off for +reasoning models. Other providers in the file are preserved, and disable removes +only the OpenCodex element. Raycast picks up the change as soon as the file is +saved, no restart needed; the models appear in Raycast's model picker grouped +under **OpenCodex**. The file has no place for a credential, so this client is +loopback-only: no `api_keys` entry is written and a non-loopback bind is refused. +The format is documented at +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). + Cursor has a tab but is not one of these switches. Regular Cursor calls custom endpoints from its own backend, so a loopback proxy is unreachable without a public tunnel, and Cursor's separate Private Inference build is configured inside Cursor. The **Cursor** tab is read-only: @@ -130,7 +153,7 @@ than 1000 levels — which locks the switch instead, so nothing is silently chan **OMP** is unaffected by sibling edits too, for a different reason: its writer patches only its own `providers.opencodex` range byte-wise, so the rest of the file is never rewritten. For the remaining formats that can carry comments -(Hermes, OpenClaw, Kimi Code, Gajae Code, MiniMax Code — YAML, JSON5 and TOML +(Hermes, OpenClaw, Kimi Code, Gajae Code, MiniMax Code, Raycast — YAML, JSON5 and TOML written as whole documents), or whenever our own entries were edited, the switch locks and disable refuses rather than guessing which edits were yours. @@ -216,10 +239,12 @@ ocx integration client enable --client mcode ocx mcode ``` -Once connected, `ocx sync` refreshes owned MCode, Pi, and Aside catalogs with the current -model selection, context windows, and reasoning-effort ladders. Changes to model visibility, -provider selection, or presets also refresh connected Pi and Aside catalogs. Foreign-edited -or unsafe blocks stay untouched, as do previously owned blocks you removed manually. +Once connected, `ocx sync` and `POST /api/sync` refresh owned MCode, Pi, Aside, and +Raycast catalogs with the current model selection, context windows, and reasoning-effort +ladders. Proxy startup refreshes an owned Raycast catalog. Changes to model visibility, +provider selection, or presets also refresh connected Pi, Aside, and Raycast catalogs. +Missing, foreign-edited, or unsafe blocks stay untouched, as do previously owned blocks +you removed manually. An enabled Aside profile is an exception to the usual owned-only refresh: if its account directory exists and it has never had an owned block, sync may create its first block when that slot is empty. A prior Aside connection enables this behavior for all registered diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index cd1b4fa30f..a223362a56 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -125,7 +125,7 @@ Grok Build モデル フェンスを管理および適用します。 ## クライアント設定のエクスポート -### `ocx export --client ` +### `ocx export --client ` 実行中のプロキシに接続するクライアント設定を出力します。このコマンドは、ベース URL、モデル一覧、およびクライアントに応じた認証情報参照または `opencodex-loopback` プレースホルダーを含む `opencodex` プロバイダーブロックを、選択したクライアントのネイティブ形式でシリアル化します。 @@ -133,7 +133,7 @@ Grok Build モデル フェンスを管理および適用します。 |旗 |アクション | | --- | --- | -| `--client ` |必須。クライアントの設定形式を選択します。 | +| `--client ` |必須。クライアントの設定形式を選択します。 | | `--json` |構成 JSON のみを標準出力に出力するため、リダイレクトはバイト正確な出力をキャプチャします。 `--out` 書き込みメモを含むすべての診断は stderr に送られます。 | | `--out ` |設定を `` に書き込みます。既存のファイルの置き換えを拒否します。 | | `--force` | `--out` が既存のファイルを置き換えることを許可します。 | @@ -160,6 +160,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`、次に旧 `MAVIS_DATA_DIR` が設定時に優先。相対値は拒否されます) | `mcode-config.yaml` | なし — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` が設定時に優先。相対値は拒否されます) | `config.json` | なし — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` が設定時に優先。相対値は拒否されます) | `prime-models.json` | なし — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml` (macOS と Windows で同じ。Raycast は `XDG_CONFIG_HOME` を尊重しません) | `raycast-providers.yaml` | なし — loopback のみ。`api_keys` エントリは書き込まれません | + +Raycast のエクスポートは、`providers` シーケンスに `id: opencodex` 要素を 1 つだけ持つ独立した `providers.yaml` 文書です。内容は `name: OpenCodex`、プロキシの `/v1` ベース URL、および `abilities` 付きのルーティング済み全モデルです (`tools` と `system_message` は常にサポート、`vision` はカタログの入力モダリティから、`reasoning_effort` はモデルに effort ラダーがある場合、`temperature` は推論モデルではオフ)。Custom Providers は Raycast Pro の機能で、Raycast はこのファイルを監視しているため、保存した変更は再起動なしで反映されます。形式は [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) に記載されています。`api_keys` エントリは書き込まれないため、このエクスポートは loopback 専用で、loopback 以外のバインドは拒否されます。 opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex が生成する Pi のエクスポートには環境変数が不要で、リテラルのプレースホルダー `opencodex-loopback` が入ります。この値は必須です。Pi はモデル リストを構築する際に `apiKey` を解決し、既存の設定に未設定の環境変数参照がある場合はプロバイダー全体を隠すためです。ループバックでは、生成されたプレースホルダーをプロキシが検査することはありません。 diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index a229551a83..3624a2a803 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -131,7 +131,7 @@ Grok Build model fence를 관리하고 적용합니다. ## 클라이언트 설정 내보내기 -### `ocx export --client ` +### `ocx export --client ` 실행 중인 프록시에 연결할 client config를 출력합니다. 이 명령은 base URL, model list, 그리고 client에 따라 credential reference 또는 `opencodex-loopback` placeholder를 포함한 `opencodex` provider block을 선택한 client의 네이티브 형식으로 직렬화합니다. @@ -139,7 +139,7 @@ Grok Build model fence를 관리하고 적용합니다. | 플래그 | 동작 | | --- | --- | -| `--client ` | 필수입니다. 클라이언트 설정 형식을 선택합니다. | +| `--client ` | 필수입니다. 클라이언트 설정 형식을 선택합니다. | | `--json` | config JSON만 stdout에 출력하므로, redirect가 byte-exact 출력을 캡처합니다. `--out` write note를 포함한 모든 진단 메시지는 stderr로 갑니다. | | `--out ` | config를 ``에 씁니다. 기존 파일이 있으면 덮어쓰지 않습니다. | | `--force` | `--out`이 기존 파일을 덮어쓰도록 허용합니다. | @@ -166,6 +166,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, 그다음 레거시 `MAVIS_DATA_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `mcode-config.yaml` | 없음 — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `config.json` | 없음 — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `prime-models.json` | 없음 — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml` (macOS와 Windows 모두 동일. Raycast는 `XDG_CONFIG_HOME`을 따르지 않습니다) | `raycast-providers.yaml` | 없음 — loopback 전용. `api_keys` 항목은 쓰지 않습니다 | + +Raycast 내보내기는 `providers` 시퀀스에 `id: opencodex` 요소 하나만 담은 독립 `providers.yaml` 문서입니다. 내용은 `name: OpenCodex`, proxy의 `/v1` base URL, 그리고 `abilities`가 붙은 라우팅된 모든 모델입니다(`tools`와 `system_message`는 항상 지원, `vision`은 카탈로그의 입력 모달리티를 따름, `reasoning_effort`는 모델에 effort 사다리가 있을 때, `temperature`는 추론 모델에서 꺼짐). Custom Providers는 Raycast Pro 기능이며, Raycast가 이 파일을 감시하므로 저장한 변경은 재시작 없이 적용됩니다. 형식은 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)에 문서화되어 있습니다. `api_keys` 항목은 쓰지 않으므로 이 내보내기는 loopback 전용이며, loopback이 아닌 bind는 거부됩니다. opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 생성한 Pi 블록에는 환경 변수가 필요 없으며, 리터럴 placeholder인 `opencodex-loopback`이 들어갑니다. 이 값은 필수입니다. Pi는 모델 목록을 만들 때 `apiKey`를 해석하고, 기존 config에 설정되지 않은 env 참조가 있으면 provider 전체를 숨기기 때문입니다. 루프백에서 proxy는 생성된 placeholder를 검사하지 않습니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index e6470eae0e..300bb7d5e2 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -207,7 +207,7 @@ Manage and apply the Grok Build model fence. ## Client config export -### `ocx export --client ` +### `ocx export --client ` Print a client config wired to the running proxy. The command serializes the `opencodex` provider block — base URL, model list, and the client's credential @@ -218,7 +218,7 @@ models Codex can currently see. | Flag | Action | | --- | --- | -| `--client ` | Required. Selects the client config dialect. | +| `--client ` | Required. Selects the client config dialect. | | `--json` | Print the generated document as JSON on stdout for scripts. This is JSON even when the selected client's native format is YAML, TOML, or JSON5. | | `--out ` | Write the client's native config format to ``. Refuses to replace an existing file. | | `--force` | Allow `--out` to replace an existing file. | @@ -248,6 +248,7 @@ client applies its own defaults for those). | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` wins when set; a relative value is refused) | `config.json` | none — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` wins when set; a relative value is refused) | `prime-models.json` | none — loopback placeholder | | `aside` | `~/.aside/u//models.json` for the account Aside's own `accounts.json` names as current; an unreadable manifest is refused rather than defaulting to an account | `aside-models.json` | none — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml` on macOS and Windows alike (Raycast does not honor `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | none — loopback only, no `api_keys` entry is written | The managed DSH export requires DSH 0.1.0-rc.6 or newer and owns only `llm-pi-ai.providers.opencodex`. DSH hot reloads that provider; the user's default model and @@ -260,6 +261,15 @@ hide the whole provider when an existing config contains an unset env reference. checks the generated placeholder on loopback. OMP supports provider-level headers, but this initial integration deliberately remains loopback-only; remote `x-opencodex-api-key` wiring is deferred. +The Raycast export is a standalone `providers.yaml` document with one `id: opencodex` element +in the `providers` sequence: `name: OpenCodex`, the proxy's `/v1` base URL, and every routed model +with its `abilities` (`tools` and `system_message` always supported, `vision` from the catalog's +input modalities, `reasoning_effort` when the model has an effort ladder, `temperature` off for +reasoning models). Custom Providers is a Raycast Pro feature, and Raycast watches the file, so a +saved change takes effect without a restart. The format is documented at +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). No +`api_keys` entry is written, so this export is loopback-only and a non-loopback bind is refused. + The MCode, ZCode and Prime exports are loopback-only for the same reason and likewise carry the `opencodex-loopback` placeholder rather than a real credential. Prime Agent reads the same `models.json` contract Pi does, so the two exports produce the same document; only the destination diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index b49162dbc6..8df8175173 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -152,7 +152,7 @@ override, но файлы на диске никогда не меняются. ## Экспорт client config -### `ocx export --client ` +### `ocx export --client ` Печатает client config, направленный на работающий прокси. Команда сериализует блок провайдера `opencodex` в нативном формате выбранного клиента: base URL, список моделей и, @@ -163,7 +163,7 @@ override, но файлы на диске никогда не меняются. | Флаг | Действие | | --- | --- | -| `--client ` | Обязателен. Выбирает формат конфигурации клиента. | +| `--client ` | Обязателен. Выбирает формат конфигурации клиента. | | `--json` | Печатать только JSON-конфиг в stdout, чтобы redirect сохранял побайтно точный вывод. Вся диагностика, включая заметку о записи через `--out`, идёт в stderr. | | `--out ` | Записать конфиг в ``. Перезаписывать существующий файл не позволит. | | `--force` | Разрешить `--out` заменить существующий файл. | @@ -193,6 +193,17 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, затем устаревшая `MAVIS_DATA_DIR`, имеют приоритет, если заданы; относительное значение отклоняется) | `mcode-config.yaml` | нет — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` имеет приоритет, если задана; относительное значение отклоняется) | `config.json` | нет — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` имеет приоритет, если задана; относительное значение отклоняется) | `prime-models.json` | нет — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml` одинаково на macOS и Windows (Raycast не учитывает `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | нет — только loopback, запись `api_keys` не создаётся | + +Экспорт для Raycast — это отдельный документ `providers.yaml` с одним элементом `id: opencodex` в +последовательности `providers`: `name: OpenCodex`, базовый URL прокси с `/v1` и каждая маршрутизируемая +модель с её `abilities` (`tools` и `system_message` поддерживаются всегда, `vision` берётся из входных +модальностей каталога, `reasoning_effort` задаётся, когда у модели есть шкала усилий, `temperature` +отключена для рассуждающих моделей). Custom Providers — функция Raycast Pro, а Raycast следит за файлом, +поэтому сохранённое изменение вступает в силу без перезапуска. Формат описан на +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). Запись +`api_keys` не создаётся, поэтому этот экспорт работает только через loopback, а привязка вне loopback +отклоняется. opencode интерполирует `{env:OPENCODEX_OPENCODE_API_KEY}`. Сгенерированный opencodex экспорт для Pi не требует переменной окружения и несёт литеральную заглушку `opencodex-loopback`. Это значение diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index fea4b37dd4..ff0dcaa41e 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Entegrasyonlar -description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness ve MiniMax Code'u opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. +description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code ve Raycast'i opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. --- **Entegrasyonlar** sekmesi, opencodex'in sağlayıcı bloğunu istemcinin kendi -yapılandırma dosyasına yazar ve tekrar kaldırır. Dokuz istemci bu şekilde +yapılandırma dosyasına yazar ve tekrar kaldırır. On istemci bu şekilde çalışır, her biri bir anahtarla: | İstemci | Yapılandırma dosyası | Format | Değişiklik ne zaman geçerli olur? | Kimlik bilgisi | @@ -18,6 +18,7 @@ yapılandırma dosyasına yazar ve tekrar kaldırır. Dokuz istemci bu şekilde | Gajae Code | `~/.gjc/agent/models.yml` | YAML | yeni oturumlarda veya `/model` açtığınızda | `OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (varsayılan `~/.dsh/settings.yaml`) | YAML | çalışırken yeniden yükleme | gizli olmayan geri döngü bearer yer tutucusu | | MiniMax Code | `~/.minimax/config.yaml` | YAML | yeni oturumlarda veya model seçici açıldıktan sonra | geri döngü (loopback) yer tutucusu | +| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | kaydedildiği anda — Raycast dosyayı izler | yok — yalnızca geri döngü | Yönetilen DSH desteğinin en düşük uyumlu sürümü **DSH 0.1.0-rc.6**'dır. OpenCodex yalnızca `llm-pi-ai.providers.opencodex` bölümünü yönetir: Uygula ve Yenile bu bölümü değiştirir, Devre Dışı @@ -35,6 +36,29 @@ Entegrasyon yenilendiğinde model başına doğrulanmış bağlam pencereleri ve çabası seçenekleri de yenilenir; bilinmeyen yetenekler atlanır ve MCode oturumunun yönettiği geçerli çaba seçimi korunur. +Raycast'in iki ön koşulu vardır. Özel sağlayıcılar (Custom Providers) bir **Raycast Pro** +özelliğidir: ücretsiz planda dosya yine yazılır, ancak Raycast onu okumayacağı için +`ocx integration client status --client raycast` ve Entegrasyonlar sayfası bir uyarı +bildirir. Ayrıca Raycast `ai` klasörünü yalnızca Raycast → Settings → AI → +**Reveal Providers Config** seçeneğini bir kez açtığınızda oluşturur; opencodex bu +klasörü kurulum sinyali olarak kullanır ve klasör var olana kadar istemciyi kurulu değil +olarak bildirir. Raycast, `~/.config/raycast/ai/providers.yaml` dosyasını macOS ve +Windows'ta aynı şekilde okur ve `XDG_CONFIG_HOME` değerini dikkate almaz; bu nedenle bu +yol taşınamaz. + +Yönetilen blok, dosyanın `providers` dizisindeki tek bir öğedir: `id: opencodex`, +`name: OpenCodex`, `base_url: http://:/v1` ve `abilities` alanıyla birlikte +yönlendirilen her model — `tools` ve `system_message` her zaman destekli, `vision` +kataloğun giriş modalitelerini izler, `reasoning_effort` modelin bir çaba merdiveni +varsa ayarlanır ve `temperature` akıl yürütme modelleri için kapatılır. Dosyadaki diğer +sağlayıcılar korunur ve devre dışı bırakma yalnızca OpenCodex öğesini kaldırır. Raycast +değişikliği dosya kaydedilir kaydedilmez, yeniden başlatma gerekmeden alır; modeller +Raycast'in model seçicisinde **OpenCodex** altında gruplanmış olarak görünür. Dosyada +kimlik bilgisi için bir yer yoktur, bu yüzden bu istemci yalnızca geri döngü içindir: +hiçbir `api_keys` girdisi yazılmaz ve geri döngü dışı bir bağlama reddedilir. Format +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) +adresinde belgelenmiştir. + Yollar, varsa her istemcinin kendi ortam geçersiz kılmalarını dikkate alır. OMP için `OMP_PROFILE`, açıkça boş olduğunda bile varlığıyla `PI_PROFILE`'a üstün gelir. Adlandırılmış bir profil, `PI_CONFIG_DIR`'i kullanıcının ev dizinine göre @@ -112,7 +136,7 @@ hiçbir şey sessizce değiştirilmez veya düşürülmez. **OMP** de yanındaki düzenlemelerden etkilenmez, ama başka bir nedenle: writer'ı yalnızca kendi `providers.opencodex` aralığını bayt bayt yamalar, dosyanın geri kalanı hiçbir zaman yeniden yazılmaz. Yorum taşıyabilen diğer biçimlerde (Hermes, OpenClaw, -Kimi Code, Gajae Code, MiniMax Code — bütün belge olarak yazılan YAML, JSON5 ve TOML) veya +Kimi Code, Gajae Code, MiniMax Code, Raycast — bütün belge olarak yazılan YAML, JSON5 ve TOML) veya kendi girdilerimiz düzenlenmişse, anahtar kilitlenir ve hangi düzenlemelerin size ait olduğunu tahmin etmek yerine devre dışı bırakmayı reddeder. @@ -192,10 +216,12 @@ ocx integration client enable --client mcode ocx mcode ``` -Bağlandıktan sonra `ocx sync`, yönetilen MCode bloğunu güncel bağlam pencereleri ve -akıl yürütme çabası seçenekleriyle de yeniler. Eksik, dışarıdan düzenlenmiş, güvenli -olmayan veya hiç sahiplenilmemiş bloklara dokunmaz; yeniden bağlamak istediğinizde -entegrasyonu açıkça yeniden etkinleştirin. +Bağlandıktan sonra `ocx sync` ve `POST /api/sync`, yönetilen MCode, Pi, Aside ve +Raycast kataloglarını yeniler. Proxy başlangıcı da yönetilen Raycast kataloğunu +yeniler. Model görünürlüğü, sağlayıcı veya ön ayar değişiklikleri Pi, Aside ve +Raycast kataloglarını günceller. Eksik, dışarıdan düzenlenmiş, güvenli olmayan +veya elle kaldırılmış bloklara dokunmaz; yeniden bağlamak istediğinizde +entegrasyonu açıkça etkinleştirin. Ayrı MiniMax platform CLI'si (`mmx`) bir dosya anahtarı entegrasyonu değildir. Metin komutları MiniMax'ın Anthropic uyumlu uç noktasını kullandığı için OpenCodex, diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index a3e184661d..04e72a766c 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -191,7 +191,7 @@ Grok Build model çitini yönetin ve uygulayın. ## İstemci yapılandırma dışa aktarma -### `ocx export --client ` +### `ocx export --client ` Çalışan proxy'ye bağlı bir istemci yapılandırmasını yazdırın. Komut, `opencodex` sağlayıcı bloğunu — temel URL, model listesi ve istemcinin kimlik bilgisi @@ -203,7 +203,7 @@ yalnızca Codex'in şu anda görebildiği modelleri yayınlar. | Bayrak | Eylem | | --- | --- | -| `--client ` | Gerekli. İstemci yapılandırma lehçesini seçer. | +| `--client ` | Gerekli. İstemci yapılandırma lehçesini seçer. | | `--json` | Betikler için stdout üzerinde oluşturulan belgeyi JSON olarak yazdırın. Bu, seçilen istemcinin yerel formatı YAML, TOML veya JSON5 olsa bile JSON'dur. | | `--out ` | İstemcinin yerel yapılandırma formatını `` konumuna yazın. Mevcut bir dosyanın üzerine yazmayı reddeder. | | `--force` | `--out`'un mevcut bir dosyanın üzerine yazmasına izin verin. | @@ -233,6 +233,18 @@ için kendi varsayılanlarını uygular) gelir. | `mcode` | `~/.minimax/config.yaml` (ayarlandığında `MINIMAX_DATA_DIR`, ardından eski `MAVIS_DATA_DIR` öncelikli; göreli değer reddedilir) | `mcode-config.yaml` | yok — geri döngü yer tutucusu | | `zcode` | `~/.zcode/v2/config.json` (ayarlandığında `ZCODE_DATA_DIR` öncelikli; göreli değer reddedilir) | `config.json` | yok — geri döngü yer tutucusu | | `prime` | `~/.prime/agent/models.json` (ayarlandığında `PRIME_AGENT_CODING_AGENT_DIR` öncelikli; göreli değer reddedilir) | `prime-models.json` | yok — geri döngü yer tutucusu | +| `raycast` | `~/.config/raycast/ai/providers.yaml`, macOS ve Windows'ta aynı (Raycast `XDG_CONFIG_HOME` değerini dikkate almaz) | `raycast-providers.yaml` | yok — yalnızca geri döngü, `api_keys` girdisi yazılmaz | + +Raycast dışa aktarımı, `providers` dizisinde tek bir `id: opencodex` öğesi içeren bağımsız +bir `providers.yaml` belgesidir: `name: OpenCodex`, proxy'nin `/v1` temel URL'si ve +`abilities` alanıyla birlikte yönlendirilen her model (`tools` ve `system_message` her +zaman destekli, `vision` kataloğun giriş modalitelerinden, `reasoning_effort` modelin bir +çaba merdiveni varsa, `temperature` akıl yürütme modelleri için kapalı). Özel sağlayıcılar +bir Raycast Pro özelliğidir ve Raycast dosyayı izlediği için kaydedilen bir değişiklik +yeniden başlatma gerekmeden etkili olur. Format +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) +adresinde belgelenmiştir. Hiçbir `api_keys` girdisi yazılmaz; bu yüzden bu dışa aktarım +yalnızca geri döngü içindir ve geri döngü dışı bir bağlama reddedilir. opencode `{env:OPENCODEX_OPENCODE_API_KEY}` değerini enterpole eder. Üretilen Pi ve OMP dışa aktarımları bir ortam değişkeni gerektirmez: her biri değişmez diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index 11e1c38ee1..89203420e9 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -132,7 +132,7 @@ ocx claude desktop import [--apply] Validate and import JSON ## Client config export -### `ocx export --client ` +### `ocx export --client ` 输出连接到正在运行代理的客户端配置。此命令会以所选客户端的原生格式序列化 `opencodex` provider 块,其中包含基础 URL、模型列表,以及该客户端适用的凭据引用或 `opencodex-loopback` 占位值。 @@ -140,7 +140,7 @@ ocx claude desktop import [--apply] Validate and import JSON | 标志 | 动作 | | --- | --- | -| `--client ` | 必需。选择客户端配置格式。 | +| `--client ` | 必需。选择客户端配置格式。 | | `--json` | 仅在 stdout 打印配置 JSON,这样重定向即可捕获字节级精确输出。包括 `--out` 写入提示在内的所有诊断信息都会输出到 stderr。 | | `--out ` | 将配置写入 ``。拒绝替换已存在的文件。 | | `--force` | 允许 `--out` 替换已存在的文件。 | @@ -167,6 +167,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (设置后 `MINIMAX_DATA_DIR` 优先,其次是旧的 `MAVIS_DATA_DIR`;相对路径会被拒绝) | `mcode-config.yaml` | 无 — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (设置后 `ZCODE_DATA_DIR` 优先;相对路径会被拒绝) | `config.json` | 无 — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (设置后 `PRIME_AGENT_CODING_AGENT_DIR` 优先;相对路径会被拒绝) | `prime-models.json` | 无 — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml`(macOS 与 Windows 相同;Raycast 不遵循 `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | 无 — 仅限回环,不会写入 `api_keys` 条目 | + +Raycast 导出是一份独立的 `providers.yaml` 文档,在 `providers` 序列中只有一个 `id: opencodex` 元素:`name: OpenCodex`、代理的 `/v1` 基础 URL,以及每个已路由模型及其 `abilities`(`tools` 与 `system_message` 始终支持,`vision` 取自目录的输入模态,`reasoning_effort` 在模型有 effort 阶梯时设置,`temperature` 对推理模型关闭)。Custom Providers 是 Raycast Pro 功能,且 Raycast 会监视该文件,因此保存后的更改无需重启即可生效。格式见 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。不会写入任何 `api_keys` 条目,所以该导出仅限回环,非回环绑定会被拒绝。 opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi 导出不需要环境变量,而是携带字面占位值 `opencodex-loopback`。这个值是必需的:Pi 在构建模型列表时会解析 `apiKey`,如果已有配置包含未设置的环境变量引用,它就会隐藏整个 provider。回环上的代理从不校验生成的占位值。 diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 54751d5620..ebf2e6f0de 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -1,9 +1,9 @@ --- title: 整合 -description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、Gajae Code、DeepSeek Harness 與 MiniMax Code——每個客戶端一個開關,每次寫入前都會先備份。 +description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、Gajae Code、DeepSeek Harness、MiniMax Code 與 Raycast——每個客戶端一個開關,每次寫入前都會先備份。 --- -**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有九個客戶端以這種方式運作,每個都有一個開關: +**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十個客戶端以這種方式運作,每個都有一個開關: | 客戶端 | 設定檔 | 格式 | 變更生效時機 | 憑證 | |---|---|---|---|---| @@ -16,6 +16,7 @@ description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、 | Gajae Code | `~/.gjc/agent/models.yml` | YAML | 新 sessions,或當你開啟 `/model` 時 | `OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml`(預設 `~/.dsh/settings.yaml`) | YAML | 熱重載 | 非秘密的 loopback bearer 佔位符 | | MiniMax Code | `~/.minimax/config.yaml` | YAML | 新 sessions,或開啟模型選擇器後 | loopback 佔位符 | +| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | 儲存後立即生效——Raycast 會監看該檔案 | 無——僅限 loopback | 受管理 DSH 支援的相容性下限是 **DSH 0.1.0-rc.6**。OpenCodex 只擁有 `llm-pi-ai.providers.opencodex`:Apply 與 Refresh 會取代該片段,Disable 只移除該片段, @@ -30,6 +31,22 @@ MiniMax Code 依序遵循 `MINIMAX_DATA_DIR`、`MAVIS_DATA_DIR`,最後才回 逐模型 context window 與 reasoning-effort 選項;未知能力會省略,而 MCode session 目前選取的 effort 不會被覆寫。 +Raycast 有兩個前提。Custom Providers 是 **Raycast Pro** 功能:免費方案下檔案仍會被寫入,但 +`ocx integration client status --client raycast` 與整合頁面會回報警告,因為 Raycast 不會讀取它。 +另外,Raycast 只有在你開啟一次 Raycast → Settings → AI → **Reveal Providers Config** 後才會建立 +`ai` 資料夾;opencodex 以該資料夾作為安裝訊號,在它存在之前都會回報客戶端尚未安裝。Raycast 在 +macOS 與 Windows 上同樣讀取 `~/.config/raycast/ai/providers.yaml`,且不遵循 `XDG_CONFIG_HOME`, +所以該路徑無法搬移。 + +受管理區塊是檔案 `providers` 序列中的單一元素 `id: opencodex`:`name: OpenCodex`、 +`base_url: http://:/v1`,以及每個路由模型及其 `abilities`——`tools` 與 +`system_message` 一律支援,`vision` 依目錄的輸入模態而定,`reasoning_effort` 在模型有 effort +階梯時設定,`temperature` 對推理模型關閉。檔案中的其他 provider 會被保留,停用只移除 OpenCodex +元素。檔案一儲存 Raycast 就會套用變更,不需重新啟動;模型會在 Raycast 的模型選擇器中歸在 +**OpenCodex** 群組下。該檔案沒有存放憑證的位置,因此此客戶端僅限 loopback:不會寫入任何 +`api_keys` 項目,非 loopback 的 bind 會被拒絕。格式說明見 +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。 + 路徑遵循客戶端自己的環境覆寫(environment override)。對 OMP 而言,`OMP_PROFILE` 以存在與否優先於 `PI_PROFILE`,即使明確為空也一樣。具名 profile 會把 `PI_CONFIG_DIR` 當作相對於使用者家目錄的目錄名稱,並忽略 `PI_CODING_AGENT_DIR`;沒有具名 profile 時,`PI_CODING_AGENT_DIR` 勝出。OMP 支援 provider 層級的 headers,但這個最初的整合刻意只支援 loopback;遠端 `x-opencodex-api-key` 的連線設定被延後。搬移過的 `HERMES_HOME`、`KIMI_CODE_HOME` 與 `XDG_CONFIG_HOME` 路徑同樣會被遵循,而非猜測。表格列出每個客戶端的預設值。 對原生 OpenAI 模型,產生的 OMP 區塊會選用其模型層級的 Responses API,保留圖片輸入與 reasoning-effort 控制。路由模型則維持 provider 的 Chat Completions 方言,讓它們既有的 adapters 保持相容。 @@ -52,7 +69,7 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil - **Restore this point…** 會出現在較舊的操作上,或當檔案在那次操作之後有變更時。跨過這樣的變更做回復會再詢問一次,才覆蓋你的較新編輯——並且也會備份它們,所以那次的回復本身也可以復原。 - 每個客戶端保留十份備份。超過之後,最舊的快照檔案會被移除,其歷史列顯示為 **Backup expired**。 -停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP** 同樣不受旁邊編輯影響,但原因不同:它的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(Hermes、OpenClaw、Kimi Code、Gajae Code、MiniMax Code——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 +停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP** 同樣不受旁邊編輯影響,但原因不同:它的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(Hermes、OpenClaw、Kimi Code、Gajae Code、MiniMax Code、Raycast——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 ## 誠實的預期 @@ -98,9 +115,11 @@ ocx integration client enable --client mcode ocx mcode ``` -完成一次連接後,`ocx sync` 也會以目前的 context window 與 reasoning-effort 階梯更新 -OpenCodex 已擁有的 MCode 區塊。若區塊已刪除、遭外部修改、不安全或從未由 OpenCodex -建立,sync 會保持原檔不動;只有在你確定要重新連接時才再次執行 enable。 +完成一次連接後,`ocx sync` 與 `POST /api/sync` 會更新 OpenCodex 已擁有的 +MCode、Pi、Aside 與 Raycast 目錄。proxy 啟動也會更新已擁有的 Raycast 目錄。 +模型可見性、provider 或 preset 變更會更新 Pi、Aside 與 Raycast。若區塊已刪除、 +遭外部修改、不安全或由你手動移除,sync 會保持原檔不動;只有在你確定要重新 +連接時才再次執行 enable。 另一個 MiniMax 平台 CLI(`mmx`)不是檔案開關整合。其文字命令使用 MiniMax 的 Anthropic 相容端點,因此 OpenCodex 提供憑證隔離、僅限 loopback 的 launcher: diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 497d2e4252..d04c099ebf 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -130,7 +130,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON ## 客戶端設定匯出 -### `ocx export --client ` +### `ocx export --client ` 印出連接到執行中代理的客戶端設定。此指令會用所選客戶端的原生格式,序列化含有 base URL、模型清單,以及適用的環境變數參考或 loopback 佔位符的 `opencodex` provider 區塊。 @@ -138,7 +138,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON | 旗標 | 動作 | | --- | --- | -| `--client ` | 必填。選擇客戶端設定格式。 | +| `--client ` | 必填。選擇客戶端設定格式。 | | `--json` | 僅在 stdout 印出設定 JSON,使重導向能擷取逐位元組輸出。所有診斷訊息(含 `--out` 寫入提示)皆送至 stderr。 | | `--out ` | 將設定寫入 ``。拒絕覆寫既有檔案。 | | `--force` | 允許 `--out` 覆寫既有檔案。 | @@ -165,6 +165,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (設定後 `MINIMAX_DATA_DIR` 優先,其次為舊的 `MAVIS_DATA_DIR`;相對路徑會被拒絕) | `mcode-config.yaml` | 無——loopback 佔位符 | | `zcode` | `~/.zcode/v2/config.json` (設定後 `ZCODE_DATA_DIR` 優先;相對路徑會被拒絕) | `config.json` | 無——loopback 佔位符 | | `prime` | `~/.prime/agent/models.json` (設定後 `PRIME_AGENT_CODING_AGENT_DIR` 優先;相對路徑會被拒絕) | `prime-models.json` | 無——loopback 佔位符 | +| `raycast` | `~/.config/raycast/ai/providers.yaml`(macOS 與 Windows 相同;Raycast 不遵循 `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | 無——僅限 loopback,不會寫入 `api_keys` 項目 | + +Raycast 匯出是一份獨立的 `providers.yaml` 文件,在 `providers` 序列中只有一個 `id: opencodex` 元素:`name: OpenCodex`、proxy 的 `/v1` base URL,以及每個路由模型及其 `abilities`(`tools` 與 `system_message` 一律支援,`vision` 依目錄的輸入模態而定,`reasoning_effort` 在模型有 effort 階梯時設定,`temperature` 對推理模型關閉)。Custom Providers 是 Raycast Pro 功能,且 Raycast 會監看該檔案,因此儲存後的變更不需重新啟動即可生效。格式說明見 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。不會寫入任何 `api_keys` 項目,所以此匯出僅限 loopback,非 loopback 的 bind 會被拒絕。 opencode 會插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。Pi 與 OMP 的匯出不需要環境變數, 而是帶有字面值 `opencodex-loopback`。DSH 匯出需要 DSH 0.1.0-rc.6 或更新版本,且只擁有 diff --git a/gui/public/provider-icons/README.md b/gui/public/provider-icons/README.md index 1fc7c57857..f5e64b568b 100644 --- a/gui/public/provider-icons/README.md +++ b/gui/public/provider-icons/README.md @@ -47,6 +47,16 @@ Export-client marks (used by the API tab's connect rows, not the provider list): on the web (`aside.com/favicon.svg` is a 404), so the shipping application is the first-party source. +- `raycast.svg` — fetched 2026-09-04 from + `https://fz1sd71lwhbqy6sh.public.blob.vercel-storage.com/press/images/logo/raycast-logo-dark.svg`, + the "Logo (dark)" download Raycast's own press kit (`raycast.com/press`) links. + `raycast.com/favicon.svg` and the other conventional paths are 404s, so the + press kit is the first-party source. Path data and the `#FF6363` fill are + verbatim; the fixed `width`/`height` are dropped in favour of the `viewBox`, + and the `` wrapper — a full-frame white `` the export tool left + behind — is removed because the path never leaves the frame and the rect + would read as a second ink to the mark tooling here. + - `minimax.svg` — fetched 2026-08-31 from `https://raw.githubusercontent.com/MiniMax-AI/MiniMax-01/main/figures/minimax.svg`, MiniMax's own symbol as committed in their own model repository. The API-docs @@ -135,6 +145,9 @@ Decisions that are not obvious from looking at the file: - `aside.svg` **is masked.** It already paints with `currentColor`, so it would follow the theme either way; masking keeps it consistent with the other silhouettes rather than depending on inherited color. +- `raycast.svg` **is not masked.** One ink, but that ink is #FF6363 — Raycast + red, the same case as `openai.svg` and `deepseek-harness.svg`. Legible on both + surfaces as an image. Both directions are enforced in `gui/tests/integration-marks.test.ts`, including a luminance check that fails any single-ink near-neutral mark left as an image. That diff --git a/gui/public/provider-icons/raycast.svg b/gui/public/provider-icons/raycast.svg new file mode 100644 index 0000000000..b6a40c7ba2 --- /dev/null +++ b/gui/public/provider-icons/raycast.svg @@ -0,0 +1,3 @@ + + + diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index bab41b1eee..c5971ffb6b 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -100,6 +100,7 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/zcode", "integrations/prime", "integrations/aside", + "integrations/raycast", ] as const; export function hashBelongsToPage(rawHash: string, page: Page): boolean { diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index c7c42d3e56..afd4484551 100644 --- a/gui/src/components/apikeys-workspace/client-config-clients.ts +++ b/gui/src/components/apikeys-workspace/client-config-clients.ts @@ -8,7 +8,7 @@ * with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row * until this tuple changes. */ -export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"] as const; +export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"] as const; export type ExportClientId = (typeof CLIENTS)[number]; export const CLIENT_LABEL_KEYS = { @@ -24,6 +24,7 @@ export const CLIENT_LABEL_KEYS = { zcode: "api.clientConfig.clientZcode", prime: "api.clientConfig.clientPrime", aside: "api.clientConfig.clientAside", + raycast: "api.clientConfig.clientRaycast", } as const; /** @@ -70,6 +71,8 @@ export const CLIENT_MARKS: Partial> = { zcode: "/provider-icons/zcode.svg", prime: "/provider-icons/prime-agent.svg", aside: "/provider-icons/aside.svg", + // Raycast red (#FF6363) is the brand, so like `dsh` it stays an image. + raycast: "/provider-icons/raycast.svg", }; /** diff --git a/gui/src/components/integration-marks.ts b/gui/src/components/integration-marks.ts index e8786224ec..eca38510bd 100644 --- a/gui/src/components/integration-marks.ts +++ b/gui/src/components/integration-marks.ts @@ -57,6 +57,7 @@ export const INTEGRATION_MARKS: Record = { zcode: CLIENT_MARKS.zcode ?? null, prime: CLIENT_MARKS.prime ?? null, aside: CLIENT_MARKS.aside ?? null, + raycast: CLIENT_MARKS.raycast ?? null, }; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 9086c9bf42..af5546775a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1093,6 +1093,7 @@ export const de: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside-Profile", "integrations.aside.profilesHint": "Wähle, welche Profile die ausgewählten Modelle erhalten. Das aktive Aside-Profil bleibt unverändert.", "integrations.aside.all": "Alle Profile synchronisieren", @@ -1252,6 +1253,10 @@ export const de: Record = { "integrations.semantics.zcode": "Verwaltet nur provider.opencodex in ~/.zcode/v2/config.json. Z.ai-Anmeldung und andere Provider bleiben unverändert. ZCode nach Änderungen neu starten.", "integrations.semantics.prime": "Verwaltet nur providers.opencodex in der models.json von Prime Agent — ~/.prime/agent, sofern PRIME_AGENT_CODING_AGENT_DIR sie nicht umleitet. Andere Provider und Modell-Overrides bleiben unverändert. Gilt für neue Sitzungen.", "integrations.semantics.aside": "Verwaltet nur providers.opencodex in der ~/.aside/u//models.json dieses Profils. Andere Provider bleiben unverändert. Beende Aside nach dem Anwenden vollständig und öffne es erneut.", + "integrations.semantics.raycast": "Fügt einen OpenCodex-Provider-Eintrag in die providers.yaml von Raycast ein, damit jedes geroutete Modell in der Modellauswahl von Raycast AI erscheint. Raycast Pro erforderlich.", + "integrations.raycast.proRequired": "Custom Providers ist eine Funktion von Raycast Pro. Die Datei wird geschrieben, aber Raycast ignoriert sie, bis ein Pro-Abonnement aktiv ist.", + "integrations.raycast.planUnknown": "Der Abonnementstatus von Raycast konnte nicht gelesen werden; Custom Providers erfordert Raycast Pro.", + "integrations.raycast.revealConfig": "Öffnen Sie Raycast → Einstellungen → AI und klicken Sie einmal auf „Reveal Providers Config“, damit der Providers-Ordner existiert.", "codexAuth.mainAccount": "Hauptkonto", "codexAuth.logLabel": "Log-Kennung", "codexAuth.codexApp": "Codex App", @@ -1580,6 +1585,7 @@ export const de: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Konfiguration kopieren", "api.clientConfig.download": "Herunterladen", "api.clientConfig.loading": "Client-Konfiguration wird erstellt…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0d2f1d05d4..71bfd5e2c3 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1600,6 +1600,7 @@ export const en = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside profiles", "integrations.aside.profilesHint": "Choose which profiles receive the selected models. Aside’s active profile stays unchanged.", "integrations.aside.all": "Sync all profiles", @@ -1799,6 +1800,10 @@ export const en = { "integrations.semantics.zcode": "Manages only provider.opencodex in ~/.zcode/v2/config.json. Your Z.ai login and other providers stay unchanged. Restart ZCode after changes.", "integrations.semantics.prime": "Manages only providers.opencodex in Prime Agent's models.json — ~/.prime/agent unless PRIME_AGENT_CODING_AGENT_DIR redirects it. Your other providers and model overrides stay unchanged. Applies to new sessions.", "integrations.semantics.aside": "Manages only providers.opencodex in this profile’s ~/.aside/u//models.json. Your other providers stay unchanged. Fully quit and reopen Aside after applying.", + "integrations.semantics.raycast": "Adds an OpenCodex provider entry to Raycast's providers.yaml so every routed model appears in the Raycast AI model picker. Raycast Pro required.", + "integrations.raycast.proRequired": "Custom Providers is a Raycast Pro feature. The file will be written, but Raycast ignores it until a Pro subscription is active.", + "integrations.raycast.planUnknown": "Could not read the Raycast subscription state; Custom Providers requires Raycast Pro.", + "integrations.raycast.revealConfig": "Open Raycast → Settings → AI and click Reveal Providers Config once so the providers folder exists.", "codexAuth.mainAccount": "Main Account", "codexAuth.logLabel": "Log label", "codexAuth.codexApp": "Codex App", @@ -2138,6 +2143,7 @@ export const en = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Copy config", "api.clientConfig.download": "Download", "api.clientConfig.loading": "Building client config…", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b6eb03f0b0..028e910bc3 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1572,6 +1572,7 @@ export const fr: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Profils Aside", "integrations.aside.profilesHint": "Choisissez les profils qui recevront les modèles sélectionnés. Le profil actif dans Aside reste inchangé.", "integrations.aside.all": "Synchroniser tous les profils", @@ -1731,6 +1732,10 @@ export const fr: Record = { "integrations.semantics.zcode": "Gère uniquement provider.opencodex dans ~/.zcode/v2/config.json. Votre connexion Z.ai et les autres fournisseurs restent inchangés. Redémarrez ZCode après toute modification.", "integrations.semantics.prime": "Gère uniquement providers.opencodex dans le models.json de Prime Agent — ~/.prime/agent, sauf si PRIME_AGENT_CODING_AGENT_DIR le redirige. Vos autres fournisseurs et surcharges de modèles restent inchangés. S'applique aux nouvelles sessions.", "integrations.semantics.aside": "Gère uniquement providers.opencodex dans le fichier ~/.aside/u//models.json de ce profil. Vos autres fournisseurs restent inchangés. Quittez complètement Aside et relancez-le après application.", + "integrations.semantics.raycast": "Ajoute une entrée de fournisseur OpenCodex dans le providers.yaml de Raycast afin que chaque modèle routé apparaisse dans le sélecteur de modèles de Raycast AI. Raycast Pro requis.", + "integrations.raycast.proRequired": "Custom Providers est une fonctionnalité Raycast Pro. Le fichier sera écrit, mais Raycast l'ignore tant qu'un abonnement Pro n'est pas actif.", + "integrations.raycast.planUnknown": "Impossible de lire l'état de l'abonnement Raycast ; Custom Providers nécessite Raycast Pro.", + "integrations.raycast.revealConfig": "Ouvrez Raycast → Réglages → AI et cliquez une fois sur « Reveal Providers Config » pour que le dossier des fournisseurs existe.", "codexAuth.mainAccount": "Compte principal", "codexAuth.logLabel": "Libellé du journal", "codexAuth.codexApp": "Application Codex", @@ -2057,6 +2062,7 @@ export const fr: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Copier la configuration", "api.clientConfig.download": "Télécharger", "api.clientConfig.loading": "Génération de la configuration du client…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 4b16912324..864b498483 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1513,6 +1513,7 @@ export const ja: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Asideのプロファイル", "integrations.aside.profilesHint": "選択したモデルを同期するプロファイルを選んでください。Asideで使用中のプロファイルは変わりません。", "integrations.aside.all": "すべてのプロファイルを同期", @@ -1672,6 +1673,10 @@ export const ja: Record = { "integrations.semantics.zcode": "~/.zcode/v2/config.json の provider.opencodex のみを管理します。Z.ai ログインと他のプロバイダーは変更しません。変更後は ZCode を再起動してください。", "integrations.semantics.prime": "Prime Agent の models.json 内の providers.opencodex のみを管理します。場所は ~/.prime/agent ですが、PRIME_AGENT_CODING_AGENT_DIR が設定されている場合はそちらが優先されます。他のプロバイダーとモデルオーバーライドは変更しません。新しいセッションから適用されます。", "integrations.semantics.aside": "このプロファイルの ~/.aside/u//models.json 内の providers.opencodex のみを管理します。他のプロバイダーは変更しません。適用後は Aside を完全に終了してから開き直してください。", + "integrations.semantics.raycast": "Raycast の providers.yaml に OpenCodex のプロバイダーエントリを追加し、ルーティングされたすべてのモデルを Raycast AI のモデル選択に表示します。Raycast Pro が必要です。", + "integrations.raycast.proRequired": "Custom Providers は Raycast Pro の機能です。ファイルは書き込まれますが、Pro サブスクリプションが有効になるまで Raycast はこれを無視します。", + "integrations.raycast.planUnknown": "Raycast のサブスクリプション状態を読み取れませんでした。Custom Providers には Raycast Pro が必要です。", + "integrations.raycast.revealConfig": "Raycast → 設定 → AI を開き、「Reveal Providers Config」を一度クリックして providers フォルダを作成してください。", "codexAuth.mainAccount": "メインアカウント", "codexAuth.logLabel": "ログラベル", "codexAuth.codexApp": "Codex App", @@ -2005,6 +2010,7 @@ export const ja: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "設定をコピー", "api.clientConfig.download": "ダウンロード", "api.clientConfig.loading": "クライアント設定を生成中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a87bf608e5..364805943e 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1117,6 +1117,7 @@ export const ko: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside 프로필", "integrations.aside.profilesHint": "선택한 모델을 동기화할 프로필을 고르세요. Aside에서 사용 중인 프로필은 바뀌지 않습니다.", "integrations.aside.all": "모든 프로필 동기화", @@ -1276,6 +1277,10 @@ export const ko: Record = { "integrations.semantics.zcode": "~/.zcode/v2/config.json의 provider.opencodex만 관리하며 Z.ai 로그인과 다른 프로바이더는 변경하지 않습니다. 변경 후 ZCode를 재시작하세요.", "integrations.semantics.prime": "Prime Agent의 models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.prime/agent이며 PRIME_AGENT_CODING_AGENT_DIR가 설정되면 그쪽이 우선합니다. 다른 프로바이더와 모델 오버라이드는 변경하지 않습니다. 새 세션부터 적용됩니다.", "integrations.semantics.aside": "이 프로필의 ~/.aside/u//models.json에서 providers.opencodex만 관리합니다. 다른 프로바이더는 그대로 유지됩니다. 적용 후 Aside를 완전히 종료하고 다시 여세요.", + "integrations.semantics.raycast": "Raycast의 providers.yaml에 OpenCodex 프로바이더 항목을 추가해 라우팅된 모든 모델이 Raycast AI 모델 선택기에 표시되도록 합니다. Raycast Pro가 필요합니다.", + "integrations.raycast.proRequired": "Custom Providers는 Raycast Pro 기능입니다. 파일은 기록되지만 Pro 구독이 활성화될 때까지 Raycast는 이를 무시합니다.", + "integrations.raycast.planUnknown": "Raycast 구독 상태를 읽을 수 없습니다. Custom Providers에는 Raycast Pro가 필요합니다.", + "integrations.raycast.revealConfig": "Raycast → 설정 → AI를 열고 「Reveal Providers Config」를 한 번 클릭해 providers 폴더를 만드세요.", "codexAuth.mainAccount": "메인 계정", "codexAuth.logLabel": "로그 라벨", "codexAuth.codexApp": "Codex App", @@ -1607,6 +1612,7 @@ export const ko: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "설정 복사", "api.clientConfig.download": "다운로드", "api.clientConfig.loading": "클라이언트 설정 생성 중…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 70eb364002..bfd6e0182e 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1583,6 +1583,7 @@ export const ru: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Профили Aside", "integrations.aside.profilesHint": "Выберите профили, в которые будут добавлены выбранные модели. Активный профиль Aside не изменится.", "integrations.aside.all": "Синхронизировать все профили", @@ -1742,6 +1743,10 @@ export const ru: Record = { "integrations.semantics.zcode": "Управляет только provider.opencodex в ~/.zcode/v2/config.json. Вход Z.ai и другие провайдеры не меняются. Перезапустите ZCode после изменений.", "integrations.semantics.prime": "Управляет только providers.opencodex в models.json Prime Agent — ~/.prime/agent, если PRIME_AGENT_CODING_AGENT_DIR не переопределяет путь. Другие провайдеры и переопределения моделей не меняются. Применяется к новым сессиям.", "integrations.semantics.aside": "Управляет только providers.opencodex в файле ~/.aside/u//models.json этого профиля. Другие провайдеры остаются без изменений. После применения полностью закройте и снова откройте Aside.", + "integrations.semantics.raycast": "Добавляет запись провайдера OpenCodex в providers.yaml Raycast, чтобы каждая маршрутизируемая модель появилась в выборе моделей Raycast AI. Требуется Raycast Pro.", + "integrations.raycast.proRequired": "Custom Providers — функция Raycast Pro. Файл будет записан, но Raycast игнорирует его, пока не активна подписка Pro.", + "integrations.raycast.planUnknown": "Не удалось прочитать состояние подписки Raycast; для Custom Providers требуется Raycast Pro.", + "integrations.raycast.revealConfig": "Откройте Raycast → Настройки → AI и один раз нажмите «Reveal Providers Config», чтобы папка провайдеров появилась.", "codexAuth.mainAccount": "Основной аккаунт", "codexAuth.logLabel": "Метка журнала", "codexAuth.codexApp": "Codex App", @@ -2075,6 +2080,7 @@ export const ru: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Копировать конфигурацию", "api.clientConfig.download": "Скачать", "api.clientConfig.loading": "Формируется конфигурация клиента…", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ca233f452e..83ad09002b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1590,6 +1590,7 @@ export const tr: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside profilleri", "integrations.aside.profilesHint": "Seçili modellerin hangi profillere aktarılacağını seçin. Aside’ın etkin profili değişmez.", "integrations.aside.all": "Tüm profilleri eşitle", @@ -1748,6 +1749,10 @@ export const tr: Record = { "integrations.semantics.zcode": "Yalnızca ~/.zcode/v2/config.json içindeki provider.opencodex bölümünü yönetir. Z.ai oturumu ve diğer sağlayıcılar değişmez. Değişikliklerden sonra ZCode'u yeniden başlatın.", "integrations.semantics.prime": "Yalnızca Prime Agent'ın models.json dosyasındaki providers.opencodex bölümünü yönetir — PRIME_AGENT_CODING_AGENT_DIR ayarlı değilse ~/.prime/agent. Diğer sağlayıcılar ve model geçersiz kılmaları değişmez. Yeni oturumlarda geçerli olur.", "integrations.semantics.aside": "Yalnızca bu profilin ~/.aside/u//models.json dosyasındaki providers.opencodex bölümünü yönetir. Diğer sağlayıcılarınız değişmez. Uyguladıktan sonra Aside’ı tamamen kapatıp yeniden açın.", + "integrations.semantics.raycast": "Raycast'in providers.yaml dosyasına bir OpenCodex sağlayıcı girdisi ekler; böylece yönlendirilen her model Raycast AI model seçicisinde görünür. Raycast Pro gerekir.", + "integrations.raycast.proRequired": "Custom Providers bir Raycast Pro özelliğidir. Dosya yazılır, ancak bir Pro aboneliği etkin olana kadar Raycast bunu yok sayar.", + "integrations.raycast.planUnknown": "Raycast abonelik durumu okunamadı; Custom Providers için Raycast Pro gerekir.", + "integrations.raycast.revealConfig": "Raycast → Ayarlar → AI bölümünü açıp sağlayıcı klasörünün oluşması için „Reveal Providers Config“ seçeneğine bir kez tıklayın.", "integrations.semantics.omp": "Kataloğu yüklemek için OMP'yi yeniden başlatın.", "codexAuth.mainAccount": "Ana Hesap", "codexAuth.logLabel": "Günlük etiketi", @@ -2082,6 +2087,7 @@ export const tr: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "JSON Kopyala", "api.clientConfig.download": "İndir", "api.clientConfig.loading": "İstemci konfigürasyonu oluşturuluyor…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 2b7e6ac6ba..4e137ebdd6 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2178,6 +2178,7 @@ export const zhTW: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside 設定檔", "integrations.aside.profilesHint": "選擇要接收所選模型的設定檔。Aside 目前使用的設定檔不會改變。", "integrations.aside.all": "同步所有設定檔", @@ -2337,6 +2338,10 @@ export const zhTW: Record = { "integrations.semantics.zcode": "僅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不會變更 Z.ai 登入狀態或其他供應商。變更後請重新啟動 ZCode。", "integrations.semantics.prime": "僅管理 Prime Agent 的 models.json 中的 providers.opencodex;預設位於 ~/.prime/agent,若設定 PRIME_AGENT_CODING_AGENT_DIR 則以其為準。不會變更其他供應商或模型覆寫設定。對新工作階段生效。", "integrations.semantics.aside": "僅管理此設定檔的 ~/.aside/u//models.json 中的 providers.opencodex。其他供應商維持不變。套用後請完全結束並重新開啟 Aside。", + "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中新增一個 OpenCodex 供應商項目,讓所有已路由的模型出現在 Raycast AI 模型選擇器中。需要 Raycast Pro。", + "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。檔案會被寫入,但在 Pro 訂閱生效之前 Raycast 會忽略它。", + "integrations.raycast.planUnknown": "無法讀取 Raycast 訂閱狀態;Custom Providers 需要 Raycast Pro。", + "integrations.raycast.revealConfig": "開啟 Raycast → 設定 → AI,點一次「Reveal Providers Config」,以便建立 providers 資料夾。", "codexAuth.pinned": "已固定", "codexAuth.pinnedHint": "你手動選取了此帳號,因此較高的選擇順序不會越過它。此固定會持續到該帳號用盡、你改選其他帳號,或你變更任一選擇順序為止。", "codexAuth.requestUserInput": "在 Default 模式中要求輸入", @@ -2378,6 +2383,7 @@ export const zhTW: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "cws.tabsLabel": "Combo 詳細區段", "cws.field.nativeAlias": "原生 OpenAI 別名", "cws.field.nativeAliasHint": "讓此 combo 擁有受支援的未限定原生 OpenAI 模型 ID。帶有帳號或供應商限定的 OpenAI 路由仍保持獨立。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 42ac3941d4..67400eaaea 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1110,6 +1110,7 @@ export const zh: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside 配置文件", "integrations.aside.profilesHint": "选择要接收所选模型的配置文件。Aside 当前使用的配置文件不会改变。", "integrations.aside.all": "同步所有配置文件", @@ -1269,6 +1270,10 @@ export const zh: Record = { "integrations.semantics.zcode": "仅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不会更改 Z.ai 登录状态或其他提供商。更改后请重启 ZCode。", "integrations.semantics.prime": "仅管理 Prime Agent 的 models.json 中的 providers.opencodex;默认位于 ~/.prime/agent,若设置 PRIME_AGENT_CODING_AGENT_DIR 则以其为准。不会更改其他提供商或模型覆盖设置。对新会话生效。", "integrations.semantics.aside": "仅管理此配置文件的 ~/.aside/u//models.json 中的 providers.opencodex。其他提供商保持不变。应用后请完全退出并重新打开 Aside。", + "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中添加一个 OpenCodex 提供商条目,让所有已路由的模型出现在 Raycast AI 模型选择器中。需要 Raycast Pro。", + "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。文件会被写入,但在 Pro 订阅生效之前 Raycast 会忽略它。", + "integrations.raycast.planUnknown": "无法读取 Raycast 订阅状态;Custom Providers 需要 Raycast Pro。", + "integrations.raycast.revealConfig": "打开 Raycast → 设置 → AI,点击一次“Reveal Providers Config”,以便创建 providers 文件夹。", "codexAuth.mainAccount": "主账号", "codexAuth.logLabel": "日志标签", "codexAuth.codexApp": "Codex App", @@ -1600,6 +1605,7 @@ export const zh: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "复制配置", "api.clientConfig.download": "下载", "api.clientConfig.loading": "正在生成客户端配置…", diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 51db75bd9f..2eef2c5cf0 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -8,6 +8,7 @@ import { markFor } from "../../components/integration-marks"; import IntegrationStateBadge from "./IntegrationStateBadge"; import ConsequenceDialog, { type ConsequenceCopy } from "./ConsequenceDialog"; import RestoreDialog from "./RestoreDialog"; +import RaycastPlanNotice from "./RaycastPlanNotice"; import { RollbackHistory } from "./RollbackHistory"; import { describeRefusal } from "./refusal-copy"; import { @@ -57,6 +58,7 @@ const SEMANTICS_KEY: Record = { zcode: "integrations.semantics.zcode", prime: "integrations.semantics.prime", aside: "integrations.semantics.aside", + raycast: "integrations.semantics.raycast", }; const TAB_LABEL_KEY: Record = { @@ -72,6 +74,7 @@ const TAB_LABEL_KEY: Record = { zcode: "integrations.tab.zcode", prime: "integrations.tab.prime", aside: "integrations.tab.aside", + raycast: "integrations.tab.raycast", }; export default function FileIntegrationPage({ @@ -261,6 +264,8 @@ export default function FileIntegrationPage({

{t(SEMANTICS_KEY[client])}

{status.configPath}

+ {/* Only the raycast envelope carries this; the guard is the field, not the id. */} + {status.raycast && } {status.appliedAt && (

diff --git a/gui/src/pages/integrations/RaycastPlanNotice.tsx b/gui/src/pages/integrations/RaycastPlanNotice.tsx new file mode 100644 index 0000000000..9f08446751 --- /dev/null +++ b/gui/src/pages/integrations/RaycastPlanNotice.tsx @@ -0,0 +1,32 @@ +import { useT } from "../../i18n/shared"; +import { Notice } from "../../ui"; +import type { RaycastInstall } from "./integration-api"; + +/* + * Raycast is the one file client whose `current` state can still mean + * "ignored": Custom Providers is a Pro feature, and the file is read from a + * folder Raycast only creates after a click in its own settings. Neither fact + * is a reason to refuse the write -- the user may be about to subscribe, or + * has already clicked and the folder is seconds old -- so the page writes and + * says so here instead of showing a green badge that overstates the result. + * + * `free` is a warning because it is a known blocker; `unknown` stays muted + * because on Linux and Windows there is no subscription signal to read, and a + * Pro user there must not be told they are not one. + */ +export default function RaycastPlanNotice({ install }: { install: RaycastInstall }) { + const t = useT(); + return ( + <> + {install.plan === "free" && ( + {t("integrations.raycast.proRequired")} + )} + {install.plan === "unknown" && ( +

{t("integrations.raycast.planUnknown")}

+ )} + {!install.aiDirPresent && ( +

{t("integrations.raycast.revealConfig")}

+ )} + + ); +} diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 7a9139f436..38b0e0fe89 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -14,6 +14,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "zcode", "prime", "aside", + "raycast", ] as const; export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number]; @@ -36,6 +37,19 @@ export type IntegrationRefusalReason = | "snapshot_expired" | "write_failed"; +export type RaycastPlan = "pro" | "free" | "unknown"; + +/** + * Raycast's app-side facts, sent only on `/api/client-integrations/raycast`. + * Custom Providers is a Pro feature, so a `current` file can still be one + * Raycast ignores — this is what lets the page say so instead of showing green. + */ +export interface RaycastInstall { + plan: RaycastPlan; + appPath: string | null; + aiDirPresent: boolean; +} + export interface IntegrationStatus { clientId: FileIntegrationClientId; state: IntegrationState; @@ -49,6 +63,7 @@ export interface IntegrationStatus { /** Aside's explicit account-backed profile scope and desired sync state. */ profileId?: number; enabled?: boolean; + raycast?: RaycastInstall; } export interface IntegrationStateListEnvelope { diff --git a/gui/src/pages/integrations/integration-tabs.ts b/gui/src/pages/integrations/integration-tabs.ts index 33c4f04358..99502bde87 100644 --- a/gui/src/pages/integrations/integration-tabs.ts +++ b/gui/src/pages/integrations/integration-tabs.ts @@ -46,6 +46,7 @@ export const TABS: readonly TabDefinition[] = [ { id: "zcode", hash: "integrations/zcode", labelKey: "integrations.tab.zcode" }, { id: "prime", hash: "integrations/prime", labelKey: "integrations.tab.prime" }, { id: "aside", hash: "integrations/aside", labelKey: "integrations.tab.aside" }, + { id: "raycast", hash: "integrations/raycast", labelKey: "integrations.tab.raycast" }, ] as const; export const FILE_CLIENTS = new Set([ @@ -61,4 +62,5 @@ export const FILE_CLIENTS = new Set([ "zcode", "prime", "aside", + "raycast", ]); diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index 4dd347b90c..7932cf5648 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -152,6 +152,7 @@ const FILE_LABEL_KEY: Record = { zcode: "integrations.tab.zcode", prime: "integrations.tab.prime", aside: "integrations.tab.aside", + raycast: "integrations.tab.raycast", }; /** A file client's block is in the file for both `current` and `stale`. */ diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index 8acc44e9ee..ea8210e7e4 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -170,8 +170,8 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut .find(el => el.textContent?.trim() === label)!; } -test("the API download surface includes DSH, MiniMax Code and Aside as clients", () => { - expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); +test("the API download surface includes DSH, MiniMax Code, Aside and Raycast as clients", () => { + expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh"); expect(CLIENT_LABEL_KEYS.mcode).toBe("api.clientConfig.clientMcode"); expect(CLIENT_LABEL_KEYS.zcode).toBe("api.clientConfig.clientZcode"); diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 221de55d00..8c1ca29ada 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -118,6 +118,8 @@ const INTENTIONAL_ENGLISH = new Set([ "api.clientConfig.clientPrime", "integrations.tab.aside", "api.clientConfig.clientAside", + "integrations.tab.raycast", + "api.clientConfig.clientRaycast", "models.reasoningEffort.minimal", "models.reasoningEffort.max", "pws.pacingRpmUnit", diff --git a/gui/tests/integration-marks.test.ts b/gui/tests/integration-marks.test.ts index b964bc4ce1..b13387d1b6 100644 --- a/gui/tests/integration-marks.test.ts +++ b/gui/tests/integration-marks.test.ts @@ -61,15 +61,16 @@ test("no multi-color asset is masked", () => { /* * The inverse rule, and the one that cannot be derived from the file: a mark may * be a single ink and still not be a masking candidate, because that ink is the - * brand. openai.svg is #10A37F and deepseek-harness.svg is #4d6bfe; masking - * either repaints a trademark in the theme's text color. Pinned with their inks - * so a vendor changing its asset shows up here rather than silently satisfying - * the assertion. + * brand. openai.svg is #10A37F, deepseek-harness.svg is #4d6bfe and raycast.svg + * is #FF6363; masking any of them repaints a trademark in the theme's text + * color. Pinned with their inks so a vendor changing its asset shows up here + * rather than silently satisfying the assertion. */ test("a single-ink asset whose ink is a brand color is not masked", () => { for (const [src, ink] of [ ["/provider-icons/openai.svg", "#10a37f"], ["/provider-icons/deepseek-harness.svg", "#4d6bfe"], + ["/provider-icons/raycast.svg", "#ff6363"], ] as const) { expect(MASKED_MARKS.has(src), `${src} must not be masked`).toBe(false); expect([...inksOf(bodyOf(src))], `${src} ink changed upstream`).toEqual([ink]); diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 4338d9ead8..eea7dcfa0c 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -16,9 +16,9 @@ import { const originalFetch = globalThis.fetch; -test("DSH and Aside are file integration clients", () => { +test("DSH, Aside and Raycast are file integration clients", () => { expect(FILE_INTEGRATION_CLIENTS).toEqual([ - "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", + "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", ]); }); diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index 5bd673f849..54809a4422 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -290,12 +290,17 @@ test("every client counts toward the summary, not just the file clients", () => test("an unsettled file list renders unknown rows instead of dropping them", () => { const built = buildOverviewRows(sources({ clients: [], clientsSettled: false })); - expect(built.rows).toHaveLength(17); + expect(built.rows).toHaveLength(18); expect(rowById(built, "omp").state).toBe("unknown"); expect(rowById(built, "mcode").state).toBe("unknown"); expect(rowById(built, "zcode").state).toBe("unknown"); expect(rowById(built, "prime").state).toBe("unknown"); expect(rowById(built, "aside").state).toBe("unknown"); + expect(rowById(built, "raycast")).toMatchObject({ + hash: "integrations/raycast", + labelKey: "integrations.tab.raycast", + state: "unknown", + }); expect(rowById(built, "kimi").state).toBe("unknown"); expect(rowById(built, "dsh")).toMatchObject({ hash: "integrations/dsh", diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 11976154c9..5ea6785493 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -130,6 +130,8 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "api.clientConfig.clientPrime", "integrations.tab.aside", "api.clientConfig.clientAside", + "integrations.tab.raycast", + "api.clientConfig.clientRaycast", "integrations.codex.title", // Provider proper nouns kept in English "provider.name.commandCodeAuth", diff --git a/gui/tests/raycast-plan-notice.test.tsx b/gui/tests/raycast-plan-notice.test.tsx new file mode 100644 index 0000000000..4a8d44a77d --- /dev/null +++ b/gui/tests/raycast-plan-notice.test.tsx @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { I18nContext, type TFn } from "../src/i18n/shared"; +import RaycastPlanNotice from "../src/pages/integrations/RaycastPlanNotice"; +import type { RaycastInstall } from "../src/pages/integrations/integration-api"; + +/* + * Raycast reads providers.yaml only on a Pro plan and only from a folder it + * creates itself, so a `current` badge can be a lie. The notice is the one place + * that lie is corrected, and each of its three lines answers a different + * question; a regression that drops one leaves the page green and silent. + */ + +const echoT: TFn = key => key; + +function render(install: RaycastInstall): string { + return renderToStaticMarkup( + createElement( + I18nContext.Provider, + { value: { locale: "en", setLocale: () => {}, t: echoT } }, + createElement(RaycastPlanNotice, { install }), + ), + ); +} + +test("a Pro install with the ai folder renders nothing", () => { + expect(render({ plan: "pro", appPath: "/Applications/Raycast.app", aiDirPresent: true })).toBe(""); +}); + +test("a free plan is a warning notice, never a refusal", () => { + const markup = render({ plan: "free", appPath: "/Applications/Raycast.app", aiDirPresent: true }); + expect(markup).toContain("notice-warn"); + expect(markup).toContain("integrations.raycast.proRequired"); + expect(markup).not.toContain("notice-err"); + expect(markup).not.toContain("integrations.raycast.planUnknown"); +}); + +test("an unreadable plan stays muted, because non-macOS hosts have no signal", () => { + const markup = render({ plan: "unknown", appPath: null, aiDirPresent: true }); + expect(markup).toContain('data-raycast-plan="unknown"'); + expect(markup).toContain("integrations.raycast.planUnknown"); + expect(markup).not.toContain("notice-warn"); +}); + +test("a missing ai folder adds the reveal hint independently of the plan", () => { + const markup = render({ plan: "free", appPath: "/Applications/Raycast.app", aiDirPresent: false }); + expect(markup).toContain("integrations.raycast.proRequired"); + expect(markup).toContain('data-raycast-ai-dir="absent"'); + expect(markup).toContain("integrations.raycast.revealConfig"); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d309073a3f..753b896416 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -690,6 +690,7 @@ "install-scripts.test.ts": "ci-workflows", "integrations-invariants.test.ts": "gui", "integrations-journal.test.ts": "clients", + "integrations-merge.test.ts": "clients", "integrations-serialize.test.ts": "clients", "integrations-state.test.ts": "clients", "integrations-writer.test.ts": "clients", @@ -998,6 +999,8 @@ "reserve-quota-scope.test.ts": "codex-integration", "rate-limit-reset-credits.test.ts": "gui", "rate-limit-retry.test.ts": "providers", + "raycast-client.test.ts": "clients", + "raycast-detect.test.ts": "clients", "reasoning-effort.test.ts": "codex-integration", "reasoning-replay-identity.test.ts": "adapters", "reasoning-replay-robustness.test.ts": "adapters", diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 6d018536c7..c884bb2e5d 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -403,7 +403,7 @@ const commandRunners: Record = { }, config, port: live.port, - }, ["mcode", "pi"])); + }, ["mcode", "pi", "raycast"])); } catch (error) { console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/cli/help.ts b/src/cli/help.ts index 0b3652ab59..0cd6bec4dc 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -77,7 +77,7 @@ Usage: ocx memory [--json] Alias of ocx observe memory ocx api-key Alias of ocx access key ocx access External API keys and endpoint information - ocx export --client Print a client config wired to the running proxy (12 clients) + ocx export --client Print a client config wired to the running proxy (13 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection diff --git a/src/cli/index.ts b/src/cli/index.ts index 7b863630b9..9d9f08951c 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -92,6 +92,15 @@ import { grokSyncFailureMessage, reconcileEnsureDesiredIntegrations, } from "./ensure-desired-integrations"; +import { refreshOwnedCatalogIntegrations } from "../integrations/catalog-refresh"; +import { loadExportModels } from "../server/management/model-rows"; + +import { removeOwnedConfigAfterDesktopCleanup } from "./uninstall-client-state"; +import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; +import { initializeNodeLauncherContext } from "./launcher-context"; +import { createLocalAttestationSecret } from "../lib/local-management-attestation"; +import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../lib/system-restart-contract"; /** * A failed shell-hook reconcile is not cosmetic: a stale hook keeps sourcing @@ -105,13 +114,25 @@ function reportShellHookFailure(result: { state: "installed" | "absent" | "faile console.warn(" Check ~/.zshrc for the '# opencodex claude-env hook' block."); } - -import { removeOwnedConfigAfterDesktopCleanup } from "./uninstall-client-state"; -import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; -import { selfLaunchArgv } from "../lib/self-launch-argv"; -import { initializeNodeLauncherContext } from "./launcher-context"; -import { createLocalAttestationSecret } from "../lib/local-management-attestation"; -import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../lib/system-restart-contract"; +async function refreshOwnedRaycastCatalog( + config: ReturnType, + port: number, +): Promise { + try { + const outcomes = await refreshOwnedCatalogIntegrations({ + models: () => loadExportModels(config), + config, + port, + }, ["raycast"]); + for (const outcome of outcomes) { + if (!outcome.ok) { + console.error(`⚠️ Raycast integration was not refreshed: ${outcome.reason}`); + } + } + } catch (error) { + console.error(`⚠️ Raycast integration was not refreshed: ${error instanceof Error ? error.message : String(error)}`); + } +} initializeNodeLauncherContext(); @@ -493,6 +514,7 @@ async function handleStart(options: { block?: boolean } = {}) { }, ); if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native."); + await refreshOwnedRaycastCatalog(config, port); // #1046: one warning per startup, after BOTH writes. The server's cache // invalidation happens first and the catalog sync second, so the mtime is only // final here — and neither write site warns on its own, or a boot that hits @@ -558,6 +580,7 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom return null; }); if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); + await refreshOwnedRaycastCatalog(config, live.port); // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature). const systemEnv = await injectSystemEnv(live.port, config).catch(() => ({ injected: false })); reportShellHookFailure(reconcileShellHook(systemEnv.injected)); @@ -602,6 +625,7 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom return null; }); if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); + await refreshOwnedRaycastCatalog(config, port); // The child opens /healthz before its best-effort roster reconcile. Await the same idempotent // operation in the parent so `ocx ensure` cannot report success while stale ocx-*.md files are // still observable. Always use the live port, including fallback-port starts. diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index bcb87d5d18..89ab3ee046 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -161,6 +161,39 @@ export async function handleGrokCommand(argv: string[], deps: RuntimeApiDeps = { }); } +/** The Raycast-only block the single-client route adds; see IntegrationStateEnvelope. */ +interface RaycastStatusBlock { + plan: string; + aiDirPresent: boolean; +} + +function raycastBlock(result: unknown): RaycastStatusBlock | null { + if (!result || typeof result !== "object") return null; + const block = (result as { raycast?: unknown }).raycast; + if (!block || typeof block !== "object") return null; + const { plan, aiDirPresent } = block as Partial; + return typeof plan === "string" && typeof aiDirPresent === "boolean" ? { plan, aiDirPresent } : null; +} + +/** + * Text view of one client's status. + * + * Raycast carries an extra block, and the generic summary would print it as + * three dotted keys. A `current` file that Raycast ignores for want of a Pro + * subscription is the one fact this view must not bury, so `plan` gets its own + * line and a missing `ai` folder gets the instruction that creates it. + */ +function singleClientStatusLines(result: unknown): string[] { + const raycast = raycastBlock(result); + if (!raycast) return summaryLines(result); + const rest = Object.fromEntries(Object.entries(result as Record).filter(([key]) => key !== "raycast")); + const lines = [...summaryLines(rest), `plan: ${raycast.plan}`]; + if (!raycast.aiDirPresent) { + lines.push('Open Raycast → Settings → AI → "Reveal Providers Config" once so the ai folder exists.'); + } + return lines; +} + /** * The headless half of the client-integration toggle. * @@ -197,7 +230,7 @@ export async function handleClientIntegrationCommand( : [String((result as { error?: string }).error ?? "No Aside profiles found.")] : rows ? rows.map(row => `${String(row.clientId)}: ${String(row.state)}${row.installed ? "" : " (not installed)"}`) - : summaryLines(result)); + : singleClientStatusLines(result)); return; } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 00ff25ed52..467a0f7971 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -287,8 +287,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "api-key", usage: "ocx api-key ...", summary: "Alias of ocx access key." }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside) wired to the running proxy.", + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", "--out writes the native config there and refuses to replace an existing file without --force.", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 372abcc00e..8fb42f311d 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -37,6 +37,8 @@ export type { OmpModelEntry, OmpProviderBlock, OmpGeneratedConfig } from "./conf export type { ZcodeModelEntry, ZcodeProviderBlock, ZcodeGeneratedConfig } from "./config-export/zcode"; export type { DshReasoningEffort, DshWireReasoningEffort, DshModelEntry, DshProviderBlock, DshGeneratedConfig } from "./config-export/dsh"; export type { McodeProviderBlock, McodeModelEntry, McodeGeneratedConfig } from "./config-export/mcode"; +export type { RaycastAbility, RaycastAbilityName, RaycastModelEntry, RaycastProviderEntry, RaycastGeneratedConfig } from "./config-export/raycast"; +export { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast"; import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts"; import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, GAJAE_API_KEY_ENV, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants"; @@ -45,6 +47,7 @@ import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./conf import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh"; import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode"; import { buildZcodeClientConfig, summarizeZcode, buildZcodeContribution } from "./config-export/zcode"; +import { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast"; @@ -533,6 +536,22 @@ export function asideConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(asideAccountDir(env, home), "models.json"); } +/** + * Raycast's Custom Providers directory. Raycast hard-codes + * `~/.config/raycast/ai` on macOS AND Windows: it neither honors + * `XDG_CONFIG_HOME` nor ships a variable of its own that relocates the file, so + * unlike `opencodeGlobalConfigPath` there is no override to mirror and the env + * parameter exists only to keep the resolver signature uniform with the rest. + */ +export function raycastAiDir(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(home, ".config", "raycast", "ai"); +} + +/** The providers file Raycast watches (manual.raycast.com/ai/custom-providers). */ +export function raycastConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(raycastAiDir(env, home), "providers.yaml"); +} + /** Endpoint plus admission, identical for the V1 `options` and V2 `settings` field. */ function opencodeProviderConnection(baseURL: string, config: OcxConfig): OpencodeProviderConnection { const options: OpencodeProviderConnection = { baseURL }; @@ -1259,6 +1278,23 @@ export const EXPORT_CLIENTS: Record = { // bind would generate a config that 401s. loopbackOnly: true, }, + raycast: { + id: "raycast", + // Not a bare `providers.yaml`: same Downloads-folder collision argument as + // `aside-models.json`. + filename: "raycast-providers.yaml", + destination: env => raycastConfigPath(env), + apiKeyEnv: "", + exportHint: "Raycast reads providers.yaml with no api_keys entry; loopback needs no key.", + build: buildRaycastClientConfig, + format: "yaml", + summarize: summarizeRaycast, + buildContribution: buildRaycastContribution, + // Raycast's provider entry has no header field, and its `api_keys` value + // is read literally (no env interpolation), so the only way to admit a + // remote bind would be a plaintext secret on disk. Refuse instead. + loopbackOnly: true, + }, }; export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[]; diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index 039d7eaaf0..c888a4c257 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -93,7 +93,8 @@ export type ExportClientId = | "mcode" | "zcode" | "prime" - | "aside"; + | "aside" + | "raycast"; export interface ExportClientSpec { id: ExportClientId; diff --git a/src/clients/config-export/raycast.ts b/src/clients/config-export/raycast.ts new file mode 100644 index 0000000000..2cf9396ed5 --- /dev/null +++ b/src/clients/config-export/raycast.ts @@ -0,0 +1,86 @@ +import { exportPresentationLabel } from "../model-presentation"; +import { OPENCODE_PROVIDER_ID } from "./constants"; +import type { ExportContext, ManagedContribution } from "./contracts"; +import { authoritativeContextWindow, normalizeExportModels, singleFragment } from "./model-metadata"; + +export interface RaycastAbility { + supported: boolean; +} + +export type RaycastAbilityName = + | "temperature" + | "vision" + | "system_message" + | "tools" + | "reasoning_effort"; + +export interface RaycastModelEntry { + id: string; + name: string; + context?: number; + abilities: Record; +} + +export interface RaycastProviderEntry { + id: string; + name: string; + base_url: string; + models: RaycastModelEntry[]; +} + +export interface RaycastGeneratedConfig { + providers: RaycastProviderEntry[]; +} + +/** + * Raycast appends `/chat/completions` to `base_url`, so the proxy's `/v1` + * root is passed through unchanged. The format has no safe credential + * interpolation, which is why the registry exposes it only on loopback. + */ +export function buildRaycastClientConfig(ctx: ExportContext): RaycastGeneratedConfig { + const models: RaycastModelEntry[] = normalizeExportModels(ctx.models).map(model => { + const hasLadder = (model.reasoningEfforts?.length ?? 0) > 0; + const context = authoritativeContextWindow(model.contextWindow); + return { + id: model.namespaced, + name: exportPresentationLabel(model), + ...(context !== undefined ? { context } : {}), + abilities: { + temperature: { supported: !hasLadder }, + vision: { supported: model.inputModalities?.includes("image") ?? false }, + system_message: { supported: true }, + tools: { supported: true }, + reasoning_effort: { supported: hasLadder }, + }, + }; + }); + return { + providers: [ + { id: OPENCODE_PROVIDER_ID, name: "OpenCodex", base_url: ctx.baseUrl, models }, + ], + }; +} + +export function summarizeRaycast( + document: unknown, +): { modelCount: number; modelsWithoutLimits: number } { + const providers = (document as RaycastGeneratedConfig | undefined)?.providers ?? []; + const models = providers.find(provider => provider.id === OPENCODE_PROVIDER_ID)?.models ?? []; + return { + modelCount: models.length, + modelsWithoutLimits: models.filter(model => model.context === undefined).length, + }; +} + +/** + * Raycast stores providers in a sequence. The stable id selector owns only + * OpenCodex's element, preserving user-defined providers around it. + */ +export function buildRaycastContribution(ctx: ExportContext): ManagedContribution { + const doc = buildRaycastClientConfig(ctx); + return singleFragment( + "raycast", + ["providers", `[id=${OPENCODE_PROVIDER_ID}]`], + doc.providers[0]!, + ); +} diff --git a/src/clients/model-presentation.ts b/src/clients/model-presentation.ts new file mode 100644 index 0000000000..9a5f9ae3c3 --- /dev/null +++ b/src/clients/model-presentation.ts @@ -0,0 +1,61 @@ +import { CURSOR_CAPABILITIES } from "../adapters/cursor/catalog"; +import { nativeOpenAiCapabilityDisplayName } from "../codex/catalog/metadata"; +import type { ExportModel } from "./config-export/contracts"; + +const KNOWN_ACRONYMS = new Set(["gpt", "glm", "grok"]); + +function titleWord(word: string): string { + const lower = word.toLowerCase(); + if (KNOWN_ACRONYMS.has(lower)) return lower.toUpperCase(); + if (/^\d+\.\d+$/.test(word)) return word; + return lower.charAt(0).toUpperCase() + lower.slice(1); +} + +/** + * Last-resort label when no catalog or operator name exists. Joins dotted version + * tails (`5-1` → `5.1`, `2-5` → `2.5`) so Raycast reads like a product name + * instead of a slug. + */ +function humanizeModelSlug(modelId: string): string { + const parts = modelId.split("-"); + const words: string[] = []; + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]!; + const next = parts[index + 1]; + if (/^\d+$/.test(part) && next !== undefined && /^\d+$/.test(next)) { + words.push(`${part}.${next}`); + index += 1; + continue; + } + words.push(part); + } + return words.map(titleWord).join(" "); +} + +function wireModelId(model: ExportModel): string { + if (model.id?.trim()) return model.id.trim(); + const slash = model.namespaced.lastIndexOf("/"); + return slash >= 0 ? model.namespaced.slice(slash + 1) : model.namespaced; +} + +/** + * Human-facing model label for clients whose picker shows `name` verbatim. + * + * Raycast has no second column for provider, so the shared `exportModelLabel` + * suffix `(anthropic)` would be noise — and its fallback is the raw wire id + * because management slugs are deliberately withheld from ExportModel. Resolve + * operator labels first, then the canonical capability tables, then a slug + * humanizer. + */ +export function exportPresentationLabel(model: ExportModel): string { + const configured = model.displayName?.trim(); + if (configured) return configured; + const wireId = wireModelId(model); + const fromCursor = CURSOR_CAPABILITIES[wireId]?.displayName; + if (fromCursor) return fromCursor; + if (model.native) { + const native = nativeOpenAiCapabilityDisplayName(wireId); + if (native) return native; + } + return humanizeModelSlug(wireId); +} diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts index 8efb990002..8b89762f30 100644 --- a/src/integrations/catalog-refresh.ts +++ b/src/integrations/catalog-refresh.ts @@ -10,7 +10,7 @@ import { /** Refresh only previously connected clients; a refused file never blocks its peers. */ export async function refreshOwnedCatalogIntegrations( input: Omit, - clientIds: readonly IntegrationClientId[] = ["pi", "aside"], + clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast"], ): Promise { let models: Promise | undefined; const loadModels = () => models ??= Promise.resolve().then(() => diff --git a/src/integrations/merge.ts b/src/integrations/merge.ts index 4dccd48e50..768ddc755f 100644 --- a/src/integrations/merge.ts +++ b/src/integrations/merge.ts @@ -20,18 +20,103 @@ function clone(value: T): T { return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); } -/** Write `value` at `path`, creating intermediate objects. Returns a new document. */ +/** + * `[field=value]` addresses the ONE element of a sequence whose `field` equals + * `value`. Raycast keeps its providers as a YAML list, so the element is the + * smallest thing we can own there; an index would move under us the moment + * the user reordered their own entries. Any other segment is a plain key. + */ +const ARRAY_SELECTOR = /^\[([A-Za-z_][A-Za-z0-9_]*)=([^\]]+)\]$/u; + +export type PathSegment = + | { kind: "key"; key: string } + | { kind: "select"; field: string; value: string }; + +export function parseSegment(raw: string): PathSegment { + const match = ARRAY_SELECTOR.exec(raw); + if (!match) return { kind: "key", key: raw }; + return { kind: "select", field: match[1]!, value: match[2]! }; +} + +/** + * Thrown when a selector matches more than one element. Picking either one + * would silently rewrite an entry the user may have written; the writer maps + * this to an `unsafe` refusal instead. + */ +export class AmbiguousSelectorError extends Error { + constructor(field: string, value: string) { + super(`more than one entry has ${field}=${value}`); + this.name = "AmbiguousSelectorError"; + } +} + +/** The index of the element a selector names, -1 when none matches. */ +function selectIndex(items: readonly unknown[], field: string, value: string): number { + const matches: number[] = []; + items.forEach((item, index) => { + if (isPlainRecord(item) && item[field] === value) matches.push(index); + }); + if (matches.length > 1) throw new AmbiguousSelectorError(field, value); + return matches[0] ?? -1; +} + +function assertNever(segment: never): never { + throw new Error(`unknown path segment ${JSON.stringify(segment)}`); +} + +/** + * Write `value` at `path`, creating intermediate containers. Returns a new document. + * + * A `key` segment descends through a record, creating `{}` where the slot is + * absent or holds something else. A `select` segment descends through an + * array the same way, creating `[]`; a missing element is pushed, a matching + * one is replaced in place so the user's ordering survives. + */ export function setPath(doc: unknown, path: readonly string[], value: unknown): unknown { if (path.length === 0) throw new Error("setPath needs a non-empty path"); - const root: Record = isPlainRecord(doc) ? clone(doc) : {}; - let cursor = root; - for (const key of path.slice(0, -1)) { - const next = cursor[key]; - if (!isPlainRecord(next)) cursor[key] = {}; - cursor = cursor[key] as Record; + /* + * `parent[slot]` is the position the segment just consumed addresses. The + * root sits in a one-key holder so the first segment needs no special case: + * a non-record document is replaced by `{}` exactly as before. + */ + const holder: Record = { root: isPlainRecord(doc) ? clone(doc) : {} }; + let parent: Record | unknown[] = holder; + let slot: string | number = "root"; + const read = (): unknown => (Array.isArray(parent) ? parent[slot as number] : parent[slot as string]); + const write = (next: unknown): void => { + if (Array.isArray(parent)) parent[slot as number] = next; + else parent[slot as string] = next; + }; + for (const raw of path) { + const segment = parseSegment(raw); + switch (segment.kind) { + case "key": { + if (!isPlainRecord(read())) write({}); + parent = read() as Record; + slot = segment.key; + break; + } + case "select": { + if (!Array.isArray(read())) write([]); + const items = read() as unknown[]; + const found = selectIndex(items, segment.field, segment.value); + parent = items; + if (found >= 0) { + slot = found; + } else { + // Seed the element so the selector stays true for whatever a deeper + // segment writes into it; a last-position select replaces it whole. + slot = items.length; + items.push({ [segment.field]: segment.value }); + } + break; + } + default: + return assertNever(segment); + } } - cursor[path[path.length - 1]!] = clone(value); - return root; + write(clone(value)); + return holder.root; } /** @@ -54,27 +139,53 @@ export function deletePath( ): { doc: unknown; removed: boolean } { if (!isPlainRecord(doc) || path.length === 0) return { doc, removed: false }; const root = clone(doc) as Record; - const chain: Record[] = [root]; - let cursor: Record = root; - for (const key of path.slice(0, -1)) { - const next = cursor[key]; - if (!isPlainRecord(next)) return { doc: root, removed: false }; - cursor = next; - chain.push(cursor); + // `chain[i]` is the container segment `i` is resolved against; `slots[i]` is + // the key or index it resolved to, so the prune walk can delete by position. + const chain: (Record | unknown[])[] = [root]; + const slots: (string | number)[] = []; + for (let depth = 0; depth < path.length; depth += 1) { + const container = chain[depth]!; + const segment = parseSegment(path[depth]!); + switch (segment.kind) { + case "key": { + if (Array.isArray(container) || !(segment.key in container)) return { doc: root, removed: false }; + slots.push(segment.key); + chain.push(container[segment.key] as Record | unknown[]); + break; + } + case "select": { + if (!Array.isArray(container)) return { doc: root, removed: false }; + const found = selectIndex(container, segment.field, segment.value); + if (found < 0) return { doc: root, removed: false }; + slots.push(found); + chain.push(container[found] as Record | unknown[]); + break; + } + default: + return assertNever(segment); + } + // Only the leaf may be a scalar; walking into one means the path is absent. + if (depth < path.length - 1) { + const next = chain[depth + 1]; + if (!isPlainRecord(next) && !Array.isArray(next)) return { doc: root, removed: false }; + } } - const leaf = path[path.length - 1]!; - if (!(leaf in cursor)) return { doc: root, removed: false }; - delete cursor[leaf]; + const remove = (container: Record | unknown[], slot: string | number): void => { + if (Array.isArray(container)) container.splice(slot as number, 1); + else delete container[slot as string]; + }; + remove(chain[path.length - 1]!, slots[path.length - 1]!); /* * Walk back up, pruning only containers this deletion emptied AND that we * created. The root is never pruned. */ - for (let index = chain.length - 1; index >= 1; index -= 1) { + for (let index = path.length - 1; index >= 1; index -= 1) { const container = chain[index]!; - if (Object.keys(container).length > 0) break; + const empty = Array.isArray(container) ? container.length === 0 : Object.keys(container).length === 0; + if (!empty) break; const containerPath = path.slice(0, index).join("\u0000"); if (!createdContainers.has(containerPath)) break; - delete chain[index - 1]![path[index - 1]!]; + remove(chain[index - 1]!, slots[index - 1]!); } return { doc: root, removed: true }; } @@ -121,9 +232,31 @@ export function createdContainerPaths( for (const fragment of contribution.fragments) { let cursor: unknown = doc; for (let depth = 0; depth < fragment.path.length - 1; depth += 1) { - const key = fragment.path[depth]!; - const next = isPlainRecord(cursor) ? cursor[key] : undefined; - if (!isPlainRecord(next)) { + const segment = parseSegment(fragment.path[depth]!); + let next: unknown; + switch (segment.kind) { + case "key": { + /* + * The container this key must hold is whatever the NEXT segment + * descends into: an array when that is a selector, a record + * otherwise. Either one is ours to create when absent. + */ + const nextSegment = parseSegment(fragment.path[depth + 1]!); + next = isPlainRecord(cursor) ? cursor[segment.key] : undefined; + if (nextSegment.kind === "select" ? !Array.isArray(next) : !isPlainRecord(next)) next = undefined; + break; + } + case "select": { + // A selector that matches nothing means setPath will push the element. + next = Array.isArray(cursor) + ? cursor.find(item => isPlainRecord(item) && item[segment.field] === segment.value) + : undefined; + break; + } + default: + return assertNever(segment); + } + if (next === undefined) { created.add(fragment.path.slice(0, depth + 1).join("\u0000")); cursor = undefined; continue; diff --git a/src/integrations/raycast-detect.ts b/src/integrations/raycast-detect.ts new file mode 100644 index 0000000000..5e79578f46 --- /dev/null +++ b/src/integrations/raycast-detect.ts @@ -0,0 +1,110 @@ +/** + * Detect a Raycast install and whether Custom Providers can take effect. + * + * Custom Providers is a Raycast Pro feature: Raycast reads + * `~/.config/raycast/ai/providers.yaml` only while a subscription is active, and + * the `ai` directory itself only exists once the user has clicked "Reveal + * Providers Config" in Settings > AI. Neither fact stops the writer — the plan + * (devlog/_plan/260904_raycast_integration/000_plan.md) makes a free plan a + * WARNING, never a refusal — so this module only answers what status and the + * GUI need to explain a file that is written but ignored. + * + * Detection is read-only and injectable, like cursor-detect.ts: nothing here + * touches the Raycast install or its preferences, and the tests run against + * stubbed deps rather than the machine they execute on. + */ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; + +export type RaycastPlan = "pro" | "free" | "unknown"; + +export interface RaycastInstall { + /** The app bundle or install directory, or null when none of the well-known locations exist. */ + appPath: string | null; + /** `~/.config/raycast/ai` exists — the install signal the registry uses. */ + aiDirPresent: boolean; + plan: RaycastPlan; +} + +export interface RaycastDetectDeps { + platform: string; + homedir: string; + env: Record; + exists(path: string): boolean; + /** stdout of `defaults read ` trimmed, or null when the command fails / is unavailable. */ + readDefault(domain: string, key: string): string | null; +} + +/** + * The preference Raycast writes for its subscription state. Read through + * `defaults` rather than by parsing the plist: cfprefsd caches writes, so the + * file on disk can lag what the running app believes. + */ +const RAYCAST_DEFAULTS_DOMAIN = "com.raycast.macos.v1"; +const RAYCAST_SUBSCRIPTION_KEY = "subscriptions_active"; + +export function realRaycastDetectDeps(): RaycastDetectDeps { + return { + platform: process.platform, + homedir: homedir(), + env: process.env, + exists: path => { + try { + return existsSync(path); + } catch { + return false; + } + }, + readDefault: (domain, key) => { + // `defaults` is macOS-only; elsewhere the plan is simply unknown. + if (process.platform !== "darwin") return null; + try { + const result = Bun.spawnSync(["defaults", "read", domain, key], { stdout: "pipe", stderr: "pipe" }); + if (result.exitCode !== 0) return null; + return result.stdout.toString().trim(); + } catch { + return null; + } + }, + }; +} + +function appPathFor(deps: RaycastDetectDeps): string | null { + // Join with the target platform's separator so a test describing another OS + // gets that OS's paths, not the host's. + const { join } = deps.platform === "win32" ? win32 : posix; + if (deps.platform === "darwin") { + for (const candidate of ["/Applications/Raycast.app", join(deps.homedir, "Applications", "Raycast.app")]) { + if (deps.exists(candidate)) return candidate; + } + return null; + } + if (deps.platform === "win32") { + const local = deps.env.LOCALAPPDATA; + if (!local) return null; + const candidate = join(local, "Programs", "Raycast"); + return deps.exists(candidate) ? candidate : null; + } + return null; +} + +function planFor(deps: RaycastDetectDeps): RaycastPlan { + if (deps.platform !== "darwin") return "unknown"; + // Read once: `defaults` spawns a process, and the answer cannot change + // between two reads inside one detection. + const value = deps.readDefault(RAYCAST_DEFAULTS_DOMAIN, RAYCAST_SUBSCRIPTION_KEY); + if (value === "1") return "pro"; + if (value === "0") return "free"; + return "unknown"; +} + +export function detectRaycast(deps: RaycastDetectDeps = realRaycastDetectDeps()): RaycastInstall { + const { join } = deps.platform === "win32" ? win32 : posix; + return { + appPath: appPathFor(deps), + // Raycast ignores XDG and uses this path on every platform it ships on. + aiDirPresent: deps.exists(join(deps.homedir, ".config", "raycast", "ai")), + plan: planFor(deps), + }; +} diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 13662d52d5..f5780f4f98 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -35,6 +35,8 @@ import { piConfigPath, primeAgentDir, primeConfigPath, + raycastAiDir, + raycastConfigPath, zcodeConfigPath, zcodeHomeDir, type ExportClientId, @@ -261,6 +263,22 @@ export const INTEGRATION_CLIENTS: Record join(asideHomeDir(env, home), "u"), }, + raycast: { + id: "raycast", + configPath: (env = process.env, home = homedir()) => raycastConfigPath(env, home), + /* + * The `ai` directory, not `Raycast.app`. Raycast creates it only when the + * user clicks "Reveal Providers Config" in Settings > AI, which is exactly + * the signal that Custom Providers is reachable on this install; an app + * bundle alone says nothing about the plan or the feature. + * + * No `sourcePreservingYaml`: that patcher handles block-map leaves only, + * and our entry is a SEQUENCE item, so the file is re-rendered through + * `renderYaml` (block style). The `[id=opencodex]` selector keeps the user's + * other providers in place across that re-render. + */ + detectDir: (env = process.env, home = homedir()) => raycastAiDir(env, home), + }, }; export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] = diff --git a/src/integrations/state.ts b/src/integrations/state.ts index 008f46fbf1..bb0e5b3567 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -12,6 +12,7 @@ import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel import type { OcxConfig } from "../types"; import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; import { SNAPSHOT_RETENTION } from "./journal"; +import { parseSegment, type PathSegment } from "./merge"; import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership"; import { protectedContributionFingerprint, @@ -52,11 +53,41 @@ export interface IntegrationStatus { retentionDegraded: boolean; } +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertNever(segment: never): never { + throw new Error(`unknown path segment ${JSON.stringify(segment)}`); +} + +/** The element a selector names, or `undefined` when none matches. */ +function selectElement(items: readonly unknown[], segment: PathSegment & { kind: "select" }): unknown { + return items.find(item => isPlainRecord(item) && item[segment.field] === segment.value); +} + +/** + * Same segment grammar as `setPath`: a plain key reads through a record, a + * `[field=value]` selector reads through an array. Because the classifier and + * the writer share this one function, status and mutation cannot disagree + * about which element is ours. + */ export function readPath(doc: unknown, path: readonly string[]): unknown { let cursor: unknown = doc; - for (const key of path) { - if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) return undefined; - cursor = (cursor as Record)[key]; + for (const raw of path) { + const segment = parseSegment(raw); + switch (segment.kind) { + case "key": + if (!isPlainRecord(cursor)) return undefined; + cursor = cursor[segment.key]; + break; + case "select": + if (!Array.isArray(cursor)) return undefined; + cursor = selectElement(cursor, segment); + break; + default: + return assertNever(segment); + } if (cursor === undefined) return undefined; } return cursor; @@ -82,10 +113,35 @@ export function blockedContainerPath( doc: unknown, contribution: ManagedContribution, ): readonly string[] | null { + /* + * What a segment needs the value it walks through to BE: a record for a key, + * an array for a selector. `typeof null === "object"`, so null is excluded + * by both checks rather than walking straight into the dereference below. + */ + const holds = (segment: PathSegment, value: unknown): boolean => { + switch (segment.kind) { + case "key": + return isPlainRecord(value); + case "select": + return Array.isArray(value); + default: + return assertNever(segment); + } + }; + const step = (segment: PathSegment, value: unknown): unknown => { + switch (segment.kind) { + case "key": + return (value as Record)[segment.key]; + case "select": + return selectElement(value as readonly unknown[], segment); + default: + return assertNever(segment); + } + }; for (const fragment of contribution.fragments) { let cursor: unknown = doc; for (let depth = 0; depth < fragment.path.length - 1; depth += 1) { - const key = fragment.path[depth]!; + const segment = parseSegment(fragment.path[depth]!); /* * ONLY `undefined` means absent. A missing file parses as `{}`, so an * absent prefix reads `undefined` — but a parsed `null` is a value the @@ -94,14 +150,10 @@ export function blockedContainerPath( * "successful" apply. */ if (cursor === undefined) break; - // `typeof null === "object"`, so null has to be named explicitly or it - // walks straight into the dereference below. - if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor)) { - return fragment.path.slice(0, depth); - } - const next = (cursor as Record)[key]; + if (!holds(segment, cursor)) return fragment.path.slice(0, depth); + const next = step(segment, cursor); if (next === undefined) break; - if (typeof next !== "object" || next === null || Array.isArray(next)) { + if (!holds(parseSegment(fragment.path[depth + 1]!), next)) { return fragment.path.slice(0, depth + 1); } cursor = next; diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 23b3eaaad4..7ec543715a 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -27,7 +27,7 @@ import { refreshablePathsOf, semanticProtectedContributionFingerprint, } from "./ownership-policy"; -import { createdContainerPaths, mergeContribution, removeFragments } from "./merge"; +import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge"; import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; import { classifyIntegration, exportContextOf } from "./state"; import type { IntegrationState } from "./state"; @@ -352,36 +352,39 @@ function applyOrRefreshIntegration( * concludes the user owns it, and the replacement record forgets we made it * — so a later disable strands it forever. */ - const base = classified.state === "stale" && record - ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc - : classified.state === "conflict" && record - /* - * A forced overwrite of a `foreign-edit` conflict drops what the previous - * record owned for the same reason a stale refresh does: the replacement - * record covers the paths we are about to write, so a path the old record - * owned and the new one does not would be stranded forever, unremovable by - * any later disable. - * - * With NO record -- an `unowned-key` conflict -- there is nothing to drop and - * the merge runs against the user's document directly. That is correct: - * createdContainerPaths then attributes every container they already had to - * them, so a later disable removes our leaves and leaves their structure - * standing. - */ - ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc - : parsed; - // Computed against the document as it stands BEFORE the merge: afterwards - // every container exists and "did we create this?" is unanswerable. - const created = createdContainerPaths(base, contribution); /* * A document can hold a value its own format cannot round-trip through our * renderers. That used to throw straight out of the writer and reach the * user as a 500 with no path and no advice; it is a refusal like any other, - * and the file is untouched because this happens before any write. + * and the file is untouched because this happens before any write. The + * removal and merge sit inside the same guard: a sequence holding two + * entries our selector matches is equally unwritable, and equally untouched. */ - const nextDocument = mergeContribution(base, contribution); + let created: string[]; let text: string; try { + const base = classified.state === "stale" && record + ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc + : classified.state === "conflict" && record + /* + * A forced overwrite of a `foreign-edit` conflict drops what the previous + * record owned for the same reason a stale refresh does: the replacement + * record covers the paths we are about to write, so a path the old record + * owned and the new one does not would be stranded forever, unremovable by + * any later disable. + * + * With NO record -- an `unowned-key` conflict -- there is nothing to drop and + * the merge runs against the user's document directly. That is correct: + * createdContainerPaths then attributes every container they already had to + * them, so a later disable removes our leaves and leaves their structure + * standing. + */ + ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc + : parsed; + // Computed against the document as it stands BEFORE the merge: afterwards + // every container exists and "did we create this?" is unanswerable. + created = createdContainerPaths(base, contribution); + const nextDocument = mergeContribution(base, contribution); if (spec.sourcePreservingYaml && before !== null) { const value = sourcePreservingFragmentValue(contribution, spec.sourcePreservingYaml.path); const patched = value === undefined @@ -401,6 +404,10 @@ function applyOrRefreshIntegration( text = serializeDocument(nextDocument, exportSpec.format); } } catch (error) { + if (error instanceof AmbiguousSelectorError) { + return refuse(clientId, "unsafe", "unsafe", + `${configPath} holds more than one entry matching ours, so it was left alone`); + } if (!(error instanceof UnserializableValueError)) throw error; return refuse(clientId, "unsafe", "unsafe", `${configPath} contains something opencodex cannot rewrite safely (${error.message}), so it was left alone`); @@ -527,11 +534,15 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { return refuse(clientId, "unsafe", "unsafe", `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`); } - const { doc, removed } = removeFragments( - parsed, - record!.fragmentPaths, - new Set(prunableCreated), - ); + let doc: unknown; + let removed: boolean; + try { + ({ doc, removed } = removeFragments(parsed, record!.fragmentPaths, new Set(prunableCreated))); + } catch (error) { + if (!(error instanceof AmbiguousSelectorError)) throw error; + return refuse(clientId, "unsafe", "unsafe", + `${configPath} holds more than one entry matching ours, so nothing was removed`); + } if (!removed) { return { ok: true, changed: false, state: "absent", clientId, message: "nothing to remove" }; } diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index ac6929c968..4d551a886d 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -161,7 +161,7 @@ interface ClientIntegrationSyncOutcome { } /** - * Re-inject native clients that are switched ON and file integrations whose + * Re-inject native clients that are switched ON and every file integration whose * OpenCodex ownership record is the operator's durable opt-in. * * Only Codex used to run here, so a catalog change reached Codex and nothing else: a Grok @@ -169,6 +169,10 @@ interface ClientIntegrationSyncOutcome { * next `ocx start`. The startup path already gates each client on its own toggle * (`src/cli/index.ts`), and this is that same fan-out for the on-demand command. * + * File integrations use the catalog-refresh coordinator so owned blocks are + * updated without claiming unowned files. Aside remains on its multi-profile + * server-owned path inside that coordinator. + * * A client that is OFF or never connected is omitted from the result rather than reported as skipped — the * caller has to be able to tell "not touched" from "tried and failed". A client that fails * does not fail the sync: Codex is the one that matters for routing, and a broken Grok file @@ -233,7 +237,7 @@ export async function syncEnabledClientIntegrations( }, config, port, - }, ["mcode", "pi", "aside"])); + }, ["mcode", "pi", "aside", "raycast"])); return out; } diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index b332718e07..43c0e6a98c 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -22,6 +22,7 @@ import { isIntegrationClientId, type IntegrationClientId, } from "../../integrations/registry"; +import { detectRaycast, type RaycastInstall } from "../../integrations/raycast-detect"; import { readIntegrationState } from "../../integrations/state"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../integrations/store"; import { @@ -58,6 +59,13 @@ type RestoreResult = Awaited>; export type IntegrationStateEnvelope = { clientId: IntegrationClientId; + /** + * Raycast only, and only on the single-client read. Custom Providers is a + * Pro feature, so a file that is `current` can still be one Raycast ignores; + * this is the fact that lets status and the GUI say so. It is not part of + * the shared `IntegrationStatus`, which describes the file, not the app. + */ + raycast?: RaycastInstall; } & IntegrationStateRecord; export interface IntegrationStateListEnvelope { @@ -141,6 +149,17 @@ export function setIntegrationPathTestHooks(hooks: { env?: NodeJS.ProcessEnv; ho integrationPathTestHooks = hooks; } +/** + * Raycast detection override for tests. The real detector spawns `defaults` and + * reads the developer's own subscription state, which is exactly the kind of + * host fact a route test must not depend on. + */ +let raycastDetectTestHook: (() => RaycastInstall) | null = null; + +export function setRaycastDetectTestHook(hook: (() => RaycastInstall) | null): void { + raycastDetectTestHook = hook; +} + /** The `env`/`home` overrides, spread into every registry-resolving call. */ function pathOverrides(): { env?: NodeJS.ProcessEnv; home?: string } { return { @@ -177,7 +196,10 @@ export function setIntegrationMutationFlightTestHooks( setIntegrationMutationFlightTestHook(hooks?.run ?? null); // Path overrides are part of the same isolation contract: clearing flights // while leaving a temp home bound would let the next suite write real files. - if (hooks === null) integrationPathTestHooks = null; + if (hooks === null) { + integrationPathTestHooks = null; + raycastDetectTestHook = null; + } } /** @@ -633,7 +655,12 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { + test("a selector splits into field and value; anything else is a key", () => { + expect(parseSegment("[id=opencodex]")).toEqual({ kind: "select", field: "id", value: "opencodex" }); + expect(parseSegment("[model_id=anthropic/claude-opus-5]")) + .toEqual({ kind: "select", field: "model_id", value: "anthropic/claude-opus-5" }); + expect(parseSegment("providers")).toEqual({ kind: "key", key: "providers" }); + // Near misses stay keys: a client whose map literally has such a key keeps working. + expect(parseSegment("[id=]")).toEqual({ kind: "key", key: "[id=]" }); + expect(parseSegment("[=x]")).toEqual({ kind: "key", key: "[=x]" }); + expect(parseSegment("[id=x")).toEqual({ kind: "key", key: "[id=x" }); + }); +}); + +describe("setPath with a selector", () => { + test("replaces the matching element in place and keeps siblings and order", () => { + const doc = { providers: [THEIRS, { id: "opencodex", name: "old" }, { id: "other" }], keep: true }; + const next = setPath(doc, SELECT, OURS) as typeof doc; + expect(next.providers).toEqual([THEIRS, OURS, { id: "other" }]); + expect(next.keep).toBe(true); + // The input is not mutated. + expect(doc.providers[1]).toEqual({ id: "opencodex", name: "old" }); + }); + + test("pushes when no element matches", () => { + const next = setPath({ providers: [THEIRS] }, SELECT, OURS) as { providers: unknown[] }; + expect(next.providers).toEqual([THEIRS, OURS]); + }); + + test("creates the array when absent, and createdContainerPaths reports it", () => { + expect(createdContainerPaths({}, contribution(SELECT))).toEqual(["providers"]); + expect(createdContainerPaths({ providers: {} }, contribution(SELECT))).toEqual(["providers"]); + expect(createdContainerPaths({ providers: [THEIRS] }, contribution(SELECT))).toEqual([]); + expect(setPath({}, SELECT, OURS)).toEqual({ providers: [OURS] }); + // A record where the array belongs is replaced, exactly as a scalar under a key is. + expect(setPath({ providers: {} }, SELECT, OURS)).toEqual({ providers: [OURS] }); + }); + + test("descends into a matched element, seeding one when absent", () => { + const path = ["providers", "[id=opencodex]", "name"]; + expect(setPath({ providers: [THEIRS] }, path, "X")) + .toEqual({ providers: [THEIRS, { id: "opencodex", name: "X" }] }); + expect(setPath({ providers: [OURS, THEIRS] }, path, "X")) + .toEqual({ providers: [{ id: "opencodex", name: "X" }, THEIRS] }); + // The element the selector would create is recorded, the existing array is not. + expect(createdContainerPaths({ providers: [THEIRS] }, contribution(path, "X"))) + .toEqual(["providers\u0000[id=opencodex]"]); + expect(createdContainerPaths({ providers: [OURS] }, contribution(path, "X"))).toEqual([]); + }); + + test("throws AmbiguousSelectorError when two elements match", () => { + const doc = { providers: [OURS, THEIRS, { id: "opencodex", name: "dupe" }] }; + expect(() => setPath(doc, SELECT, OURS)).toThrow(AmbiguousSelectorError); + expect(() => deletePath(doc, SELECT)).toThrow(AmbiguousSelectorError); + }); +}); + +describe("deletePath with a selector", () => { + test("removes only the matching element and leaves siblings", () => { + const { doc, removed } = deletePath({ providers: [THEIRS, OURS, { id: "other" }], keep: 1 }, SELECT); + expect(removed).toBe(true); + expect(doc).toEqual({ providers: [THEIRS, { id: "other" }], keep: 1 }); + }); + + test("reports nothing removed when no element matches or the slot is not an array", () => { + expect(deletePath({ providers: [THEIRS] }, SELECT)).toEqual({ doc: { providers: [THEIRS] }, removed: false }); + expect(deletePath({ providers: {} }, SELECT)).toEqual({ doc: { providers: {} }, removed: false }); + expect(deletePath({}, SELECT)).toEqual({ doc: {}, removed: false }); + }); + + test("prunes an emptied array we created and keeps one we did not", () => { + const created = new Set(["providers"]); + expect(deletePath({ providers: [OURS], keep: 1 }, SELECT, created).doc).toEqual({ keep: 1 }); + expect(deletePath({ providers: [OURS], keep: 1 }, SELECT).doc).toEqual({ providers: [], keep: 1 }); + // A sibling keeps the array alive even when we created it. + expect(deletePath({ providers: [OURS, THEIRS] }, SELECT, created).doc).toEqual({ providers: [THEIRS] }); + }); + + test("a leaf inside a selected element is removed without touching the element", () => { + const path = ["providers", "[id=opencodex]", "name"]; + const created = new Set(["providers", "providers\u0000[id=opencodex]"]); + // The seeded element keeps its selector field, so it is never empty and the prune walk + // stops at it. No client owns a leaf inside a selected element today; when one does, it + // decides whether a `{ id }` husk is residue worth a dedicated rule. + expect(deletePath({ providers: [{ id: "opencodex", name: "X" }] }, path, created).doc) + .toEqual({ providers: [{ id: "opencodex" }] }); + expect(deletePath({ providers: [{ id: "opencodex", name: "X", extra: 1 }] }, path, created).doc) + .toEqual({ providers: [{ id: "opencodex", extra: 1 }] }); + }); +}); + +describe("readPath and blockedContainerPath with a selector", () => { + test("readPath finds the element through a selector", () => { + const doc = { providers: [THEIRS, OURS] }; + expect(readPath(doc, SELECT)).toEqual(OURS); + expect(readPath(doc, ["providers", "[id=opencodex]", "name"])).toBe("OpenCodex"); + expect(readPath(doc, ["providers", "[id=missing]"])).toBeUndefined(); + expect(readPath({ providers: {} }, SELECT)).toBeUndefined(); + expect(readPath({ providers: "x" }, SELECT)).toBeUndefined(); + }); + + test("blockedContainerPath blocks a non-array where the selector expects one", () => { + expect(blockedContainerPath({ providers: {} }, contribution(SELECT))).toEqual(["providers"]); + expect(blockedContainerPath({ providers: "x" }, contribution(SELECT))).toEqual(["providers"]); + expect(blockedContainerPath({ providers: null }, contribution(SELECT))).toEqual(["providers"]); + expect(blockedContainerPath({ providers: [THEIRS] }, contribution(SELECT))).toBeNull(); + expect(blockedContainerPath({}, contribution(SELECT))).toBeNull(); + // Reading through a matched element continues the walk: a scalar element is blocked, + // a record one is fine, an absent one is simply not there yet. + const deep = ["providers", "[id=opencodex]", "name"]; + expect(blockedContainerPath({ providers: [OURS] }, contribution(deep, "X"))).toBeNull(); + expect(blockedContainerPath({ providers: [THEIRS] }, contribution(deep, "X"))).toBeNull(); + expect(blockedContainerPath({ providers: [{ id: "opencodex", name: 1 }] }, contribution(["providers", "[id=opencodex]", "name", "leaf"], "X"))) + .toEqual(["providers", "[id=opencodex]", "name"]); + }); +}); + +describe("plain-key paths are unchanged", () => { + test("setPath, deletePath, readPath, createdContainerPaths and blockedContainerPath behave as before", () => { + const path = ["providers", "opencodex", "api_key"]; + expect(setPath({}, path, "k")).toEqual({ providers: { opencodex: { api_key: "k" } } }); + expect(setPath({ providers: "x" }, path, "k")).toEqual({ providers: { opencodex: { api_key: "k" } } }); + expect(setPath({ providers: [1] }, path, "k")).toEqual({ providers: { opencodex: { api_key: "k" } } }); + expect(setPath({ providers: { other: 1 } }, path, "k")) + .toEqual({ providers: { other: 1, opencodex: { api_key: "k" } } }); + expect(createdContainerPaths({}, contribution(path, "k"))).toEqual(["providers", "providers\u0000opencodex"]); + expect(createdContainerPaths({ providers: { other: 1 } }, contribution(path, "k"))).toEqual(["providers\u0000opencodex"]); + + const created = new Set(["providers", "providers\u0000opencodex"]); + expect(deletePath({ providers: { opencodex: { api_key: "k" } } }, path, created)).toEqual({ doc: {}, removed: true }); + expect(deletePath({ providers: { opencodex: { api_key: "k" } } }, path)).toEqual({ doc: { providers: { opencodex: {} } }, removed: true }); + expect(deletePath({ providers: { opencodex: { api_key: "k", other: 1 } }, x: 1 }, path, created)) + .toEqual({ doc: { providers: { opencodex: { other: 1 } }, x: 1 }, removed: true }); + expect(deletePath({ providers: {} }, path)).toEqual({ doc: { providers: {} }, removed: false }); + expect(deletePath({ providers: [] }, path)).toEqual({ doc: { providers: [] }, removed: false }); + expect(deletePath({ providers: { opencodex: "x" } }, path)).toEqual({ doc: { providers: { opencodex: "x" } }, removed: false }); + expect(deletePath({ providers: { opencodex: { api_key: null } } }, path, created)).toEqual({ doc: {}, removed: true }); + + expect(readPath({ providers: { opencodex: { api_key: "k" } } }, path)).toBe("k"); + expect(readPath({ providers: [OURS] }, ["providers", "0"])).toBeUndefined(); + expect(readPath({ providers: null }, path)).toBeUndefined(); + + expect(blockedContainerPath({ providers: ["x"] }, contribution(path, "k"))).toEqual(["providers"]); + expect(blockedContainerPath({ providers: { opencodex: null } }, contribution(path, "k"))).toEqual(["providers", "opencodex"]); + expect(blockedContainerPath(null, contribution(path, "k"))).toEqual([]); + expect(blockedContainerPath({ providers: { opencodex: {} } }, contribution(path, "k"))).toBeNull(); + expect(blockedContainerPath(undefined, contribution(path, "k"))).toBeNull(); + }); +}); + +/** + * End to end through the real writer: Raycast is the first client whose + * fragment path carries a selector, so this is where status and mutation are + * shown agreeing on which sequence element is ours. + */ +describe("raycast writer round trip", () => { + const TEST_ENV = {} as NodeJS.ProcessEnv; + const MODELS: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000 }, + ]; + const CONFIG: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as unknown as OcxConfig; + let home: string; + let store: IntegrationStateStore; + + beforeEach(() => { + const base = mkdtempSync(join(tmpdir(), "ocx-integrations-merge-")); + home = join(base, "home"); + mkdirSync(home, { recursive: true }); + store = createIntegrationStateStore(join(base, "store", "integrations")); + }); + + afterEach(() => { + removeTreeWithRetry(dirname(home)); + }); + + function installRaycast(): string { + const spec = INTEGRATION_CLIENTS.raycast; + mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); + const configPath = spec.configPath(TEST_ENV, home); + mkdirSync(dirname(configPath), { recursive: true }); + return configPath; + } + + function input(): IntegrationWriteInput { + return { clientId: "raycast", models: MODELS, config: CONFIG, port: 10100, env: TEST_ENV, home, store }; + } + + test("apply appends beside the user's provider, disable removes only ours", () => { + const configPath = installRaycast(); + writeFileSync(configPath, Bun.YAML.stringify({ providers: [THEIRS] })); + + expect(readIntegrationState(input())).toMatchObject({ state: "absent" }); + expect(applyIntegration(input())).toMatchObject({ ok: true, changed: true }); + const applied = Bun.YAML.parse(readFileSync(configPath, "utf8")) as { providers: Array<{ id: string }> }; + expect(applied.providers.map(item => item.id)).toEqual(["lmstudio", "opencodex"]); + expect(readIntegrationState(input())).toMatchObject({ state: "current" }); + + expect(disableIntegration(input())).toMatchObject({ ok: true, changed: true }); + // The user's array was there before us, so it survives with their entry intact. + expect(Bun.YAML.parse(readFileSync(configPath, "utf8"))).toEqual({ providers: [THEIRS] }); + expect(readIntegrationState(input())).toMatchObject({ state: "absent" }); + }); + + test("a providers map instead of a sequence is unsafe for status and writer alike", () => { + const configPath = installRaycast(); + writeFileSync(configPath, Bun.YAML.stringify({ providers: { opencodex: {} } })); + expect(readIntegrationState(input())).toMatchObject({ state: "unsafe", reason: "blocked-container" }); + expect(applyIntegration(input())).toMatchObject({ ok: false, reason: "unsafe" }); + expect(Bun.YAML.parse(readFileSync(configPath, "utf8"))).toEqual({ providers: { opencodex: {} } }); + }); + + test("two entries with our id refuse as unsafe and leave the file alone", () => { + const configPath = installRaycast(); + const text = Bun.YAML.stringify({ providers: [{ id: "opencodex", name: "a" }, { id: "opencodex", name: "b" }] }); + writeFileSync(configPath, text); + // Neither entry is ours on record, so status reads conflict and a plain apply refuses + // there. The explicit overwrite reaches the merge, which is where the ambiguity is + // detected: it must surface as an `unsafe` refusal, never as a thrown error. + expect(readIntegrationState(input())).toMatchObject({ state: "conflict" }); + expect(applyIntegration(input())).toMatchObject({ ok: false, reason: "conflict" }); + const result = overwriteIntegration(input()); + expect(result).toMatchObject({ ok: false, reason: "unsafe", state: "unsafe" }); + if (!result.ok) expect(result.message).toContain("more than one entry"); + expect(readFileSync(configPath, "utf8")).toBe(text); + expect(store.listOperations("raycast")).toHaveLength(0); + }); +}); diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts index 872e9b3824..56093b3dd6 100644 --- a/tests/clients/integrations-state.test.ts +++ b/tests/clients/integrations-state.test.ts @@ -775,9 +775,9 @@ describe("installation detection is independent of config state", () => { * from. Rationale and the per-client table: 020 §1 amendment. */ describe("the loopback-only set is one fact, read through one seam", () => { - test("omp, pi, kimi, gajae, dsh, mcode, zcode, prime and aside are loopback-only and nobody else is", () => { + test("omp, pi, kimi, gajae, dsh, mcode, zcode, prime, aside and raycast are loopback-only and nobody else is", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); }); test("the registry restates nothing — it reads the export spec", () => { diff --git a/tests/clients/raycast-client.test.ts b/tests/clients/raycast-client.test.ts new file mode 100644 index 0000000000..148f84babb --- /dev/null +++ b/tests/clients/raycast-client.test.ts @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + EXPORT_CLIENTS, + OPENCODE_PROVIDER_ID, + buildClientConfig, + buildClientConfigText, + buildClientContribution, + raycastAiDir, + raycastConfigPath, + type ExportContext, + type ExportModel, + type RaycastGeneratedConfig, +} from "../../src/clients/config-export"; +import { exportPresentationLabel } from "../../src/clients/model-presentation"; +import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration, disableIntegration, refreshIntegration } from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const CONFIG = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +// One model per cell of the vision x reasoning matrix, so every ability +// branch is exercised by a row that differs from its neighbours in one axis. +const MODELS: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-5", provider: "anthropic", id: "claude-opus-5", contextWindow: 200_000, inputModalities: ["text", "image"] }, + { namespaced: "openai/gpt-5.6-sol", provider: "openai", id: "gpt-5.6-sol", contextWindow: 922_000, reasoningEfforts: ["low", "medium", "high"] }, + { namespaced: "mystery/model", provider: "mystery", id: "model" }, + { namespaced: "google/gemini-3-pro", provider: "google", id: "gemini-3-pro", contextWindow: 1_048_576, inputModalities: ["text", "image"], reasoningEfforts: ["low", "high"] }, +]; + +function context(models: readonly ExportModel[] = MODELS): ExportContext { + return { baseUrl: "http://127.0.0.1:10100/v1", config: CONFIG, models }; +} + +// A provider the user wrote by hand: the merge must carry it through every +// apply, refresh and disable untouched. +const LMSTUDIO = { id: "lmstudio", name: "LM Studio", base_url: "http://localhost:1234/v1", models: [] }; +const USER_SEED = [ + "providers:", + " - id: lmstudio", + " name: LM Studio", + " base_url: http://localhost:1234/v1", + " models: []", + "", +].join(String.fromCharCode(10)); + +function ourProvider(document: RaycastGeneratedConfig) { + return document.providers.find(provider => provider.id === OPENCODE_PROVIDER_ID)!; +} + +function abilitiesOf(document: RaycastGeneratedConfig, id: string): Record { + const model = ourProvider(document).models.find(entry => entry.id === id)!; + return Object.fromEntries(Object.entries(model.abilities).map(([name, ability]) => [name, ability.supported])); +} + +let home: string; +let store: IntegrationStateStore; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-raycast-")); + store = createIntegrationStateStore(mkdtempSync(join(tmpdir(), "ocx-raycast-store-"))); +}); + +afterEach(() => { + removeTreeWithRetry(home); +}); + +/** Raycast "installed" for our purposes: the `ai` directory exists. */ +function installRaycast(seed?: string): string { + const spec = INTEGRATION_CLIENTS.raycast; + mkdirSync(spec.detectDir({}, home), { recursive: true }); + const configPath = spec.configPath({}, home); + if (seed !== undefined) writeFileSync(configPath, seed); + return configPath; +} + +function readProviders(configPath: string): RaycastGeneratedConfig { + return Bun.YAML.parse(readFileSync(configPath, "utf8")) as RaycastGeneratedConfig; +} + +function request(models: readonly ExportModel[] = MODELS) { + return { clientId: "raycast" as const, models, config: CONFIG, port: 10100, env: {}, home, store }; +} + +describe("Raycast client config", () => { + /* + * The shape is Raycast's, not ours: `providers` is a SEQUENCE, `base_url` + * ends in `/v1` without `/chat/completions`, and there is no `api_keys` at + * all because a loopback bind is unauthenticated. Every model carries all + * five abilities so Raycast never has to guess at a missing one. + */ + test("emits one provider element with the documented field vocabulary", () => { + const document = buildClientConfig("raycast", context()) as RaycastGeneratedConfig; + expect(Object.keys(document)).toEqual(["providers"]); + expect(document.providers.map(provider => provider.id)).toEqual([OPENCODE_PROVIDER_ID]); + + const provider = ourProvider(document); + expect(Object.keys(provider)).toEqual(["id", "name", "base_url", "models"]); + expect(provider.name).toBe("OpenCodex"); + expect(provider.base_url).toBe("http://127.0.0.1:10100/v1"); + expect(Object.keys(provider)).not.toContain("api_keys"); + + for (const model of provider.models) { + expect(Object.keys(model.abilities)).toEqual(["temperature", "vision", "system_message", "tools", "reasoning_effort"]); + } + const claude = provider.models.find(model => model.id === "anthropic/claude-opus-5")!; + // Raycast shows `name` verbatim with no provider suffix; capability tables + // supply the product label when ExportModel has no operator override. + expect(claude.name).toBe("Claude Opus 5"); + expect(claude.context).toBe(200_000); + // No authoritative window means the key is absent, not zero or null. + const unknown = provider.models.find(model => model.id === "mystery/model")!; + expect("context" in unknown).toBe(false); + }); + + test("uses product labels instead of raw slugs or provider suffixes", () => { + expect(exportPresentationLabel({ + namespaced: "anthropic/claude-fable-5-1", provider: "anthropic", id: "claude-fable-5-1", + })).toBe("Claude Fable 5.1"); + expect(exportPresentationLabel({ + namespaced: "cursor/composer-2.5", provider: "cursor", id: "composer-2.5", + })).toBe("Composer 2.5"); + expect(exportPresentationLabel({ + namespaced: "mystery/model", provider: "mystery", id: "model", displayName: "Custom Name", + })).toBe("Custom Name"); + }); + + /* + * Abilities follow the catalog row, not the vendor name. Temperature and + * reasoning_effort are the same bit inverted: Raycast's own template notes + * that reasoning models commonly reject temperature. system_message and + * tools are always on, the same stance as Hermes. + */ + test("maps vision and reasoning ladders onto abilities per model", () => { + const document = buildClientConfig("raycast", context()) as RaycastGeneratedConfig; + expect(abilitiesOf(document, "anthropic/claude-opus-5")).toEqual({ + temperature: true, vision: true, system_message: true, tools: true, reasoning_effort: false, + }); + expect(abilitiesOf(document, "openai/gpt-5.6-sol")).toEqual({ + temperature: false, vision: false, system_message: true, tools: true, reasoning_effort: true, + }); + expect(abilitiesOf(document, "mystery/model")).toEqual({ + temperature: true, vision: false, system_message: true, tools: true, reasoning_effort: false, + }); + expect(abilitiesOf(document, "google/gemini-3-pro")).toEqual({ + temperature: false, vision: true, system_message: true, tools: true, reasoning_effort: true, + }); + }); + + test("native YAML round-trips, leads with our element, and never carries a credential", () => { + const sentinel = ["sk", "live", "raycast", "sentinel"].join("-"); + const withKey = { ...CONFIG, apiKeys: [{ key: sentinel }] } as OcxConfig; + const built = buildClientConfigText("raycast", { ...context(), config: withKey }); + expect(built.format).toBe("yaml"); + expect(built.text.startsWith(["providers:", " - id: opencodex"].join(String.fromCharCode(10)))).toBe(true); + expect(Bun.YAML.parse(built.text)).toEqual(built.document as never); + expect(built.text).not.toContain(sentinel); + expect(built.text).not.toContain("api_keys"); + }); + + test("the contribution owns the providers element selected by our id", () => { + const contribution = buildClientContribution("raycast", context()); + expect(contribution.clientId).toBe("raycast"); + expect(contribution.fragments.map(fragment => fragment.path)).toEqual([["providers", `[id=${OPENCODE_PROVIDER_ID}]`]]); + expect((contribution.fragments[0]!.value as { id: string }).id).toBe(OPENCODE_PROVIDER_ID); + }); + + test("resolves under the home directory and ignores XDG_CONFIG_HOME", () => { + // Raycast hardcodes ~/.config/raycast on macOS and Windows alike; honoring + // XDG here would name a file Raycast never reads. + const env = { XDG_CONFIG_HOME: join(home, "elsewhere") }; + expect(raycastAiDir(env, home)).toBe(join(home, ".config", "raycast", "ai")); + expect(raycastConfigPath(env, home)).toBe(join(home, ".config", "raycast", "ai", "providers.yaml")); + expect(INTEGRATION_CLIENTS.raycast.configPath(env, home)).toBe(raycastConfigPath(env, home)); + expect(INTEGRATION_CLIENTS.raycast.detectDir(env, home)).toBe(raycastAiDir(env, home)); + }); + + test("ships as a loopback-only integration with no env var to export", () => { + const spec = EXPORT_CLIENTS.raycast; + // `api_keys` is read literally, so a remote bind would need a plaintext + // secret on disk; the spec refuses instead. + expect(spec.loopbackOnly).toBe(true); + expect(spec.apiKeyEnv).toBe(""); + expect(spec.format).toBe("yaml"); + // Not a bare providers.yaml: a download would collide with other clients'. + expect(spec.filename).toBe("raycast-providers.yaml"); + }); + + /* + * The whole point of the `[id=opencodex]` selector: the user's own element + * survives every operation, we replace only ours, and a disable leaves the + * sequence exactly as the user wrote it. + */ + test("apply, refresh and disable touch only our element of the sequence", () => { + const configPath = installRaycast(USER_SEED); + + const applied = applyIntegration(request()); + expect(applied.ok).toBe(true); + const afterApply = readProviders(configPath); + expect(new Set(afterApply.providers.map(provider => provider.id))).toEqual(new Set(["lmstudio", OPENCODE_PROVIDER_ID])); + expect(afterApply.providers.find(provider => provider.id === "lmstudio")).toEqual(LMSTUDIO); + expect(ourProvider(afterApply).models.map(model => model.id)).toEqual(MODELS.map(model => model.namespaced).sort()); + + // A smaller catalog rewrites our element in place and nothing else. + const fewer = MODELS.filter(model => model.namespaced !== "mystery/model"); + const refreshed = refreshIntegration(request(fewer)); + expect(refreshed.ok).toBe(true); + const afterRefresh = readProviders(configPath); + expect(afterRefresh.providers.map(provider => provider.id)).toEqual(afterApply.providers.map(provider => provider.id)); + expect(afterRefresh.providers.find(provider => provider.id === "lmstudio")).toEqual(LMSTUDIO); + expect(ourProvider(afterRefresh).models.map(model => model.id)).toEqual(fewer.map(model => model.namespaced).sort()); + + const disabled = disableIntegration(request(fewer)); + expect(disabled.ok).toBe(true); + const afterDisable = readProviders(configPath); + expect(afterDisable.providers).toEqual([LMSTUDIO]); + }); + + test("the default catalog refresh updates an owned Raycast provider", async () => { + const configPath = installRaycast(USER_SEED); + expect(applyIntegration(request()).ok).toBe(true); + const fewer = MODELS.filter(model => model.namespaced !== "mystery/model"); + let loads = 0; + + const outcomes = await refreshOwnedCatalogIntegrations({ + models: async () => { + loads += 1; + return fewer; + }, + config: CONFIG, + port: 10100, + env: {}, + home, + store, + }); + + expect(outcomes).toEqual([{ client: "raycast", ok: true, changed: true }]); + expect(loads).toBe(1); + expect(readProviders(configPath).providers.find(provider => provider.id === "lmstudio")).toEqual(LMSTUDIO); + expect(ourProvider(readProviders(configPath)).models.map(model => model.id)) + .toEqual(fewer.map(model => model.namespaced).sort()); + }); + + test("refuses a file whose providers is a map rather than a sequence", () => { + // `providers: {}` is a container we would have to REPLACE with `[]` to + // write our element, and replacing a user's container is never a success. + const configPath = installRaycast("providers: {}" + String.fromCharCode(10)); + const result = applyIntegration(request()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("unsafe"); + expect(readFileSync(configPath, "utf8")).toBe("providers: {}" + String.fromCharCode(10)); + }); + + test("refuses when the ai directory does not exist yet", () => { + // The directory appears only after "Reveal Providers Config" in Raycast's + // AI settings, which is the signal that Custom Providers is reachable. + const result = applyIntegration(request()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("not_installed"); + }); +}); diff --git a/tests/clients/raycast-detect.test.ts b/tests/clients/raycast-detect.test.ts new file mode 100644 index 0000000000..4e4268b29b --- /dev/null +++ b/tests/clients/raycast-detect.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { detectRaycast, type RaycastDetectDeps } from "../../src/integrations/raycast-detect"; + +/** + * Stubbed deps only. The real detector spawns `defaults` and reads the + * developer's subscription state, and this suite must pass identically on a + * machine with Raycast Pro, with the free tier, and with no Raycast at all. + */ +function fakeDeps( + platform: string, + existing: readonly string[], + options: { env?: Record; defaultValue?: string | null; homedir?: string } = {}, +): RaycastDetectDeps & { defaultsReads: number } { + const present = new Set(existing); + const deps = { + platform, + homedir: options.homedir ?? (platform === "win32" ? "C:\\Users\\u" : "/home/u"), + env: options.env ?? {}, + defaultsReads: 0, + exists: (path: string) => present.has(path), + readDefault: (domain: string, key: string) => { + deps.defaultsReads += 1; + expect(domain).toBe("com.raycast.macos.v1"); + expect(key).toBe("subscriptions_active"); + return options.defaultValue ?? null; + }, + }; + return deps; +} + +describe("detectRaycast", () => { + test("darwin: a Pro subscription, the app bundle and the revealed ai folder", () => { + const deps = fakeDeps("darwin", ["/Applications/Raycast.app", "/home/u/.config/raycast/ai"], { defaultValue: "1" }); + expect(detectRaycast(deps)).toEqual({ + appPath: "/Applications/Raycast.app", + aiDirPresent: true, + plan: "pro", + }); + // One process spawn per detection, not one per field. + expect(deps.defaultsReads).toBe(1); + }); + + test("darwin: the free tier is reported, not refused, and the user-local bundle is found", () => { + const deps = fakeDeps("darwin", ["/home/u/Applications/Raycast.app"], { defaultValue: "0" }); + expect(detectRaycast(deps)).toEqual({ + appPath: "/home/u/Applications/Raycast.app", + aiDirPresent: false, + plan: "free", + }); + }); + + test("darwin: a failed or unexpected defaults read is unknown, never free", () => { + expect(detectRaycast(fakeDeps("darwin", [], { defaultValue: null })).plan).toBe("unknown"); + expect(detectRaycast(fakeDeps("darwin", [], { defaultValue: "(null)" })).plan).toBe("unknown"); + expect(detectRaycast(fakeDeps("darwin", [], { defaultValue: "" })).plan).toBe("unknown"); + }); + + test("win32: LOCALAPPDATA\\Programs\\Raycast is the install path and the plan is unknown", () => { + const local = "C:\\Users\\u\\AppData\\Local"; + const deps = fakeDeps("win32", [`${local}\\Programs\\Raycast`, "C:\\Users\\u\\.config\\raycast\\ai"], { + env: { LOCALAPPDATA: local }, + defaultValue: "1", + }); + expect(detectRaycast(deps)).toEqual({ + appPath: `${local}\\Programs\\Raycast`, + aiDirPresent: true, + plan: "unknown", + }); + // `defaults` does not exist off macOS, so it is never asked. + expect(deps.defaultsReads).toBe(0); + }); + + test("win32: no LOCALAPPDATA means no app path rather than a guessed one", () => { + expect(detectRaycast(fakeDeps("win32", [])).appPath).toBeNull(); + }); + + test("linux: nothing is detected and nothing is spawned", () => { + const deps = fakeDeps("linux", [], { defaultValue: "1" }); + expect(detectRaycast(deps)).toEqual({ appPath: null, aiDirPresent: false, plan: "unknown" }); + expect(deps.defaultsReads).toBe(0); + }); +}); diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index 5661373642..9050eecd3b 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -65,7 +65,7 @@ describe("ocx sync fans out to enabled native clients and owned file integration expect(fn).toContain("grokIntegrationEnabled(config)"); expect(fn).toContain("claudeDesktopIntegrationEnabled(config)"); - expect(fn).toContain('["mcode", "pi", "aside"]'); + expect(fn).toContain('["mcode", "pi", "aside", "raycast"]'); expect(fn).toContain("refreshOwnedCatalogIntegrations"); // Native clients keep their catches; the owned catalog helper isolates file clients. expect(fn.match(/catch \(error\)/g)?.length).toBe(2); @@ -651,17 +651,29 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { }); }); -test("the direct ocx sync command refreshes MCode, Pi and Aside instead of relying on /api/sync", async () => { +test("the direct ocx sync command refreshes MCode, Pi, Raycast and server-owned Aside", async () => { const src = await Bun.file(new URL("../../src/cli/dispatch.ts", import.meta.url)).text(); const start = src.indexOf("sync: async deps =>"); const command = src.slice(start, src.indexOf("v2: async deps =>", start)); expect(command).toContain("refreshOwnedCatalogIntegrations"); - expect(command).toContain('["mcode", "pi"]'); + expect(command).toContain('["mcode", "pi", "raycast"]'); expect(command).toContain("refreshAsideProfilesThroughServer"); expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations")); expect(command).toContain('synced.status !== "refused"'); }); +test("startup and ensure refresh owned Raycast through the catalog coordinator", async () => { + const src = await Bun.file(new URL("../../src/cli/index.ts", import.meta.url)).text(); + const start = src.slice(src.indexOf("async function handleStart"), src.indexOf("function detachedStartEnvironment")); + const ensure = src.slice(src.indexOf("async function handleEnsure"), src.indexOf("async function handleTrayProxyStart")); + expect(src).toContain("refreshOwnedCatalogIntegrations"); + expect(src).toContain('}, ["raycast"]);'); + expect(start).toContain("await refreshOwnedRaycastCatalog(config, port)"); + expect(ensure).toContain("await refreshOwnedRaycastCatalog(config, live.port)"); + expect(ensure).toContain("await refreshOwnedRaycastCatalog(config, port)"); + expect(src).not.toContain("refreshAllOwnedIntegrations"); +}); + test("identical explicit mutation keys join but cannot swallow a different apply or disable", async () => { let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); diff --git a/tests/config/client-config-export-new-clients.test.ts b/tests/config/client-config-export-new-clients.test.ts index 6b6b4c4e80..5a381d6237 100644 --- a/tests/config/client-config-export-new-clients.test.ts +++ b/tests/config/client-config-export-new-clients.test.ts @@ -58,12 +58,14 @@ function ctx(config: OcxConfig = LOOPBACK): ExportContext { describe("no secret reaches a client config", () => { test("the generated client support policy identifies every loopback-only integration", () => { - // Pi, Kimi, Gajae and Aside cannot emit the dedicated admission header -- - // Aside's observed provider block has four keys and none is `headers`. OMP - // and Prime can carry provider headers, but remote credential wiring is - // deliberately deferred from those initial generated integrations. + // Pi, Kimi, Gajae, Aside and Raycast cannot emit the dedicated admission + // header -- Aside's observed provider block has four keys and none is + // `headers`; Raycast's `api_keys` is read literally with no env + // interpolation. OMP and Prime can carry provider headers, but remote + // credential wiring is deliberately deferred from those initial generated + // integrations. const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); }); test("every client that is not loopback-only carries the header on a remote bind", () => { diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 707d6dd62c..6a71813e9f 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -32,6 +32,7 @@ import { normalizeExportModels as leafNormalizeExportModels } from "../../src/cl import * as omp from "../../src/clients/config-export/omp"; import * as dsh from "../../src/clients/config-export/dsh"; import * as mcode from "../../src/clients/config-export/mcode"; +import * as raycast from "../../src/clients/config-export/raycast"; import * as zcode from "../../src/clients/config-export/zcode"; /** @@ -100,6 +101,7 @@ describe("split config-export public facade", () => { ["dsh", dsh.buildDshClientConfig, dsh.summarizeDsh, dsh.buildDshContribution], ["mcode", mcode.buildMcodeClientConfig, mcode.summarizeMcode, mcode.buildMcodeContribution], ["zcode", zcode.buildZcodeClientConfig, zcode.summarizeZcode, zcode.buildZcodeContribution], + ["raycast", raycast.buildRaycastClientConfig, raycast.summarizeRaycast, raycast.buildRaycastContribution], ] as const; for (const [id, build, summarize, contribute] of leaves) { expect(EXPORT_CLIENTS[id].build).toBe(build); @@ -803,8 +805,8 @@ describe("hub-resolved Fast exports", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the twelve file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); + test("covers exactly the thirteen file-toggle clients", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true); // The exception clients keep their own surfaces and are not export clients. expect(isExportClientId("claude-desktop")).toBe(false); diff --git a/tests/config/client-config-new-clients.test.ts b/tests/config/client-config-new-clients.test.ts index 65b52727c9..7deb7fdb36 100644 --- a/tests/config/client-config-new-clients.test.ts +++ b/tests/config/client-config-new-clients.test.ts @@ -17,6 +17,7 @@ import { type OpenclawGeneratedConfig, } from "../../src/clients/config-export"; import { serializeDocument } from "../../src/integrations/serialize"; +import { readPath } from "../../src/integrations/state"; import type { OcxConfig } from "../../src/types"; /** @@ -159,14 +160,11 @@ describe("contributions describe what a writer would own", () => { test("every client's fragments point at real entries in its own document", () => { for (const clientId of EXPORT_CLIENT_IDS) { - const document = buildClientConfig(clientId, ctx()) as Record; + const document = buildClientConfig(clientId, ctx()); for (const fragment of EXPORT_CLIENTS[clientId].buildContribution(ctx()).fragments) { - let cursor: unknown = document; - for (const key of fragment.path) { - expect(cursor && typeof cursor === "object").toBe(true); - cursor = (cursor as Record)[key]; - } - expect(cursor).toEqual(fragment.value); + // Read through the writer's own segment grammar: Raycast's path holds + // a `[id=opencodex]` selector into a sequence, not a map key. + expect(readPath(document, fragment.path)).toEqual(fragment.value); } } }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6565f12821..a00407e89a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -525,6 +525,7 @@ "install-scripts.test.ts": "ci-workflows", "integrations-invariants.test.ts": "gui", "integrations-journal.test.ts": "clients", + "integrations-merge.test.ts": "clients", "integrations-serialize.test.ts": "clients", "integrations-state.test.ts": "clients", "integrations-writer.test.ts": "clients", @@ -833,6 +834,8 @@ "reserve-quota-scope.test.ts": "codex-integration", "rate-limit-reset-credits.test.ts": "gui", "rate-limit-retry.test.ts": "providers", + "raycast-client.test.ts": "clients", + "raycast-detect.test.ts": "clients", "reasoning-effort.test.ts": "codex-integration", "reasoning-replay-identity.test.ts": "adapters", "reasoning-replay-robustness.test.ts": "adapters", diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index 33e7480f86..2353104311 100644 --- a/tests/gui/integrations-invariants.test.ts +++ b/tests/gui/integrations-invariants.test.ts @@ -6,7 +6,7 @@ import { EXPORT_CLIENTS, EXPORT_CLIENT_IDS, type ExportModel } from "../../src/c import { parseConfig } from "../../src/integrations/config-io"; import { INTEGRATION_CLIENTS, INTEGRATION_CLIENT_IDS, type IntegrationClientId } from "../../src/integrations/registry"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; -import { readIntegrationState } from "../../src/integrations/state"; +import { readIntegrationState, readPath } from "../../src/integrations/state"; import { applyIntegration, disableIntegration, restoreIntegration } from "../../src/integrations/writer"; import { printSubcommandUsage, printUsage } from "../../src/cli/help"; import type { OcxConfig } from "../../src/types"; @@ -78,9 +78,9 @@ afterEach(() => { }); describe("the client registries cannot drift apart", () => { - test("every list of clients holds exactly the same twelve ids", async () => { + test("every list of clients holds exactly the same thirteen ids", async () => { /* - * Five lists name the same twelve clients, and two of them are maintained by + * Five lists name the same thirteen clients, and two of them are maintained by * hand: the GUI cannot import the backend registry, because that would * pull node:os and node:path into the browser bundle. A client added * server-side renders no row until someone remembers the tuple, and the @@ -91,7 +91,7 @@ describe("the client registries cannot drift apart", () => { const guiRouting = await import("../../gui/src/app-routing"); const expected = [...EXPORT_CLIENT_IDS].sort(); - expect(expected).toHaveLength(12); + expect(expected).toHaveLength(13); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -170,6 +170,13 @@ describe("every client survives a full lifecycle", () => { prime: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', // Aside reads the same models.json contract as Pi and Prime. aside: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', + // Raycast's `providers` is a SEQUENCE keyed by `id`, so the user's entry is + // a sibling element rather than a sibling map key. + raycast: "providers:\n - id: lmstudio\n name: LM Studio\n base_url: http://localhost:1234/v1\n models: []\n", + }; + /** Where the seed's user-owned entry lives when the seed is a sequence. */ + const USER_ELEMENT: Partial> = { + raycast: ["providers", "[id=lmstudio]"], }; for (const clientId of INTEGRATION_CLIENT_IDS) { @@ -190,18 +197,22 @@ describe("every client survives a full lifecycle", () => { const afterApply = parseConfig(readFileSync(configPath, "utf8"), format); const record = store.readRecords()[clientId]!; expect(record.fragmentPaths.length).toBeGreaterThan(0); + // Read through the writer's own segment grammar: Raycast's path holds a + // `[id=opencodex]` selector into a sequence, not a map key. for (const path of record.fragmentPaths) { - let cursor: unknown = afterApply; - for (const segment of path) { - expect(cursor && typeof cursor === "object").toBe(true); - cursor = (cursor as Record)[segment]; - } - expect(cursor).toBeDefined(); + expect(readPath(afterApply, path)).toBeDefined(); + } + // …and the user's own entry is untouched. `toMatchObject` treats an + // array as exact-length, so a sequence-shaped seed is checked by the + // same selector the writer uses to find its own element. + const userElement = USER_ELEMENT[clientId]; + if (userElement) { + expect(readPath(afterApply, userElement)).toEqual(readPath(original, userElement)); + } else { + expect((afterApply as Record)).toMatchObject( + original as Record, + ); } - // …and the user's own entry is untouched. - expect((afterApply as Record)).toMatchObject( - original as Record, - ); const disabled = disableIntegration({ clientId, models: MODELS, config: CONFIG, port: 10100, diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index 1f8cba92a2..e0d6563aea 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -14,6 +14,7 @@ import { handleManagementAPI } from "../../src/server/management-api"; import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks, + setRaycastDetectTestHook, } from "../../src/server/management/integration-routes"; import type { OcxConfig } from "../../src/types"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; @@ -274,6 +275,31 @@ describe("GET /api/client-integrations", () => { // A read is a read: it appends nothing. expect(store.listOperations()).toHaveLength(before); }); + + test("the raycast envelope carries the plan block; every other client's does not", async () => { + // Stubbed: the real detector spawns `defaults` and would report the + // developer's own subscription. + setRaycastDetectTestHook(() => ({ appPath: "/Applications/Raycast.app", aiDirPresent: false, plan: "free" })); + try { + const raycast = await api("/api/client-integrations/raycast"); + expect(raycast.status).toBe(200); + const body = await raycast.json() as { clientId: string; raycast?: { plan: string; appPath: string | null; aiDirPresent: boolean } }; + expect(body.clientId).toBe("raycast"); + expect(body.raycast).toEqual({ appPath: "/Applications/Raycast.app", aiDirPresent: false, plan: "free" }); + + installHermes(); + const hermes = await api("/api/client-integrations/hermes"); + expect(hermes.status).toBe(200); + expect("raycast" in (await hermes.json() as Record)).toBe(false); + + // The collection read describes files, not apps: no client gets the block there. + const list = await api("/api/client-integrations"); + const { clients } = await list.json() as { clients: Array> }; + expect(clients.some(client => "raycast" in client)).toBe(false); + } finally { + setRaycastDetectTestHook(null); + } + }); }); /** The models the route itself derives, so expectations cannot drift from it. */ From ea3d03aeccd1cd9ebf37508f81ae7ff113e140b0 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:40:03 +0900 Subject: [PATCH 78/99] fix(integrations): repair Raycast #3733 ownership and plan guidance Reject duplicate selectors before ownership classification with or without a record. Validate unknown summary shapes; repair French, Turkish and Traditional Chinese client catalogs and optional api_keys guidance. Clarify unknown Pro activity in all GUI locales and advisory-only private detection. Retain export capability conventions and document their limits. Add selector cardinality, malformed summaries, unowned refresh, admission refusal and Windows notice regressions. Tests, typecheck, lint and builds NOT RUN per worker scope; combined remote CI and rendered preview belong to main. git diff --check passed. Co-authored-by: Chanhee Lee --- .../content/docs/fr/guides/integrations.md | 21 ++++--- .../src/content/docs/guides/integrations.md | 16 +++-- .../content/docs/tr/guides/integrations.md | 19 ++++-- .../content/docs/zh-tw/guides/integrations.md | 16 +++-- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- gui/src/i18n/zh.ts | 2 +- gui/src/pages/integrations/integration-api.ts | 1 + gui/tests/raycast-plan-notice.test.tsx | 18 ++++-- src/clients/config-export/raycast.ts | 26 +++++++- src/integrations/merge.ts | 4 +- src/integrations/raycast-detect.ts | 5 +- src/integrations/state.ts | 25 ++++++-- src/integrations/writer.ts | 8 ++- tests/clients/integrations-merge.test.ts | 63 ++++++++++++++----- tests/clients/raycast-client.test.ts | 49 ++++++++++++++- 22 files changed, 223 insertions(+), 66 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index c70d471f39..6a0a6d0dd3 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Intégrations -description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code et Raycast depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture. +description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside et Raycast depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture. --- L'onglet **Intégrations** écrit le bloc fournisseur d'opencodex dans le fichier de configuration du client, -puis peut le retirer. Dix clients fonctionnent ainsi, chacun avec son propre commutateur : +puis peut le retirer. Treize clients fonctionnent ainsi, chacun avec son propre commutateur : | Client | Fichier de configuration | Format | Prise d'effet de la modification | Identifiant | |---|---|---|---|---| @@ -17,6 +17,9 @@ puis peut le retirer. Dix clients fonctionnent ainsi, chacun avec son propre com | Gajae Code | `~/.gjc/agent/models.yml` | YAML | dans les nouvelles sessions ou à l'ouverture de `/model` |`OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (`~/.dsh/settings.yaml` par défaut) | YAML | rechargement à chaud | jeton porteur fictif et non secret pour le bouclage | | MiniMax Code | `~/.minimax/config.yaml` | YAML | dans les nouvelles sessions ou après l’ouverture du sélecteur de modèles | valeur fictive de bouclage | +| Prime Agent | `~/.prime/agent/models.json` | JSON | dans les nouvelles sessions | valeur fictive de bouclage | +| ZCode | `~/.zcode/v2/config.json` | JSON | au redémarrage | valeur fictive de bouclage | +| Aside | `~/.aside/u//models.json` | JSON | après avoir quitté complètement puis rouvert Aside | valeur fictive de bouclage | | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immédiatement à l'enregistrement — Raycast surveille le fichier | aucun — bouclage uniquement | La prise en charge gérée de DSH exige au minimum **DSH 0.1.0-rc.6**. OpenCodex ne possède que le fragment @@ -45,14 +48,18 @@ d'installation et indique que le client n'est pas installé tant qu'il n'existe Le bloc géré est un seul élément, `id: opencodex`, dans la séquence `providers` du fichier : `name: OpenCodex`, `base_url: http://:/v1`, et chaque modèle routé avec ses `abilities` — -`tools` et `system_message` sont toujours pris en charge, `vision` suit les modalités d'entrée du +`tools` et `system_message` sont définis à `true` par convention d’export, `vision` suit les modalités d'entrée du catalogue, `reasoning_effort` est défini lorsque le modèle dispose d'une échelle d'effort, et `temperature` est désactivé pour les modèles de raisonnement. Les autres fournisseurs du fichier sont préservés, et la désactivation ne retire que l'élément OpenCodex. Raycast prend en compte la modification dès l'enregistrement du fichier, sans redémarrage ; les modèles apparaissent dans le -sélecteur de modèles de Raycast regroupés sous **OpenCodex**. Le fichier n'a aucun emplacement pour -un identifiant, ce client est donc limité au bouclage : aucune entrée `api_keys` n'est écrite et une -liaison hors bouclage est refusée. Le format est documenté sur +sélecteur de modèles de Raycast regroupés sous **OpenCodex**. Raycast accepte le champ facultatif +`api_keys`, mais OpenCodex l’omet volontairement et refuse les cibles hors bouclage ou exigeant +authentification : cette intégration ne fournit pas l’en-tête d’admission requis par OpenCodex. +Le signal Pro issu d’une préférence privée macOS est indicatif ; Windows ne la lit jamais et +renvoie un état inconnu. Il ne bloque pas l’écriture. Les métadonnées exportées ne prouvent pas +la prise en charge des outils pour chaque modèle. Les valeurs des autres fournisseurs sont +préservées, sans garantie pour les commentaires ou la mise en forme YAML. Le format est documenté sur [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). Les chemins respectent les variables de remplacement propres à chaque client, lorsqu'elles existent. Pour @@ -115,7 +122,7 @@ niveaux. Dans ces cas, le commutateur est verrouillé afin que rien ne soit modi **OMP** n'est pas affecté non plus par les modifications voisines, mais pour une autre raison : son outil d'écriture ne modifie, octet par octet, que sa propre plage `providers.opencodex` ; le reste du fichier n'est jamais réécrit. Pour les autres formats susceptibles de contenir des commentaires (Hermes, OpenClaw, -Kimi Code, Gajae Code, MiniMax Code et Raycast — documents YAML, JSON5 et TOML réécrits en entier), ou lorsque les propres entrées +Kimi Code, Gajae Code, MiniMax Code, ZCode, Prime Agent, Aside et Raycast — documents YAML, JSON5 et TOML réécrits en entier), ou lorsque les propres entrées d'opencodex ont été modifiées, le commutateur se verrouille et la désactivation est refusée plutôt que de deviner quelles modifications vous appartiennent. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index bb8cea7001..e6230e4956 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -73,14 +73,22 @@ not honor `XDG_CONFIG_HOME`, so that path is not relocatable. The managed block is one element, `id: opencodex`, in the file's `providers` sequence: `name: OpenCodex`, `base_url: http://:/v1`, and every -routed model with its `abilities` — `tools` and `system_message` are always -supported, `vision` follows the catalog's input modalities, `reasoning_effort` +routed model with its `abilities` — the exporter sets `tools` and `system_message` to +`true` as a client-export convention, `vision` follows the catalog's input modalities, `reasoning_effort` is set when the model has an effort ladder, and `temperature` is turned off for reasoning models. Other providers in the file are preserved, and disable removes only the OpenCodex element. Raycast picks up the change as soon as the file is saved, no restart needed; the models appear in Raycast's model picker grouped -under **OpenCodex**. The file has no place for a credential, so this client is -loopback-only: no `api_keys` entry is written and a non-loopback bind is refused. +under **OpenCodex**. Raycast supports optional `api_keys`, but OpenCodex intentionally +omits them and refuses non-loopback or admission-authenticated targets; this integration +cannot supply OpenCodex's required admission header. + +The macOS private preference is only an advisory Pro hint; Windows never reads it and +reports the plan as unknown. Plan detection does not authorize or block a write. +The export metadata has no authoritative tool-support flag, so `tools: true` does not +prove every routed model supports tools. Vision and effort flags follow catalog metadata; +turning temperature off for an effort ladder is conservative export behavior. +Provider values are preserved; YAML formatting and comments are not guaranteed to survive. The format is documented at [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index ff0dcaa41e..145cd8c6bc 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Entegrasyonlar -description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code ve Raycast'i opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. +description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside ve Raycast'i opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. --- **Entegrasyonlar** sekmesi, opencodex'in sağlayıcı bloğunu istemcinin kendi -yapılandırma dosyasına yazar ve tekrar kaldırır. On istemci bu şekilde +yapılandırma dosyasına yazar ve tekrar kaldırır. On üç istemci bu şekilde çalışır, her biri bir anahtarla: | İstemci | Yapılandırma dosyası | Format | Değişiklik ne zaman geçerli olur? | Kimlik bilgisi | @@ -18,6 +18,9 @@ yapılandırma dosyasına yazar ve tekrar kaldırır. On istemci bu şekilde | Gajae Code | `~/.gjc/agent/models.yml` | YAML | yeni oturumlarda veya `/model` açtığınızda | `OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (varsayılan `~/.dsh/settings.yaml`) | YAML | çalışırken yeniden yükleme | gizli olmayan geri döngü bearer yer tutucusu | | MiniMax Code | `~/.minimax/config.yaml` | YAML | yeni oturumlarda veya model seçici açıldıktan sonra | geri döngü (loopback) yer tutucusu | +| Prime Agent | `~/.prime/agent/models.json` | JSON | yeni oturumlarda | geri döngü yer tutucusu | +| ZCode | `~/.zcode/v2/config.json` | JSON | yeniden başlatmada | geri döngü yer tutucusu | +| Aside | `~/.aside/u//models.json` | JSON | Aside tamamen kapatılıp yeniden açıldıktan sonra | geri döngü yer tutucusu | | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | kaydedildiği anda — Raycast dosyayı izler | yok — yalnızca geri döngü | Yönetilen DSH desteğinin en düşük uyumlu sürümü **DSH 0.1.0-rc.6**'dır. OpenCodex yalnızca @@ -48,14 +51,18 @@ yol taşınamaz. Yönetilen blok, dosyanın `providers` dizisindeki tek bir öğedir: `id: opencodex`, `name: OpenCodex`, `base_url: http://:/v1` ve `abilities` alanıyla birlikte -yönlendirilen her model — `tools` ve `system_message` her zaman destekli, `vision` +yönlendirilen her model — dışa aktarma kuralı olarak `tools` ve `system_message` değeri `true` olur, `vision` kataloğun giriş modalitelerini izler, `reasoning_effort` modelin bir çaba merdiveni varsa ayarlanır ve `temperature` akıl yürütme modelleri için kapatılır. Dosyadaki diğer sağlayıcılar korunur ve devre dışı bırakma yalnızca OpenCodex öğesini kaldırır. Raycast değişikliği dosya kaydedilir kaydedilmez, yeniden başlatma gerekmeden alır; modeller -Raycast'in model seçicisinde **OpenCodex** altında gruplanmış olarak görünür. Dosyada -kimlik bilgisi için bir yer yoktur, bu yüzden bu istemci yalnızca geri döngü içindir: -hiçbir `api_keys` girdisi yazılmaz ve geri döngü dışı bir bağlama reddedilir. Format +Raycast'in model seçicisinde **OpenCodex** altında gruplanmış olarak görünür. Raycast şeması +isteğe bağlı `api_keys` alanını destekler; OpenCodex bu alanı bilerek yazmaz ve geri döngü +dışı veya kimlik doğrulaması gerektiren hedefleri reddeder. Bu entegrasyon OpenCodex'in +zorunlu kabul başlığını sağlayamaz. macOS'taki özel tercih yalnızca bir Pro ipucudur; +Windows bu tercihi hiç okumaz ve durumu bilinmiyor olarak bildirir. Bu bilgi yazmayı engellemez. +Dışa aktarılan meta veriler her modelin araç desteğini doğrulamaz. Diğer sağlayıcıların +değerleri korunur; YAML biçimlendirmesi ve yorumlarının korunması garanti edilmez. Format [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) adresinde belgelenmiştir. diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index ebf2e6f0de..6a42ea8571 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -1,9 +1,9 @@ --- title: 整合 -description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、Gajae Code、DeepSeek Harness、MiniMax Code 與 Raycast——每個客戶端一個開關,每次寫入前都會先備份。 +description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、Gajae Code、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside 與 Raycast——每個客戶端一個開關,每次寫入前都會先備份。 --- -**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十個客戶端以這種方式運作,每個都有一個開關: +**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十三個客戶端以這種方式運作,每個都有一個開關: | 客戶端 | 設定檔 | 格式 | 變更生效時機 | 憑證 | |---|---|---|---|---| @@ -16,6 +16,9 @@ description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、 | Gajae Code | `~/.gjc/agent/models.yml` | YAML | 新 sessions,或當你開啟 `/model` 時 | `OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml`(預設 `~/.dsh/settings.yaml`) | YAML | 熱重載 | 非秘密的 loopback bearer 佔位符 | | MiniMax Code | `~/.minimax/config.yaml` | YAML | 新 sessions,或開啟模型選擇器後 | loopback 佔位符 | +| Prime Agent | `~/.prime/agent/models.json` | JSON | 新 sessions | loopback 佔位符 | +| ZCode | `~/.zcode/v2/config.json` | JSON | 重新啟動時 | loopback 佔位符 | +| Aside | `~/.aside/u//models.json` | JSON | 完全結束並重新開啟 Aside 後 | loopback 佔位符 | | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | 儲存後立即生效——Raycast 會監看該檔案 | 無——僅限 loopback | 受管理 DSH 支援的相容性下限是 **DSH 0.1.0-rc.6**。OpenCodex 只擁有 @@ -40,11 +43,14 @@ macOS 與 Windows 上同樣讀取 `~/.config/raycast/ai/providers.yaml`,且不 受管理區塊是檔案 `providers` 序列中的單一元素 `id: opencodex`:`name: OpenCodex`、 `base_url: http://:/v1`,以及每個路由模型及其 `abilities`——`tools` 與 -`system_message` 一律支援,`vision` 依目錄的輸入模態而定,`reasoning_effort` 在模型有 effort +`system_message` 依匯出慣例設為 `true`,`vision` 依目錄的輸入模態而定,`reasoning_effort` 在模型有 effort 階梯時設定,`temperature` 對推理模型關閉。檔案中的其他 provider 會被保留,停用只移除 OpenCodex 元素。檔案一儲存 Raycast 就會套用變更,不需重新啟動;模型會在 Raycast 的模型選擇器中歸在 -**OpenCodex** 群組下。該檔案沒有存放憑證的位置,因此此客戶端僅限 loopback:不會寫入任何 -`api_keys` 項目,非 loopback 的 bind 會被拒絕。格式說明見 +**OpenCodex** 群組下。Raycast 支援選填的 `api_keys`,但 OpenCodex 刻意省略該欄位,並拒絕 +非 loopback 或需要准入驗證的目標,因為此整合無法提供 OpenCodex 要求的准入標頭。 +macOS 私有偏好設定僅提供 Pro 狀態提示;Windows 完全不讀取該設定,狀態會是未知。 +此提示不會阻擋寫入。匯出中繼資料並未證實每個模型的工具能力。其他 provider 的值會保留, +但不保證 YAML 格式與註解不變。格式說明見 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。 路徑遵循客戶端自己的環境覆寫(environment override)。對 OMP 而言,`OMP_PROFILE` 以存在與否優先於 `PI_PROFILE`,即使明確為空也一樣。具名 profile 會把 `PI_CONFIG_DIR` 當作相對於使用者家目錄的目錄名稱,並忽略 `PI_CODING_AGENT_DIR`;沒有具名 profile 時,`PI_CODING_AGENT_DIR` 勝出。OMP 支援 provider 層級的 headers,但這個最初的整合刻意只支援 loopback;遠端 `x-opencodex-api-key` 的連線設定被延後。搬移過的 `HERMES_HOME`、`KIMI_CODE_HOME` 與 `XDG_CONFIG_HOME` 路徑同樣會被遵循,而非猜測。表格列出每個客戶端的預設值。 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index af5546775a..85e5c90a25 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1255,7 +1255,7 @@ export const de: Record = { "integrations.semantics.aside": "Verwaltet nur providers.opencodex in der ~/.aside/u//models.json dieses Profils. Andere Provider bleiben unverändert. Beende Aside nach dem Anwenden vollständig und öffne es erneut.", "integrations.semantics.raycast": "Fügt einen OpenCodex-Provider-Eintrag in die providers.yaml von Raycast ein, damit jedes geroutete Modell in der Modellauswahl von Raycast AI erscheint. Raycast Pro erforderlich.", "integrations.raycast.proRequired": "Custom Providers ist eine Funktion von Raycast Pro. Die Datei wird geschrieben, aber Raycast ignoriert sie, bis ein Pro-Abonnement aktiv ist.", - "integrations.raycast.planUnknown": "Der Abonnementstatus von Raycast konnte nicht gelesen werden; Custom Providers erfordert Raycast Pro.", + "integrations.raycast.planUnknown": "Es konnte nicht festgestellt werden, ob Raycast Pro aktiv ist; Custom Providers erfordert Raycast Pro.", "integrations.raycast.revealConfig": "Öffnen Sie Raycast → Einstellungen → AI und klicken Sie einmal auf „Reveal Providers Config“, damit der Providers-Ordner existiert.", "codexAuth.mainAccount": "Hauptkonto", "codexAuth.logLabel": "Log-Kennung", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 71bfd5e2c3..4d9514e4de 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1802,7 +1802,7 @@ export const en = { "integrations.semantics.aside": "Manages only providers.opencodex in this profile’s ~/.aside/u//models.json. Your other providers stay unchanged. Fully quit and reopen Aside after applying.", "integrations.semantics.raycast": "Adds an OpenCodex provider entry to Raycast's providers.yaml so every routed model appears in the Raycast AI model picker. Raycast Pro required.", "integrations.raycast.proRequired": "Custom Providers is a Raycast Pro feature. The file will be written, but Raycast ignores it until a Pro subscription is active.", - "integrations.raycast.planUnknown": "Could not read the Raycast subscription state; Custom Providers requires Raycast Pro.", + "integrations.raycast.planUnknown": "Could not determine whether Raycast Pro is active; Custom Providers requires Raycast Pro.", "integrations.raycast.revealConfig": "Open Raycast → Settings → AI and click Reveal Providers Config once so the providers folder exists.", "codexAuth.mainAccount": "Main Account", "codexAuth.logLabel": "Log label", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 028e910bc3..3b370fa20d 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1734,7 +1734,7 @@ export const fr: Record = { "integrations.semantics.aside": "Gère uniquement providers.opencodex dans le fichier ~/.aside/u//models.json de ce profil. Vos autres fournisseurs restent inchangés. Quittez complètement Aside et relancez-le après application.", "integrations.semantics.raycast": "Ajoute une entrée de fournisseur OpenCodex dans le providers.yaml de Raycast afin que chaque modèle routé apparaisse dans le sélecteur de modèles de Raycast AI. Raycast Pro requis.", "integrations.raycast.proRequired": "Custom Providers est une fonctionnalité Raycast Pro. Le fichier sera écrit, mais Raycast l'ignore tant qu'un abonnement Pro n'est pas actif.", - "integrations.raycast.planUnknown": "Impossible de lire l'état de l'abonnement Raycast ; Custom Providers nécessite Raycast Pro.", + "integrations.raycast.planUnknown": "Impossible de déterminer si Raycast Pro est actif ; Custom Providers nécessite Raycast Pro.", "integrations.raycast.revealConfig": "Ouvrez Raycast → Réglages → AI et cliquez une fois sur « Reveal Providers Config » pour que le dossier des fournisseurs existe.", "codexAuth.mainAccount": "Compte principal", "codexAuth.logLabel": "Libellé du journal", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 864b498483..2112788943 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1675,7 +1675,7 @@ export const ja: Record = { "integrations.semantics.aside": "このプロファイルの ~/.aside/u//models.json 内の providers.opencodex のみを管理します。他のプロバイダーは変更しません。適用後は Aside を完全に終了してから開き直してください。", "integrations.semantics.raycast": "Raycast の providers.yaml に OpenCodex のプロバイダーエントリを追加し、ルーティングされたすべてのモデルを Raycast AI のモデル選択に表示します。Raycast Pro が必要です。", "integrations.raycast.proRequired": "Custom Providers は Raycast Pro の機能です。ファイルは書き込まれますが、Pro サブスクリプションが有効になるまで Raycast はこれを無視します。", - "integrations.raycast.planUnknown": "Raycast のサブスクリプション状態を読み取れませんでした。Custom Providers には Raycast Pro が必要です。", + "integrations.raycast.planUnknown": "Raycast Pro が有効かどうか確認できませんでした。Custom Providers には Raycast Pro が必要です。", "integrations.raycast.revealConfig": "Raycast → 設定 → AI を開き、「Reveal Providers Config」を一度クリックして providers フォルダを作成してください。", "codexAuth.mainAccount": "メインアカウント", "codexAuth.logLabel": "ログラベル", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 364805943e..2edc3de875 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1279,7 +1279,7 @@ export const ko: Record = { "integrations.semantics.aside": "이 프로필의 ~/.aside/u//models.json에서 providers.opencodex만 관리합니다. 다른 프로바이더는 그대로 유지됩니다. 적용 후 Aside를 완전히 종료하고 다시 여세요.", "integrations.semantics.raycast": "Raycast의 providers.yaml에 OpenCodex 프로바이더 항목을 추가해 라우팅된 모든 모델이 Raycast AI 모델 선택기에 표시되도록 합니다. Raycast Pro가 필요합니다.", "integrations.raycast.proRequired": "Custom Providers는 Raycast Pro 기능입니다. 파일은 기록되지만 Pro 구독이 활성화될 때까지 Raycast는 이를 무시합니다.", - "integrations.raycast.planUnknown": "Raycast 구독 상태를 읽을 수 없습니다. Custom Providers에는 Raycast Pro가 필요합니다.", + "integrations.raycast.planUnknown": "Raycast Pro 활성 여부를 확인할 수 없습니다. Custom Providers에는 Raycast Pro가 필요합니다.", "integrations.raycast.revealConfig": "Raycast → 설정 → AI를 열고 「Reveal Providers Config」를 한 번 클릭해 providers 폴더를 만드세요.", "codexAuth.mainAccount": "메인 계정", "codexAuth.logLabel": "로그 라벨", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index bfd6e0182e..a0675e554d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1745,7 +1745,7 @@ export const ru: Record = { "integrations.semantics.aside": "Управляет только providers.opencodex в файле ~/.aside/u//models.json этого профиля. Другие провайдеры остаются без изменений. После применения полностью закройте и снова откройте Aside.", "integrations.semantics.raycast": "Добавляет запись провайдера OpenCodex в providers.yaml Raycast, чтобы каждая маршрутизируемая модель появилась в выборе моделей Raycast AI. Требуется Raycast Pro.", "integrations.raycast.proRequired": "Custom Providers — функция Raycast Pro. Файл будет записан, но Raycast игнорирует его, пока не активна подписка Pro.", - "integrations.raycast.planUnknown": "Не удалось прочитать состояние подписки Raycast; для Custom Providers требуется Raycast Pro.", + "integrations.raycast.planUnknown": "Не удалось определить, активен ли Raycast Pro; для Custom Providers требуется Raycast Pro.", "integrations.raycast.revealConfig": "Откройте Raycast → Настройки → AI и один раз нажмите «Reveal Providers Config», чтобы папка провайдеров появилась.", "codexAuth.mainAccount": "Основной аккаунт", "codexAuth.logLabel": "Метка журнала", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 83ad09002b..8b8a9aa0c5 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1751,7 +1751,7 @@ export const tr: Record = { "integrations.semantics.aside": "Yalnızca bu profilin ~/.aside/u//models.json dosyasındaki providers.opencodex bölümünü yönetir. Diğer sağlayıcılarınız değişmez. Uyguladıktan sonra Aside’ı tamamen kapatıp yeniden açın.", "integrations.semantics.raycast": "Raycast'in providers.yaml dosyasına bir OpenCodex sağlayıcı girdisi ekler; böylece yönlendirilen her model Raycast AI model seçicisinde görünür. Raycast Pro gerekir.", "integrations.raycast.proRequired": "Custom Providers bir Raycast Pro özelliğidir. Dosya yazılır, ancak bir Pro aboneliği etkin olana kadar Raycast bunu yok sayar.", - "integrations.raycast.planUnknown": "Raycast abonelik durumu okunamadı; Custom Providers için Raycast Pro gerekir.", + "integrations.raycast.planUnknown": "Raycast Pro’nun etkin olup olmadığı belirlenemedi; Custom Providers için Raycast Pro gerekir.", "integrations.raycast.revealConfig": "Raycast → Ayarlar → AI bölümünü açıp sağlayıcı klasörünün oluşması için „Reveal Providers Config“ seçeneğine bir kez tıklayın.", "integrations.semantics.omp": "Kataloğu yüklemek için OMP'yi yeniden başlatın.", "codexAuth.mainAccount": "Ana Hesap", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 4e137ebdd6..51369b8bca 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2340,7 +2340,7 @@ export const zhTW: Record = { "integrations.semantics.aside": "僅管理此設定檔的 ~/.aside/u//models.json 中的 providers.opencodex。其他供應商維持不變。套用後請完全結束並重新開啟 Aside。", "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中新增一個 OpenCodex 供應商項目,讓所有已路由的模型出現在 Raycast AI 模型選擇器中。需要 Raycast Pro。", "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。檔案會被寫入,但在 Pro 訂閱生效之前 Raycast 會忽略它。", - "integrations.raycast.planUnknown": "無法讀取 Raycast 訂閱狀態;Custom Providers 需要 Raycast Pro。", + "integrations.raycast.planUnknown": "無法確認 Raycast Pro 是否已啟用;Custom Providers 需要 Raycast Pro。", "integrations.raycast.revealConfig": "開啟 Raycast → 設定 → AI,點一次「Reveal Providers Config」,以便建立 providers 資料夾。", "codexAuth.pinned": "已固定", "codexAuth.pinnedHint": "你手動選取了此帳號,因此較高的選擇順序不會越過它。此固定會持續到該帳號用盡、你改選其他帳號,或你變更任一選擇順序為止。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 67400eaaea..0ec38172c5 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1272,7 +1272,7 @@ export const zh: Record = { "integrations.semantics.aside": "仅管理此配置文件的 ~/.aside/u//models.json 中的 providers.opencodex。其他提供商保持不变。应用后请完全退出并重新打开 Aside。", "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中添加一个 OpenCodex 提供商条目,让所有已路由的模型出现在 Raycast AI 模型选择器中。需要 Raycast Pro。", "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。文件会被写入,但在 Pro 订阅生效之前 Raycast 会忽略它。", - "integrations.raycast.planUnknown": "无法读取 Raycast 订阅状态;Custom Providers 需要 Raycast Pro。", + "integrations.raycast.planUnknown": "无法确定 Raycast Pro 是否已激活;Custom Providers 需要 Raycast Pro。", "integrations.raycast.revealConfig": "打开 Raycast → 设置 → AI,点击一次“Reveal Providers Config”,以便创建 providers 文件夹。", "codexAuth.mainAccount": "主账号", "codexAuth.logLabel": "日志标签", diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 38b0e0fe89..85ffdc7be4 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -26,6 +26,7 @@ export type IntegrationReason = | "foreign-edit" | "unowned-key" | "blocked-container" + | "ambiguous-selector" | "unresolvable-path"; export type IntegrationRefusalReason = diff --git a/gui/tests/raycast-plan-notice.test.tsx b/gui/tests/raycast-plan-notice.test.tsx index 4a8d44a77d..7f08550317 100644 --- a/gui/tests/raycast-plan-notice.test.tsx +++ b/gui/tests/raycast-plan-notice.test.tsx @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { I18nContext, type TFn } from "../src/i18n/shared"; +import { DICTS, I18nContext, type TFn } from "../src/i18n/shared"; import RaycastPlanNotice from "../src/pages/integrations/RaycastPlanNotice"; import type { RaycastInstall } from "../src/pages/integrations/integration-api"; @@ -14,11 +14,11 @@ import type { RaycastInstall } from "../src/pages/integrations/integration-api"; const echoT: TFn = key => key; -function render(install: RaycastInstall): string { +function render(install: RaycastInstall, t: TFn = echoT): string { return renderToStaticMarkup( createElement( I18nContext.Provider, - { value: { locale: "en", setLocale: () => {}, t: echoT } }, + { value: { locale: "en", setLocale: () => {}, t } }, createElement(RaycastPlanNotice, { install }), ), ); @@ -36,7 +36,7 @@ test("a free plan is a warning notice, never a refusal", () => { expect(markup).not.toContain("integrations.raycast.planUnknown"); }); -test("an unreadable plan stays muted, because non-macOS hosts have no signal", () => { +test("an unknown plan stays muted, because non-macOS hosts have no signal", () => { const markup = render({ plan: "unknown", appPath: null, aiDirPresent: true }); expect(markup).toContain('data-raycast-plan="unknown"'); expect(markup).toContain("integrations.raycast.planUnknown"); @@ -49,3 +49,13 @@ test("a missing ai folder adds the reveal hint independently of the plan", () => expect(markup).toContain('data-raycast-ai-dir="absent"'); expect(markup).toContain("integrations.raycast.revealConfig"); }); + +test("a Windows install reports unknown Pro activity without claiming a preference read failed", () => { + const markup = render({ + plan: "unknown", appPath: "C:\\Users\\u\\AppData\\Local\\Programs\\Raycast", aiDirPresent: true, + }, key => DICTS.en[key]); + expect(markup).toContain("Could not determine whether Raycast Pro is active"); + expect(markup).not.toContain("Could not read"); + expect(markup).not.toContain("notice-warn"); + expect(markup).not.toContain(" { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export function summarizeRaycast( document: unknown, ): { modelCount: number; modelsWithoutLimits: number } { - const providers = (document as RaycastGeneratedConfig | undefined)?.providers ?? []; - const models = providers.find(provider => provider.id === OPENCODE_PROVIDER_ID)?.models ?? []; + const empty = { modelCount: 0, modelsWithoutLimits: 0 }; + if (!isRecord(document) || !Array.isArray(document.providers)) return empty; + const providers = document.providers.filter( + provider => isRecord(provider) && provider.id === OPENCODE_PROVIDER_ID, + ); + // An ambiguous managed provider has no meaningful summary either. + if (providers.length !== 1) return empty; + const provider: unknown = providers[0]; + if (!isRecord(provider) || !Array.isArray(provider.models)) return empty; + const models = provider.models.filter((model): model is Record => ( + isRecord(model) + && typeof model.id === "string" && model.id.trim().length > 0 + && typeof model.name === "string" && model.name.trim().length > 0 + )); return { modelCount: models.length, - modelsWithoutLimits: models.filter(model => model.context === undefined).length, + modelsWithoutLimits: models.filter(model => ( + typeof model.context !== "number" || authoritativeContextWindow(model.context) === undefined + )).length, }; } diff --git a/src/integrations/merge.ts b/src/integrations/merge.ts index 768ddc755f..ab2099b424 100644 --- a/src/integrations/merge.ts +++ b/src/integrations/merge.ts @@ -51,7 +51,7 @@ export class AmbiguousSelectorError extends Error { } /** The index of the element a selector names, -1 when none matches. */ -function selectIndex(items: readonly unknown[], field: string, value: string): number { +export function selectIndex(items: readonly unknown[], field: string, value: string): number { const matches: number[] = []; items.forEach((item, index) => { if (isPlainRecord(item) && item[field] === value) matches.push(index); @@ -249,7 +249,7 @@ export function createdContainerPaths( case "select": { // A selector that matches nothing means setPath will push the element. next = Array.isArray(cursor) - ? cursor.find(item => isPlainRecord(item) && item[segment.field] === segment.value) + ? cursor[selectIndex(cursor, segment.field, segment.value)] : undefined; break; } diff --git a/src/integrations/raycast-detect.ts b/src/integrations/raycast-detect.ts index 5e79578f46..7ae70edf46 100644 --- a/src/integrations/raycast-detect.ts +++ b/src/integrations/raycast-detect.ts @@ -10,7 +10,7 @@ * GUI need to explain a file that is written but ignored. * * Detection is read-only and injectable, like cursor-detect.ts: nothing here - * touches the Raycast install or its preferences, and the tests run against + * writes to the Raycast install or its preferences, and the tests run against * stubbed deps rather than the machine they execute on. */ import { existsSync } from "node:fs"; @@ -37,7 +37,8 @@ export interface RaycastDetectDeps { } /** - * The preference Raycast writes for its subscription state. Read through + * A private preference used only as an advisory subscription hint, not an + * entitlement API or a condition for writes. Read through * `defaults` rather than by parsing the plist: cfprefsd caches writes, so the * file on disk can lag what the running app believes. */ diff --git a/src/integrations/state.ts b/src/integrations/state.ts index bb0e5b3567..0249987027 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -12,7 +12,7 @@ import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel import type { OcxConfig } from "../types"; import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; import { SNAPSHOT_RETENTION } from "./journal"; -import { parseSegment, type PathSegment } from "./merge"; +import { AmbiguousSelectorError, parseSegment, selectIndex, type PathSegment } from "./merge"; import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership"; import { protectedContributionFingerprint, @@ -36,6 +36,7 @@ export type StateReason = | "unowned-key" /** A container we would have to write through holds a non-object value. */ | "blocked-container" + | "ambiguous-selector" /** A path selector we cannot resolve, e.g. a relative OPENCLAW_CONFIG_PATH. */ | "unresolvable-path"; @@ -63,7 +64,7 @@ function assertNever(segment: never): never { /** The element a selector names, or `undefined` when none matches. */ function selectElement(items: readonly unknown[], segment: PathSegment & { kind: "select" }): unknown { - return items.find(item => isPlainRecord(item) && item[segment.field] === segment.value); + return items[selectIndex(items, segment.field, segment.value)]; } /** @@ -291,8 +292,24 @@ export function classifyIntegration(input: { * Checked BEFORE `absent`: our leaf is missing in exactly this case, so the * absent branch would authorize an apply that replaces the user's value. */ - if (blockedContainerPath(input.parsed, input.contribution)) { - return { state: "unsafe", reason: "blocked-container" }; + try { + if (blockedContainerPath(input.parsed, input.contribution)) { + return { state: "unsafe", reason: "blocked-container" }; + } + // Check every selector before presence/fingerprint short-circuits, including + // paths an older ownership record may remove during refresh or disable. + const paths = [ + ...input.contribution.fragments.map(fragment => fragment.path), + ...(input.record?.fragmentPaths ?? []), + ]; + for (const path of paths) { + if (Array.isArray(path) && path.every(key => typeof key === "string")) { + readPath(input.parsed, path); + } + } + } catch (error) { + if (!(error instanceof AmbiguousSelectorError)) throw error; + return { state: "unsafe", reason: "ambiguous-selector" }; } if (!hasOurFragments(input.parsed, input.contribution)) return { state: "absent" }; diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 7ec543715a..514fbc3220 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -321,7 +321,9 @@ function applyOrRefreshIntegration( return refuse(clientId, "unsafe", "unsafe", classified.reason === "blocked-container" ? `${configPath} holds a value where opencodex would have to write a section, so applying would replace it` - : `${configPath} cannot be changed safely`); + : classified.reason === "ambiguous-selector" + ? `${configPath} has more than one entry matching a managed selector` + : `${configPath} cannot be changed safely`); } /* * An implicit catalog sync is refresh-only. Keeping this decision inside the @@ -511,7 +513,9 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { return refuse(clientId, "unsafe", "unsafe", classified.reason === "blocked-container" ? `${configPath} holds a value where opencodex would have to read a section, so nothing can be removed safely` - : `${configPath} cannot be changed safely`); + : classified.reason === "ambiguous-selector" + ? `${configPath} has more than one entry matching a managed selector` + : `${configPath} cannot be changed safely`); } /* diff --git a/tests/clients/integrations-merge.test.ts b/tests/clients/integrations-merge.test.ts index 6bea116319..6585d9f1e9 100644 --- a/tests/clients/integrations-merge.test.ts +++ b/tests/clients/integrations-merge.test.ts @@ -17,6 +17,7 @@ import { applyIntegration, disableIntegration, overwriteIntegration, + refreshIntegration, type IntegrationWriteInput, } from "../../src/integrations/writer"; import type { OcxConfig } from "../../src/types"; @@ -88,6 +89,9 @@ describe("setPath with a selector", () => { const doc = { providers: [OURS, THEIRS, { id: "opencodex", name: "dupe" }] }; expect(() => setPath(doc, SELECT, OURS)).toThrow(AmbiguousSelectorError); expect(() => deletePath(doc, SELECT)).toThrow(AmbiguousSelectorError); + expect(() => readPath(doc, SELECT)).toThrow(AmbiguousSelectorError); + expect(() => createdContainerPaths(doc, contribution([...SELECT, "name"]))) + .toThrow(AmbiguousSelectorError); }); }); @@ -250,19 +254,48 @@ describe("raycast writer round trip", () => { expect(Bun.YAML.parse(readFileSync(configPath, "utf8"))).toEqual({ providers: { opencodex: {} } }); }); - test("two entries with our id refuse as unsafe and leave the file alone", () => { - const configPath = installRaycast(); - const text = Bun.YAML.stringify({ providers: [{ id: "opencodex", name: "a" }, { id: "opencodex", name: "b" }] }); - writeFileSync(configPath, text); - // Neither entry is ours on record, so status reads conflict and a plain apply refuses - // there. The explicit overwrite reaches the merge, which is where the ambiguity is - // detected: it must surface as an `unsafe` refusal, never as a thrown error. - expect(readIntegrationState(input())).toMatchObject({ state: "conflict" }); - expect(applyIntegration(input())).toMatchObject({ ok: false, reason: "conflict" }); - const result = overwriteIntegration(input()); - expect(result).toMatchObject({ ok: false, reason: "unsafe", state: "unsafe" }); - if (!result.ok) expect(result.message).toContain("more than one entry"); - expect(readFileSync(configPath, "utf8")).toBe(text); - expect(store.listOperations("raycast")).toHaveLength(0); - }); + for (const recorded of [false, true]) { + for (const count of [0, 1, 2]) { + test(`${count} matching rows with record=${recorded} agree across status and mutation`, () => { + const configPath = installRaycast(); + writeFileSync(configPath, Bun.YAML.stringify({ providers: [THEIRS] })); + let managed: unknown = OURS; + if (recorded) { + expect(applyIntegration(input())).toMatchObject({ ok: true }); + const applied = Bun.YAML.parse(readFileSync(configPath, "utf8")) as { providers: unknown[] }; + managed = applied.providers[1]; + } + // For one owned row retain the writer's exact bytes, so this exercises + // current rather than an unrelated whole-file formatting conflict. + if (!recorded || count !== 1) { + writeFileSync(configPath, Bun.YAML.stringify({ + providers: [THEIRS, ...Array.from({ length: count }, () => managed)], + })); + } + const text = readFileSync(configPath, "utf8"); + const records = store.readRecords(); + const operations = store.listOperations("raycast"); + const expected = count === 0 ? "absent" : count === 2 ? "unsafe" : recorded ? "current" : "conflict"; + expect(readIntegrationState(input()).state).toBe(expected); + if (count === 2) { + expect(readIntegrationState(input()).reason).toBe("ambiguous-selector"); + for (const mutate of [applyIntegration, refreshIntegration, disableIntegration, overwriteIntegration]) { + expect(mutate(input())).toMatchObject({ ok: false, state: "unsafe", reason: "unsafe" }); + expect(readFileSync(configPath, "utf8")).toBe(text); + expect(store.readRecords()).toEqual(records); + expect(store.listOperations("raycast")).toEqual(operations); + } + } else if (count === 0) { + expect(refreshIntegration(input())).toMatchObject({ ok: true, changed: false, state: "absent" }); + expect(readFileSync(configPath, "utf8")).toBe(text); + } else if (recorded) { + expect(applyIntegration(input())).toMatchObject({ ok: true, changed: false, state: "current" }); + } else { + expect(applyIntegration(input())).toMatchObject({ ok: false, reason: "conflict" }); + expect(disableIntegration(input())).toMatchObject({ ok: false, reason: "conflict" }); + expect(readFileSync(configPath, "utf8")).toBe(text); + } + }); + } + } }); diff --git a/tests/clients/raycast-client.test.ts b/tests/clients/raycast-client.test.ts index 148f84babb..d84123f458 100644 --- a/tests/clients/raycast-client.test.ts +++ b/tests/clients/raycast-client.test.ts @@ -10,6 +10,7 @@ import { buildClientContribution, raycastAiDir, raycastConfigPath, + summarizeRaycast, type ExportContext, type ExportModel, type RaycastGeneratedConfig, @@ -73,6 +74,7 @@ beforeEach(() => { afterEach(() => { removeTreeWithRetry(home); + removeTreeWithRetry(store.root); }); /** Raycast "installed" for our purposes: the `ai` directory exists. */ @@ -123,6 +125,25 @@ describe("Raycast client config", () => { expect("context" in unknown).toBe(false); }); + test("summarizes unknown file shapes without trusting parsed YAML", () => { + const empty = { modelCount: 0, modelsWithoutLimits: 0 }; + for (const document of [undefined, null, false, 42, "providers", [], {}, + { providers: null }, { providers: {} }, { providers: "bad" }, + { providers: [null, false, "bad", [], {}] }, + ...[undefined, null, false, 42, "bad", {}].map(models => ({ providers: [{ id: "opencodex", models }] })), + { providers: [{ id: "opencodex", models: [] }, { id: "opencodex", models: [] }] }, + ]) expect(summarizeRaycast(document)).toEqual(empty); + expect(summarizeRaycast({ providers: [null, { id: "foreign", models: "bad" }, { + id: "opencodex", models: [null, false, 1, "bad", [], {}, { id: "x" }, + { id: "", name: "empty id" }, { id: "x", name: 1 }, + { id: "known", name: "Known", context: 1000 }, + { id: "unknown", name: "Unknown" }, + { id: "invalid", name: "Invalid", context: "1000" }, + { id: "negative", name: "Negative", context: -1 }, + ], + }] })).toEqual({ modelCount: 4, modelsWithoutLimits: 3 }); + }); + test("uses product labels instead of raw slugs or provider suffixes", () => { expect(exportPresentationLabel({ namespaced: "anthropic/claude-fable-5-1", provider: "anthropic", id: "claude-fable-5-1", @@ -137,9 +158,9 @@ describe("Raycast client config", () => { /* * Abilities follow the catalog row, not the vendor name. Temperature and - * reasoning_effort are the same bit inverted: Raycast's own template notes - * that reasoning models commonly reject temperature. system_message and - * tools are always on, the same stance as Hermes. + * reasoning_effort use opposite flags as a conservative export convention. + * This is not a complete per-model capability oracle. system_message and + * tools retain the client export convention, not verified per-model support. */ test("maps vision and reasoning ladders onto abilities per model", () => { const document = buildClientConfig("raycast", context()) as RaycastGeneratedConfig; @@ -251,6 +272,28 @@ describe("Raycast client config", () => { .toEqual(fewer.map(model => model.namespaced).sort()); }); + test("implicit catalog refresh neither loads models nor connects an unowned Raycast", async () => { + const configPath = installRaycast(USER_SEED); + const outcomes = await refreshOwnedCatalogIntegrations({ + ...request(), + models: async () => { throw new Error("unowned client must not load models"); }, + }, ["raycast"]); + expect(outcomes).toEqual([]); + expect(readFileSync(configPath, "utf8")).toBe(USER_SEED); + expect(store.readRecords().raycast).toBeUndefined(); + expect(store.listOperations("raycast")).toEqual([]); + }); + + for (const hostname of ["0.0.0.0", "192.0.2.1"]) { + test(`refuses admission-authenticated bind ${hostname} without changing the file`, () => { + const configPath = installRaycast(USER_SEED); + const result = applyIntegration({ ...request(), config: { ...CONFIG, hostname } }); + expect(result).toMatchObject({ ok: false, reason: "non_loopback" }); + expect(readFileSync(configPath, "utf8")).toBe(USER_SEED); + expect(store.listOperations("raycast")).toEqual([]); + }); + } + test("refuses a file whose providers is a map rather than a sequence", () => { // `providers: {}` is a container we would have to REPLACE with `[]` to // write our element, and replacing a user's container is never a success. From 387d787b43b9a8b3e1b048d9913c927e98531565 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:45:00 +0900 Subject: [PATCH 79/99] fix(raycast): honor live export admission and defer ensure refresh (#3733) Use the existing live client-config API for Raycast CLI export, refusing admission-authenticated destinations before serialization. Resolve declared unauthenticated loopback ports consistently in CLI/API URL composition. Preserve other exporter policy and add OpenCode compatibility coverage. Remove parent-side ensure catalog refresh from saved config; actual server startup and explicit sync remain responsible. Add reachable CLI/API admission/listener tests and an executed-command-body ensure divergence regression. Tests, typecheck, lint and builds NOT RUN per worker scope; git diff --check passed. Main owns final CI and rendered preview. Co-authored-by: Chanhee Lee --- .../content/docs/fr/guides/integrations.md | 6 ++ .../src/content/docs/guides/integrations.md | 6 ++ .../content/docs/tr/guides/integrations.md | 6 ++ .../content/docs/zh-tw/guides/integrations.md | 5 ++ src/cli/export-command.ts | 36 +++++++---- src/cli/index.ts | 7 ++- src/server/management/model-routes.ts | 10 +++- tests/cli/cli-export-command.test.ts | 59 +++++++++++++++++++ .../clients/sync-client-integrations.test.ts | 45 +++++++++++++- .../management-client-config-route.test.ts | 45 ++++++++++++++ 10 files changed, 207 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index 6a0a6d0dd3..c718801ddc 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -62,6 +62,12 @@ la prise en charge des outils pour chaque modèle. Les valeurs des autres fourni préservées, sans garantie pour les commentaires ou la mise en forme YAML. Le format est documenté sur [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). +Les exports Raycast en CLI et les téléchargements utilisent la destination et la politique +d’admission du serveur actif, y compris son listener de bouclage sans authentification. +`ocx ensure` ne réactualise pas Raycast depuis sa copie de configuration enregistrée, qui peut +différer du serveur actif. Le démarrage du serveur et la synchronisation explicite restent disponibles. + + Les chemins respectent les variables de remplacement propres à chaque client, lorsqu'elles existent. Pour OMP, la présence de `OMP_PROFILE` l'emporte sur `PI_PROFILE`, même si sa valeur est explicitement vide. Un profil nommé emploie `PI_CONFIG_DIR` comme nom de répertoire relatif au dossier personnel de l'utilisateur diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index e6230e4956..ea3c93f2dd 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -92,6 +92,12 @@ Provider values are preserved; YAML formatting and comments are not guaranteed t The format is documented at [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). +Raycast CLI exports and dashboard downloads use the running server's destination and +admission policy, including a configured unauthenticated loopback listener. `ocx ensure` +does not refresh Raycast from its saved configuration snapshot: that can differ from the +running server. Server startup and explicit sync remain the catalog refresh paths. + + Cursor has a tab but is not one of these switches. Regular Cursor calls custom endpoints from its own backend, so a loopback proxy is unreachable without a public tunnel, and Cursor's separate Private Inference build is configured inside Cursor. The **Cursor** tab is read-only: diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index 145cd8c6bc..f068b0233f 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -66,6 +66,12 @@ değerleri korunur; YAML biçimlendirmesi ve yorumlarının korunması garanti e [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) adresinde belgelenmiştir. +Raycast CLI dışa aktarmaları ve panel indirmeleri, yapılandırılmış kimlik doğrulamasız +geri döngü dinleyicisi dahil çalışan sunucunun adresini ve kabul politikasını kullanır. +`ocx ensure`, çalışan sunucudan farklı olabilecek kayıtlı yapılandırma kopyasıyla Raycast'i +yenilemez. Sunucu başlangıcı ve açık senkronizasyon katalog yenilemeye devam eder. + + Yollar, varsa her istemcinin kendi ortam geçersiz kılmalarını dikkate alır. OMP için `OMP_PROFILE`, açıkça boş olduğunda bile varlığıyla `PI_PROFILE`'a üstün gelir. Adlandırılmış bir profil, `PI_CONFIG_DIR`'i kullanıcının ev dizinine göre diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 6a42ea8571..46b03df9a1 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -53,6 +53,11 @@ macOS 私有偏好設定僅提供 Pro 狀態提示;Windows 完全不讀取該 但不保證 YAML 格式與註解不變。格式說明見 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。 +Raycast CLI 匯出與儀表板下載會使用執行中伺服器的目標位址和准入規則,包含已設定的 +無驗證 loopback listener。`ocx ensure` 不會以可能與執行中伺服器不同的已儲存設定快照 +重新整理 Raycast;伺服器啟動與明確執行的同步仍會更新目錄。 + + 路徑遵循客戶端自己的環境覆寫(environment override)。對 OMP 而言,`OMP_PROFILE` 以存在與否優先於 `PI_PROFILE`,即使明確為空也一樣。具名 profile 會把 `PI_CONFIG_DIR` 當作相對於使用者家目錄的目錄名稱,並忽略 `PI_CODING_AGENT_DIR`;沒有具名 profile 時,`PI_CODING_AGENT_DIR` 勝出。OMP 支援 provider 層級的 headers,但這個最初的整合刻意只支援 loopback;遠端 `x-opencodex-api-key` 的連線設定被延後。搬移過的 `HERMES_HOME`、`KIMI_CODE_HOME` 與 `XDG_CONFIG_HOME` 路徑同樣會被遵循,而非猜測。表格列出每個客戶端的預設值。 對原生 OpenAI 模型,產生的 OMP 區塊會選用其模型層級的 Responses API,保留圖片輸入與 reasoning-effort 控制。路由模型則維持 provider 的 Chat Completions 方言,讓它們既有的 adapters 保持相容。 diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index c576435432..906065b2e8 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -122,10 +122,10 @@ export function exportModelsFromProxyRows( * Resolved ONCE and handed back to `runtimeRequest` as `baseUrl`, so the catalog and the * exported endpoint can never come from two different probes. */ -function proxyV1BaseUrl(root: string): string { +function proxyV1BaseUrl(root: string, config: OcxConfig): string { const url = new URL(root); const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80; - return opencodeProxyBaseUrl(port, url.hostname); + return opencodeProxyBaseUrl(port, url.hostname, config); } function parseClient(args: string[]): ExportClientId { @@ -172,17 +172,29 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep const spec = EXPORT_CLIENTS[client]; const root = await runtimeBaseUrl(deps); - const rows = await runtimeRequest("/api/models", {}, { ...deps, baseUrl: root }); - if (!Array.isArray(rows)) { - throw new RuntimeApiError("Management API returned an unexpected /api/models payload.", 502, rows); + let built: { document: unknown; text: string }; + if (client === "raycast") { + // The dial address alone cannot distinguish a wildcard authenticated bind + // from loopback. Let the live server resolve its admission/listener policy; + // saved config can differ from the process serving this request. + const exported = await runtimeRequest<{ + client: string; format: string; config: unknown; text: string; + }>("/api/client-config?client=raycast", {}, { ...deps, baseUrl: root }); + if (!exported || exported.client !== "raycast" || exported.format !== "yaml" + || typeof exported.text !== "string" || exported.config === undefined) { + throw new RuntimeApiError("Management API returned an unexpected Raycast export payload.", 502, null); + } + built = { document: exported.config, text: exported.text }; + } else { + const rows = await runtimeRequest("/api/models", {}, { ...deps, baseUrl: root }); + if (!Array.isArray(rows)) { + throw new RuntimeApiError("Management API returned an unexpected /api/models payload.", 502, rows); + } + // Discovery can persist selection; preserve the existing exporters' flow. + const config = (deps.configImpl ?? loadConfig)(); + const models = exportModelsFromProxyRows(rows, config); + built = buildClientConfigText(client, { baseUrl: proxyV1BaseUrl(root, config), models, config }); } - // Discovery can persist pending -> ready selection. Read from the caller's - // config source after the response, rather than filtering with a stale snapshot. - const config = (deps.configImpl ?? loadConfig)(); - const models = exportModelsFromProxyRows(rows, config); - // The text is the client's OWN format — YAML, TOML and JSON5 clients would - // otherwise receive a JSON rendering their parser reads differently. - const built = buildClientConfigText(client, { baseUrl: proxyV1BaseUrl(root), models, config }); const clientConfig = built.document; const text = built.text; diff --git a/src/cli/index.ts b/src/cli/index.ts index 9d9f08951c..663514a120 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -580,7 +580,9 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom return null; }); if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); - await refreshOwnedRaycastCatalog(config, live.port); + // Do not refresh Raycast from saved config here: live bind/admission and + // secondary-listener settings may differ. Explicit sync or server startup + // owns catalog refresh; ensure must not overwrite a working destination. // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature). const systemEnv = await injectSystemEnv(live.port, config).catch(() => ({ injected: false })); reportShellHookFailure(reconcileShellHook(systemEnv.injected)); @@ -625,7 +627,8 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom return null; }); if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); - await refreshOwnedRaycastCatalog(config, port); + // The child performs Raycast refresh with its actual startup config. The + // parent's pre-spawn snapshot is not authoritative for a client-file write. // The child opens /healthz before its best-effort roster reconcile. Await the same idempotent // operation in the parent so `ocx ensure` cannot report success while stale ocx-*.md files are // still observable. Always use the live port, including fallback-port starts. diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 28d1bef0ec..dd84d8af8c 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; +import { shouldInjectApiAuthHeader } from "../../codex/loopback-target"; /** * Codex parses a catalog entry's `input_modalities` as a closed enum, and one out-of-enum @@ -480,6 +481,13 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { expect(parsed.provider.opencodex!.options.baseURL).not.toContain(":10100/"); }); + test("existing OpenCode export also resolves the declared unauthenticated listener", async () => { + const proxy = fakeProxy(); + const result = await run(["--client", "opencode", "--json"], { + baseUrl: proxy.baseUrl, + config: config({ hostname: "0.0.0.0", unauthenticatedLoopbackListener: { enabled: true, port: 10237 } }), + }); + expect(result.code).toBe(0); + const parsed = JSON.parse(result.stdout) as { provider: Record }; + expect(parsed.provider.opencodex!.options.baseURL).toBe("http://127.0.0.1:10237/v1"); + }); + test("disabled rows never reach the exported config", async () => { const proxy = fakeProxy(); const result = await run(["--client", "pi", "--json"], { baseUrl: proxy.baseUrl }); @@ -543,3 +554,51 @@ describe("export allowlist parity", () => { ], cfg).map(row => row.namespaced)).toEqual(["slash/org-model"]); }); }); + +describe("Raycast export uses the live management admission policy", () => { + for (const secondary of [false, true]) { + test(`live wildcard bind with secondary=${secondary} wins over saved loopback config`, async () => { + const oldHome = process.env.OPENCODEX_HOME; + const oldCodexHome = process.env.CODEX_HOME; + const root = tempDir(); + process.env.OPENCODEX_HOME = join(root, "ocx"); + process.env.CODEX_HOME = join(root, "codex"); + mkdirSync(process.env.CODEX_HOME, { recursive: true }); + try { + const liveConfig = config({ + hostname: "0.0.0.0", + providers: { mock: { + adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1", + liveModels: false, models: ["fixture-model"], + } }, + ...(secondary ? { unauthenticatedLoopbackListener: { enabled: true, port: 10237 } } : {}), + }); + const proxy = managementProxy(liveConfig); + const out = join(root, "providers.yaml"); + writeFileSync(out, "keep existing export\n"); + const result = await run(["--client", "raycast", "--json", "--out", out, "--force"], { + baseUrl: proxy.baseUrl, + // Deliberately contradict both live bind and secondary port. + config: config({ unauthenticatedLoopbackListener: { enabled: true, port: 10999 } }), + }); + if (secondary) { + expect(result.code).toBe(0); + const document = JSON.parse(result.stdout) as { providers: Array<{ base_url: string }> }; + expect(document.providers[0]!.base_url).toBe("http://127.0.0.1:10237/v1"); + expect(readFileSync(out, "utf8")).toContain("10237/v1"); + expect(readFileSync(out, "utf8")).not.toContain("10999"); + } else { + expect(result.code).not.toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("non_loopback"); + expect(readFileSync(out, "utf8")).toBe("keep existing export\n"); + } + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = oldCodexHome; + } + }); + } +}); diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index 9050eecd3b..65dc41a2b3 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -662,18 +662,57 @@ test("the direct ocx sync command refreshes MCode, Pi, Raycast and server-owned expect(command).toContain('synced.status !== "refused"'); }); -test("startup and ensure refresh owned Raycast through the catalog coordinator", async () => { +test("server startup owns Raycast refresh; ensure does not reuse a saved-config snapshot", async () => { const src = await Bun.file(new URL("../../src/cli/index.ts", import.meta.url)).text(); const start = src.slice(src.indexOf("async function handleStart"), src.indexOf("function detachedStartEnvironment")); const ensure = src.slice(src.indexOf("async function handleEnsure"), src.indexOf("async function handleTrayProxyStart")); expect(src).toContain("refreshOwnedCatalogIntegrations"); expect(src).toContain('}, ["raycast"]);'); expect(start).toContain("await refreshOwnedRaycastCatalog(config, port)"); - expect(ensure).toContain("await refreshOwnedRaycastCatalog(config, live.port)"); - expect(ensure).toContain("await refreshOwnedRaycastCatalog(config, port)"); + expect(ensure).not.toContain("await refreshOwnedRaycastCatalog("); expect(src).not.toContain("refreshAllOwnedIntegrations"); }); +test("already-running ensure leaves Raycast untouched when saved host and listener policy diverge", async () => { + // Exercise the actual command body with external effects injected. Importing + // index.ts directly starts CLI dispatch, so isolate only handleEnsure here. + const src = await Bun.file(new URL("../../src/cli/index.ts", import.meta.url)).text(); + const command = src.slice(src.indexOf("async function handleEnsure"), src.indexOf("async function handleTrayProxyStart")); + const executable = new Bun.Transpiler({ loader: "ts" }).transformSync(command); + const root = mkdtempSync(join(tmpdir(), "ocx-ensure-raycast-divergence-")); + const configPath = join(root, "providers.yaml"); + const original = "providers:\n - id: opencodex\n base_url: http://127.0.0.1:10237/v1\n"; + writeFileSync(configPath, original); + const savedConfig = { + port: 10100, hostname: "192.0.2.40", providers: {}, defaultProvider: "mock", + unauthenticatedLoopbackListener: { enabled: true, port: 10999 }, + } as OcxConfig; + let refreshCalls = 0; + const deps = { + findProxyOwnerBeforeJournalRecovery: async () => ({ live: { hostname: "127.0.0.1", port: 10237 } }), + loadConfig: () => savedConfig, + codexAutoStartEnabled: () => true, + syncModelsToCodex: async () => ({ status: "skipped" }), + refreshOwnedRaycastCatalog: async () => { + refreshCalls += 1; + writeFileSync(configPath, "wrong saved destination"); + }, + injectSystemEnv: async () => ({ injected: true }), + reportShellHookFailure: () => {}, + reconcileShellHook: () => ({ state: "installed" }), + reconcileEnsureDesiredIntegrations: async () => {}, + console: { log: () => {}, error: () => {} }, + }; + try { + const ensure = new Function(...Object.keys(deps), `${executable}; return handleEnsure;`)(...Object.values(deps)) as () => Promise; + expect(await ensure()).toBe(true); + expect(refreshCalls).toBe(0); + expect(readFileSync(configPath, "utf8")).toBe(original); + } finally { + removeTreeWithRetry(root); + } +}); + test("identical explicit mutation keys join but cannot swallow a different apply or disable", async () => { let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index d3d91aebdf..9fcb92e81d 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -23,6 +23,7 @@ import { type McodeGeneratedConfig, type OpencodeGeneratedConfig, type PiGeneratedConfig, + type RaycastGeneratedConfig, } from "../../src/clients/config-export"; import type { OcxConfig } from "../../src/types"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; @@ -215,6 +216,50 @@ describe("native Anthropic effort ladder reaches the Aside document", () => { }); }); describe("GET /api/client-config", () => { + for (const hostname of ["0.0.0.0", "::", "192.0.2.40"]) { + test(`Raycast export refuses authenticated bind ${hostname} before generating a document`, async () => { + const response = await clientConfigApi(baseConfig({ hostname }), "?client=raycast"); + expect(response.status).toBe(400); + const body = await response.json() as Record; + expect(body.reason).toBe("non_loopback"); + expect(body.config).toBeUndefined(); + expect(body.text).toBeUndefined(); + }); + } + + test("Raycast export uses the declared unauthenticated listener instead of the management port", async () => { + const response = await clientConfigApi(baseConfig({ + hostname: "0.0.0.0", + unauthenticatedLoopbackListener: { enabled: true, port: 10237 }, + }), "?client=raycast"); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + const document = body.config as RaycastGeneratedConfig; + expect(document.providers[0]!.base_url).toBe("http://127.0.0.1:10237/v1"); + expect(document.providers[0]!.models.length).toBeGreaterThan(0); + expect(body.text).not.toContain(REAL_LOOKING_KEY); + expect(body.text).not.toContain("api_keys"); + }); + + test("OpenCode export keeps its envelope and uses the declared unauthenticated listener", async () => { + const response = await clientConfigApi(baseConfig({ + hostname: "0.0.0.0", unauthenticatedLoopbackListener: { enabled: true, port: 10237 }, + }), "?client=opencode"); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + expect(body.client).toBe("opencode"); + expect((body.config as OpencodeGeneratedConfig).provider.opencodex!.options.baseURL) + .toBe("http://127.0.0.1:10237/v1"); + }); + + test("Raycast export uses the main port for an ordinary loopback bind", async () => { + const response = await clientConfigApi(baseConfig(), "?client=raycast"); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + expect((body.config as RaycastGeneratedConfig).providers[0]!.base_url) + .toBe("http://127.0.0.1:10100/v1"); + }); + test("opencode envelope carries the shared builder's exact bytes", async () => { const config = baseConfig(); const response = await clientConfigApi(config, "?client=opencode"); From 22f39ff105bea7675a82076b44b30aaf109826df Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 08:10:12 +0900 Subject: [PATCH 80/99] test(responses): exercise empty effort ladder through valid ingress CI 34065098625 and 34065112182 exposed a numeric-effort fixture rejected by parseRequest before it reaches the adapter. Use schema-valid high effort to exercise provider-wide omission and input immutability; preserve strict ingress validation. No production change and no local suites. Co-authored-by: jamespan --- tests/responses/openai-responses-passthrough.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index 7e97217dc2..24bc8e0e02 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -598,13 +598,13 @@ describe("DeepSeek Responses endpoint contract", () => { } }); - test("a provider-wide empty ladder removes even non-string raw effort", () => { + test("a provider-wide empty ladder removes schema-valid raw effort", () => { const keyed = { adapter: "openai-responses", baseUrl: "https://example.test/v1", authMode: "key" as const }; - const raw = { model: "model", input: "ping", reasoning: { effort: 123, summary: "auto" } }; + const raw = { model: "model", input: "ping", reasoning: { effort: "high", summary: "auto" } }; const wire = JSON.parse(createResponsesPassthroughAdapter({ ...keyed, reasoningEfforts: [] }) .buildRequest(parseRequest(raw)).body); expect(wire.reasoning).toEqual({ summary: "auto" }); - expect(raw.reasoning.effort).toBe(123); + expect(raw.reasoning.effort).toBe("high"); }); test("empty-ladder repair preserves unknown, non-rankable and native forward effort behavior", () => { From 95edd0aec05043d2e162c30edea98e174ad284e9 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:47:42 +0900 Subject: [PATCH 81/99] fix(export): preserve live CLI destination despite saved listener drift (#3733) Restore non-Raycast CLI URL composition from the live root only. Raycast remains delegated to the authoritative client-config API; API behavior is unchanged. Replace the OpenCode saved-listener substitution test with a live 10100 versus saved 10999 regression. Tests and suites NOT RUN; git diff --check passed. Co-authored-by: Chanhee Lee --- src/cli/export-command.ts | 6 +++--- tests/cli/cli-export-command.test.ts | 23 +++++++++++++++-------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 906065b2e8..739889a026 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -122,10 +122,10 @@ export function exportModelsFromProxyRows( * Resolved ONCE and handed back to `runtimeRequest` as `baseUrl`, so the catalog and the * exported endpoint can never come from two different probes. */ -function proxyV1BaseUrl(root: string, config: OcxConfig): string { +function proxyV1BaseUrl(root: string): string { const url = new URL(root); const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80; - return opencodeProxyBaseUrl(port, url.hostname, config); + return opencodeProxyBaseUrl(port, url.hostname); } function parseClient(args: string[]): ExportClientId { @@ -193,7 +193,7 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep // Discovery can persist selection; preserve the existing exporters' flow. const config = (deps.configImpl ?? loadConfig)(); const models = exportModelsFromProxyRows(rows, config); - built = buildClientConfigText(client, { baseUrl: proxyV1BaseUrl(root, config), models, config }); + built = buildClientConfigText(client, { baseUrl: proxyV1BaseUrl(root), models, config }); } const clientConfig = built.document; const text = built.text; diff --git a/tests/cli/cli-export-command.test.ts b/tests/cli/cli-export-command.test.ts index 03bfd199a7..6d8a513558 100644 --- a/tests/cli/cli-export-command.test.ts +++ b/tests/cli/cli-export-command.test.ts @@ -204,15 +204,22 @@ describe("ocx export --json (accept criterion 1)", () => { expect(parsed.provider.opencodex!.options.baseURL).not.toContain(":10100/"); }); - test("existing OpenCode export also resolves the declared unauthenticated listener", async () => { - const proxy = fakeProxy(); - const result = await run(["--client", "opencode", "--json"], { - baseUrl: proxy.baseUrl, - config: config({ hostname: "0.0.0.0", unauthenticatedLoopbackListener: { enabled: true, port: 10237 } }), + test("OpenCode export keeps the live port when saved listener settings point at a future port", async () => { + const code = await handleExportCommand(["--client", "opencode", "--json"], { + baseUrl: "http://127.0.0.1:10100", + configImpl: () => config({ + hostname: "0.0.0.0", + unauthenticatedLoopbackListener: { enabled: true, port: 10999 }, + }), + fetchImpl: (async input => { + expect(String(input)).toBe("http://127.0.0.1:10100/api/models"); + return Response.json(ROWS); + }) as typeof fetch, }); - expect(result.code).toBe(0); - const parsed = JSON.parse(result.stdout) as { provider: Record }; - expect(parsed.provider.opencodex!.options.baseURL).toBe("http://127.0.0.1:10237/v1"); + expect(code).toBe(0); + const parsed = JSON.parse(stdout()) as { provider: Record }; + expect(parsed.provider.opencodex!.options.baseURL).toBe("http://127.0.0.1:10100/v1"); + expect(parsed.provider.opencodex!.options.baseURL).not.toContain(":10999/"); }); test("disabled rows never reach the exported config", async () => { From d175335943da2a2e64c5dac92a99a99295199335 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 08:12:44 +0900 Subject: [PATCH 82/99] test(container): verify first-start migrations before persistence baseline [skip ci] Remote source startup proved the expected tier/subagent migrations and schema-default persistence, followed by byte-stable second startup. Verify exact seed bytes before startup, six explicit migration/default additions at readiness, and an immutable post-start hash through bootstrap refusal and recreation. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- scripts/ci/docker-smoke.ts | 51 +++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/scripts/ci/docker-smoke.ts b/scripts/ci/docker-smoke.ts index eb488cf3b1..e26b11de34 100644 --- a/scripts/ci/docker-smoke.ts +++ b/scripts/ci/docker-smoke.ts @@ -127,6 +127,7 @@ const token = randomBytes(32).toString("hex"); const replacement = randomBytes(32).toString("hex"); const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); let seededConfigHash = ""; +let readyConfigHash = ""; // Check the loader, including its schema-repair/default-provider fallback, before server startup // and again in each running container. This isolates synthetic inference, not all process egress. @@ -184,6 +185,9 @@ async function inspect() { const stateProbe = ` import { readFileSync, statSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; + import { isDeepStrictEqual } from 'node:util'; + const phase = await Bun.stdin.text(); + if (!['seed', 'first-ready', 'steady'].includes(phase)) throw new Error('invalid state phase'); ${fixtureConfigCheck} const homes = ['/home/bun/.opencodex', '/home/bun/.codex']; const uid = process.getuid(); @@ -202,14 +206,51 @@ const stateProbe = ` if (s.uid !== uid || (s.mode & 0o777) !== 0o600 || s.size > 65536) throw new Error('file permissions/size'); return createHash('sha256').update(readFileSync(path)).digest('hex'); }); + // The immutable shipped config was byte-verified before fixture creation. Reconstruct only + // the deliberate fixture route edits, then compare every original key on disk (not loader defaults). + const seed = JSON.parse(readFileSync('docker/config.json', 'utf8')); + seed.providers = { smoke: { adapter: 'openai-responses', baseUrl: 'http://127.0.0.1:9/v1', authMode: 'local', allowPrivateNetwork: true } }; + seed.defaultProvider = 'smoke'; + const persisted = JSON.parse(readFileSync(paths[0], 'utf8')); + const loaded = JSON.parse(JSON.stringify(effective)); + for (const key of Object.keys(seed)) { + for (const config of [persisted, loaded]) { + if (!Object.hasOwn(config, key) || !isDeepStrictEqual(config[key], seed[key])) throw new Error('seed semantics changed'); + } + } + // Independent oracle measured by isolated startup; update only for an intentional contract change. + // Do not derive expected values from runtime migration/default helpers. + const additions = { + appOwnedMemoryBudgetMb: 256, fastRows: true, managementUsageMaxReadBytes: 67108864, + openaiProviderTierVersion: 2, + subagentModels: ['gpt-6-astra', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5'], + subagentModelsVersion: 1, + }; + for (const config of [persisted, loaded]) { + if (Object.keys(config).some(key => !Object.hasOwn(seed, key) && !Object.hasOwn(additions, key))) throw new Error('unexpected startup config addition'); + for (const [key, expected] of Object.entries(additions)) { + if (phase !== 'seed' || Object.hasOwn(config, key)) { + if (!Object.hasOwn(config, key) || !isDeepStrictEqual(config[key], expected)) throw new Error('startup oracle mismatch'); + } + } + } + if (phase === 'seed' && Object.keys(persisted).some(key => !Object.hasOwn(seed, key))) throw new Error('premature seed addition'); console.log(JSON.stringify(hashes)); `; -async function state() { - const hashes = JSON.parse(await compose(["exec", "-T", "hub", "bun", "-e", stateProbe])) as string[]; +async function state(phase: "seed" | "first-ready" | "steady" = "steady") { + const invocation = phase === "seed" ? ["run", "--rm", "-T", "--no-deps"] : ["exec", "-T"]; + const hashes = JSON.parse(await compose([...invocation, "hub", "bun", "-e", stateProbe], phase)) as string[]; check(hashes.length === 3 && hashes.every(hash => /^[a-f0-9]{64}$/.test(hash)), "invalid state evidence"); - check(hashes[0] === seededConfigHash, "seeded config changed"); check(hashes[1] === sha256(`${token}\n`) && hashes[2] === sha256(fixture), "token/catalog changed"); + if (phase === "first-ready") { + check(!readyConfigHash, "post-start config baseline already established"); + // stateProbe has checked persisted/effective semantics and the independent startup oracle. + readyConfigHash = hashes[0]!; + } else { + check(hashes[0] === (phase === "seed" ? seededConfigHash : readyConfigHash), + phase === "seed" ? "seeded config changed before startup" : "post-start config changed"); + } return JSON.stringify(hashes); } @@ -353,11 +394,13 @@ async function main() { check(/^[a-f0-9]{64}$/.test(seededConfigHash), "invalid seeded config evidence"); progress("bootstrap throwaway token"); await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "run", "docker/bootstrap-token.ts"], `${token}\n`); + progress("verify exact seed state before startup"); + await state("seed"); progress("start and check admission"); await compose(["up", "--no-build", "--wait", "--wait-timeout", "120", "hub"], undefined, 150_000); const first = await inspect(); await acceptance(first.url); - const before = await state(); + const before = await state("first-ready"); progress("refuse token replacement"); const refused = await run(["docker", ...composeArgs, "run", "--rm", "-T", "--no-deps", "hub", "bun", "run", "docker/bootstrap-token.ts"], `${replacement}\n`); From 68d90aa37d63e9c51a4d538d009633d0fd33d93b Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:43:01 +0900 Subject: [PATCH 83/99] docs(claude): describe redacted reasoning replay Carry the documentation-only tail of PR #3815 through 22135366225391494ca98301114486c85be466a4 across eight locales. Runtime and test trees remain identical to the verified protocol candidate. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> Co-authored-by: Yumi --- docs-site/src/content/docs/fr/guides/claude-code.md | 1 + docs-site/src/content/docs/guides/claude-code.md | 1 + docs-site/src/content/docs/ja/guides/claude-code.md | 1 + docs-site/src/content/docs/ko/guides/claude-code.md | 1 + docs-site/src/content/docs/ru/guides/claude-code.md | 1 + docs-site/src/content/docs/tr/guides/claude-code.md | 1 + docs-site/src/content/docs/zh-cn/guides/claude-code.md | 1 + docs-site/src/content/docs/zh-tw/guides/claude-code.md | 1 + 8 files changed, 8 insertions(+) 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 7f1c0a54aa..2ae3940254 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -519,6 +519,7 @@ Sur l’adaptateur Anthropic prévu, les blocs signés non masqués (y compris t | Battement de coeur | `ping` | | Deltas de texte | `content_block_start` → `content_block_delta` (texte) → `content_block_stop` | | Résumé ou texte de raisonnement | Bloc `thinking` avec la signature relue, ou une enveloppe de secours `ocxr1` bornée | +| Raisonnement expurgé | Blocs `redacted_thinking` relus depuis l'enveloppe de raisonnement | | Trames d'appel de fonction | Bloc `tool_use` avec `input_json_delta` | | Événement terminal | `message_delta` → `message_stop` | | EOF avant la borne | style 502 `api_error` | diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index f1c1bfbfe1..14dbe92c81 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -533,6 +533,7 @@ name. | Heartbeat | `ping` | | Text deltas | `content_block_start` → `content_block_delta` (text) → `content_block_stop` | | Reasoning summary/text | `thinking` block with the replayed signature, or a bounded `ocxr1` fallback envelope | +| Redacted reasoning | `redacted_thinking` blocks replayed from the reasoning envelope | | Function-call frames | `tool_use` block with `input_json_delta` | | Terminal event | `message_delta` → `message_stop` | | EOF before terminal | 502-style `api_error` | 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 2445e7fa3a..384c3f50df 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -387,6 +387,7 @@ role、`tool_use_id` のない `tool_result`、id/name のない `tool_use`、na | Heartbeat | `ping` | | テキスト delta | `content_block_start` → `content_block_delta`(text) → `content_block_stop` | | 推論要約/テキスト | 再生されたシグネチャ、または境界付き `ocxr1` フォールバックを持つ `thinking` ブロック | +| 秘匿化された推論 | 推論エンベロープから再生される `redacted_thinking` ブロック | | Function-call フレーム | `input_json_delta` を持つ `tool_use` ブロック | | 終了イベント | `message_delta` → `message_stop` | | 終了前に EOF | 502 形式 `api_error` | 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 4e3535d821..676800d1e6 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -425,6 +425,7 @@ role, `tool_use_id` 없는 `tool_result`, id/name 없는 `tool_use`, name 없는 | Heartbeat | `ping` | | 텍스트 delta | `content_block_start` → `content_block_delta`(text) → `content_block_stop` | | 추론 요약/텍스트 | 재생된 서명 또는 제한된 `ocxr1` 폴백이 있는 `thinking` 블록 | +| 비공개 추론 | 추론 봉투에서 재생되는 `redacted_thinking` 블록 | | Function-call 프레임 | `input_json_delta`가 있는 `tool_use` 블록 | | 종료 이벤트 | `message_delta` → `message_stop` | | 종료 전에 EOF | 502 형식 `api_error` | 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 847e79964a..60464bfb90 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -413,6 +413,7 @@ id/name; именованный `tool_choice` без имени. | Heartbeat | `ping` | | Текстовые дельты | `content_block_start` → `content_block_delta` (text) → `content_block_stop` | | Резюме/текст рассуждений | Блок `thinking` с повторно переданной подписью или ограниченным резервным конвертом `ocxr1` | +| Скрытое рассуждение | Блоки `redacted_thinking`, воспроизведённые из конверта рассуждений | | Кадры function-call | Блок `tool_use` с `input_json_delta` | | Завершающее событие | `message_delta` → `message_stop` | | EOF до завершающего события | `api_error` в стиле 502 | 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 f1c7fca438..4be81a4de8 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -602,6 +602,7 @@ kimlik/ad içermeyen `tool_use`; ad içermeyen adlandırılmış `tool_choice`. | Kalp atışı (Heartbeat) | `ping` | | Metin farkları | `content_block_start` → `content_block_delta` (metin) → `content_block_stop` | | Akıl yürütme özeti/metni | Tekrarlanan imzayı veya sınırlı bir `ocxr1` yedeğini taşıyan `thinking` bloğu | +| Gizli akıl yürütme | Akıl yürütme zarfından yeniden oynatılan `redacted_thinking` blokları | | Fonksiyon çağrısı çerçeveleri | `input_json_delta` ile `tool_use` bloğu | | Terminal olayı | `message_delta` → `message_stop` | | Terminalden önce EOF | 502 tarzı `api_error` | 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 2b8c0bfa98..dd8bc740b8 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 @@ -364,6 +364,7 @@ role;`tool_result` 缺少 `tool_use_id`;`tool_use` 缺少 id/name;指定 | 心跳 | `ping` | | 文本增量 | `content_block_start` → `content_block_delta`(文本)→ `content_block_stop` | | 推理摘要/文本 | 带重放签名或有界 `ocxr1` 回退信封的 `thinking` 块 | +| 脱敏推理 | 从推理信封重放的 `redacted_thinking` 块 | | Function-call 帧 | 带 `input_json_delta` 的 `tool_use` 块 | | 终止事件 | `message_delta` → `message_stop` | | 在终止事件前 EOF | 502 风格的 `api_error` | 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 db1c779145..86a3ea0782 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 @@ -440,6 +440,7 @@ role;`tool_result` 缺少 `tool_use_id`;`tool_use` 缺少 id/name;指定 | 心跳 | `ping` | | 文字增量 | `content_block_start` → `content_block_delta`(文字)→ `content_block_stop` | | 推理摘要/文字 | 帶重播簽名或有界 `ocxr1` 備援信封的 `thinking` 塊 | +| 遮蔽推理 | 從推理信封重播的 `redacted_thinking` 塊 | | Function-call 幀 | 帶 `input_json_delta` 的 `tool_use` 塊 | | 終止事件 | `message_delta` → `message_stop` | | 在終止事件前 EOF | 502 風格的 `api_error` | From e87300809ce67a946ccf2059e80b19d435032738 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:49:52 +0900 Subject: [PATCH 84/99] docs(devlog): record verified protocol delivery and remainders --- .../260907_axis3_protocol/000_plan.md | 4 +++ .../260907_axis3_protocol/001_roadmap_lock.md | 0 .../010_prepare_and_verify.md | 0 .../260907_axis3_protocol/011_candidate.md | 0 .../260907_axis3_protocol/020_delivery.md | 15 +++++++++ .../021_delivery_record.md | 31 +++++++++++++++++++ .../260907_axis3_protocol/020_delivery.md | 7 ----- 7 files changed, 50 insertions(+), 7 deletions(-) rename devlog/{_plan => _fin}/260907_axis3_protocol/000_plan.md (90%) rename devlog/{_plan => _fin}/260907_axis3_protocol/001_roadmap_lock.md (100%) rename devlog/{_plan => _fin}/260907_axis3_protocol/010_prepare_and_verify.md (100%) rename devlog/{_plan => _fin}/260907_axis3_protocol/011_candidate.md (100%) create mode 100644 devlog/_fin/260907_axis3_protocol/020_delivery.md create mode 100644 devlog/_fin/260907_axis3_protocol/021_delivery_record.md delete mode 100644 devlog/_plan/260907_axis3_protocol/020_delivery.md diff --git a/devlog/_plan/260907_axis3_protocol/000_plan.md b/devlog/_fin/260907_axis3_protocol/000_plan.md similarity index 90% rename from devlog/_plan/260907_axis3_protocol/000_plan.md rename to devlog/_fin/260907_axis3_protocol/000_plan.md index 36cfe95772..d0511f46ec 100644 --- a/devlog/_plan/260907_axis3_protocol/000_plan.md +++ b/devlog/_fin/260907_axis3_protocol/000_plan.md @@ -11,3 +11,7 @@ Success: roadmap verified, accepted changes reviewed and remotely validated, com Acceptance: (1) thinking then text/tool then result retains order and genuine signatures; opaque blocks remain bounded and malformed/nested signatures fail closed. (2) Grok user agent receives ordinary Responses data without codex.rate_limits/codex.response.metadata, while proxy inspection and normal clients retain metadata. (3) valid external task seeds preserve text/order; absent metadata invalid tool outputs still reject. (4) no credential, admission, cache-retention default, provider/routing policy mutation. (5) final CI must really run relevant tests/typecheck, not skip/cancel or fabricate success. No local suite was run. Final failure permits lower-layer CI for localization; unrelated failures may defer delivery, never count as success. Sources: PRs https://github.com/lidge-jun/opencodex/pull/3815 and /pull/3816; issues /issues/3807 and /issues/3719. Current dev 137d6a727. Evidence snapshots under .tmp/axis3. Public notes contain no unreleased vulnerability detail; any new security investigation stays in scratch. + +## Terminal outcome + +Runtime scope delivered in3830–3832 with the evidence and explicit diagnostic remainders in021_delivery_record.md. Documentation-only completion retains the late source-author rows and archives this unit. Initial planning statements are historical; the delivery record is the outcome authority. diff --git a/devlog/_plan/260907_axis3_protocol/001_roadmap_lock.md b/devlog/_fin/260907_axis3_protocol/001_roadmap_lock.md similarity index 100% rename from devlog/_plan/260907_axis3_protocol/001_roadmap_lock.md rename to devlog/_fin/260907_axis3_protocol/001_roadmap_lock.md diff --git a/devlog/_plan/260907_axis3_protocol/010_prepare_and_verify.md b/devlog/_fin/260907_axis3_protocol/010_prepare_and_verify.md similarity index 100% rename from devlog/_plan/260907_axis3_protocol/010_prepare_and_verify.md rename to devlog/_fin/260907_axis3_protocol/010_prepare_and_verify.md diff --git a/devlog/_plan/260907_axis3_protocol/011_candidate.md b/devlog/_fin/260907_axis3_protocol/011_candidate.md similarity index 100% rename from devlog/_plan/260907_axis3_protocol/011_candidate.md rename to devlog/_fin/260907_axis3_protocol/011_candidate.md diff --git a/devlog/_fin/260907_axis3_protocol/020_delivery.md b/devlog/_fin/260907_axis3_protocol/020_delivery.md new file mode 100644 index 0000000000..714a9c0b8e --- /dev/null +++ b/devlog/_fin/260907_axis3_protocol/020_delivery.md @@ -0,0 +1,15 @@ +# Publish and deliver verified manual chain + +Prerequisite: wp1 accepted-source review and successful final-head remote validation, or source-grounded defer outcome. Publish ordinary PRs targeting dev then the parent branch, using every repository template section. Bodies name source PRs, own layer-only diff, exact final combined CI evidence and explicit lower-layer CI deferral per owner instruction. Do not attest local CI. Preserve original contributor trailers in commits; admin merge with merge commits preserves their identity. + +Read live native-stack membership and head/base identity before merge. Never register a native stack. Parent merges to dev first; retain its branch, retarget child to dev, verify current head and ancestry. If integration tree changes materially, refresh final combined CI before landing. Use --admin and --match-head-commit exact guard. Do not merge into the parent branch by mistake. Refresh origin/dev and prove each merge SHA ancestor. Close superseded source PRs only after equivalent fix is actually landed, with credit and replacement link. Keep #3807 and #3719 open if real reproduction/cache acceptance remains unmet. No release or deployment. + +Record final PR URLs, source-to-delivery mapping, commit authors/trailers, CI run and exact SHA, review verdicts, remaining limitations and preserved dirty-work evidence. No fabricated status checks. Completion: every candidate has an honest disposition, accepted work is landed, unresolved diagnostics explicitly deferred under user direction. + +## Delivery revalidation + +Previous D: all 24 real GitHub runtime producer jobs succeeded at final9b5b670db; same-head remote Bun1.4 full suite20897pass18skip0fail, focused405pass, docs build pass. Aggregate ci is still queued; do not claim the workflow complete or manufacture a status. Its only operation is combining those passed producer results. Maintainer explicitly authorized admin merge, and live dev rules expose no required_status_checks rule. Delivery may use the actual completed producer evidence with aggregation status explicitly disclosed; never waive an unrun or failed runtime producer. + +Carry late source docs #3815 through221353662: eight outbound redacted-reasoning table rows in the same eight locales. Prepared docs-only68d90aa37 has runtime/test trees identical to9b and remote docs build passed. After three runtime PRs land bottom-up, bring this docs-only tail and a final delivery record into a fourth ordinary PR. MOVE the completed owning unit from devlog/_plan/260907_axis3_protocol to devlog/_fin/260907_axis3_protocol and NEW021_delivery.md with actual merge/CI/source-credit evidence and deferred issues. No new runtime tests: exact code-tree equality plus docs build/hygiene are the applicable checks. + +Refresh dev and membership before each guarded admin merge; compare merged runtime tree with tested9b. Any unrelated concurrent dev change requires integration review and appropriate renewed evidence. Original #3815 and #3816 close only after their full carried changes (including docs tail) are landed. #3807 and #3719 remain open for the already recorded limits. diff --git a/devlog/_fin/260907_axis3_protocol/021_delivery_record.md b/devlog/_fin/260907_axis3_protocol/021_delivery_record.md new file mode 100644 index 0000000000..e27fd8b41b --- /dev/null +++ b/devlog/_fin/260907_axis3_protocol/021_delivery_record.md @@ -0,0 +1,31 @@ +# Axis 3 delivery record + +## Delivered runtime + +Ordinary PR chain, merged bottom-up with explicit owner admin authorization: + +| PR | Scope | Merge commit | +| --- | --- | --- | +| [3830](https://github.com/lidge-jun/opencodex/pull/3830) | Claude envelope foundation from #3815 | 2269e076d4222ada6ea3694fb8eed04f91a201d2 | +| [3831](https://github.com/lidge-jun/opencodex/pull/3831) | Grok strict-client projection from #3816 and SSE field correction | 07f8d70a75f088b19f4c9dd849e34034a88ab5f3 | +| [3832](https://github.com/lidge-jun/opencodex/pull/3832) | Replay boundaries, terminal overflow, established-history fixtures | 4349cf3cefdb5ed04575f49023ae34ffe6462e1c | + +Original contributors are retained in commits: SB Yoon and Yumi for #3815, Danh Thanh for #3816. Merge commits preserve the carried commits. The documentation-only tail of #3815 through221353662 is carried with both original contributor trailers in68d90aa37. + +## Verification + +- [Final candidate CI](https://github.com/lidge-jun/opencodex/actions/runs/34065721438) completed SUCCESS: all25 jobs at9b5b670db3e24ae5522c5d61e74c071c71257a26, including Linux, macOS shards/control and all six Windows shards. +- Same-head remote Linux Bun1.4.0 full suite:20897pass18skip0fail with `bun run test -- --parallel=1`; typecheck, privacy scan and documentation build passed. Focused protocol coverage:405pass1skip0fail. +- While CI ran, dev advanced to b65b9d8f2 with BigModel/Raycast changes. Conflict-free integration cc6afe2c97fb423363e99682b906bcb529478688 passed remote typecheck and633tests1skip0fail across15 relevant files, including shared passthrough/registry/layout guards. +- Actual runtime landing tree at4349cf3ce equals the integration tree ccaf0a0383cb3e8808e24576271c861625b506fb exactly. This is integration proof, not a claim that the earlier full CI ran on4349cf3ce. +- Independent Astra high source/security/integration reviews passed. The terminal-closure overflow finding was fixed before acceptance. New-test oracle mistakes found remotely were corrected without weakening exact assistant-array or pairing assertions. +- The earlier parallel remote run had21 catalog timeouts; isolated and final sequential runs passed, and final hosted CI passed. No separate root-cause fix is claimed. +- No local test suite or typecheck ran. All pushes used `--no-verify`. Native stacks and fabricated check statuses were not used. Automatic lower/intermediate CI was deferred or cancelled under the owner's combined-first direction. + +## Explicit remainders + +#3807 remains open: the demonstrated complete external-task envelope is already supported, and current-version raw reporter reproduction is unavailable. New ordinary, stored-ID continuation, v2-trigger and v1-compact fixtures preserve established history without relaxing missing-call-ID validation. + +#3719 remains open: live intended-Anthropic acceptance and controlled cache measurements are unverified. Locally hidden text through the Claude boundary and legacy combined-envelope streaming ordering remain outside the preservation claim. Existing compatibility enforcement, hidden display, authentication, routing and cache-retention defaults remain intact. + +The supplied dirty worktree and existing remote main checkout were preserved; execution used separate task worktrees. No release, deployment or account configuration change was made. diff --git a/devlog/_plan/260907_axis3_protocol/020_delivery.md b/devlog/_plan/260907_axis3_protocol/020_delivery.md deleted file mode 100644 index 050cc76d54..0000000000 --- a/devlog/_plan/260907_axis3_protocol/020_delivery.md +++ /dev/null @@ -1,7 +0,0 @@ -# Publish and deliver verified manual chain - -Prerequisite: wp1 accepted-source review and successful final-head remote validation, or source-grounded defer outcome. Publish ordinary PRs targeting dev then the parent branch, using every repository template section. Bodies name source PRs, own layer-only diff, exact final combined CI evidence and explicit lower-layer CI deferral per owner instruction. Do not attest local CI. Preserve original contributor trailers in commits; admin merge with merge commits preserves their identity. - -Read live native-stack membership and head/base identity before merge. Never register a native stack. Parent merges to dev first; retain its branch, retarget child to dev, verify current head and ancestry. If integration tree changes materially, refresh final combined CI before landing. Use --admin and --match-head-commit exact guard. Do not merge into the parent branch by mistake. Refresh origin/dev and prove each merge SHA ancestor. Close superseded source PRs only after equivalent fix is actually landed, with credit and replacement link. Keep #3807 and #3719 open if real reproduction/cache acceptance remains unmet. No release or deployment. - -Record final PR URLs, source-to-delivery mapping, commit authors/trailers, CI run and exact SHA, review verdicts, remaining limitations and preserved dirty-work evidence. No fabricated status checks. Completion: every candidate has an honest disposition, accepted work is landed, unresolved diagnostics explicitly deferred under user direction. From 91fba4b4cd3d31e79b7d404b3d201c7b9896f67d Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:41:25 +0900 Subject: [PATCH 85/99] ci: gate source-build Docker lifecycle verification [skip ci] Add Docker packaging paths to the existing CI scope, execute the isolated lifecycle smoke, and include it in the aggregate gate. Extend the workflow oracle and typecheck the new script. Final integration will be dispatched explicitly with lane=all; redundant lower-layer runs are intentionally skipped. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- .github/workflows/ci.yml | 31 ++++++++++++++++- .../src/content/docs/guides/remote-hub.md | 6 ++++ tests/ci-workflows/ci-workflows.test.ts | 34 ++++++++++++++++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c15cb696a..a6c0e7ab1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,10 @@ on: push: branches: [main, preview, dev] paths: + - "Dockerfile" + - "compose.yaml" + - ".dockerignore" + - "docker/**" - "src/**" - "bin/**" - "tests/**" @@ -180,6 +184,10 @@ jobs: # start the workflow so the aggregate check exists, while these # paths decide whether the expensive test jobs need to run. ci: + - 'Dockerfile' + - 'compose.yaml' + - '.dockerignore' + - 'docker/**' - 'src/**' - 'bin/**' - 'tests/**' @@ -423,6 +431,7 @@ jobs: run: | bun x tsc --noEmit bun x tsc --noEmit -p tests/tsconfig.doctor-service-memory-contract.json + bun x tsc --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts - name: GUI tests run: cd gui && bun test --isolate tests @@ -908,6 +917,26 @@ jobs: bun run scripts/keyring-smoke.ts ' + # Exercise the source-build Compose contract, including real volume reuse. + # Host fixtures cannot prove image construction or container recreation. + docker-smoke: + name: docker smoke + needs: changes + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Build, start, and recreate the container + run: bun scripts/ci/docker-smoke.ts + npm-global-smoke: name: npm-global ${{ matrix.os }} needs: changes @@ -986,7 +1015,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped` — which the gate is required to read as a deliberate skip. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, npm-global-smoke] + needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, npm-global-smoke] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 460fe4651d..6a2b2a33bd 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -291,6 +291,12 @@ unreadable, a non-loopback hub must not be accepted as ready. Never treat livene `docker compose down --volumes` as destructive: it deletes configuration, OAuth credentials, usage history, the data-plane token, and persisted Codex state together. +Cross-platform CI builds the source image and checks startup, data-plane token admission, and +container recreation using an isolated Compose project with throwaway credentials. It verifies that +both named volumes and a synthetic catalog survive replacement. This check does not validate a +real provider account, OAuth callback, custom mount migration, or every CPU architecture; perform +the authenticated routed-response check above for your deployment. + ## Rollback Inspect existing Serve mappings before changing them. `tailscale serve reset` removes every mapping diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index ec2c919c45..77192bbb48 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -506,17 +506,21 @@ describe("GitHub Actions hardening", () => { // allowlist. PRs always create the workflow and aggregate check; this list // decides whether the costly jobs run. Pin the entire list on both paths. const ciPaths = [ + ".dockerignore", ".gitattributes", ".github/workflows/ci.yml", ".github/workflows/enforce-pr-target.yml", ".github/workflows/release.yml", ".github/workflows/stale-needs-info.yml", ".npmignore", + "Dockerfile", "LICENSE", "README.md", "assets/**", "bin/**", "bun.lock", + "compose.yaml", + "docker/**", "gui/**", "package.json", "scripts/**", @@ -563,7 +567,7 @@ describe("GitHub Actions hardening", () => { expect(scopeIndex).toBeGreaterThan(filterIndex); const scopedCondition = "github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'"; - for (const jobName of ["test", "storage-policy", "gates", "platform-macos", "keyring-smoke"]) { + for (const jobName of ["test", "storage-policy", "gates", "platform-macos", "keyring-smoke", "docker-smoke"]) { const job = ci.jobs?.[jobName] as { needs?: string; if?: string } | undefined; expect(`${jobName}:${job?.needs}`).toBe(`${jobName}:changes`); expect(`${jobName}:${job?.if}`).toBe(`${jobName}:${scopedCondition}`); @@ -573,6 +577,34 @@ describe("GitHub Actions hardening", () => { expect(macosControlIf?.if).toBe("github.event_name == 'workflow_dispatch'"); }); + test("Docker smoke executes the source-build lifecycle and gates its result", async () => { + const ci = Bun.YAML.parse(await readText(".github/workflows/ci.yml")) as { + jobs?: Record; + steps?: Array<{ name?: string; run?: string; if?: string; "continue-on-error"?: boolean }>; + }>; + }; + const smoke = ci.jobs?.["docker-smoke"]; + expect(smoke?.["runs-on"]).toBe("ubuntu-latest"); + expect(smoke?.["timeout-minutes"]).toBe(20); + expect(smoke?.["continue-on-error"]).toBeUndefined(); + expect(smoke?.permissions).toBeUndefined(); // Inherits workflow contents:read. + const execution = smoke?.steps?.find(step => + hasExactShellCommand(step.run, "bun scripts/ci/docker-smoke.ts")); + expect(execution).toBeDefined(); + expect(execution?.if).toBeUndefined(); + expect(execution?.["continue-on-error"]).toBeUndefined(); + expect(ci.jobs?.ci?.needs).toContain("docker-smoke"); + const typecheck = ci.jobs?.gates?.steps?.find(step => step.name === "Typecheck"); + expect(hasExactShellCommand(typecheck?.run, + "bun x tsc --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts", + )).toBe(true); + }); + test("cross-platform CI keeps the GUI lint and build gates", async () => { // Review finding (PR #97): the GUI build gate was silently dropped once; assert the // enhanced gate (PR #99) stays wired so broken GUI builds cannot merge unnoticed. From 15fa571554d8eda59b91ca05a931dcb98c2c8a2c Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 08:54:51 +0900 Subject: [PATCH 86/99] fix(container): retain routed catalog across managed recreation [skip ci] Real Docker CI exposed foreground shutdown stripping routed catalog rows despite persistent volumes. Declare the existing service lifecycle mode in the runtime image so Docker-managed stop/recreate preserves routed state. Keep the routed fixture and exact-byte checks. Two independent lifecycle/security reviews accepted this bounded packaging change; isolated CLI before/after reproduced the defect and preservation with service mode. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- Dockerfile | 3 +++ .../025_container_lifecycle_mode.md | 10 ++++++++++ docs-site/src/content/docs/guides/remote-hub.md | 6 +++++- scripts/ci/docker-smoke.ts | 1 + tests/service/container-bootstrap.test.ts | 1 + 5 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260907_platform_validation/025_container_lifecycle_mode.md diff --git a/Dockerfile b/Dockerfile index 1ed000743d..5987bfa991 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,10 @@ RUN cd gui && bun run build FROM ${BUN_IMAGE} AS runtime WORKDIR /home/bun/app +# Docker supervises this foreground process; retain routed state on stop/recreate. +# This uses the existing service lifecycle mode and does not install a service manager. ENV NODE_ENV=production \ + OCX_SERVICE=1 \ OPENCODEX_HOME=/home/bun/.opencodex \ CODEX_HOME=/home/bun/.codex \ OCX_API_TOKEN_FILE=/home/bun/.opencodex/service-api-token diff --git a/devlog/_plan/260907_platform_validation/025_container_lifecycle_mode.md b/devlog/_plan/260907_platform_validation/025_container_lifecycle_mode.md new file mode 100644 index 0000000000..c86f4fc765 --- /dev/null +++ b/devlog/_plan/260907_platform_validation/025_container_lifecycle_mode.md @@ -0,0 +1,10 @@ +# Container lifecycle mode + +Amendment after real Docker recreation verification. Docker supervises the foreground hub and must retain persisted routed state across replacement. + +MODIFY Dockerfile runtime ENV: set existing OCX_SERVICE=1, with no service manager installation or privilege change. Preserve image digest, foreground CMD, listener authentication, separate writable homes and read-only root. +MODIFY scripts/ci/docker-smoke.ts: assert the actual container process receives service lifecycle mode. Retain the routed synthetic slug and exact token/catalog/config hashes across graceful recreation. +MODIFY tests/service/container-bootstrap.test.ts: include the runtime ENV declaration in the existing packaging contract. +MODIFY docs-site/src/content/docs/guides/remote-hub.md: document service-mode foreground lifecycle, Compose restart/recreation, and the limit on other dashboard restart paths. + +Independent Astra high lifecycle/security review accepted the bounded packaging change. Actual remote CLI comparison confirmed preservation with service mode. Final image CI must prove the same real container lifecycle; no local tests or Docker execution. This does not change shared CLI cleanup, restart policy, or authentication code. diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 2334303be7..460fe4651d 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -167,7 +167,11 @@ opencodex does not publish an official container image. The repository does main [`compose.yaml`](https://github.com/lidge-jun/opencodex/blob/main/compose.yaml), and a narrow `.dockerignore`. The build pins the multi-platform Bun 1.4.0 image index by digest, runs the proxy as the non-root `bun` user, keeps the root filesystem read-only, drops Linux capabilities, and publishes -only the data listener on the host's `127.0.0.1:10100` by default. +only the data listener on the host's `127.0.0.1:10100` by default. The foreground process uses +`OCX_SERVICE=1`, so stopping or recreating the container preserves routed Codex state instead +of restoring a native desktop configuration. Docker supplies supervision; no OS service manager +is installed in the image. Use Compose to restart/recreate the container; this does not extend +support to every dashboard restart path. The image seeds a first-run `hub` configuration that binds the container listener to `0.0.0.0`. Before the first normal start, stream a freshly generated data-plane token into the bootstrap helper. diff --git a/scripts/ci/docker-smoke.ts b/scripts/ci/docker-smoke.ts index e26b11de34..c2a7ec0ee2 100644 --- a/scripts/ci/docker-smoke.ts +++ b/scripts/ci/docker-smoke.ts @@ -190,6 +190,7 @@ const stateProbe = ` if (!['seed', 'first-ready', 'steady'].includes(phase)) throw new Error('invalid state phase'); ${fixtureConfigCheck} const homes = ['/home/bun/.opencodex', '/home/bun/.codex']; + if (process.env.OCX_SERVICE !== '1') throw new Error('image service lifecycle mode missing'); const uid = process.getuid(); if (uid === 0) throw new Error('root user'); const status = readFileSync('/proc/self/status', 'utf8'); diff --git a/tests/service/container-bootstrap.test.ts b/tests/service/container-bootstrap.test.ts index ada807d1d0..9eec8edd6b 100644 --- a/tests/service/container-bootstrap.test.ts +++ b/tests/service/container-bootstrap.test.ts @@ -63,6 +63,7 @@ describe("container deployment contract", () => { const runtime = readFileSync(repoPath("Dockerfile"), "utf8").split(" AS runtime")[1]!; expect(runtime).toContain("OPENCODEX_HOME=/home/bun/.opencodex"); expect(runtime).toContain("CODEX_HOME=/home/bun/.codex"); + expect(runtime).toContain("OCX_SERVICE=1"); expect(runtime).toContain("install -d -m 0700 -o bun -g bun /home/bun/.opencodex /home/bun/.codex"); expect(runtime).toContain('VOLUME ["/home/bun/.opencodex", "/home/bun/.codex"]'); expect(runtime).toContain("USER bun"); From 6f2ad1ef32c113ff5827c58959c85cefa923b15c Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:52:43 +0900 Subject: [PATCH 87/99] ci: select explicit-file typecheck mode for TypeScript 7 [skip ci] Final run 34064754947 passed root typechecks but rejected the standalone smoke-script command with TS5112. Explicit --ignoreConfig retains all strict script flags and preserves the existing root/project checks. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- .github/workflows/ci.yml | 2 +- tests/ci-workflows/ci-workflows.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6c0e7ab1a..00abe5ec24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -431,7 +431,7 @@ jobs: run: | bun x tsc --noEmit bun x tsc --noEmit -p tests/tsconfig.doctor-service-memory-contract.json - bun x tsc --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts + bun x tsc --ignoreConfig --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts - name: GUI tests run: cd gui && bun test --isolate tests diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 77192bbb48..c967f9d4c0 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -601,7 +601,7 @@ describe("GitHub Actions hardening", () => { expect(ci.jobs?.ci?.needs).toContain("docker-smoke"); const typecheck = ci.jobs?.gates?.steps?.find(step => step.name === "Typecheck"); expect(hasExactShellCommand(typecheck?.run, - "bun x tsc --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts", + "bun x tsc --ignoreConfig --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts", )).toBe(true); }); From a3c2eb56216ac4f3331e7bc4b43b47e1d5d2ac6c Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 09:15:21 +0900 Subject: [PATCH 88/99] fix(anthropic): expire retained quota measurements at known resets [skip ci] Address #3825 review discussion_r3945728864. Retained standard and model-specific measurements become unknown after their known reset, including idle reads, hydration, persistence and joined failed probes. Reset-only headers cannot renew old usage. Keep unknown-reset behavior, probe clocks, unavailability and credential policy unchanged. Add real quota-evidence/manual-selection and persistence regressions; no local suites run per maintainer instruction. Original #3809 credit remains in ancestor f215f79b4. --- .../src/content/docs/guides/claude-code.md | 4 +- .../docs/reference/configuration/providers.md | 7 +- src/providers/quota.ts | 67 ++++- .../anthropic-ratelimit-headers.test.ts | 237 +++++++++++++++++- 4 files changed, 299 insertions(+), 16 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 2e841e205c..720a303b00 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -36,7 +36,9 @@ Operational contract when enabled: two the response carries is recorded for that account — each window independently, and a refusal counts as well as a success. Usage-aware selection works from ordinary traffic, without waiting for a dashboard poll. Headers preserve model-specific quota windows and do - not postpone usage probes or clear a failed usage probe's unavailable status. + not postpone usage probes or clear a failed usage probe's unavailable status. Measurements + whose known reset time has passed are discarded as unknown, including retained model-specific + windows. Values without a known reset are preserved; missing data is never reported as zero usage. - Affinity is **process-local** (lost on proxy restart). - **401/403** credential failures quarantine the account (`needsReauth`) so it is excluded from selection until re-authenticated. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c80176868e..710c069b24 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -446,7 +446,12 @@ of those two a given response carries is recorded against that account — each on refusals as well as successes. Usage-aware selection therefore works from the accounts you actually use, without waiting for the dashboard Providers page to poll them. These readings refresh the existing row rather than replacing it, so the model-scoped weekly bars that only the usage -endpoint reports are preserved. Header observations do not postpone usage probes or clear a failed +endpoint reports are preserved until their known reset time passes. Expired measurements become +unknown, including retained standard windows omitted by later headers. A reset-only header cannot +extend an older utilization measurement. Values with no known reset retain their existing behavior; +missing measurements are never replaced with zero usage. + +Header observations do not postpone usage probes or clear a failed probe's unavailable status. After restart, cached Anthropic observations remain available while the next quota read probes again, because the saved observations do not include the probe clock. diff --git a/src/providers/quota.ts b/src/providers/quota.ts index dbdb6699f7..5dcb69beb3 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1552,6 +1552,37 @@ type AccountQuotaCacheEntry = { identity?: string; isCurrent?: () => boolean; }; +/** Expired measurements become unknown; missing reset evidence never implies a fresh allowance. */ +function normalizeAnthropicQuota(quota: ProviderQuota | null | undefined, now: number): ProviderQuota | null { + if (!quota) return null; + const expired = (resetAt: number | undefined): boolean => typeof resetAt === "number" + && Number.isFinite(resetAt) && resetAt > 0 && resetAt <= now; + let result = quota; + for (const [percent, reset] of [ + ["fiveHourPercent", "fiveHourResetAt"], + ["weeklyPercent", "weeklyResetAt"], + ["monthlyPercent", "monthlyResetAt"], + ] as const) { + if (!expired(quota[reset])) continue; + if (result === quota) result = { ...quota }; + delete result[percent]; + delete result[reset]; + } + // Persisted rows validate only the outer quota object, so custom data may be malformed. + if (quota.customWindows !== undefined) { + const windows = Array.isArray(quota.customWindows) ? quota.customWindows : []; + const retained = windows.filter(window => window !== null && typeof window === "object" + && typeof window.label === "string" && typeof window.percent === "number" + && Number.isFinite(window.percent) && !expired(window.resetAt)); + if (!Array.isArray(quota.customWindows) || retained.length !== windows.length) { + if (result === quota) result = { ...quota }; + if (retained.length) result.customWindows = retained; + else delete result.customWindows; + } + } + return hasQuotaRows(result) ? result : null; +} + const accountQuotaCache = new Map(); let explicitAccountEpoch = 0; @@ -1570,15 +1601,21 @@ function hydrateAccountQuotaCache(): void { for (const [key, quota] of readPersistedAccountQuotas()) { // Disk stores observation time, not the Anthropic usage probe's clock. if (!accountQuotaCache.has(key)) { - accountQuotaCache.set(key, { ts: key.startsWith("anthropic\u0000") ? 0 : quota.updatedAt, quota }); + const anthropic = key.startsWith("anthropic\u0000"); + accountQuotaCache.set(key, { + ts: anthropic ? 0 : quota.updatedAt, + quota: anthropic ? normalizeAnthropicQuota(quota, Date.now()) : quota, + }); } } } function persistAccountQuotaCache(): void { schedulePersistAccountQuotas(function* () { + const now = Date.now(); for (const [key, entry] of accountQuotaCache) { - if (entry.quota) yield [key, entry.quota] as [string, ProviderQuota]; + const quota = key.startsWith("anthropic\u0000") ? normalizeAnthropicQuota(entry.quota, now) : entry.quota; + if (quota) yield [key, quota] as [string, ProviderQuota]; } }); } @@ -1628,7 +1665,7 @@ function accountCacheKey(provider: string, accountId: string): string { export function getCachedProviderAccountQuota(provider: string, accountId: string): ProviderQuota | null { const entry = accountQuotaCache.get(accountCacheKey(provider, accountId)); if (entry?.isCurrent && !entry.isCurrent()) return null; - return entry?.quota ?? null; + return provider === "anthropic" ? normalizeAnthropicQuota(entry?.quota, Date.now()) : entry?.quota ?? null; } /** Test-only: seed or clear the per-account quota cache without probing upstream. */ @@ -1661,9 +1698,9 @@ export function parseAnthropicRateLimitHeaders(headers: Headers): ProviderQuota const weeklyResetAt = anthropicHeaderResetAt(headers.get("anthropic-ratelimit-unified-7d-reset")); return { ...(fiveHourPercent !== undefined ? { fiveHourPercent } : {}), - ...(fiveHourResetAt !== undefined ? { fiveHourResetAt } : {}), + ...(fiveHourPercent !== undefined && fiveHourResetAt !== undefined ? { fiveHourResetAt } : {}), ...(weeklyPercent !== undefined ? { weeklyPercent } : {}), - ...(weeklyResetAt !== undefined ? { weeklyResetAt } : {}), + ...(weeklyPercent !== undefined && weeklyResetAt !== undefined ? { weeklyResetAt } : {}), updatedAt: Date.now(), }; } @@ -1700,7 +1737,9 @@ export function recordAnthropicAccountQuotaFromHeaders( ...previous, // Headers do not prove that the last usage probe succeeded. ts: previous?.ts ?? 0, - quota: { ...(previous?.quota ?? {}), ...observed }, + quota: normalizeAnthropicQuota({ + ...normalizeAnthropicQuota(previous?.quota, observed.updatedAt), ...observed, + }, observed.updatedAt), }); persistAccountQuotaCache(); } @@ -1978,7 +2017,9 @@ async function fetchAccountQuota( const key = accountCacheKey(provider, accountId); const writerGeneration = captureConfigGeneration(); const cached = accountQuotaCache.get(key); - if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) return cached; + if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) { + return provider === "anthropic" ? { ...cached, quota: normalizeAnthropicQuota(cached.quota, Date.now()) } : cached; + } const joinable = accountQuotaInflight.get(key); if (joinable) return joinable; @@ -2016,7 +2057,8 @@ async function fetchAccountQuota( const entry: AccountQuotaCacheEntry = { ts: Date.now(), // Settle once for all joiners against observations committed during the probe. - quota: (provider === "anthropic" ? accountQuotaCache.get(key)?.quota : cached?.quota) ?? null, + quota: provider === "anthropic" + ? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null, unavailable: true, }; if (mayCommitAccountQuotaKey(key, writerGeneration)) { @@ -2026,7 +2068,9 @@ async function fetchAccountQuota( } return entry; } - const entry: AccountQuotaCacheEntry = { ts: Date.now(), quota }; + const entry: AccountQuotaCacheEntry = { + ts: Date.now(), quota: provider === "anthropic" ? normalizeAnthropicQuota(quota, Date.now()) : quota, + }; if (mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); // Exhaustion state rides the SAME commit guard as the quota row: a probe from a @@ -2038,7 +2082,8 @@ async function fetchAccountQuota( } catch { const entry: AccountQuotaCacheEntry = { ts: Date.now(), - quota: (provider === "anthropic" ? accountQuotaCache.get(key)?.quota : cached?.quota) ?? null, + quota: provider === "anthropic" + ? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null, unavailable: true, }; if (mayCommitAccountQuotaKey(key, writerGeneration)) { @@ -2070,7 +2115,7 @@ export async function fetchProviderAccountQuotas( const entry = await fetchAccountQuota(provider, account.id, forceRefresh, providerConfig); const result: ProviderAccountQuota = { accountId: account.id, - quota: entry.quota, + quota: provider === "anthropic" ? normalizeAnthropicQuota(entry.quota, Date.now()) : entry.quota, ...(entry.unavailable ? { unavailable: true as const } : {}), }; if (!explicitAccountReader(provider)) return result; diff --git a/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts b/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts index 21ae2d8489..77989064ff 100644 --- a/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts +++ b/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts @@ -1,6 +1,6 @@ /** Anthropic response observations must preserve account usage and probe semantics. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -9,8 +9,11 @@ import { forgetAnthropicFailoverQuorum, getAnthropicAccountHealthSnapshot, rotateAnthropicAccountOn429, + resetAnthropicRoutingForManualSelection, + resolveAnthropicAccountForSession, } from "../../../src/oauth/anthropic-routing"; import { projectStoredOAuthAccountHealth } from "../../../src/oauth/health"; +import { quotaEvidenceForCandidate } from "../../../src/routing/quota"; import { clearAccountQuotaCache, fetchProviderAccountQuotas, @@ -22,7 +25,7 @@ import { setCachedProviderAccountQuotaForTests, sweepExpiredProviderAccountQuotaRows, } from "../../../src/providers/quota"; -import { getAccountSet, saveCredential } from "../../../src/oauth/store"; +import { getAccountSet, saveCredential, setActiveAccount } from "../../../src/oauth/store"; import { clearPoolRotationState } from "../../../src/codex/pool-rotation"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; import type { OcxConfig } from "../../../src/types"; @@ -526,3 +529,231 @@ describe("Anthropic malformed deadlines and partial windows", () => { }); }); }); + +describe("Anthropic known-reset expiry", () => { + const start = 1_800_000_000_000; + let now: number; + + beforeEach(() => { + now = start; + Date.now = () => now; + }); + + function observe(id: string, headers: Record = { + "anthropic-ratelimit-unified-5h-utilization": "0.41", + }): void { + recordAnthropicAccountQuotaFromHeaders(id, new Headers(headers), 0); + } + + test("headers expire only known elapsed custom windows without mutating their source", async () => { + const [id] = await seed(1); + const saved = { + fiveHourPercent: 10, + customWindows: [ + { label: "Opus", percent: 100, resetAt: start + 60_000 }, + { label: "Sonnet", percent: 90, resetAt: start + 600_000 }, + { label: "Fable", percent: 70 }, + { label: "Unknown reset", percent: 60, resetAt: 0 }, + ], + updatedAt: start, + }; + setCachedProviderAccountQuotaForTests("anthropic", id!, saved); + now += 120_000; + observe(id!); + const quota = getCachedProviderAccountQuota("anthropic", id!); + expect(quota?.customWindows).toEqual(saved.customWindows.slice(1)); + expect(quota?.fiveHourPercent).toBe(41); + expect(quota?.updatedAt).toBe(now); + expect(saved.customWindows).toHaveLength(4); + expect(saved.updatedAt).toBe(start); + now += 30_000; + observe(id!); + expect(getCachedProviderAccountQuota("anthropic", id!)?.customWindows).toEqual(saved.customWindows.slice(1)); + }); + + for (const [percent, reset, observedWindow] of [ + ["fiveHourPercent", "fiveHourResetAt", "7d"], + ["weeklyPercent", "weeklyResetAt", "5h"], + ["monthlyPercent", "monthlyResetAt", "5h"], + ] as const) { + test(`partial headers remove the expired ${percent} pair without inventing zero`, async () => { + const [id] = await seed(1); + setCachedProviderAccountQuotaForTests("anthropic", id!, { + [percent]: 100, [reset]: start + 60_000, updatedAt: start, + }); + now += 60_000; + observe(id!, { [`anthropic-ratelimit-unified-${observedWindow}-utilization`]: "0.2" }); + const quota = getCachedProviderAccountQuota("anthropic", id!); + expect(quota).not.toBeNull(); + expect(quota?.[percent]).toBeUndefined(); + expect(quota?.[reset]).toBeUndefined(); + }); + } + + test("standard windows without reset evidence remain known", async () => { + const [id] = await seed(1); + setCachedProviderAccountQuotaForTests("anthropic", id!, { weeklyPercent: 100, updatedAt: start }); + now += 120_000; + observe(id!); + expect(getCachedProviderAccountQuota("anthropic", id!)?.weeklyPercent).toBe(100); + }); + + test("a reset-only header cannot extend retained usage even before the original reset", async () => { + const [id] = await seed(1); + setCachedProviderAccountQuotaForTests("anthropic", id!, { + fiveHourPercent: 10, weeklyPercent: 100, weeklyResetAt: start + 60_000, updatedAt: start, + }); + now += 30_000; + observe(id!, { + "anthropic-ratelimit-unified-5h-utilization": "0.2", + "anthropic-ratelimit-unified-7d-utilization": "invalid", + "anthropic-ratelimit-unified-7d-reset": String((start + 600_000) / 1000), + }); + expect(getCachedProviderAccountQuota("anthropic", id!)?.weeklyResetAt).toBe(start + 60_000); + now += 30_000; + expect(getCachedProviderAccountQuota("anthropic", id!)?.weeklyPercent).toBeUndefined(); + expect(getCachedProviderAccountQuota("anthropic", id!)?.weeklyResetAt).toBeUndefined(); + observe(id!, { + "anthropic-ratelimit-unified-7d-utilization": "0.3", + "anthropic-ratelimit-unified-7d-reset": String((start + 600_000) / 1000), + }); + expect(getCachedProviderAccountQuota("anthropic", id!)).toMatchObject({ + weeklyPercent: 30, weeklyResetAt: start + 600_000, + }); + }); + + test("idle cache reads cross a reset without another observation or probe", async () => { + const [id] = await seed(1); + const quota = { customWindows: [{ label: "Opus", percent: 100, resetAt: start + 60_000 }], updatedAt: start }; + setCachedProviderAccountQuotaForTests("anthropic", id!, quota); + setCachedProviderAccountQuotaForTests("kiro", "untouched", quota); + const candidate = { provider: "anthropic", model: "claude-opus-4-6", accountRef: id! }; + now += 59_999; + expect(getCachedProviderAccountQuota("anthropic", id!)).toEqual(quota); + expect(quotaEvidenceForCandidate(candidate)).toMatchObject({ known: true, exhausted: true, headroom: 0 }); + now++; + expect(getCachedProviderAccountQuota("anthropic", id!)).toBeNull(); + expect(quotaEvidenceForCandidate(candidate)).toEqual({ known: false }); + const [row] = await fetchProviderAccountQuotas("anthropic"); + expect(row?.quota).toBeNull(); + expect(row?.unavailable).toBeUndefined(); + expect(getCachedProviderAccountQuota("kiro", "untouched")).toBe(quota); + }); + + test("expired Opus evidence stops suppressing an otherwise healthy manual selection", async () => { + const [a, b] = await seed(2); + setCachedProviderAccountQuotaForTests("anthropic", a!, { + fiveHourPercent: 30, customWindows: [{ label: "Opus", percent: 100, resetAt: start + 60_000 }], updatedAt: start, + }); + setCachedProviderAccountQuotaForTests("anthropic", b!, { fiveHourPercent: 11, updatedAt: start }); + await setActiveAccount("anthropic", a!); + resetAnthropicRoutingForManualSelection(a!); + const config = poolEnabled(); + config.anthropicAccountPool = { enabled: true, strategy: "quota", autoSwitchThreshold: 20 }; + const candidate = { provider: "anthropic", model: "claude-opus-4-6", accountRef: a! }; + expect(resolveAnthropicAccountForSession(null, config, now).accountId).toBe(b); + expect(quotaEvidenceForCandidate(candidate)).toMatchObject({ known: true, exhausted: true, headroom: 0 }); + now += 60_000; + expect(resolveAnthropicAccountForSession(null, config, now)).toMatchObject({ accountId: a, reason: "manual" }); + expect(quotaEvidenceForCandidate(candidate)).toMatchObject({ known: true, exhausted: false, headroom: 0.7 }); + }); + + for (const failure of ["http", "network"] as const) { + test(`joined ${failure} failures remove windows expiring during the shared probe`, async () => { + const [id] = await seed(1); + setCachedProviderAccountQuotaForTests("anthropic", id!, { + fiveHourPercent: 10, weeklyPercent: 100, weeklyResetAt: start + 60_000, + customWindows: [{ label: "Opus", percent: 100, resetAt: start + 60_000 }, { label: "Fable", percent: 63 }], + updatedAt: start, + }); + let started!: () => void; + const dispatched = new Promise(resolve => { started = resolve; }); + let finish!: (response: Response) => void; + let fail!: (error: Error) => void; + const response = new Promise((resolve, reject) => { finish = resolve; fail = reject; }); + let calls = 0; + globalThis.fetch = (async () => { calls++; started(); return response; }) as typeof fetch; + const first = fetchProviderAccountQuotas("anthropic", true); + await dispatched; + const second = fetchProviderAccountQuotas("anthropic", true); + now += 30_000; + observe(id!); + now += 30_000; + if (failure === "http") finish(new Response("busy", { status: 429 })); + else fail(new Error("offline")); + const [a, b] = await Promise.all([first, second]); + expect(calls).toBe(1); + expect(a).toEqual(b); + expect(a[0]?.unavailable).toBe(true); + expect(a[0]?.quota).toEqual({ fiveHourPercent: 41, customWindows: [{ label: "Fable", percent: 63 }], updatedAt: start + 30_000 }); + expect(getCachedProviderAccountQuota("anthropic", id!)).toEqual(a[0]?.quota); + expect((await fetchProviderAccountQuotas("anthropic"))[0]).toEqual(a[0]); + expect(calls).toBe(1); + }); + } + + test("restart cannot revive expired bars from a recently updated disk row", async () => { + const [id] = await seed(1); + now += 120_000; + writeFileSync(join(home, "provider-account-quota-cache.json"), JSON.stringify({ version: 1, rows: { + [`anthropic\u0000${id}`]: { + fiveHourPercent: 41, weeklyPercent: 100, weeklyResetAt: start + 60_000, + customWindows: [{ label: "Opus", percent: 100, resetAt: start + 60_000 }, { label: "Fable", percent: 63 }], + updatedAt: now, + }, + } })); + clearAccountQuotaCache(); + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response("busy", { status: 429 }); }) as typeof fetch; + const [row] = await fetchProviderAccountQuotas("anthropic"); + expect(calls).toBe(1); + expect(row?.unavailable).toBe(true); + expect(row?.quota).toEqual({ fiveHourPercent: 41, customWindows: [{ label: "Fable", percent: 63 }], updatedAt: now }); + }); + + for (const malformed of [null, {}, [null, "bad", { label: "invalid", percent: "100" }]]) { + test(`malformed persisted custom windows stay unknown without breaking other rows: ${JSON.stringify(malformed)}`, async () => { + const [id] = await seed(1); + writeFileSync(join(home, "provider-account-quota-cache.json"), JSON.stringify({ version: 1, rows: { + [`anthropic\u0000${id}`]: { customWindows: malformed, updatedAt: now }, + "kiro\u0000untouched": { monthlyPercent: 17, updatedAt: now }, + } })); + clearAccountQuotaCache(); + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response("busy", { status: 429 }); }) as typeof fetch; + const [row] = await fetchProviderAccountQuotas("anthropic"); + expect(calls).toBe(1); + expect(row?.quota).toBeNull(); + expect(row?.unavailable).toBe(true); + expect(getCachedProviderAccountQuota("kiro", "untouched")).toEqual({ monthlyPercent: 17, updatedAt: now }); + }); + } + + test("fresh utilization without a reset does not inherit an expired reset", async () => { + const [id] = await seed(1); + setCachedProviderAccountQuotaForTests("anthropic", id!, { + fiveHourPercent: 100, fiveHourResetAt: start + 60_000, updatedAt: start, + }); + now += 60_000; + observe(id!); + expect(getCachedProviderAccountQuota("anthropic", id!)).toEqual({ fiveHourPercent: 41, updatedAt: now }); + }); + + test("deferred persistence evaluates expiry at write time and leaves other providers intact", async () => { + const [id] = await seed(1); + const saved = { weeklyPercent: 100, weeklyResetAt: start + 60_000, updatedAt: start }; + setCachedProviderAccountQuotaForTests("anthropic", id!, saved); + setCachedProviderAccountQuotaForTests("kiro", "untouched", saved); + let flush!: () => void; + const timer = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + flush = callback; + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + try { observe(id!); } finally { timer.mockRestore(); } + now += 60_000; + flush(); + const disk = JSON.parse(readFileSync(join(home, "provider-account-quota-cache.json"), "utf8")); + expect(disk.rows[`anthropic\u0000${id}`]).toEqual({ fiveHourPercent: 41, updatedAt: start }); + expect(disk.rows["kiro\u0000untouched"]).toEqual(saved); + }); +}); From 54fcc688d7b23677892a01803fc07663fbdd2074 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 09:22:08 +0900 Subject: [PATCH 89/99] test(anthropic): keep probe fixtures inside live reset windows [skip ci] The attribution/cache tests used July 2026 quota reset dates, which correctly expire under the known-reset fix. Generate future reset dates from one clock snapshot; preserve all existing assertions. Explicit expiry tests retain fixed simulated boundaries. No local suites run. --- tests/providers/provider-account-quota.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index 989f55a410..1b940d71d0 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.test.ts @@ -33,9 +33,11 @@ async function seedTwoAccounts(): Promise { } function usageBody(fiveHour: number, sevenDay: number): string { + // These tests exercise current account measurements, not expired historical windows. + const now = Date.now(); return JSON.stringify({ - five_hour: { utilization: fiveHour, resets_at: "2026-07-05T12:00:00Z" }, - seven_day: { utilization: sevenDay, resets_at: "2026-07-08T12:00:00Z" }, + five_hour: { utilization: fiveHour, resets_at: new Date(now + 5 * 60 * 60_000).toISOString() }, + seven_day: { utilization: sevenDay, resets_at: new Date(now + 7 * 24 * 60 * 60_000).toISOString() }, }); } From d3c70f9d8c8cc6fced7a93577b93e8b141473ea3 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 09:44:37 +0900 Subject: [PATCH 90/99] fix(anthropic): normalize retained quota metadata and guard test transport [skip ci] Reject unusable custom labels/percentages, omit invalid reset metadata while retaining valid usage, and state the existing 60-second fallback. Guard unexpected test network calls with teardown-safe restoration. No new TTL or synthetic quota values. Addresses #3825 review findings; no local suite was run. --- .../src/content/docs/guides/claude-code.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- src/providers/quota.ts | 38 ++++++++--- .../anthropic-quota-dispatch.test.ts | 64 +++++++++++++------ .../anthropic-ratelimit-headers.test.ts | 63 +++++++++++++++++- 5 files changed, 137 insertions(+), 32 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 720a303b00..4c5cc44b34 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -31,7 +31,7 @@ Operational contract when enabled: account within the same request (bounded). The cooldown uses a usable `Retry-After` when present, otherwise the latest valid reset time among windows Anthropic marks `rejected`, including weekly windows. Valid upstream deadlines are not shortened to a fixed cooldown ceiling. - A refusal with no usable deadline falls back to a default backoff. + A refusal with no usable deadline falls back to a 60-second default backoff. - Responses report the serving account's 5-hour and weekly utilization, and whichever of those two the response carries is recorded for that account — each window independently, and a refusal counts as well as a success. Usage-aware selection works from ordinary traffic, diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 710c069b24..b4d7cb2094 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -437,7 +437,7 @@ When enabled, 429 records a cooldown and may rotate within the request. The cool from a usable `Retry-After`, otherwise from the latest valid reset time among rate-limit windows Anthropic reports as `rejected`, including weekly windows. Valid upstream deadlines are not shortened to a fixed cooldown ceiling; non-finite or unrepresentable deadlines are ignored. -A refusal with no usable deadline falls back to a short default backoff. Affinity is process-local +A refusal with no usable deadline falls back to a 60-second default backoff. Affinity is process-local and size-bounded. Credential 401/403 marks the account as needing reauthentication. If all eligible accounts are cooling, clients receive 429 with `Retry-After` when known, not an authentication error. diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 5dcb69beb3..71644a9eae 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1555,26 +1555,48 @@ type AccountQuotaCacheEntry = { /** Expired measurements become unknown; missing reset evidence never implies a fresh allowance. */ function normalizeAnthropicQuota(quota: ProviderQuota | null | undefined, now: number): ProviderQuota | null { if (!quota) return null; - const expired = (resetAt: number | undefined): boolean => typeof resetAt === "number" - && Number.isFinite(resetAt) && resetAt > 0 && resetAt <= now; + const validReset = (resetAt: unknown): resetAt is number => typeof resetAt === "number" + && Number.isFinite(resetAt) && resetAt > 0 && Number.isFinite(new Date(resetAt).getTime()); let result = quota; for (const [percent, reset] of [ ["fiveHourPercent", "fiveHourResetAt"], ["weeklyPercent", "weeklyResetAt"], ["monthlyPercent", "monthlyResetAt"], ] as const) { - if (!expired(quota[reset])) continue; + const resetAt = quota[reset]; + if (resetAt === undefined) continue; + const valid = validReset(resetAt); + if (valid && resetAt > now) continue; if (result === quota) result = { ...quota }; - delete result[percent]; + if (valid) delete result[percent]; delete result[reset]; } // Persisted rows validate only the outer quota object, so custom data may be malformed. if (quota.customWindows !== undefined) { const windows = Array.isArray(quota.customWindows) ? quota.customWindows : []; - const retained = windows.filter(window => window !== null && typeof window === "object" - && typeof window.label === "string" && typeof window.percent === "number" - && Number.isFinite(window.percent) && !expired(window.resetAt)); - if (!Array.isArray(quota.customWindows) || retained.length !== windows.length) { + const retained: ProviderQuotaWindow[] = []; + let changed = !Array.isArray(quota.customWindows); + for (const window of windows) { + if (!window || typeof window !== "object" || typeof window.label !== "string" || !window.label.trim() + || typeof window.percent !== "number" || !Number.isFinite(window.percent) + || window.percent < 0 || window.percent > 100) { + changed = true; + continue; + } + if (validReset(window.resetAt) && window.resetAt <= now) { + changed = true; + continue; + } + if (window.resetAt !== undefined && !validReset(window.resetAt)) { + const normalized = { ...window }; + delete normalized.resetAt; + retained.push(normalized); + changed = true; + } else { + retained.push(window); + } + } + if (changed) { if (result === quota) result = { ...quota }; if (retained.length) result.customWindows = retained; else delete result.customWindows; diff --git a/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts b/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts index 6442436fbb..09952ea5ab 100644 --- a/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts +++ b/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts @@ -13,10 +13,19 @@ import type { OcxConfig, OcxProviderConfig } from "../../../src/types"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; const originalHome = process.env.OPENCODEX_HOME; +let originalFetch: typeof globalThis.fetch; +let unexpectedGlobalFetches = 0; let home: string; let sent: { authorization: string | null; apiKey: string | null; body: Record }[]; beforeEach(() => { + home = ""; + originalFetch = globalThis.fetch; + unexpectedGlobalFetches = 0; + globalThis.fetch = (async () => { + unexpectedGlobalFetches += 1; + throw new Error("Unexpected global fetch in Anthropic quota dispatch test"); + }) as typeof fetch; home = mkdtempSync(join(tmpdir(), "ocx-anthropic-quota-dispatch-")); process.env.OPENCODEX_HOME = home; sent = []; @@ -29,16 +38,25 @@ beforeEach(() => { }); afterEach(() => { - clearAnthropicAccountPoolState(); - forgetAnthropicFailoverQuorum(); - clearGenericFailoverHealth(); - // Cancel the debounced persistence before restoring the real home. - clearAccountQuotaCache(); - resetProviderQuotaReconcileStateForTests(); - clearResponseStateForTests(); - if (originalHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = originalHome; - removeTreeWithRetry(home); + try { + // Provider code may catch the guard's rejection; the attempted network call still fails the test. + expect(unexpectedGlobalFetches).toBe(0); + } finally { + try { + // Cancel the debounced persistence before restoring the real home. + clearAccountQuotaCache(); + clearAnthropicAccountPoolState(); + forgetAnthropicFailoverQuorum(); + clearGenericFailoverHealth(); + resetProviderQuotaReconcileStateForTests(); + clearResponseStateForTests(); + } finally { + globalThis.fetch = originalFetch; + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + if (home) removeTreeWithRetry(home); + } + } }); function credential(index: number) { @@ -136,8 +154,9 @@ test("main A429 -> B200 records both physical responses against their sending ac return answer(body.stream === true); }); const response = await post(config); + const responseText = await response.text(); expect(response.status).toBe(200); - expect(await response.text()).toContain("The answer is complete."); + expect(responseText).toContain("The answer is complete."); expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); expectQuota(a!, 100, 61); expectQuota(b!, 23, 47); @@ -151,8 +170,8 @@ test("terminal 429 after both accounts are exhausted records both refused physic expectQuota(a!, 100, 61); return limited("0.89", "1"); })); - expect(response.status).toBe(429); await response.text(); + expect(response.status).toBe(429); expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); expectQuota(a!, 100, 61); expectQuota(b!, 89, 100); @@ -165,15 +184,16 @@ test("manual active switch while A is pending keeps A's measurement off B", asyn const config = configFor(() => { entered.resolve(); return returned.promise; }); const pending = post(config); await entered.promise; + let response!: Response; try { expect(sent[0]!.authorization).toBe(`Bearer ${credential(0).access}`); expect(await setActiveAccount("anthropic", b!)).toBe(true); } finally { returned.resolve(answer(false, "0.37", "0.53")); + response = await pending; + await response.text(); } - const response = await pending; expect(response.status).toBe(200); - await response.text(); expect(sent).toHaveLength(1); expect(getAccountSet("anthropic")!.activeAccountId).toBe(b!); expectQuota(a!, 37, 53); @@ -186,15 +206,16 @@ test("credential replacement while A is pending skips its old-generation respons const returned = deferred(); const pending = post(configFor(() => { entered.resolve(); return returned.promise; })); await entered.promise; + let response!: Response; try { expect(sent[0]!.authorization).toBe(`Bearer ${credential(0).access}`); await saveAccountCredential("anthropic", a!, { ...credential(0), access: "synthetic-replacement-access", refresh: "synthetic-replacement-refresh" }); } finally { returned.resolve(answer(false)); + response = await pending; + await response.text(); } - const response = await pending; expect(response.status).toBe(200); - await response.text(); expect(sent).toHaveLength(1); expect(getAccountSet("anthropic")!.accounts.find(row => row.id === a)!.credential.access).toBe("synthetic-replacement-access"); expect(getCachedProviderAccountQuota("anthropic", a!)).toBeNull(); @@ -208,8 +229,8 @@ const overriddenHeaders: { label: string; headers: Record; autho test.each(overriddenHeaders)("$label skips quota attribution even when a selected OAuth account exists", async ({ headers, authorization, apiKey }) => { const ids = await seed(); const response = await post(configFor(body => answer(body.stream === true), headers)); - expect(response.status).toBe(200); await response.text(); + expect(response.status).toBe(200); expect(sent).toHaveLength(1); expect(sent[0]).toMatchObject({ authorization, apiKey }); for (const id of ids) expect(getCachedProviderAccountQuota("anthropic", id)).toBeNull(); @@ -227,8 +248,9 @@ test("real web-search routed loop records A429 and B200 through fetchForRequest" }); config.webSearchSidecar = { backend: "anthropic", enabled: true }; const response = await post(config, { tools: [{ type: "web_search" }] }); + const responseText = await response.text(); expect(response.status).toBe(200); - expect(await response.text()).toContain("The answer is complete."); + expect(responseText).toContain("The answer is complete."); expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); expectQuota(a!, 100, 61); expectQuota(b!, 23, 47); @@ -252,8 +274,9 @@ test("real terminal continuation records A429 before retrying the continuation o input: "Please modify the file now", tools: [{ type: "function", name: "read_file", description: "read a file", parameters: { type: "object" } }], }); + const responseText = await response.text(); expect(response.status).toBe(200); - expect(await response.text()).toContain("The answer is complete."); + expect(responseText).toContain("The answer is complete."); expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); expectQuota(a!, 100, 61); expectQuota(b!, 23, 47); @@ -275,8 +298,9 @@ test("real image bridge routed loop records A429 and B200 through fetchForReques adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "key", apiKey: "synthetic-image-key", }; const response = await post(config, { stream: true, tools: [{ type: "image_generation" }] }); + const responseText = await response.text(); expect(response.status).toBe(200); - expect(await response.text()).toContain("The answer is complete."); + expect(responseText).toContain("The answer is complete."); expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); expectQuota(a!, 100, 61); expectQuota(b!, 23, 47); diff --git a/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts b/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts index 77989064ff..f1ad70542e 100644 --- a/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts +++ b/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts @@ -561,14 +561,56 @@ describe("Anthropic known-reset expiry", () => { now += 120_000; observe(id!); const quota = getCachedProviderAccountQuota("anthropic", id!); - expect(quota?.customWindows).toEqual(saved.customWindows.slice(1)); + const retained = [saved.customWindows[1], saved.customWindows[2], { label: "Unknown reset", percent: 60 }]; + expect(quota?.customWindows).toEqual(retained); expect(quota?.fiveHourPercent).toBe(41); expect(quota?.updatedAt).toBe(now); expect(saved.customWindows).toHaveLength(4); expect(saved.updatedAt).toBe(start); now += 30_000; observe(id!); - expect(getCachedProviderAccountQuota("anthropic", id!)?.customWindows).toEqual(saved.customWindows.slice(1)); + expect(getCachedProviderAccountQuota("anthropic", id!)?.customWindows).toEqual(retained); + }); + + test("custom windows reject empty labels and invalid percentages while preserving valid objects", async () => { + const [id] = await seed(1); + const valid = [{ label: "Opus", percent: 0 }, { label: "Sonnet", percent: 100, resetAt: start + 60_000 }]; + const saved = { customWindows: [ + ...valid, + { label: "", percent: 50 }, { label: " ", percent: 50 }, + { label: "negative", percent: -1 }, { label: "too high", percent: 101 }, + { label: "not finite", percent: Number.NaN }, { label: "infinite", percent: Infinity }, + ], updatedAt: start }; + setCachedProviderAccountQuotaForTests("anthropic", id!, saved); + const normalized = getCachedProviderAccountQuota("anthropic", id!); + expect(normalized?.customWindows).toEqual(valid); + expect(normalized?.customWindows?.[0]).toBe(valid[0]); + expect(saved.customWindows).toHaveLength(8); + setCachedProviderAccountQuotaForTests("anthropic", id!, normalized!); + expect(getCachedProviderAccountQuota("anthropic", id!)).toBe(normalized); + }); + + test("invalid reset metadata is removed without discarding valid usage", async () => { + const [id] = await seed(1); + const invalidResets = [0, -1, Number.NaN, Infinity, 8_640_000_000_000_001]; + const saved = { + fiveHourPercent: 40, fiveHourResetAt: 0, + weeklyPercent: 50, weeklyResetAt: Infinity, + monthlyPercent: 60, monthlyResetAt: 8_640_000_000_000_001, + customWindows: invalidResets.map((resetAt, index) => ({ label: `window-${index}`, percent: 70, resetAt })), + updatedAt: start, + }; + setCachedProviderAccountQuotaForTests("anthropic", id!, saved); + const normalized = getCachedProviderAccountQuota("anthropic", id!); + expect(normalized).toEqual({ + fiveHourPercent: 40, weeklyPercent: 50, monthlyPercent: 60, + customWindows: invalidResets.map((_, index) => ({ label: `window-${index}`, percent: 70 })), + updatedAt: start, + }); + expect(saved.customWindows[0]?.resetAt).toBe(0); + expect(saved.fiveHourResetAt).toBe(0); + setCachedProviderAccountQuotaForTests("anthropic", id!, normalized!); + expect(getCachedProviderAccountQuota("anthropic", id!)).toBe(normalized); }); for (const [percent, reset, observedWindow] of [ @@ -729,6 +771,23 @@ describe("Anthropic known-reset expiry", () => { }); } + test("persisted nonnumeric reset metadata does not erase otherwise valid windows", async () => { + const [id] = await seed(1); + writeFileSync(join(home, "provider-account-quota-cache.json"), JSON.stringify({ version: 1, rows: { + [`anthropic\u0000${id}`]: { + weeklyPercent: 80, weeklyResetAt: "unknown", + customWindows: [{ label: "Opus", percent: 70, resetAt: null }, { label: "Sonnet", percent: 60, resetAt: "later" }], + updatedAt: now, + }, + } })); + clearAccountQuotaCache(); + globalThis.fetch = (async () => new Response("busy", { status: 429 })) as typeof fetch; + const [row] = await fetchProviderAccountQuotas("anthropic"); + expect(row?.quota).toEqual({ weeklyPercent: 80, + customWindows: [{ label: "Opus", percent: 70 }, { label: "Sonnet", percent: 60 }], updatedAt: now }); + expect(row?.unavailable).toBe(true); + }); + test("fresh utilization without a reset does not inherit an expired reset", async () => { const [id] = await seed(1); setCachedProviderAccountQuotaForTests("anthropic", id!, { From 872f0e5aa714f6a2e757510195d1c038ac70e26d Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:41:57 +0900 Subject: [PATCH 91/99] fix(cli): explain which side of a version mismatch is older [skip ci] Address the diagnostic residual of #3464. Keep raw mismatch and placeholder behavior, compare valid SemVer precedence, and avoid false doctor match claims. No automatic service repair or request-policy change. Local suites omitted by owner instruction; final cumulative hosted CI pending. Reported-by: garysassano <10464497+garysassano@users.noreply.github.com> --- .../docs/ko/reference/cli/lifecycle.md | 13 +++ .../content/docs/reference/cli/lifecycle.md | 20 ++++- .../docs/ru/reference/cli/lifecycle.md | 13 +++ src/cli/doctor.ts | 4 +- src/cli/version-skew.ts | 39 ++++++++- tests/cli/cli-status-json.test.ts | 82 ++++++++++++++++++- tests/cli/cli-version-skew.test.ts | 70 +++++++++++++++- tests/codex-integration/doctor.test.ts | 63 +++++++++++++- 8 files changed, 291 insertions(+), 13 deletions(-) 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 4847614674..068807025b 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -82,6 +82,19 @@ dedicated-provider history도 포함됩니다. 상태를 백업하고 이 전체 ### `ocx status [--json]` +status와 `ocx doctor`는 현재 CLI와 실행 중인 프록시의 버전을 비교합니다. CLI가 더 새로우면 +원하는 최신 설치로 프록시를 재시작하십시오. 백그라운드 서비스라면 `ocx service repair`를 +실행합니다(`ocx service restart`는 별칭). 프록시가 더 새로우면 CLI를 업그레이드하거나 +`PATH`가 원하는 설치를 가리키도록 수정하십시오. 이 진단은 서비스를 복구하거나 요청 허용 +여부를 바꾸지 않습니다. + +버전 문자열이 같거나 어느 쪽이 `unknown` / `0.0.0`이면 경고하지 않으며, 프록시 버전이 없어도 +경고하지 않습니다. doctor는 placeholder를 버전 일치로 확정하지 않습니다. 엄격한 SemVer로 +해석할 수 없는 서로 다른 문자열이나 build metadata만 다른 버전은 어느 쪽이 오래됐다고 +단정하지 않는 중립 경고를 표시합니다. 공백을 제거하거나 앞의 `v`를 정규화하지 않습니다. +JSON의 `versionSkew`에도 같은 안내가 들어가며 필드는 `cliVersion`, `proxyVersion`, `skewed`, +`warning` 그대로입니다. + 읽기 전용 진단 요약을 출력합니다. 프록시 PID, `/healthz` 도달 가능 여부, 대시보드 URL, 설정 경로, 기본 공급자, Codex 자동 시작 설정, 서비스 상태, shim 상태, 그리고 마스킹된 실제로 적용되는 Codex 홈이 포함됩니다. 명시적이고 높은 신뢰도의 Windows Orca 런타임 홈 시그니처만 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index e75a2b6241..0dda487b3a 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -88,6 +88,19 @@ are left in place. ### `ocx status [--json]` +Status and `ocx doctor` compare this CLI's version with the running proxy. If the CLI is newer, +restart the proxy using the intended current installation; for a background service, run +`ocx service repair` (`ocx service restart` is an alias). If the proxy is newer, upgrade the CLI +or resolve `PATH` to the intended installation. These diagnostics do not repair the service or +change whether requests are allowed. + +Identical version strings and the `unknown` / `0.0.0` placeholders suppress the warning, as does +an absent proxy version. Doctor does not report placeholders as a confirmed match. Different +strings still produce a neutral warning when they cannot be strictly parsed as SemVer or differ +only in build metadata; neither side is called older. Versions are not trimmed and a leading `v` +is not normalized. JSON exposes the same advice in `versionSkew`, whose fields remain +`cliVersion`, `proxyVersion`, `skewed`, and `warning`. + Print a read-only diagnostic summary: proxy PID, `/healthz` reachability, dashboard URL, config path, default provider, Codex autostart setting, service state, shim state, and the redacted effective Codex home. Only the explicit, high-confidence Windows Orca runtime-home signature adds an actionable App-home @@ -261,9 +274,10 @@ bundled Bun paths are deliberately rediscovered after upgrades instead of being Definitions installed before this change still carry the old versioned paths and cannot migrate themselves — once the old executable is deleted, no opencodex code runs to fix it. Run `ocx service repair` once after upgrading; after that, each service start follows the launcher. -An already-running proxy is not replaced by an external upgrade: restart the service (or run -`ocx service repair`) so the new build serves, and treat a CLI/proxy version mismatch warning as -exactly that signal. +An already-running proxy is not replaced by an external upgrade: when the installed CLI is newer +than the running proxy, restart the service (or run `ocx service repair`) so the new build serves. +If the proxy is newer instead, check the CLI installation and `PATH` as described under +[`ocx status`](#ocx-status---json). | Subcommand | Action | | --- | --- | 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 1ace7cc10f..7be5d5ad77 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -89,6 +89,19 @@ ocx eject back ### `ocx status [--json]` +Status и `ocx doctor` сравнивают версии текущего CLI и работающего прокси. Если CLI новее, +перезапустите прокси из нужной актуальной установки. Для фоновой службы используйте +`ocx service repair` (`ocx service restart` — её псевдоним). Если новее прокси, обновите CLI +или исправьте `PATH`, чтобы он указывал на нужную установку. Диагностика не ремонтирует службу +и не меняет разрешение запросов. + +При одинаковых строках версий, значениях `unknown` / `0.0.0` или отсутствии версии прокси +предупреждение подавляется. Doctor не считает placeholder подтверждённым совпадением. +Разные строки, которые нельзя строго разобрать как SemVer, и версии, отличающиеся только +build metadata, вызывают нейтральное предупреждение без указания устаревшей стороны. +Пробелы не удаляются, префикс `v` не нормализуется. JSON содержит ту же рекомендацию в +`versionSkew` с прежними полями `cliVersion`, `proxyVersion`, `skewed` и `warning`. + Печатает read-only диагностическую сводку: PID прокси, достижимость `/healthz`, URL дашборда, путь к конфигу, провайдера по умолчанию, настройку автозапуска Codex, состояние службы, состояние shim'а и redacted effective Codex home. Только явная и высокоуверенная сигнатура mismatch diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 1ab4fe9f1b..d7148530a0 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1157,11 +1157,11 @@ export async function runDoctor(args: string[] = []): Promise { // No extra probe -- findLiveProxy already carried the version back. { const { packageVersion } = await import("./help"); - const { computeVersionSkew } = await import("./version-skew"); + const { computeVersionSkew, isConfirmedVersionMatch } = await import("./version-skew"); const skew = computeVersionSkew(packageVersion(), live?.version); if (skew.skewed && skew.warning) { console.log(`!! ${skew.warning}`); - } else if (skew.proxyVersion !== null) { + } else if (isConfirmedVersionMatch(skew)) { console.log(`ok ocx ${skew.cliVersion} matches the running proxy`); } } diff --git a/src/cli/version-skew.ts b/src/cli/version-skew.ts index 588d29a307..48b71a51ee 100644 --- a/src/cli/version-skew.ts +++ b/src/cli/version-skew.ts @@ -1,5 +1,5 @@ /** - * CLI-versus-proxy version skew (#2701). + * CLI-versus-proxy version skew (#2701, #3464). * * The reported failure: `ocx` on PATH is an older install than the running proxy, so its * help describes commands the proxy does not have and its output describes a different @@ -9,6 +9,7 @@ * comparison instead of reimplementing it -- two diagnostics disagreeing about whether an * install is stale would be worse than neither reporting it. */ +import { parseStrictSemver, type StrictSemver } from "../lib/strict-semver"; /** Placeholder versions that mean "unknown", not "different". */ const PLACEHOLDERS = new Set(["unknown", "0.0.0"]); @@ -22,6 +23,30 @@ export interface VersionSkew { readonly warning: string | null; } +/** Suppressed comparisons are not confirmed matches, even when both placeholders agree. */ +export function isConfirmedVersionMatch(skew: VersionSkew): boolean { + return skew.proxyVersion === skew.cliVersion && !PLACEHOLDERS.has(skew.cliVersion); +} + +/** SemVer precedence ignores build metadata; raw equality is handled separately. */ +function compareVersions(cli: StrictSemver, proxy: StrictSemver): number { + for (let i = 0; i < cli.core.length; i++) { + if (cli.core[i]! !== proxy.core[i]!) return cli.core[i]! > proxy.core[i]! ? 1 : -1; + } + if (cli.prerelease.length === 0) return proxy.prerelease.length === 0 ? 0 : 1; + if (proxy.prerelease.length === 0) return -1; + for (let i = 0; i < Math.max(cli.prerelease.length, proxy.prerelease.length); i++) { + const left = cli.prerelease[i]; + const right = proxy.prerelease[i]; + if (left === right) continue; + if (left === undefined) return -1; + if (right === undefined) return 1; + if (typeof left !== typeof right) return typeof left === "bigint" ? -1 : 1; + return left > right ? 1 : -1; + } + return 0; +} + /** * Compare the running CLI against the live proxy. * @@ -36,11 +61,19 @@ export function computeVersionSkew(cliVersion: string, proxyVersion: string | un if (proxy === null || PLACEHOLDERS.has(proxy) || PLACEHOLDERS.has(cliVersion) || proxy === cliVersion) { return { cliVersion, proxyVersion: proxy, skewed: false, warning: null }; } + const cliSemver = parseStrictSemver(cliVersion); + const proxySemver = parseStrictSemver(proxy); + const order = cliSemver && proxySemver ? compareVersions(cliSemver, proxySemver) : 0; + const advice = order > 0 + ? "the running proxy is older than this CLI. Restart the proxy using the intended current installation. " + + "For a background service, run ocx service repair (ocx service restart is an alias)." + : order < 0 + ? "this ocx on PATH is older than the running proxy. Upgrade the CLI or resolve PATH to the intended installation." + : "the versions differ, but neither can be identified as older. Check which installations the CLI and proxy use."; return { cliVersion, proxyVersion: proxy, skewed: true, - warning: `CLI ${cliVersion} does not match the running proxy ${proxy} — this ocx on PATH is stale. ` - + "Its help and features describe a different build. Reinstall, or run the proxy's own binary.", + warning: `CLI ${cliVersion} does not match the running proxy ${proxy} — ${advice}`, }; } diff --git a/tests/cli/cli-status-json.test.ts b/tests/cli/cli-status-json.test.ts index 31371baa33..10ab4f110e 100644 --- a/tests/cli/cli-status-json.test.ts +++ b/tests/cli/cli-status-json.test.ts @@ -10,9 +10,11 @@ import { fileURLToPath } from "node:url"; import { isConnectionRefused, isUncleanExitEvidence, proxyHealthFailureReason, resolveStatusPid, selectListenTarget } from "../../src/cli/status"; import * as statusFacade from "../../src/cli/status"; import * as statusProbes from "../../src/cli/status-probes"; +import { packageVersion } from "../../src/cli/help"; +import { getDefaultConfig } from "../../src/config"; import { findDeadPid } from "../helpers/dead-pid"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { STORE_BUDGET_MS } from "../helpers/test-budget"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS, STORE_BUDGET_MS } from "../helpers/test-budget"; import { inspectClientRotationRecoveryGate, readClientConnectionState } from "../../src/client/state"; import * as lifecycleLock from "../../src/client/lifecycle-lock"; import { writeDesktopDisconnectReceipt } from "../../src/claude/desktop-remote-store"; @@ -28,6 +30,84 @@ function runStatusJson(opencodexHome: string) { }); } +describe("status version skew projection", () => { + test.each([ + ["0.0.1", "the running proxy is older"], + ["999999.0.0", "this ocx on PATH is older"], + [packageVersion(), null], + [`${packageVersion()}+skew-fixture`, "neither can be identified as older"], + ["not-a-version", "neither can be identified as older"], + ["unknown", null], + ["0.0.0", null], + [undefined, null], + ] as const)("projects proxy %s in JSON and human output", async (proxyVersion, expected) => { + const home = mkdtempSync(join(tmpdir(), "ocx-status-skew-")); + const codexHome = join(home, "codex"); + let server: ReturnType | undefined; + try { + // Explicit CODEX_HOME must exist before the CLI imports codex/paths.ts. + mkdirSync(codexHome, { recursive: true }); + server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch(request) { + return new URL(request.url).pathname === "/healthz" + ? Response.json({ service: "opencodex", status: "ok", version: proxyVersion, uptime: 1 }) + : new Response("not found", { status: 404 }); + }, + }); + writeFileSync(join(home, "config.json"), JSON.stringify({ + ...getDefaultConfig(), port: server.port, hostname: "127.0.0.1", codexAutoStart: false, + })); + for (const json of [true, false]) { + // Async child execution lets the fixture answer the real identity/health probes. + const child = Bun.spawn([process.execPath, cliPath, "status", ...(json ? ["--json"] : [])], { + cwd: repoRoot, + env: { ...process.env, OPENCODEX_HOME: home, CODEX_HOME: codexHome }, + stdout: "pipe", stderr: "pipe", + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, INTERNAL_DEADLINE_MS); + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, + ]); + expect(timedOut).toBe(false); + // Preserve both gates while surfacing the child error when startup fails. + expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" }); + if (json) { + const parsed = JSON.parse(stdout); + expect(parsed.schemaVersion).toBe(1); + expect(Object.keys(parsed.versionSkew).sort()).toEqual(["cliVersion", "proxyVersion", "skewed", "warning"]); + expect(parsed.versionSkew.cliVersion).toBe(packageVersion()); + expect(parsed.versionSkew.proxyVersion).toBe(proxyVersion ?? null); + expect(parsed.versionSkew.skewed).toBe(expected !== null); + if (expected === null) expect(parsed.versionSkew.warning).toBeNull(); + else expect(parsed.versionSkew.warning).toContain(expected); + } else if (expected === null) { + expect(stdout).not.toContain("does not match the running proxy"); + } else { + expect(stdout).toContain(expected); + } + } finally { + clearTimeout(timer); + if (child.exitCode === null) child.kill("SIGKILL"); + await child.exited; + } + } + expect(existsSync(join(home, "ocx.pid"))).toBe(false); + } finally { + try { + await server?.stop(true); + } finally { + removeTreeWithRetry(home); + } + } + }, SPAWN_BUDGET_MS); +}); + function withRecoveryStatusFixture(work: (fixture: { home: string; lockDeps: { lockPath: string }; diff --git a/tests/cli/cli-version-skew.test.ts b/tests/cli/cli-version-skew.test.ts index 6e45f83c28..36fb6845f9 100644 --- a/tests/cli/cli-version-skew.test.ts +++ b/tests/cli/cli-version-skew.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { computeVersionSkew } from "../../src/cli/version-skew"; +import { computeVersionSkew, isConfirmedVersionMatch } from "../../src/cli/version-skew"; import { packageVersion } from "../../src/cli/help"; /** @@ -7,20 +7,83 @@ import { packageVersion } from "../../src/cli/help"; * build, and nothing surfaced it because the CLI never compared the two versions. */ describe("version skew detection", () => { - test("reports skew when the proxy reports a different version", () => { + test("directs an older CLI to upgrade or resolve PATH", () => { const skew = computeVersionSkew("2.35.0", "2.36.1"); expect(skew.skewed).toBe(true); expect(skew.cliVersion).toBe("2.35.0"); expect(skew.proxyVersion).toBe("2.36.1"); expect(skew.warning).toContain("2.35.0"); expect(skew.warning).toContain("2.36.1"); - expect(skew.warning).toContain("stale"); + expect(skew.warning).toContain("this ocx on PATH is older"); + expect(skew.warning).toContain("Upgrade the CLI or resolve PATH"); + expect(skew.warning).not.toContain("ocx service repair"); + }); + + test("#3464 directs a newer CLI to restart the older proxy", () => { + const skew = computeVersionSkew("2.42.0", "2.10.1-preview.20260805"); + expect(skew).toEqual({ + cliVersion: "2.42.0", + proxyVersion: "2.10.1-preview.20260805", + skewed: true, + warning: "CLI 2.42.0 does not match the running proxy 2.10.1-preview.20260805 — " + + "the running proxy is older than this CLI. Restart the proxy using the intended current installation. " + + "For a background service, run ocx service repair (ocx service restart is an alias).", + }); + expect(skew.warning).not.toContain("this ocx on PATH is older"); + }); + + test.each([ + ["2.43.0", "2.43.0-preview.1"], + ["2.43.0-preview.10", "2.43.0-preview.2"], + ["2.43.0-preview.beta", "2.43.0-preview.10"], + ["2.43.0-preview.1", "2.43.0-preview"], + ["2.43.0-beta", "2.43.0-alpha"], + ["2.44.0-preview.1", "2.43.0"], + ["10.0.0", "9.99.99"], + ["2.43.1", "2.43.0"], + ["2.43.0-preview.9007199254740993", "2.43.0-preview.9007199254740992"], + ])("orders %s above %s in both directions", (newer, older) => { + expect(computeVersionSkew(newer, older).warning).toContain("the running proxy is older"); + expect(computeVersionSkew(older, newer).warning).toContain("this ocx on PATH is older"); + }); + + test.each([ + ["2.43.0+build.1", "2.43.0+build.2"], + ["2.43.0", "2.43.0+build.1"], + ["2.43.0-preview.1+a", "2.43.0-preview.1+b"], + ["invalid", "2.43.0"], + ["2.43", "2.43.0"], + ["v2.43.0", "2.43.0"], + [" 2.43.0", "2.43.0"], + ["2.43.0 ", "2.43.0"], + ["2.43.0-preview.01", "2.43.0-preview.1"], + ["", "2.43.0"], + ])("keeps raw unequal %s / %s neutral in both directions", (left, right) => { + for (const [cli, proxy] of [[left, right], [right, left]]) { + const skew = computeVersionSkew(cli!, proxy!); + expect(skew.cliVersion).toBe(cli); + expect(skew.proxyVersion).toBe(proxy); + expect(skew.skewed).toBe(true); + expect(skew.warning).toContain("neither can be identified as older"); + expect(skew.warning).not.toContain("ocx service repair"); + expect(isConfirmedVersionMatch(skew)).toBe(false); + } + }); + + test.each(["unknown", "0.0.0"])("suppresses %s on either side without confirming a match", placeholder => { + for (const [cli, proxy] of [[placeholder, "2.43.0"], ["2.43.0", placeholder], [placeholder, placeholder]]) { + const skew = computeVersionSkew(cli!, proxy!); + expect(skew.skewed).toBe(false); + expect(skew.warning).toBeNull(); + expect(isConfirmedVersionMatch(skew)).toBe(false); + } }); test("stays quiet when the versions match", () => { const skew = computeVersionSkew("2.35.0", "2.35.0"); expect(skew.skewed).toBe(false); expect(skew.warning).toBeNull(); + expect(isConfirmedVersionMatch(skew)).toBe(true); }); test("stays quiet when nothing is live", () => { @@ -28,6 +91,7 @@ describe("version skew detection", () => { expect(skew.skewed).toBe(false); expect(skew.proxyVersion).toBeNull(); expect(skew.warning).toBeNull(); + expect(isConfirmedVersionMatch(skew)).toBe(false); }); test("suppresses the warning when the proxy reports the 0.0.0 placeholder", () => { diff --git a/tests/codex-integration/doctor.test.ts b/tests/codex-integration/doctor.test.ts index 9fdb7ee30d..acb0f1b87e 100644 --- a/tests/codex-integration/doctor.test.ts +++ b/tests/codex-integration/doctor.test.ts @@ -1,4 +1,7 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import * as proxyLiveness from "../../src/server/proxy-liveness"; +import * as cliHelp from "../../src/cli/help"; +import { getDefaultConfig } from "../../src/config"; import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, utimesSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -32,6 +35,7 @@ import { } from "../../src/lib/local-management-capability"; import { findDeadPid } from "../helpers/dead-pid"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { STORE_BUDGET_MS } from "../helpers/test-budget"; const TEST_DIR = join(import.meta.dir, ".tmp-doctor-test"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); @@ -780,6 +784,63 @@ describe("doctor abandoned response-state temps", () => { }); }); +describe("doctor version skew projection", () => { + test.each([ + ["2.42.0", "2.10.1-preview.20260805", "the running proxy is older"], + ["2.35.0", "2.36.1", "this ocx on PATH is older"], + ["2.43.0", "2.43.0", "ok ocx 2.43.0 matches the running proxy"], + ["2.43.0+a", "2.43.0+b", "neither can be identified as older"], + ["v2.43.0", "2.43.0", "neither can be identified as older"], + ["2.43.0", "unknown", null], + ["unknown", "2.43.0", null], + ["2.43.0", "0.0.0", null], + ["0.0.0", "0.0.0", null], + ["unknown", "unknown", null], + ["2.43.0", undefined, null], + ] as const)("projects CLI %s / proxy %s without false matches", async (cli, proxy, expected) => { + const home = mkdtempSync(join(tmpdir(), "ocx-doctor-skew-")); + const codexHome = join(home, "codex"); + const previousHome = process.env.OPENCODEX_HOME; + const previousCodexHome = process.env.CODEX_HOME; + const previousExitCode = process.exitCode; + const restore: Array<() => void> = []; + try { + // Runtime history diagnostics resolve and stat an explicit CODEX_HOME. + mkdirSync(codexHome, { recursive: true }); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = codexHome; + writeFileSync(join(home, "config.json"), JSON.stringify({ ...getDefaultConfig(), port: 9, codexAutoStart: false })); + const logged: string[] = []; + const log = spyOn(console, "log").mockImplementation((...args: unknown[]) => { logged.push(args.map(String).join(" ")); }); + restore.push(() => log.mockRestore()); + const version = spyOn(cliHelp, "packageVersion").mockReturnValue(cli); + restore.push(() => version.mockRestore()); + // Other doctor sections probe upstream health; this diagnostic fixture must stay offline. + const fetch = spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status: 503 })); + restore.push(() => fetch.mockRestore()); + const proxyInfo: proxyLiveness.LiveProxy = { + pid: null, port: 9, hostname: "127.0.0.1", source: "config", ...(proxy === undefined ? {} : { version: proxy }), + }; + const live = spyOn(proxyLiveness, "findLiveProxy").mockResolvedValue(proxyInfo); + restore.push(() => live.mockRestore()); + await runDoctor([]); + const output = logged.join("\n"); + if (expected !== null) expect(output).toContain(expected); + else expect(output).not.toContain("does not match the running proxy"); + if (cli !== "2.43.0" || proxy !== "2.43.0") expect(output).not.toContain("matches the running proxy"); + if (expected === "the running proxy is older") expect(output).toContain("ocx service repair"); + } finally { + for (const cleanup of restore.reverse()) cleanup(); + process.exitCode = previousExitCode; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); + } + }, STORE_BUDGET_MS); +}); + describe("doctor reclaim wiring (end to end)", () => { // The formatter tests above cannot observe deletion. This covers the call site itself: // inverting the report/reclaim ternary in runDoctor must fail a test. From 2e8ef03428f8e619dc92b250fbbc5d5dd7ad53cb Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:42:20 +0900 Subject: [PATCH 92/99] fix(responses): classify encrypted task recovery failures Distinguish HTTP refusal, timeout, shared abort, transport failure and invalid output through the existing shared-flight cache. Preserve strict admission, success-only cache, wrapper APIs and caller-local cancellation. Addresses the diagnostic residual of #3661; multipart/retry policy stays deferred. Reported-by: Hu9956 <282876394+Hu9956@users.noreply.github.com> --- .../021_source_review.md | 7 ++ .../content/docs/reference/architecture.md | 14 ++- src/lib/bounded-body.ts | 29 +++-- .../responses/agent-task-recovery-cache.ts | 57 ++++++--- src/server/responses/agent-task-recovery.ts | 44 ++++--- structure/04_transports-and-sidecars.md | 14 ++- .../server/agent-task-recovery-cache.test.ts | 54 +++++++-- tests/server/agent-task-recovery.test.ts | 114 +++++++++++++++--- tests/server/bounded-body.test.ts | 62 +++++++++- 9 files changed, 327 insertions(+), 68 deletions(-) create mode 100644 devlog/_plan/260907_axis1_bugfixes/021_source_review.md diff --git a/devlog/_plan/260907_axis1_bugfixes/021_source_review.md b/devlog/_plan/260907_axis1_bugfixes/021_source_review.md new file mode 100644 index 0000000000..516a760813 --- /dev/null +++ b/devlog/_plan/260907_axis1_bugfixes/021_source_review.md @@ -0,0 +1,7 @@ +# wp1 source review + +Three bounded patches implemented with regression coverage. Hooke independently passed the physical-response quota observer wiring; Tesla independently passed quota/recovery security and source review with zero blockers. Version comparator and status/doctor projections inspected by main. All source workers report no local suite/typecheck/build execution. + +Quota source: #3809, Éverton Toffanetto; Co-authored-by included in f215f79b4. Version report: garysassano; Reported-by included in f91e3953a. Recovery report: Hu9956; Reported-by included in recovery commit. + +Source-only checks: git diff --check and documentation fence/whitespace inspection. These do not prove runtime correctness. wp2 final cumulative hosted CI is still mandatory. Final CI dispatch includes Windows because ordinary PR workflow omits it. No release/deploy workflow will be dispatched. diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index 8e1e361a81..e0fbc8bcba 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -233,7 +233,17 @@ response is not cacheable. Post-commit and 5xx errors keep the no-resend path. When encrypted agent-task recovery refuses a routed task, its existing 400 error can include a bounded `recovery_reason`: `unsupported_envelope`, -`admission_denied`, `recovery_unavailable`, `caller_cancelled`, or `input_changed`. -The field is omitted when no classified recovery result exists. +`admission_denied`, `recovery_unavailable`, `caller_cancelled`, `input_changed`, +`recovery_http_rejected`, `recovery_timeout`, `recovery_aborted`, +`recovery_transport_error`, or `recovery_invalid_output`. +HTTP rejection requires an observed non-success response. Invalid output includes +invalid UTF-8, oversized bodies, malformed or incomplete recovery streams, and +invalid or conflicting assignments. A caller's cancellation takes precedence over +an owned deadline, which takes precedence over decode/transport failures. +`recovery_aborted` describes a shared recovery cancelled independently of that caller. +Shared-flight waiters receive the same underlying failure unless individually cancelled; +only successful plaintext is cached. Diagnostics contain no upstream error or payload text. +The field is omitted when no classified recovery result exists, and existing combo +branches that return the original target failure keep that response. `recovery_unavailable` includes cache/singleflight capacity and does not prove an upstream request was attempted. No retry or broader envelope acceptance is enabled. diff --git a/src/lib/bounded-body.ts b/src/lib/bounded-body.ts index 4016a0a753..0975268560 100644 --- a/src/lib/bounded-body.ts +++ b/src/lib/bounded-body.ts @@ -212,13 +212,28 @@ export async function readBoundedResponseBytes( } } -function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean): string { +// Mark only exceptions thrown by our decoder, preserving their identity and TypeError contract. +// Timeout-path flushing may fail too; retain that origin so callers do not lose the deadline. +const decodeFailures = new WeakMap(); + +export function boundedBodyDecodeFailure(error: unknown): "invalid_utf8" | "timeout" | undefined { + return error !== null && typeof error === "object" ? decodeFailures.get(error) : undefined; +} + +function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean, timedOut = false): string { const decoder = new TextDecoder("utf-8", { fatal }); - let text = ""; - for (const chunk of chunks) text += decoder.decode(chunk, { stream: true }); - // Flush an incomplete trailing UTF-8 sequence deterministically. - text += decoder.decode(); - return text; + try { + let text = ""; + for (const chunk of chunks) text += decoder.decode(chunk, { stream: true }); + // Flush an incomplete trailing UTF-8 sequence deterministically. + text += decoder.decode(); + return text; + } catch (error) { + if (error !== null && typeof error === "object") { + decodeFailures.set(error, timedOut ? "timeout" : "invalid_utf8"); + } + throw error; + } } /** @@ -297,7 +312,7 @@ export async function readBoundedResponseBody( "TimeoutError", ); return { - text: decodeUtf8([retained.subarray(0, retainedBytes)], options.fatalUtf8 === true), + text: decodeUtf8([retained.subarray(0, retainedBytes)], options.fatalUtf8 === true, true), truncated: true, timedOut: true, totalTimedOut: outcome === TOTAL_TIMEOUT, diff --git a/src/server/responses/agent-task-recovery-cache.ts b/src/server/responses/agent-task-recovery-cache.ts index 93d0c1778b..398a0feba4 100644 --- a/src/server/responses/agent-task-recovery-cache.ts +++ b/src/server/responses/agent-task-recovery-cache.ts @@ -2,6 +2,20 @@ const MAX_CACHE_BYTES = 8 * 1024 * 1024; const MAX_CONCURRENT_RECOVERIES = 32; const CACHE_TTL_MS = 15 * 60 * 1000; +export type AgentTaskRecoveryResolutionFailureReason = + | "recovery_unavailable" + | "caller_cancelled" + | "recovery_http_rejected" + | "recovery_timeout" + | "recovery_aborted" + | "recovery_transport_error" + | "recovery_invalid_output"; + +/** Shared flights carry bounded failures; only successful plaintext enters the cache. */ +export type AgentTaskRecoveryResolution = + | { readonly recovered: true; readonly assignment: string } + | { readonly recovered: false; readonly reason: AgentTaskRecoveryResolutionFailureReason }; + interface RecoveryCacheEntry { assignment: string; bytes: number; @@ -11,7 +25,7 @@ interface RecoveryCacheEntry { interface RecoveryFlight { controller: AbortController; - promise: Promise; + promise: Promise; waiters: number; settled: boolean; } @@ -63,7 +77,7 @@ function insertRecoveryCacheEntry(key: string, assignment: string, maxEntries: n function startRecoveryFlight( key: string, maxEntries: number, - request: (signal: AbortSignal) => Promise, + request: (signal: AbortSignal) => Promise, ): RecoveryFlight | null { const active = RECOVERY_FLIGHTS.get(key); if (active) return active; @@ -72,15 +86,15 @@ function startRecoveryFlight( const controller = new AbortController(); const flight: RecoveryFlight = { controller, - promise: Promise.resolve(null), + promise: Promise.resolve({ recovered: false, reason: "recovery_unavailable" }), waiters: 0, settled: false, }; flight.promise = request(controller.signal) - .then((assignment) => { - if (!assignment || controller.signal.aborted) return null; - insertRecoveryCacheEntry(key, assignment, maxEntries); - return assignment; + .then((result): AgentTaskRecoveryResolution => { + if (controller.signal.aborted) return { recovered: false, reason: "recovery_aborted" }; + if (result.recovered) insertRecoveryCacheEntry(key, result.assignment, maxEntries); + return result; }) .finally(() => { flight.settled = true; @@ -93,14 +107,14 @@ function startRecoveryFlight( async function waitForRecoveryFlight( flight: RecoveryFlight, abortSignal?: AbortSignal, -): Promise { - if (abortSignal?.aborted) return null; +): Promise { + if (abortSignal?.aborted) return { recovered: false, reason: "caller_cancelled" }; flight.waiters += 1; let onAbort: (() => void) | undefined; try { if (!abortSignal) return await flight.promise; - const cancelled = new Promise((resolve) => { - onAbort = () => resolve(null); + const cancelled = new Promise((resolve) => { + onAbort = () => resolve({ recovered: false, reason: "caller_cancelled" }); abortSignal.addEventListener("abort", onAbort, { once: true }); if (abortSignal.aborted) onAbort(); }); @@ -120,12 +134,27 @@ export async function resolveCachedAgentTaskRecovery( request: (signal: AbortSignal) => Promise, abortSignal?: AbortSignal, ): Promise { - if (abortSignal?.aborted) return null; + const result = await resolveCachedAgentTaskRecoveryWithResult(key, maxEntries, async signal => { + const assignment = await request(signal); + return assignment + ? { recovered: true, assignment } + : { recovered: false, reason: "recovery_unavailable" }; + }, abortSignal); + return result.recovered ? result.assignment : null; +} + +export async function resolveCachedAgentTaskRecoveryWithResult( + key: string, + maxEntries: number, + request: (signal: AbortSignal) => Promise, + abortSignal?: AbortSignal, +): Promise { + if (abortSignal?.aborted) return { recovered: false, reason: "caller_cancelled" }; sweepRecoveryCache(Date.now(), maxEntries); const cached = RECOVERY_CACHE.get(key)?.assignment; - if (cached) return cached; + if (cached) return { recovered: true, assignment: cached }; const flight = startRecoveryFlight(key, maxEntries, request); - return flight ? waitForRecoveryFlight(flight, abortSignal) : null; + return flight ? waitForRecoveryFlight(flight, abortSignal) : { recovered: false, reason: "recovery_unavailable" }; } export function discardCachedAgentTaskRecovery(key: string): void { diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index 22b7a4e66b..a15a2563ca 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -1,14 +1,16 @@ import { createHash, createHmac, randomBytes } from "node:crypto"; import { decodeJwtPayload, extractAccountId } from "../../oauth/chatgpt"; import type { OcxConfig } from "../../types"; -import { readBoundedResponseBody } from "../../lib/bounded-body"; +import { boundedBodyDecodeFailure, readBoundedResponseBody } from "../../lib/bounded-body"; import { isApiAuthRequired, isProxyAdmissionSecret } from "../auth-cors"; import { structurallyValidFernetTokens } from "./encrypted-payload"; import { cachedAgentTaskRecovery, discardCachedAgentTaskRecovery, resetAgentTaskRecoveryCache, - resolveCachedAgentTaskRecovery, + resolveCachedAgentTaskRecoveryWithResult, + type AgentTaskRecoveryResolution, + type AgentTaskRecoveryResolutionFailureReason, } from "./agent-task-recovery-cache"; /** Experimental opt-in normalization through ChatGPT's fixed Codex endpoint. */ @@ -44,9 +46,8 @@ export interface AgentTaskRecoveryOptions { export type AgentTaskRecoveryFailureReason = | "unsupported_envelope" | "admission_denied" - // Includes cache capacity rejection; does not imply an upstream request was attempted. - | "recovery_unavailable" - | "caller_cancelled" + // recovery_unavailable includes capacity rejection, which does not imply an upstream attempt. + | AgentTaskRecoveryResolutionFailureReason | "input_changed"; export type AgentTaskRecoveryResult = @@ -436,7 +437,7 @@ async function requestRecovery( envelope: AgentEnvelope, options: AgentTaskRecoveryOptions, abortSignal?: AbortSignal, -): Promise { +): Promise { const controller = new AbortController(); const timeout = setTimeout( () => controller.abort(new DOMException("Agent task recovery timed out", "TimeoutError")), @@ -454,8 +455,11 @@ async function requestRecovery( redirect: "error", }); if (!response.ok) { - try { await response.body?.cancel(); } catch { /* already closed */ } - return null; + // A rejected or never-settling cancellation must not extend the recovery deadline. + try { void response.body?.cancel().catch(() => undefined); } catch { /* already closed */ } + if (abortSignal?.aborted) return { recovered: false, reason: "recovery_aborted" }; + if (controller.signal.aborted) return { recovered: false, reason: "recovery_timeout" }; + return { recovered: false, reason: "recovery_http_rejected" }; } const body = await readBoundedResponseBody(response, { signal, @@ -465,10 +469,18 @@ async function requestRecovery( inactivityTimeoutMs: options.timeoutMs ?? 45_000, firstByteTimeoutMs: options.timeoutMs ?? 45_000, }); - if (body.truncated || body.oversized || body.timedOut || !body.displaySafe) return null; - return assignmentFromRecoverySse(body.text, envelope); - } catch { - return null; + if (abortSignal?.aborted) return { recovered: false, reason: "recovery_aborted" }; + if (controller.signal.aborted || body.timedOut) return { recovered: false, reason: "recovery_timeout" }; + if (body.truncated || body.oversized || !body.displaySafe) return { recovered: false, reason: "recovery_invalid_output" }; + const assignment = assignmentFromRecoverySse(body.text, envelope); + return assignment === null + ? { recovered: false, reason: "recovery_invalid_output" } + : { recovered: true, assignment }; + } catch (error) { + if (abortSignal?.aborted) return { recovered: false, reason: "recovery_aborted" }; + const decodeFailure = boundedBodyDecodeFailure(error); + if (controller.signal.aborted || decodeFailure === "timeout") return { recovered: false, reason: "recovery_timeout" }; + return { recovered: false, reason: decodeFailure === "invalid_utf8" ? "recovery_invalid_output" : "recovery_transport_error" }; } finally { clearTimeout(timeout); } @@ -497,23 +509,23 @@ export async function recoverEncryptedAgentTaskWithResult( const admitted = admittedRecovery(req, input, config, context.parentThreadId); if (!admitted.admitted) return { recovered: false, reason: admitted.reason }; const { admission, cacheKey, envelope } = admitted.recovery; - const assignment = await resolveCachedAgentTaskRecovery( + const result = await resolveCachedAgentTaskRecoveryWithResult( cacheKey, options.cacheEntries ?? 200, signal => requestRecovery(admission, envelope, options, signal), context.abortSignal, ); - if (!assignment) { + if (!result.recovered) { return { recovered: false, - reason: context.abortSignal?.aborted ? "caller_cancelled" : "recovery_unavailable", + reason: context.abortSignal?.aborted ? "caller_cancelled" : result.reason, }; } if (context.abortSignal?.aborted) { discardCachedAgentTaskRecovery(cacheKey); return { recovered: false, reason: "caller_cancelled" }; } - if (!injectAssignment(input, envelope, assignment)) { + if (!injectAssignment(input, envelope, result.assignment)) { discardCachedAgentTaskRecovery(cacheKey); return { recovered: false, reason: "input_changed" }; } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 7e41dc2222..d27022c2ad 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -1719,7 +1719,17 @@ response is not cacheable. Post-commit and 5xx errors keep the no-resend path. When encrypted agent-task recovery refuses a routed task, its existing 400 error can include a bounded `recovery_reason`: `unsupported_envelope`, -`admission_denied`, `recovery_unavailable`, `caller_cancelled`, or `input_changed`. -The field is omitted when no classified recovery result exists. +`admission_denied`, `recovery_unavailable`, `caller_cancelled`, `input_changed`, +`recovery_http_rejected`, `recovery_timeout`, `recovery_aborted`, +`recovery_transport_error`, or `recovery_invalid_output`. +HTTP rejection requires an observed non-success response. Invalid output includes +invalid UTF-8, oversized bodies, malformed or incomplete recovery streams, and +invalid or conflicting assignments. A caller's cancellation takes precedence over +an owned deadline, which takes precedence over decode/transport failures. +`recovery_aborted` describes a shared recovery cancelled independently of that caller. +Shared-flight waiters receive the same underlying failure unless individually cancelled; +only successful plaintext is cached. Diagnostics contain no upstream error or payload text. +The field is omitted when no classified recovery result exists, and existing combo +branches that return the original target failure keep that response. `recovery_unavailable` includes cache/singleflight capacity and does not prove an upstream request was attempted. No retry or broader envelope acceptance is enabled. diff --git a/tests/server/agent-task-recovery-cache.test.ts b/tests/server/agent-task-recovery-cache.test.ts index 2ee994f8ce..35b5ba7050 100644 --- a/tests/server/agent-task-recovery-cache.test.ts +++ b/tests/server/agent-task-recovery-cache.test.ts @@ -29,16 +29,23 @@ describe("agent task recovery cache", () => { resetAgentTaskRecoveryCache(); }); - test("shared failure gives each waiter its own result without contaminating another key", async () => { + test.each([ + { kind: "http", reason: "recovery_http_rejected" }, + { kind: "reader", reason: "recovery_transport_error" }, + { kind: "decode", reason: "recovery_invalid_output" }, + ] as const)("shared $kind failure gives each waiter its own result without contaminating another key", async ({ kind, reason }) => { let release: (() => void) | undefined; const gate = new Promise(resolve => { release = resolve; }); let fetches = 0; globalThis.fetch = (async () => { const requestNumber = ++fetches; await gate; - return requestNumber === 1 - ? new Response("raw-failure-sentinel", { status: 503 }) - : new Response(recoverySse("Independent assignment.")); + if (requestNumber !== 1) return new Response(recoverySse("Independent assignment.")); + if (kind === "decode") return new Response(new Uint8Array([0xff])); + if (kind === "reader") return new Response(new ReadableStream({ + pull(controller) { controller.error(new TypeError("private-reader-failure")); }, + })); + return new Response("raw-failure-sentinel", { status: 503 }); }) as typeof fetch; const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); const config = routedConfig(); @@ -53,8 +60,8 @@ describe("agent task recovery cache", () => { expect(fetches).toBe(2); release?.(); const [firstResult, secondResult, otherResult] = await Promise.all([first, second, other]); - expect(firstResult).toEqual({ recovered: false, reason: "recovery_unavailable" }); - expect(secondResult).toEqual({ recovered: false, reason: "recovery_unavailable" }); + expect(firstResult).toEqual({ recovered: false, reason }); + expect(secondResult).toEqual({ recovered: false, reason }); expect(firstResult).not.toBe(secondResult); expect(otherResult).toEqual({ recovered: true }); expect(firstInput).toEqual(encryptedInput()); @@ -68,6 +75,39 @@ describe("agent task recovery cache", () => { } }); + test("shared flight reset reports abort to surviving callers and never caches late plaintext", async () => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let fetches = 0; + globalThis.fetch = (async () => { + fetches++; + await gate; + return new Response(recoverySse("private-late-assignment")); + }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const firstInput = encryptedInput(); + const secondInput = encryptedInput(); + const first = recoverEncryptedAgentTaskWithResult(req, firstInput, {}, routedConfig()); + const second = recoverEncryptedAgentTaskWithResult(req, secondInput, {}, routedConfig()); + try { + expect(fetches).toBe(1); + resetAgentTaskRecoveryCache(); + release(); + const results = await Promise.all([first, second]); + expect(results).toEqual([ + { recovered: false, reason: "recovery_aborted" }, + { recovered: false, reason: "recovery_aborted" }, + ]); + expect(results[0]).not.toBe(results[1]); + expect(firstInput).toEqual(encryptedInput()); + expect(secondInput).toEqual(encryptedInput()); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 0, bytes: 0 }); + } finally { + release(); + await Promise.all([first, second]); + } + }); + for (const succeeds of [true, false]) { test(`caller cancellation stays local when the remaining waiter ${succeeds ? "succeeds" : "fails"}`, async () => { let release: (() => void) | undefined; @@ -95,7 +135,7 @@ describe("agent task recovery cache", () => { release?.(); expect(await second).toEqual(succeeds ? { recovered: true } - : { recovered: false, reason: "recovery_unavailable" }); + : { recovered: false, reason: "recovery_http_rejected" }); expect(fetches).toBe(1); expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config)).toBe(succeeds ? 1 : 0); } finally { diff --git a/tests/server/agent-task-recovery.test.ts b/tests/server/agent-task-recovery.test.ts index ceb1c5b6b5..a168f2c364 100644 --- a/tests/server/agent-task-recovery.test.ts +++ b/tests/server/agent-task-recovery.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import { warnAgentTaskRecoveryStartup } from "../../src/server"; import { @@ -7,6 +7,7 @@ import { recoverEncryptedAgentTaskWithResult, resetAgentTaskRecoveryState, restoreCachedEncryptedAgentTasks, + type AgentTaskRecoveryFailureReason, } from "../../src/server/responses/agent-task-recovery"; import { agentTaskRecoveryWaiterCountForTests } from "../../src/server/responses/agent-task-recovery-cache"; import { @@ -78,24 +79,36 @@ describe("agent task recovery (opt-in, default off)", () => { }); } - const failedRecoveries: Array<[string, () => Response]> = [ - ["HTTP 503", () => new Response("raw-error-sentinel", { status: 503 })], - ["network exception", () => { throw new Error("raw-error-sentinel"); }], - ["malformed SSE", () => new Response("data: {not-json}\n\n")], - ["missing completion", () => new Response(recoverySse("payload-sentinel").split("data: {\"type\":\"response.completed\"")[0])], - ["conflicting assignment", () => new Response(recoverySse("payload-sentinel") + recoveryCompletedSse("other-payload-sentinel"))], - ["failed terminal", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"response.failed","response":{"error":{"message":"raw-error-sentinel"}}}\n\n')], - ["incomplete terminal", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"response.incomplete"}\n\n')], - ["bare error", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"error","error":{"message":"raw-error-sentinel"}}\n\n')], + const failedRecoveries: Array<[string, () => Response, AgentTaskRecoveryFailureReason]> = [ + ["HTTP 401", () => new Response("private-error", { status: 401 }), "recovery_http_rejected"], + ["HTTP 403", () => new Response("private-error", { status: 403 }), "recovery_http_rejected"], + ["HTTP 429", () => new Response("private-error", { status: 429 }), "recovery_http_rejected"], + ["fetch TypeError", () => { throw new TypeError("private-error"); }, "recovery_transport_error"], + ["unowned TimeoutError", () => { throw new DOMException("private-error", "TimeoutError"); }, "recovery_transport_error"], + ["reader TypeError", () => new Response(new ReadableStream({ + pull(controller) { controller.error(new TypeError("private-reader-error")); }, + })), "recovery_transport_error"], + ["invalid UTF-8", () => new Response(new Uint8Array([0xff])), "recovery_invalid_output"], + ["trailing UTF-8", () => new Response(new Uint8Array([0xe2, 0x82])), "recovery_invalid_output"], + ["oversized body", () => new Response(new Uint8Array(4 * 1024 * 1024 + 1)), "recovery_invalid_output"], + ["invalid arguments", () => new Response(recoverySse("task").replace('{\\"assignment\\":\\"task\\"}', '{broken')), "recovery_invalid_output"], + ["HTTP 503", () => new Response("raw-error-sentinel", { status: 503 }), "recovery_http_rejected"], + ["network exception", () => { throw new Error("raw-error-sentinel"); }, "recovery_transport_error"], + ["malformed SSE", () => new Response("data: {not-json}\n\n"), "recovery_invalid_output"], + ["missing completion", () => new Response(recoverySse("payload-sentinel").split("data: {\"type\":\"response.completed\"")[0]), "recovery_invalid_output"], + ["conflicting assignment", () => new Response(recoverySse("payload-sentinel") + recoveryCompletedSse("other-payload-sentinel")), "recovery_invalid_output"], + ["failed terminal", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"response.failed","response":{"error":{"message":"raw-error-sentinel"}}}\n\n'), "recovery_invalid_output"], + ["incomplete terminal", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"response.incomplete"}\n\n'), "recovery_invalid_output"], + ["bare error", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"error","error":{"message":"raw-error-sentinel"}}\n\n'), "recovery_invalid_output"], // Exact-case events are also used by the pinned official Codex source. Recovery's // additional completed-status requirement remains deliberately stricter. - ["mixed-case completion", () => new Response(recoverySse("payload-sentinel").replace("response.completed", "Response.Completed"))], - ["mixed-case status", () => new Response(recoverySse("payload-sentinel").replace('"status":"completed"', '"status":"Completed"'))], - ["missing status", () => new Response(recoverySse("payload-sentinel").replace('"status":"completed",', ""))], - ["ciphertext assignment", () => new Response(recoverySse(FERNET_TASK))], + ["mixed-case completion", () => new Response(recoverySse("payload-sentinel").replace("response.completed", "Response.Completed")), "recovery_invalid_output"], + ["mixed-case status", () => new Response(recoverySse("payload-sentinel").replace('"status":"completed"', '"status":"Completed"')), "recovery_invalid_output"], + ["missing status", () => new Response(recoverySse("payload-sentinel").replace('"status":"completed",', "")), "recovery_invalid_output"], + ["ciphertext assignment", () => new Response(recoverySse(FERNET_TASK)), "recovery_invalid_output"], ]; - for (const [name, response] of failedRecoveries) { - test(`typed recovery keeps ${name} coarse and preserves false without retrying`, async () => { + for (const [name, response, reason] of failedRecoveries) { + test(`typed recovery classifies ${name} and preserves false without retrying`, async () => { const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); const config = routedConfig(); let fetches = 0; @@ -103,7 +116,7 @@ describe("agent task recovery (opt-in, default off)", () => { const input = encryptedInput(); const original = structuredClone(input); expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, config)) - .toEqual({ recovered: false, reason: "recovery_unavailable" }); + .toEqual({ recovered: false, reason }); expect(input).toEqual(original); expect(fetches).toBe(1); expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config)).toBe(0); @@ -113,6 +126,69 @@ describe("agent task recovery (opt-in, default off)", () => { }); } + test.each(["pending", "rejecting"] as const)("HTTP refusal does not await %s body cancellation", async mode => { + let cancels = 0; + let reads = 0; + let releaseCancel: (() => void) | undefined; + const cancellation = new Promise(resolve => { releaseCancel = resolve; }); + globalThis.fetch = (async () => new Response(new ReadableStream({ + pull() { reads++; }, + cancel() { + cancels++; + return mode === "pending" ? cancellation : Promise.reject(new Error("private-cancel-error")); + }, + }, { highWaterMark: 0 }), { status: 503 })) as typeof fetch; + try { + const result = await recoverEncryptedAgentTaskWithResult( + new Request("http://localhost/v1/responses", { headers: codexHeaders() }), encryptedInput(), {}, routedConfig(), + ); + expect(result).toEqual({ recovered: false, reason: "recovery_http_rejected" }); + expect(cancels).toBe(1); + expect(reads).toBe(0); + } finally { + releaseCancel?.(); + } + }); + + test.each(["headers", "body", "caller"] as const)("owned deadline classification at %s preserves cancellation precedence", async site => { + const callbacks: Array<() => void> = []; + const timers = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + callbacks.push(callback); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + const caller = new AbortController(); + let started!: () => void; + const ready = new Promise(resolve => { started = resolve; }); + let fetches = 0; + globalThis.fetch = ((_, init) => { + fetches++; + if (site === "body") return Promise.resolve(new Response(new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array([0xe2, 0x82])); + started(); + return new Promise(() => {}); + }, + }, { highWaterMark: 0 }))); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + started(); + }); + }) as typeof fetch; + try { + const pending = recoverEncryptedAgentTaskWithResult( + new Request("http://localhost/v1/responses", { headers: codexHeaders() }), encryptedInput(), {}, routedConfig(), + { abortSignal: caller.signal }, + ); + await ready; + callbacks[0]!(); // Fire the owned deadline without wall-clock sleeps. + if (site === "caller") caller.abort(new TypeError("private-caller-error")); + expect(await pending).toEqual({ recovered: false, reason: site === "caller" ? "caller_cancelled" : "recovery_timeout" }); + expect(fetches).toBe(1); + } finally { + timers.mockRestore(); + } + }); + test("keeps the disabled fail-fast response byte-identical to the absent feature", async () => { const snapshot = async (config: ReturnType) => { let fetchCalls = 0; @@ -226,7 +302,7 @@ describe("agent task recovery (opt-in, default off)", () => { expect(response.status).toBe(400); expect(json.error?.code).toBe("unreadable_encrypted_agent_task"); - expect(json.error?.recovery_reason).toBe("recovery_unavailable"); + expect(json.error?.recovery_reason).toBe("recovery_invalid_output"); expect(fetchedUrls.length).toBeGreaterThan(0); expect(fetchedUrls[0]).toContain("chatgpt.com/backend-api/codex"); }); @@ -778,7 +854,7 @@ describe("agent task recovery (opt-in, default off)", () => { expect(fetchedUrls).toHaveLength(1); expect(fetchedUrls[0]).toContain("chatgpt.com/backend-api/codex/responses"); expect(await response.json()).toMatchObject({ - error: { code: "unreadable_encrypted_agent_task", recovery_reason: "recovery_unavailable" }, + error: { code: "unreadable_encrypted_agent_task", recovery_reason: "recovery_transport_error" }, }); }); }); diff --git a/tests/server/bounded-body.test.ts b/tests/server/bounded-body.test.ts index f5223d34a4..0bf5e0ae1b 100644 --- a/tests/server/bounded-body.test.ts +++ b/tests/server/bounded-body.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { BOUNDED_BODY_MAX_BYTES, boundedBodyBufferGrowthsForTests, + boundedBodyDecodeFailure, readBoundedResponseBytes, readBoundedResponseBody, } from "../../src/lib/bounded-body"; @@ -21,6 +22,65 @@ function responseFromChunks(...chunks: Uint8Array[]): Response { } describe("readBoundedResponseBody", () => { + test("only actual decoder exceptions carry the decode discriminator", async () => { + for (const bytes of [new Uint8Array([0xff]), new Uint8Array([0xe2, 0x82])]) { + let caught: unknown; + try { await readBoundedResponseBody(responseFromChunks(bytes), { fatalUtf8: true }); } + catch (error) { caught = error; } + expect(caught).toBeInstanceOf(TypeError); + expect(boundedBodyDecodeFailure(caught)).toBe("invalid_utf8"); + } + const readerError = new TypeError("private-reader-error"); + const response = new Response(new ReadableStream({ pull(controller) { controller.error(readerError); } })); + let caught: unknown; + try { await readBoundedResponseBody(response, { fatalUtf8: true }); } + catch (error) { caught = error; } + expect(caught).toBe(readerError); + expect(boundedBodyDecodeFailure(caught)).toBeUndefined(); + }); + + test("fatal UTF-8 abort retains the exact caller reason without a decode mark", async () => { + const caller = new AbortController(); + const reason = new TypeError("private-caller-error"); + const pending = readBoundedResponseBody(new Response(new ReadableStream({})), { signal: caller.signal, fatalUtf8: true }); + caller.abort(reason); + let caught: unknown; + try { await pending; } catch (error) { caught = error; } + expect(caught).toBe(reason); + expect(boundedBodyDecodeFailure(caught)).toBeUndefined(); + }); + + test.each([0, 1])("fatal timeout flush retains deadline origin %s and cancels without waiting", async deadline => { + const callbacks: Array<() => void> = []; + const timers = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + callbacks.push(callback); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + let stalled!: () => void; + const ready = new Promise(resolve => { stalled = resolve; }); + let pulls = 0; + let cancelled = false; + const response = new Response(new ReadableStream({ + pull(controller) { + if (pulls++ === 0) controller.enqueue(new Uint8Array([0xe2, 0x82])); + else { stalled(); return new Promise(() => {}); } + }, + cancel() { cancelled = true; return new Promise(() => {}); }, + }, { highWaterMark: 0 })); + try { + const pending = readBoundedResponseBody(response, { fatalUtf8: true }); + await ready; + callbacks[deadline === 0 ? 0 : callbacks.length - 1]!(); + let caught: unknown; + try { await pending; } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(TypeError); + expect(boundedBodyDecodeFailure(caught)).toBe("timeout"); + expect(cancelled).toBe(true); + } finally { + timers.mockRestore(); + } + }); + test("the bounded JSON caller allows a full total deadline for its first byte", () => { expect(UPSTREAM_JSON_BODY_READ_OPTIONS.firstByteTimeoutMs) .toBe(UPSTREAM_JSON_BODY_READ_OPTIONS.totalTimeoutMs); From 2c8ec0bc3684d9079d0c2a50cb0ed5284c56d226 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 10:05:03 +0900 Subject: [PATCH 93/99] docs(devlog): close axis five display and CLI delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record credited admin landings, successful feature CI, exact merged-tree focused checks, and the unresolved Mac timing/stall diagnosis. Co-authored-by: Éverton Toffanetto Co-authored-by: 투린 Co-authored-by: Zig Zag --- .../260907_axis5_display_cli/000_plan.md | 4 ++ .../001_roadmap_audit.md | 0 .../260907_axis5_display_cli/010_delivery.md | 0 .../260907_axis5_display_cli/020_delivery.md | 40 +++++++++++++++++++ 4 files changed, 44 insertions(+) rename devlog/{_plan => _fin}/260907_axis5_display_cli/000_plan.md (95%) rename devlog/{_plan => _fin}/260907_axis5_display_cli/001_roadmap_audit.md (100%) rename devlog/{_plan => _fin}/260907_axis5_display_cli/010_delivery.md (100%) create mode 100644 devlog/_fin/260907_axis5_display_cli/020_delivery.md diff --git a/devlog/_plan/260907_axis5_display_cli/000_plan.md b/devlog/_fin/260907_axis5_display_cli/000_plan.md similarity index 95% rename from devlog/_plan/260907_axis5_display_cli/000_plan.md rename to devlog/_fin/260907_axis5_display_cli/000_plan.md index 222ef89899..eb6ed46d27 100644 --- a/devlog/_plan/260907_axis5_display_cli/000_plan.md +++ b/devlog/_fin/260907_axis5_display_cli/000_plan.md @@ -31,3 +31,7 @@ Review-ready requirements remain visible; local suite prohibition is explicitly CI scope refinement: the discovered editor is the final layer so the final commit and PR diff include gui/**, activating GUI lint/build/artifact jobs. ci.yml gates always run GUI tests; docs deployment is NOT dispatched because it publishes. Public docs receive static source consistency inspection here, with docs build explicitly unverified unless an existing build-only remote path is available. CI scheduling refinement: lower-layer head commits may use GitHub documented [skip ci] to avoid push/pull_request suite launches; this yields missing/pending evidence, NOT green. Final head has no skip marker and receives lane=all workflow_dispatch. Source: https://docs.github.com/en/actions/how-tos/manage-workflow-runs/skip-workflow-runs (opened 2026-09-07). Admin merge records this explicit owner-requested lower-layer waiver. Do not propagate skip markers into integration merge messages. + +## Terminal status + +DONE: all three feature layers landed; see 020_delivery.md for exact commits, verification boundaries and deferred Mac test-runner investigation. diff --git a/devlog/_plan/260907_axis5_display_cli/001_roadmap_audit.md b/devlog/_fin/260907_axis5_display_cli/001_roadmap_audit.md similarity index 100% rename from devlog/_plan/260907_axis5_display_cli/001_roadmap_audit.md rename to devlog/_fin/260907_axis5_display_cli/001_roadmap_audit.md diff --git a/devlog/_plan/260907_axis5_display_cli/010_delivery.md b/devlog/_fin/260907_axis5_display_cli/010_delivery.md similarity index 100% rename from devlog/_plan/260907_axis5_display_cli/010_delivery.md rename to devlog/_fin/260907_axis5_display_cli/010_delivery.md diff --git a/devlog/_fin/260907_axis5_display_cli/020_delivery.md b/devlog/_fin/260907_axis5_display_cli/020_delivery.md new file mode 100644 index 0000000000..0b06325172 --- /dev/null +++ b/devlog/_fin/260907_axis5_display_cli/020_delivery.md @@ -0,0 +1,40 @@ +# Axis five delivery record + +Outcome: DONE on 2026-09-07. The three feature layers landed in dev through owner-authorized admin integration. Original contribution credit is present in both carried commits and merge commits. + +| Source | Delivery | Merge commit | +| --- | --- | --- | +| #3627 native OpenAI display names | #3820 | 1e16fe4c077ecf353d79c46873d8039d9176704d | +| #3780 provider list JSONL | #3821 | be24986e5ff8474ca6699895855f0ad9352e9d86 | +| #2716 discovered-model name editor | #3824 | 44c69fdd619b272066113388edd80f6c59b0682a | + +The source pull requests were closed after landing. The late #3627 head 81f150e4 added metadata wording already covered by the delivery; its runtime files were checked byte-for-byte against dev before closure. + +## Delivered behavior + +Native labels are reversible overlays on supported bare native rows. IDs, capabilities and routing remain intact; restoring a label still respects existing pinned Astra normalization. Both retained synchronization and convergence pass the same configuration map. + +JSONL emits one configured-provider object per line using the existing JSON fields. Both conflicting flag orders fail without stdout. Multi-provider parity and escaping are covered, and all translated CLI tables and generated capability documentation were updated. + +The editor preserves exact selectors, validates labels, supports reset, and recovers confirmed saves separately from failed refreshes and unknown transport outcomes. Stalled operations use the existing bounded-fetch mechanism. Draft reconciliation preserves the mounted dialog and focus behavior. Desktop/mobile Korean rendering and save/reset/validation/focus were driven against the compiled CI artifact with disposable fixtures. + +## Verification boundaries + +- Feature head f51ec2421c49df0fd4eac8a9a56a6283b426387d: [Cross-platform CI attempt 2](https://github.com/lidge-jun/opencodex/actions/runs/34068041704/attempts/2), 25 successful jobs. Dashboard tests: 1,737 passed, zero failed. Typecheck, lint, scans and build passed. +- Late platform base changes had zero overlap with the 39-file feature delta and passed [their 26-job CI](https://github.com/lidge-jun/opencodex/actions/runs/34068218011). Independent compatibility review checked the decompression diagnostics and container lifecycle interaction. +- Prospective merge tree 85c9b25818a93859a6d6fc824e2ed0678da46c8f passed 370 focused tests on isolated Linux with project Bun 1.4.0: 344 catalog/CLI tests and 26 editor tests, zero failures. The transmitted source archive SHA-256 was ae191e1f0a809e75c9c198bc92233964880541f6747e4603a47b2c180f773d49. +- The actual final runtime merge 44c69fdd619b272066113388edd80f6c59b0682a has exactly that tested tree. This is focused merged-tree evidence plus full feature-head CI, not a claim of full CI on the final merge commit. +- No local test suite, typecheck or build ran. Pushes used --no-verify. Lower-layer CI was deferred until a final-head failure, and no cancelled or missing check was presented as passing. +- Public documentation was source-reviewed. This axis did not run a documentation build. + +## Diagnostic disposition + +One Mac shard reached its 20-minute limit after an unchanged history-lock test. The full Mac control had two Cursor decoded-frame-silence assertion failures; the separately annotated server-auth stream reset was intentional and its test passed. Only the unsuccessful Mac jobs were replayed, with unchanged source and limits, and they passed. The [baseline control comparison](https://github.com/lidge-jun/opencodex/actions/runs/34069848260) also passed. These observations do not establish the stall or timing root cause. No threshold increase, assertion suppression, or unrelated harness fix was included; deeper investigation remains deferred. + +## Attribution + +- Éverton Toffanetto +- 투린 +- Zig Zag + +The preceding numbered files are the historical roadmap and audits; their original _plan locations refer to the planning phase before this closeout. From 0ccc6bdd5d4014f365451eb540bf89d44860f154 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 10:14:55 +0900 Subject: [PATCH 94/99] docs: keep delivery attribution without contact addresses [skip ci] --- devlog/_fin/260907_axis5_display_cli/020_delivery.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/devlog/_fin/260907_axis5_display_cli/020_delivery.md b/devlog/_fin/260907_axis5_display_cli/020_delivery.md index 0b06325172..7a2b76d0f8 100644 --- a/devlog/_fin/260907_axis5_display_cli/020_delivery.md +++ b/devlog/_fin/260907_axis5_display_cli/020_delivery.md @@ -33,8 +33,10 @@ One Mac shard reached its 20-minute limit after an unchanged history-lock test. ## Attribution -- Éverton Toffanetto -- 투린 -- Zig Zag +- Éverton Toffanetto +- 투린 +- Zig Zag + +Original author identities remain in the landed commits; this note lists names without contact addresses. The preceding numbered files are the historical roadmap and audits; their original _plan locations refer to the planning phase before this closeout. From b29bbb440aaf70b283445a9c37e194a4a4e6859a Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 10:51:00 +0900 Subject: [PATCH 95/99] fix(clients): preserve exact Aside file identities [skip ci] Use private BigInt stat identities so distinct legal 64-bit file IDs are not collapsed by Number conversion. Preserve all path/link/hardlink and directory replacement checks, manifest bounds, public IO types and serialization. Add semantic high-ID and native link controls with fixture-reachability checks. No local suite was run; the earlier Windows incident IDs remain unmeasured. --- scripts/test-layout/layout.json | 1 + src/clients/aside-profiles.ts | 15 ++- tests/clients/aside-profile-identity.test.ts | 132 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 4 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 tests/clients/aside-profile-identity.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f560dffeb5..7547d467c7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -239,6 +239,7 @@ "aside-profiles-routes.test.ts": "server", "aside-profiles.test.ts": "clients", "aside-profile-paths.test.ts": "clients", + "aside-profile-identity.test.ts": "clients", "aside-profile-sync-owner.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", diff --git a/src/clients/aside-profiles.ts b/src/clients/aside-profiles.ts index 31f13d9b76..770857611a 100644 --- a/src/clients/aside-profiles.ts +++ b/src/clients/aside-profiles.ts @@ -1,4 +1,4 @@ -import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync, type Stats } from "node:fs"; +import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync, type BigIntStats } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import type { IntegrationIO } from "../integrations/config-io"; @@ -14,7 +14,7 @@ export interface AsideProfile { } const MAX_PROFILES = 128; -const MAX_MANIFEST_BYTES = 4 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 4n * 1024n * 1024n; const MAX_LEAF_LINKS = 40; function refuse(message: string): never { @@ -30,9 +30,10 @@ function object(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -function inspect(path: string, follow = false): Stats | null { +function inspect(path: string, follow = false): BigIntStats | null { try { - return follow ? statSync(path) : lstatSync(path); + // File IDs can exceed Number's exact integer range; never round identities. + return follow ? statSync(path, { bigint: true }) : lstatSync(path, { bigint: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; return refuse("a filesystem boundary could not be inspected."); @@ -110,10 +111,10 @@ export function listAsideProfiles(env: NodeJS.ProcessEnv = process.env, home: st return readProfiles(root); } -type DirectoryIdentity = { path: string; dev: number; ino: number }; +type DirectoryIdentity = { path: string; dev: bigint; ino: bigint }; type Boundary = Array; -function sameIdentity(a: Pick, b: Pick): boolean { +function sameIdentity(a: Pick, b: Pick): boolean { return a.dev === b.dev && a.ino === b.ino; } @@ -162,7 +163,7 @@ function boundary(profile: AsideProfile, profiles: AsideProfile[], mutation: boo } if (absent) return identities; const leaf = inspect(profile.configPath); - if (leaf && (leaf.isSymbolicLink() || !leaf.isFile() || leaf.nlink > 1)) { + if (leaf && (leaf.isSymbolicLink() || !leaf.isFile() || leaf.nlink > 1n)) { refuse("the model catalog is a link, shared file or non-regular file."); } if (leaf && canonical(profile.configPath) !== join(parent!, "models.json")) { diff --git a/tests/clients/aside-profile-identity.test.ts b/tests/clients/aside-profile-identity.test.ts new file mode 100644 index 0000000000..771966a1c4 --- /dev/null +++ b/tests/clients/aside-profile-identity.test.ts @@ -0,0 +1,132 @@ +import { expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { IntegrationIO } from "../../src/integrations/config-io"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// Capture real delegates before spying. Only fixture inode values are controlled; +// existence, file type, link count, realpath and link resolution remain native. +const nativeLstat = fs.lstatSync; +const nativeStat = fs.statSync; +const FIRST_INODE = 2n ** 53n; +const SECOND_INODE = FIRST_INODE + 1n; + +test("Aside preserves high file identities without admitting shared targets or directory replacement", async () => { + const home = fs.mkdtempSync(join(tmpdir(), "ocx-aside-identity-")); + const root = join(home, ".aside"); + const paths = [0, 1].map(id => join(root, "u", String(id), "models.json")); + const identities = new Map(); + const reads = new Set(); + let observingBoundary = false; + const restoreSpies: Array<() => void> = []; + + function controlledStat(delegate: typeof fs.statSync, kind: "stat" | "lstat"): typeof fs.statSync { + // Preserve fs's overload contract: the native delegate determines the result + // type, including undefined for throwIfNoEntry:false and number vs bigint. + return ((path: fs.PathLike, options?: fs.StatOptions) => { + const stats = delegate(path, options); + const inode = typeof path === "string" ? identities.get(path) : undefined; + if (stats && inode !== undefined) { + if (observingBoundary) reads.add(`${kind}:${path}`); + // Mutate this fresh native result, retaining its prototype and method + // receiver. Spreading Stats would lose native isFile/isDirectory methods. + stats.ino = options?.bigint ? inode : Number(inode); + } + return stats; + }) as typeof fs.statSync; + } + + function observe(run: () => T): T { + reads.clear(); + observingBoundary = true; + try { return run(); } finally { observingBoundary = false; } + } + + try { + for (const id of [0, 1]) fs.mkdirSync(join(root, "u", String(id)), { recursive: true }); + fs.writeFileSync(join(root, "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0 }, { id: 1 }], + })); + for (const path of paths) fs.writeFileSync(path, "{}"); + // Controlled IDs must not hide a runtime lacking native BigInt stat support. + expect(typeof nativeStat(paths[0]!, { bigint: true }).ino).toBe("bigint"); + expect(typeof nativeLstat(paths[0]!, { bigint: true }).ino).toBe("bigint"); + const lstatSpy = spyOn(fs, "lstatSync"); + restoreSpies.push(() => lstatSpy.mockRestore()); + lstatSpy.mockImplementation(controlledStat(nativeLstat, "lstat")); + const statSpy = spyOn(fs, "statSync"); + restoreSpies.push(() => statSpy.mockRestore()); + statSpy.mockImplementation(controlledStat(nativeStat, "stat")); + + // Load after spies so the regression also covers the native named-import seam. + const { assertAsideProfileBoundary, guardAsideProfileIO, listAsideProfiles } = + await import("../../src/clients/aside-profiles"); + const [selected, peer] = listAsideProfiles({}, home); + if (!selected || !peer) throw new Error("fixture requires two profiles"); + const profiles = [selected, peer]; + expect(Number(FIRST_INODE)).toBe(Number(SECOND_INODE)); + expect(FIRST_INODE).not.toBe(SECOND_INODE); + expect(nativeStat(selected.configPath, { bigint: true }).dev) + .toBe(nativeStat(peer.configPath, { bigint: true }).dev); + // Distinct catalogs and directories are allowed even though their Number + // representations collide. + // Reads are recorded only DURING boundary calls, so a missed spy binding + // cannot silently turn this into a passing ordinary-filesystem test. + for (const target of ["configPath", "detectDir"] as const) { + identities.clear(); + identities.set(selected[target], FIRST_INODE); + identities.set(peer[target], SECOND_INODE); + for (const profile of profiles) { + const sibling = profile === selected ? peer : selected; + observe(() => expect(() => assertAsideProfileBoundary(profile, profiles, true)).not.toThrow()); + expect(reads.has(`lstat:${profile[target]}`)).toBe(true); + expect(reads.has(`stat:${sibling[target]}`)).toBe(true); + } + } + + identities.clear(); + identities.set(selected.detectDir, FIRST_INODE); + let delegatedReads = 0; + const io: IntegrationIO = { + readText: () => { delegatedReads++; return { kind: "text", text: "{}" }; }, + statKind: () => "file", + writeText: () => {}, removeFile: () => {}, mkdirp: () => {}, + now: () => 0, appendJournal: () => {}, putRecord: () => {}, dropRecord: () => {}, + }; + const guarded = observe(() => guardAsideProfileIO(selected, io, profiles)); + expect(reads.has(`lstat:${selected.detectDir}`)).toBe(true); + observe(() => expect(guarded.readText(selected.configPath)).toEqual({ kind: "text", text: "{}" })); + expect(reads.has(`lstat:${selected.detectDir}`)).toBe(true); + expect(delegatedReads).toBe(1); + identities.set(selected.detectDir, SECOND_INODE); + observe(() => expect(() => guarded.readText(selected.configPath)) + .toThrow("the account directory changed after the operation began.")); + expect(reads.has(`lstat:${selected.detectDir}`)).toBe(true); + expect(delegatedReads).toBe(1); + + // No synthetic IDs for these controls: real hardlinks and symlinks must + // continue to be refused by the same boundary, with native stat delegates. + identities.clear(); + fs.unlinkSync(peer.configPath); + fs.linkSync(selected.configPath, peer.configPath); + expect(nativeLstat(selected.configPath, { bigint: true }).nlink).toBe(2n); + for (const profile of profiles) { + expect(() => assertAsideProfileBoundary(profile, profiles, true)) + .toThrow("the model catalog is a link, shared file or non-regular file."); + } + fs.unlinkSync(peer.configPath); + fs.symlinkSync(selected.configPath, peer.configPath, "file"); + expect(nativeLstat(peer.configPath, { bigint: true }).isSymbolicLink()).toBe(true); + expect(() => assertAsideProfileBoundary(selected, profiles, true)) + .toThrow("account catalogs share a target."); + expect(() => assertAsideProfileBoundary(peer, profiles, true)) + .toThrow("the model catalog is a link, shared file or non-regular file."); + } finally { + observingBoundary = false; + identities.clear(); + reads.clear(); + for (const restore of restoreSpies.reverse()) restore(); + removeTreeWithRetry(home); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2a1cac7ec3..cac40976e1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -74,6 +74,7 @@ "aside-profiles-routes.test.ts": "server", "aside-profiles.test.ts": "clients", "aside-profile-paths.test.ts": "clients", + "aside-profile-identity.test.ts": "clients", "aside-profile-sync-owner.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", From be112e4fae9596950f81fa1b039d5f1f5c365a5d Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 11:24:54 +0900 Subject: [PATCH 96/99] docs: close axis1 bug-fix delivery record [skip ci] --- .../260907_axis1_bugfixes/000_plan.md | 2 + .../260907_axis1_bugfixes/010_roadmap.md | 0 .../260907_axis1_bugfixes/011_audit.md | 0 .../260907_axis1_bugfixes/012_roadmap_lock.md | 0 .../020_bounded_fixes.md | 0 .../021_source_review.md | 0 .../260907_axis1_bugfixes/030_delivery.md | 0 .../031_delivery_record.md | 53 +++++++++++++++++++ 8 files changed, 55 insertions(+) rename devlog/{_plan => _fin}/260907_axis1_bugfixes/000_plan.md (96%) rename devlog/{_plan => _fin}/260907_axis1_bugfixes/010_roadmap.md (100%) rename devlog/{_plan => _fin}/260907_axis1_bugfixes/011_audit.md (100%) rename devlog/{_plan => _fin}/260907_axis1_bugfixes/012_roadmap_lock.md (100%) rename devlog/{_plan => _fin}/260907_axis1_bugfixes/020_bounded_fixes.md (100%) rename devlog/{_plan => _fin}/260907_axis1_bugfixes/021_source_review.md (100%) rename devlog/{_plan => _fin}/260907_axis1_bugfixes/030_delivery.md (100%) create mode 100644 devlog/_fin/260907_axis1_bugfixes/031_delivery_record.md diff --git a/devlog/_plan/260907_axis1_bugfixes/000_plan.md b/devlog/_fin/260907_axis1_bugfixes/000_plan.md similarity index 96% rename from devlog/_plan/260907_axis1_bugfixes/000_plan.md rename to devlog/_fin/260907_axis1_bugfixes/000_plan.md index 1fa7537164..ad0da69270 100644 --- a/devlog/_plan/260907_axis1_bugfixes/000_plan.md +++ b/devlog/_fin/260907_axis1_bugfixes/000_plan.md @@ -1,5 +1,7 @@ # Axis 1: measured bug fixes and failure diagnostics +Completed: see [031_delivery_record.md](031_delivery_record.md) for merged commits, final CI, attribution and deferrals. + Archetype: satisfy existing contracts. Trigger: owner assigned axis 1 (#3809, #3464, #3661). Goal: deliver reviewable fixes through a manual PR chain and merge the verified scope. Non-goals: new account/retry policy, auth defaults, multipart recovery, releases, native stacks, sibling edits. Stop: merged feasible scope plus explicit unresolved dispositions. Escalation: defer a policy-dependent or unreproducible slice; reclaim a worker slice after two failed packets. Evidence: this unit plus ignored `.tmp/axis1/` and `.codexclaw` receipts. Resources: task-owned worktree/branches and GitHub repository access; Astra high leaves within host capacity; no caller-specified token or wall-clock budget. Baseline: origin/dev 137d6a727; source PR #3809 at 4a1012359a522ddd6d7ff77203c9e5f3632d605c. Assigned 5cc8 checkout has pre-existing changes and remains untouched. Code lives in /tmp/ocx-axis1-20260907. diff --git a/devlog/_plan/260907_axis1_bugfixes/010_roadmap.md b/devlog/_fin/260907_axis1_bugfixes/010_roadmap.md similarity index 100% rename from devlog/_plan/260907_axis1_bugfixes/010_roadmap.md rename to devlog/_fin/260907_axis1_bugfixes/010_roadmap.md diff --git a/devlog/_plan/260907_axis1_bugfixes/011_audit.md b/devlog/_fin/260907_axis1_bugfixes/011_audit.md similarity index 100% rename from devlog/_plan/260907_axis1_bugfixes/011_audit.md rename to devlog/_fin/260907_axis1_bugfixes/011_audit.md diff --git a/devlog/_plan/260907_axis1_bugfixes/012_roadmap_lock.md b/devlog/_fin/260907_axis1_bugfixes/012_roadmap_lock.md similarity index 100% rename from devlog/_plan/260907_axis1_bugfixes/012_roadmap_lock.md rename to devlog/_fin/260907_axis1_bugfixes/012_roadmap_lock.md diff --git a/devlog/_plan/260907_axis1_bugfixes/020_bounded_fixes.md b/devlog/_fin/260907_axis1_bugfixes/020_bounded_fixes.md similarity index 100% rename from devlog/_plan/260907_axis1_bugfixes/020_bounded_fixes.md rename to devlog/_fin/260907_axis1_bugfixes/020_bounded_fixes.md diff --git a/devlog/_plan/260907_axis1_bugfixes/021_source_review.md b/devlog/_fin/260907_axis1_bugfixes/021_source_review.md similarity index 100% rename from devlog/_plan/260907_axis1_bugfixes/021_source_review.md rename to devlog/_fin/260907_axis1_bugfixes/021_source_review.md diff --git a/devlog/_plan/260907_axis1_bugfixes/030_delivery.md b/devlog/_fin/260907_axis1_bugfixes/030_delivery.md similarity index 100% rename from devlog/_plan/260907_axis1_bugfixes/030_delivery.md rename to devlog/_fin/260907_axis1_bugfixes/030_delivery.md diff --git a/devlog/_fin/260907_axis1_bugfixes/031_delivery_record.md b/devlog/_fin/260907_axis1_bugfixes/031_delivery_record.md new file mode 100644 index 0000000000..439c395f8b --- /dev/null +++ b/devlog/_fin/260907_axis1_bugfixes/031_delivery_record.md @@ -0,0 +1,53 @@ +# Axis 1 delivery record + +Terminal outcome: DONE for the authorized bounded bug/diagnostic scope, with the explicitly listed broader work deferred. Completed 2026-09-07. + +## Delivered + +- #3825 carries #3809 with serving-credential quota attribution, upstream deadline handling, probe-clock preservation and known-reset expiration. Invalid reset metadata does not erase otherwise valid usage; no new unknown-window TTL or synthetic zero was introduced. +- #3826 corrects CLI-versus-proxy version guidance in both directions and prevents false doctor match claims. +- #3827 exposes bounded recovery refusal/timeout/transport/invalid-output reasons through shared flights while preserving admission, success-only caching and caller-local cancellation. +- #3842 is supporting validation work: exact private BigInt file identities preserve existing Aside profile boundaries, including high-ID distinction and directory replacement detection. Public IO/serialization and link refusals remain unchanged. + +## Landing proof + +All four ordinary PRs were merged bottom-up with owner-authorized admin authority. No native stack was registered. Children were retargeted to dev before their parent branches could be automatically deleted. + +| PR | Reviewed layer head | Merge commit | +| --- | --- | --- | +| [#3825](https://github.com/lidge-jun/opencodex/pull/3825) | `d3c70f9d8c8cc6fced7a93577b93e8b141473ea3` | `85fbdb59621046da3db1839a5cce4c7260f99385` | +| [#3826](https://github.com/lidge-jun/opencodex/pull/3826) | `872f0e5aa714f6a2e757510195d1c038ac70e26d` | `860baaf9032fa7ea3030c78ab555608e3325a338` | +| [#3827](https://github.com/lidge-jun/opencodex/pull/3827) | `2e8ef03428f8e619dc92b250fbbc5d5dd7ad53cb` | `5a97db9b20f03a65e714ddc88d2523bea9aeacae` | +| [#3842](https://github.com/lidge-jun/opencodex/pull/3842) | `b29bbb440aaf70b283445a9c37e194a4a4e6859a` | `5fdf9bbdd9ff7657f0b6d7101697317d708af0e7` | + +The runtime integration commit is `5fdf9bbdd9ff7657f0b6d7101697317d708af0e7`. Its full tree `90a75118402d2f310393bef9ac3e4668cfcbdcfa` exactly matches the final combined validation candidate `9470fdb1bc9a02715a3760c36301d3d030a4e4fa`. A fresh fetch and ancestor check confirmed every merge on dev. The candidate included dev `bf85e675484a2391b94b2135bbebe739813a9621` plus all four layers. + +## Verification + +- [Cross-platform CI 34074350604](https://github.com/lidge-jun/opencodex/actions/runs/34074350604): all 26 jobs succeeded at the combined candidate, including Linux, macOS, Windows, Docker smoke, typecheck, privacy, build and operational checks. +- [Service lifecycle 34074351720](https://github.com/lidge-jun/opencodex/actions/runs/34074351720): Linux, macOS and Windows succeeded at the same candidate. +- Independent Astra high source/security audits covered the scoped implementations, merge interactions and exact-identity support. +- All current review threads on the four delivered PRs were resolved after runtime evidence was available. +- No local application test suite or local typecheck ran. Pushes used --no-verify; per-layer CI was deferred by explicit owner instruction. Cancelled and skipped checks were never represented as passing tests. +- Privacy scanning passed. Documentation static build produced 425 pages in 8.23 seconds at 2522264d5; its documentation subtree remained unchanged by the supporting identity fix. Dependencies were installed from the frozen lockfile with install scripts disabled. The build changed no tracked files. +- The assigned pre-existing working-tree changes were preserved; delivery used an isolated worktree. + +## Corrections and remaining limits + +Initial verification exposed incomplete test homes/default configuration and old calendar reset dates in current-measurement fixtures. Those fixtures were corrected without removing behavioral assertions. Known-expiry tests use explicit simulated time. Later review added expired-window handling, field normalization and a global test network guard. + +Imported axis-five closeout contact addresses blocked privacy scanning. [#3836](https://github.com/lidge-jun/opencodex/pull/3836) removed the addresses while retaining author names and all commit attribution; no scanner rule or allowlist was weakened. + +Earlier Windows Aside incidents reported an apparent shared catalog target. Their actual file IDs were not captured. The independently demonstrable Number-precision defect was corrected by #3842, and semantic/native regressions plus the previously failing route case passed in final CI. This does not retroactively prove every earlier incident's raw IDs or cause. + +An earlier Windows outbound-proxy test timed out at its existing 15-second bound. Its scoped test/transport files were unchanged and the stalled phase was not measured. No timeout increase or unrelated proxy repair was made; later passing execution is not a claim that the timing root cause was fixed. + +## Attribution and issue disposition + +Éverton Toffanetto's Co-authored-by trailer is retained in reachable commit `f215f79b4562735029ad5672a68bc6104e534b98`. The issue reporters garysassano and Hu9956 are acknowledged in the corresponding diagnostic commits. Merge commits preserve those commits and trailers. + +The original #3809 was confirmed closed with a landed-via-#3825 marker at final recheck. The initial carry source was 4a1012359; the original author subsequently updated the source PR, so this record does not claim a verbatim merge of its later head. + +#3464 remains open for its broader automatic-repair/request-policy requests. #3661 remains open for multipart reconstruction and recovery retry policy. Those choices were outside this delivery. No release, deployment, new account-selection strategy or authentication-default change was performed. + +The preceding numbered documents are historical plans and audits; their original _plan paths refer to the planning stage. From 3709dda35a9e5075190078138b700199b59dbcb9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 06:12:16 +0000 Subject: [PATCH 97/99] fix(sync): repair auto-merge fallout after v2.46.0 integration Re-merge config.ts from the v2.45 resolved fork overlay onto upstream v2.46, unify usage/log imports, restore catalog export, and take upstream cli init/dispatch signatures. Co-authored-by: SB Yoon --- src/cli/dispatch.ts | 4 +- src/codex/catalog.ts | 2 +- src/config.ts | 462 ++++++++----------------------------------- src/types/config.ts | 2 - src/usage/log.ts | 165 +++++++++++++--- 5 files changed, 225 insertions(+), 410 deletions(-) diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 79ee487b70..c884bb2e5d 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -54,9 +54,9 @@ export interface CliDispatchDeps { type CommandRunner = (deps: CliDispatchDeps) => Promise; const commandRunners: Record = { - init: async deps => { + init: async () => { const { runInit } = await import("./init"); - await runInit(deps.args.slice(1)); + await runInit(); // runInit sets process.exitCode = 1 on stdin EOF/closed; preserve it. return Number(process.exitCode ?? 0); }, diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 09aad5d926..19650604b8 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -8,7 +8,7 @@ export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, c export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata, resolveComboCatalogMember, configuredComboTargetModelsByProvider } from "./catalog/provider-fetch"; export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation"; export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation"; -export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, orderForModelPicker, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache, finalizeAutoReviewModelOverride } from "./catalog/sync"; +export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, orderForModelPicker, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache, finalizeAutoReviewModelOverride, isEligibleV2SubagentEntry } from "./catalog/sync"; export type { ObservedCatalogMergeInput } from "./catalog/sync"; export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync"; export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models"; diff --git a/src/config.ts b/src/config.ts index c8120f69f7..2dd83b80fa 100644 --- a/src/config.ts +++ b/src/config.ts @@ -8,7 +8,6 @@ import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "./config/subag export { DEFAULT_SUBAGENT_MODELS } from "./config/subagent-models"; import { apiKeyTransportConfigError, - azureCredentialConfigError, booleanRecordConfigError, modelAdapterRecordConfigError, modelDisplayNamesConfigError, @@ -19,9 +18,7 @@ import { providerBaseUrlConfigError, providerHeadersConfigError, reasoningSummaryDeliveryRecordConfigError, - maxWsFrameBytesConfigError, upstreamHttpVersionConfigError, - wsUpstreamConfigError, } from "./config/provider-validation"; import { bumpConfigGenerationAtPath, @@ -64,7 +61,6 @@ import { import { recordOwnedConfigPath } from "./lib/config-ownership"; import { assertNotRealHomeUnderTest } from "./lib/test-home-guard"; import { providerDestinationConfigError } from "./lib/destination-policy"; -import { antigravityOAuthDestinationConfigError, providerTlsProfileConfigError } from "./lib/provider-tls-profile"; import { redactSecretString } from "./lib/redact"; import { openRouterRoutingConfigError } from "./providers/openrouter-routing"; import { MODEL_ALIAS_PATTERN } from "./providers/default-aliases"; @@ -92,16 +88,16 @@ import { providerModelWireDefault, registryModelServiceTierCapabilityApplies, } from "./providers/registry"; -import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models"; import { slugEquivalenceKey, slugsEquivalent } from "./providers/slug-codec"; +import { resolveOpenAiVirtualModel } from "./providers/openai-virtual-models"; import { parseDesktopProfile } from "./claude/desktop-profile"; import { isCodexReasoningEffort } from "./reasoning-effort"; import { COST4_RATE_KEYS, - commitPersistedProviderDeletions, isValidCost4Rate, refreshPreservedProviderOwner, refreshUserCostOverlays, + withPreservedDiskOnlyProviders, } from "./usage/user-cost-overlays"; import { MAX_COST4_RATE } from "./usage/expected-prices"; import { @@ -111,12 +107,9 @@ import { } from "./lib/app-owned-memory"; import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy"; import { - AtomicWriteResidualTempError, - AtomicWriteSecretResidualError, atomicWriteFile, isMissingPathError, nextAtomicTempSequence, - resolveWriteTarget, } from "./config/atomic-write"; export { AtomicWriteResidualTempError, @@ -476,25 +469,37 @@ const transientRetryOn5xxPolicySchema = z.object({ attempts: z.number().int().min(1).max(10).optional(), }).strict(); +export function transientRetryOn5xxPolicyConfigError(policy: unknown): string | null { + if (policy === undefined) return null; + const result = transientRetryOn5xxPolicySchema.safeParse(policy); + if (result.success) return null; + const first = result.error.issues[0]; + if (!first) return "transientRetryOn5xx is invalid"; + if (first.code === "unrecognized_keys") { + const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", "); + return `transientRetryOn5xx has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`; + } + if (first.path.length === 0) return `transientRetryOn5xx is invalid (${first.message})`; + const field = String(first.path[first.path.length - 1]); + return `transientRetryOn5xx.${field} is invalid (${first.message})`; +} + const requestPacingRuleSchema = z.object({ // Keep the RPM-derived timer within the same one-hour bound as minIntervalMs. requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), minIntervalMs: z.number().int().min(1).max(3_600_000).optional(), - jitterMs: z.number().int().min(0).max(60_000).optional(), -}).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined || value.jitterMs !== undefined, { - message: "request pacing rules need requestsPerMinute, minIntervalMs, or jitterMs", +}).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined, { + message: "request pacing rules need requestsPerMinute or minIntervalMs", }); const requestPacingSchema = z.object({ enabled: z.boolean(), requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), minIntervalMs: z.number().int().min(1).max(3_600_000).optional(), - jitterMs: z.number().int().min(0).max(60_000).optional(), models: z.record(z.string().trim().min(1), requestPacingRuleSchema).optional(), }).strict().refine(value => value.enabled === false || value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined - || value.jitterMs !== undefined || (value.models !== undefined && Object.keys(value.models).length > 0), { message: "enabled request pacing needs a provider rule or model override", }); @@ -534,12 +539,6 @@ const modelDisplayNamesSchema = z.unknown().superRefine((value, ctx) => { const providerConfigSchema = z.object({ adapter: z.string().min(1), baseUrl: z.string().min(1), - azureCredential: z.object({ - type: z.literal("default-azure-credential"), - managedIdentityClientId: z.string().trim().min(1).optional(), - }).strict().transform(value => value.managedIdentityClientId === undefined - ? value - : { ...value, managedIdentityClientId: value.managedIdentityClientId.trim() }).optional(), alias: z.string().optional(), modelAliases: z.record(z.string(), z.string()).optional(), modelDisplayNames: modelDisplayNamesSchema.optional(), @@ -566,8 +565,6 @@ const providerConfigSchema = z.object({ decodesNativeCompactionBlobs: z.boolean().optional(), allowEncryptedV2AgentTasks: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), - wsUpstream: z.boolean().nullish().transform(value => value ?? undefined), - maxWsFrameBytes: z.number().int().positive().nullish().transform(value => value ?? undefined), // The management API accepts `null` as "clear this", so a config written before the POST // canonicalization below can hold one on disk. Rejecting it here would send the operator // through invalid-config recovery for a value the API told them was fine. @@ -590,14 +587,12 @@ const providerConfigSchema = z.object({ .optional(), retryOn429: retryOn429PolicySchema.optional(), transientRetryOn5xx: transientRetryOn5xxPolicySchema.optional(), - replayTransientFailures: z.boolean().optional(), codexAccountMode: z.enum(["pool", "direct"]).optional(), // Validated rather than passed through: this schema ends in `.passthrough()`, so an // undeclared key survives verbatim. A misspelled `codexToolMode` therefore used to be // accepted, persisted, and then silently resolved to the `code_mode_only` default — the // operator asked for shell mode, got code mode, and was told nothing (#2106). codexToolMode: z.enum(["code_mode_only", "shell"]).optional(), - projectContext: z.enum(["off", "on"]).optional(), responsesItemIdRepair: z.object({ message: z.array(z.string().min(1)).optional(), reasoning: z.array(z.string().min(1)).optional(), @@ -612,8 +607,6 @@ const providerConfigSchema = z.object({ export { isValidProviderName, hasOwnProvider } from "./config/provider-name"; export { apiKeyTransportConfigError, - azureCredentialConfigError, - isAzureIdentityProvider, booleanRecordConfigError, modelAdapterRecordConfigError, modelDisplayNamesConfigError, @@ -624,9 +617,7 @@ export { providerBaseUrlConfigError, providerHeadersConfigError, reasoningSummaryDeliveryRecordConfigError, - maxWsFrameBytesConfigError, upstreamHttpVersionConfigError, - wsUpstreamConfigError, } from "./config/provider-validation"; function providerResponsesPathConfigError(responsesPath: string | undefined): string | null { @@ -975,11 +966,6 @@ const agentTaskRecoverySchema = z.object({ cacheEntries: z.number().int().min(1).max(512).optional(), }).strict(); -const v2NativeParentOverrideSchema = z.object({ - enabled: z.boolean().optional(), - model: z.string().trim().min(1).optional(), -}).strict(); - const runtimeRoleSchema = z.enum(["standalone", "hub", "client"]); function canonicalHttpOrigin(value: string): string | null { @@ -1186,10 +1172,6 @@ const configSchema = z.object({ providerContextCapValues: z.record(z.string(), z.number().int().positive()).optional(), contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), - // Invalid hand edits disable only this experimental opt-in. - v2RoutedDelegationBridge: z.boolean().optional().catch(undefined), - // Invalid hand edits disable only this experimental opt-in subtree. - v2NativeParentOverride: v2NativeParentOverrideSchema.optional().catch(undefined), // Invalid optional recovery config must not discard unrelated provider/account state. agentTaskRecovery: agentTaskRecoverySchema.optional().catch(undefined), // Same rationale: a bad notify section must not cost the operator their providers. @@ -1207,12 +1189,6 @@ const configSchema = z.object({ z.string(), z.array(z.string().trim().min(1)).min(1), ).optional().catch(undefined), - // Candidate overwrite is an optional spawn policy. Invalid hand edits disable - // only that policy instead of rejecting the operator's otherwise valid config. - subagentCandidates: z.union([ - z.array(z.string().trim().min(1)).min(1), - z.record(z.string().trim().min(1), z.array(z.string().trim().min(1)).min(1)), - ]).optional().catch(undefined), codexShimAutoRestore: z.boolean().optional(), codexDesktopAuthless: z.boolean().optional().catch(undefined), pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(), @@ -1387,30 +1363,6 @@ const configSchema = z.object({ message: responsesPathError, }); } - const wsUpstreamError = wsUpstreamConfigError((provider as { wsUpstream?: unknown }).wsUpstream); - if (wsUpstreamError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "wsUpstream"], - message: wsUpstreamError, - }); - } - const maxWsFrameBytesError = maxWsFrameBytesConfigError((provider as { maxWsFrameBytes?: unknown }).maxWsFrameBytes); - if (maxWsFrameBytesError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "maxWsFrameBytes"], - message: maxWsFrameBytesError, - }); - } - const tlsProfileError = providerTlsProfileConfigError(name, provider); - if (tlsProfileError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "tlsProfile"], - message: tlsProfileError, - }); - } const headersError = providerHeadersConfigError((provider as { headers?: unknown }).headers); if (headersError) { ctx.addIssue({ @@ -1447,14 +1399,6 @@ const configSchema = z.object({ message: apiKeyTransportError, }); } - const azureCredentialError = azureCredentialConfigError(provider); - if (azureCredentialError) { - ctx.addIssue({ - code: "custom", - path: ["providers", redactSecretString(name), "azureCredential"], - message: azureCredentialError, - }); - } const modelAdaptersError = modelAdapterRecordConfigError( (provider as { modelAdapters?: unknown }).modelAdapters, "modelAdapters", @@ -1718,11 +1662,6 @@ function sanitizeRetryOn429ForLoad(parsed: unknown): void { const safeProviderName = JSON.stringify(redactSecretString(name)); if (!provider || typeof provider !== "object" || Array.isArray(provider)) continue; const p = provider as Record; - if (p.replayTransientFailures !== undefined && typeof p.replayTransientFailures !== "boolean") { - const receivedType = typeof p.replayTransientFailures; - delete p.replayTransientFailures; - console.warn(`⚠️ config.json providers.${safeProviderName}.replayTransientFailures (${receivedType}) is invalid — ignoring the field`); - } const policy = p.retryOn429; if (policy === undefined) continue; if (!policy || typeof policy !== "object" || Array.isArray(policy)) { @@ -1796,22 +1735,6 @@ export function retryOn429PolicyConfigError(policy: unknown): string | null { return `retryOn429.${field} is invalid (${first.message})`; } -/** Strict write-boundary validation for the opt-in transient 5xx retry budget. */ -export function transientRetryOn5xxPolicyConfigError(policy: unknown): string | null { - if (policy === undefined) return null; - const result = transientRetryOn5xxPolicySchema.safeParse(policy); - if (result.success) return null; - const first = result.error.issues[0]; - if (!first) return "transientRetryOn5xx is invalid"; - if (first.code === "unrecognized_keys") { - const names = first.keys.map(key => JSON.stringify(redactSecretString(key))).join(", "); - return `transientRetryOn5xx has unrecognized field${first.keys.length > 1 ? "s" : ""}: ${names}`; - } - if (first.path.length === 0) return `transientRetryOn5xx is invalid (${first.message})`; - const field = String(first.path[first.path.length - 1]); - return `transientRetryOn5xx.${field} is invalid (${first.message})`; -} - /** * Load-time degradation for `providers..modelCosts`, mirroring * {@link sanitizeRetryOn429ForLoad}. A hand-edited malformed display-price row @@ -2520,52 +2443,6 @@ export function subagentDefaultSyncEffective( return config.syncCodexSubagentDefaults === true && Boolean(config.injectionModel?.trim()); } -/** - * Resolve and normalize configured candidates for one spawned sub-agent. - * Supports a global ordered list or a record keyed by role/model, with - * `default` and `*` fallbacks for unmatched requests. - */ -export function resolveSubagentCandidates( - config: Pick | OcxConfig, - roleOrModel?: string, -): string[] { - const candidates = config?.subagentCandidates; - if (!candidates) return []; - - const normalizeList = (raw: unknown): string[] => { - if (!Array.isArray(raw)) return []; - const result: string[] = []; - const seen = new Set(); - for (const item of raw) { - if (typeof item !== "string") continue; - const trimmed = item.trim(); - if (!trimmed) continue; - const key = slugEquivalenceKey(trimmed); - if (seen.has(key)) continue; - seen.add(key); - result.push(trimmed); - } - return result; - }; - - if (Array.isArray(candidates)) return normalizeList(candidates); - if (typeof candidates !== "object") return []; - - const record = candidates as Record; - const trimmed = roleOrModel?.trim(); - let selected: unknown; - if (trimmed) { - if (Object.hasOwn(record, trimmed)) { - selected = record[trimmed]; - } else { - const matchingKey = Object.keys(record).find(key => slugsEquivalent(key, trimmed)); - if (matchingKey) selected = record[matchingKey]; - } - } - if (!selected) selected = record.default ?? record["*"]; - return normalizeList(selected); -} - function mergeConfigDefaults(parsed: unknown): unknown { if (!parsed || typeof parsed !== "object") return parsed; const defaults = getDefaultConfig(); @@ -2577,19 +2454,6 @@ function mergeConfigDefaults(parsed: unknown): unknown { return merged; } -function configNeedsProviderRepair(parsed: Record): boolean { - const providers = parsed.providers; - if (parsed.defaultProvider !== undefined - || (providers && typeof providers === "object" && !Array.isArray(providers) - && Object.keys(providers).length > 0)) return false; - const candidate = structuredClone(parsed); - sanitizeAliasesForLoad(candidate); - sanitizeRetryOn429ForLoad(candidate); - sanitizeModelCostsForLoad(candidate); - return !configSchema.safeParse(candidate).success - && configSchema.safeParse(mergeConfigDefaults(candidate)).success; -} - function schemaDiagnosticsError(error: z.ZodError): string { const details = error.issues.map(issue => { const path = issue.path.join(".") || "config"; @@ -2905,10 +2769,6 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx const result = configSchema.safeParse(value); if (result.success) { const config = normalizeApiKeyIds(result.data as OcxConfig); - for (const [name, provider] of Object.entries(config.providers)) { - const antigravityError = antigravityOAuthDestinationConfigError(name, provider); - if (antigravityError) return { ok: false, error: `providers.${name}.baseUrl: ${antigravityError}` }; - } return { ok: true, config }; } return { ok: false, error: schemaDiagnosticsError(result.error) }; @@ -3265,32 +3125,8 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync * cost-overlay registry from the persisted config so runtime estimates follow * every save path. */ -type PersistConfigAuthority = "ordinary" | "mutation" | "replacement"; - -function persistConfigUnlocked(config: OcxConfig, authority: PersistConfigAuthority = "ordinary"): boolean { +function persistConfigUnlocked(config: OcxConfig): boolean { const configPath = getConfigPath(); - // Check the resolved file target before reading it: a symlink can point from an - // isolated test home into the protected real home, where another write guard - // must not mask this refusal based on the target's current contents. - assertNotRealHomeUnderTest(dirname(resolveWriteTarget(configPath))); - const raw = readRawConfigJson(); - if (authority !== "replacement" && raw && configNeedsProviderRepair(raw)) { - throw new Error("refusing to overwrite a config repaired with defaults; fix the persisted config first"); - } - const snapshot = readConfigFileSnapshot(); - if (authority !== "replacement" && snapshot.diagnostics.source === "fallback") { - throw new Error("refusing to overwrite an invalid persisted config; fix the persisted config first"); - } - // Automatic whole-config writes own non-provider settings only. A valid disk - // registry is authoritative; explicit locked mutations pass replacement - // authority for intentional provider/default changes. - const base = snapshot.diagnostics.source === "file" && authority === "ordinary" - ? { - ...config, - providers: snapshot.diagnostics.config.providers, - defaultProvider: snapshot.diagnostics.config.defaultProvider, - } - : config; const rawBeforeWrite = readRawConfigJson(); const clientPersistenceError = failClosedClientPersistenceError(rawBeforeWrite, config); if (clientPersistenceError) throw new Error(clientPersistenceError); @@ -3300,11 +3136,7 @@ function persistConfigUnlocked(config: OcxConfig, authority: PersistConfigAuthor // Provider preservation reads symbol-keyed live-owner state, which structuredClone // intentionally drops. Resolve that ownership before projecting JSON provenance. const provenanceProjection = projectConfigRebaseProvenance(config); - const persisted = base; - const persistedProviderDeletions = authority === "mutation" && snapshot.diagnostics.source === "file" - ? Object.keys(snapshot.diagnostics.config.providers ?? {}) - .filter(name => !Object.hasOwn(persisted.providers ?? {}, name)) - : []; + const persisted = withPreservedDiskOnlyProviders(config); if (provenanceProjection.configRebaseProvenance === undefined) delete persisted.configRebaseProvenance; else persisted.configRebaseProvenance = provenanceProjection.configRebaseProvenance; const bytes = JSON.stringify(persisted, null, 2) + "\n"; @@ -3319,14 +3151,12 @@ function persistConfigUnlocked(config: OcxConfig, authority: PersistConfigAuthor // the same bytes (e.g. before a proxy notification), and Logs/Usage must // adopt the overlay without waiting for a changed save or restart. if (unchanged) { - commitPersistedProviderDeletions(persistedProviderDeletions); refreshUserCostOverlays(persisted); return false; } atomicWriteFile(configPath, bytes); // For changed saves, refresh only AFTER the write succeeded so a failed // write cannot leave estimates reflecting configuration never persisted. - commitPersistedProviderDeletions(persistedProviderDeletions); refreshUserCostOverlays(persisted); return true; } @@ -3386,183 +3216,6 @@ export function saveConfig(config: OcxConfig): void { }); } -/** Replace a validated config under the shared lock for confirmed import/init flows. */ -export function replacePersistedConfig(config: OcxConfig): void { - assertNotRealHomeUnderTest(getConfigDir()); - withConfigMutationLockSync(() => { - const projected = projectCustomModelCatalogMigration( - readRawConfigJson(), - projectConfigRebaseProvenance(config), - ); - if (persistConfigUnlocked(projected, "replacement")) bumpGenerationForCooperatingConfigWrite(); - adoptCustomModelCatalogMigration(config, projected); - if (projected.configRebaseProvenance === undefined) delete config.configRebaseProvenance; - else config.configRebaseProvenance = structuredClone(projected.configRebaseProvenance); - clearPendingConfigTopLevelDeletions(config); - }); -} - -export type PersistedConfigInitializationOutcome = "created" | "exists" | "invalid"; - -export class PersistedConfigInitializationCleanupError extends Error { - constructor(options?: ErrorOptions) { - super("Initial config publication cleanup failed after rollback", options); - this.name = "PersistedConfigInitializationCleanupError"; - } -} - -export class PersistedConfigInitializationRollbackError extends Error { - constructor(options?: ErrorOptions) { - super("Initial config publication rollback failed", options); - this.name = "PersistedConfigInitializationRollbackError"; - } -} - -export interface PersistedConfigInitializationIO { - createExclusive(path: string): void; - write(path: string, bytes: string): void; - harden(path: string): void; - publishNoReplace(temp: string, target: string): void; - truncate(path: string): void; - unlink(path: string): void; -} - -let persistedConfigInitializationBeforePublishForTests: (() => void) | null = null; - -/** Test-only one-shot seam: create a competing config after staging, before no-replace publication. */ -export function setPersistedConfigInitializationBeforePublishForTests(hook: (() => void) | null): void { - persistedConfigInitializationBeforePublishForTests = hook; -} - -function publishInitialConfigNoReplace( - config: OcxConfig, - io: PersistedConfigInitializationIO, -): boolean { - const configPath = getConfigPath(); - const target = resolveWriteTarget(configPath); - assertNotRealHomeUnderTest(dirname(target)); - recordOwnedConfigPath(getConfigDir(), configPath); - const persisted = projectConfigRebaseProvenance(config); - const bytes = JSON.stringify(persisted, null, 2) + "\n"; - const temp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`; - let staged = false; - let hardened = false; - let published = false; - let cleanupAttempted = false; - - const scrubUnpublishedTemp = (cause?: unknown): void => { - cleanupAttempted = true; - let scrubbed = false; - try { - io.truncate(temp); - scrubbed = true; - } catch (error) { - if (isMissingPathError(error)) scrubbed = true; - else { - try { io.write(temp, ""); scrubbed = true; } catch { /* removal may still succeed */ } - } - } - let removed = false; - try { - io.unlink(temp); - removed = true; - } catch (error) { - if (isMissingPathError(error)) removed = true; - else { - try { io.unlink(temp); removed = true; } - catch (retryError) { if (isMissingPathError(retryError)) removed = true; } - } - } - if (removed) forgetEphemeralSecretPath(temp); - if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(temp, { cause }); - if (!removed) throw new AtomicWriteResidualTempError(temp, hardened, { cause }); - }; - - try { - io.createExclusive(temp); - staged = true; - io.write(temp, bytes); - io.harden(temp); - hardened = true; - const hook = persistedConfigInitializationBeforePublishForTests; - persistedConfigInitializationBeforePublishForTests = null; - hook?.(); - try { - io.publishNoReplace(temp, target); - } catch (cause) { - if (!isAlreadyExistsError(cause)) throw cause; - scrubUnpublishedTemp(cause); - return false; - } - published = true; - try { - io.unlink(temp); - forgetEphemeralSecretPath(temp); - } catch (firstError) { - if (isMissingPathError(firstError)) { - forgetEphemeralSecretPath(temp); - } else try { - io.unlink(temp); - forgetEphemeralSecretPath(temp); - } catch (secondError) { - if (isMissingPathError(secondError)) { - forgetEphemeralSecretPath(temp); - } else { - // Both names point to one inode. Remove the published name before scrubbing. - try { io.unlink(target); } - catch (cause) { throw new PersistedConfigInitializationRollbackError({ cause }); } - published = false; - scrubUnpublishedTemp(secondError); - throw new PersistedConfigInitializationCleanupError({ cause: secondError }); - } - } - } - refreshUserCostOverlays(persisted); - return true; - } catch (cause) { - if (staged && !published && !cleanupAttempted) scrubUnpublishedTemp(cause); - throw cause; - } -} - -function defaultPersistedConfigInitializationIO(configPath: string): PersistedConfigInitializationIO { - return { - createExclusive: target => { writeFileSync(target, "", { flag: "wx", mode: 0o600 }); }, - write: (target, bytes) => writeFileSync(target, bytes), - harden: target => { - try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ } - if (process.platform === "win32") hardenSecretPath(target, { required: true, timeoutMemoKey: configPath }); - }, - publishNoReplace: (temp, target) => linkSync(temp, target), - truncate: target => truncateSync(target, 0), - unlink: unlinkSync, - }; -} - -/** Create the initial config under the shared lock, but never replace existing bytes. */ -export function initializePersistedConfigIfMissing( - config: OcxConfig, - io = defaultPersistedConfigInitializationIO(getConfigPath()), -): PersistedConfigInitializationOutcome { - assertNotRealHomeUnderTest(getConfigDir()); - return withConfigMutationLockSync(() => { - const snapshot = readConfigFileSnapshot(); - if (snapshot.diagnostics.source === "file") return "exists"; - if (snapshot.diagnostics.source !== "default") return "invalid"; - const projected = projectCustomModelCatalogMigration( - readRawConfigJson(), - projectConfigRebaseProvenance(config), - ); - if (!publishInitialConfigNoReplace(projected, io)) { - const winner = readConfigFileSnapshot(); - return winner.diagnostics.source === "file" ? "exists" : "invalid"; - } - bumpGenerationForCooperatingConfigWrite(); - adoptCustomModelCatalogMigration(config, projected); - return "created"; - }); -} - export type PersistedConfigMutation = { changed: boolean; value: T; @@ -3572,13 +3225,6 @@ export type PersistedConfigMutationOutcome = | { status: "committed" | "unchanged"; value: T } | { status: "unavailable"; reason: "missing" | "invalid" | "conflict" }; -export class ConfigMutationValidationError extends Error { - constructor(readonly validationError: string) { - super(`Config mutation rejected: ${validationError}`); - this.name = "ConfigMutationValidationError"; - } -} - const CONFIG_MUTATION_MAX_REBASE_ATTEMPTS = 3; let persistedConfigMutationBeforeCommitForTests: (() => void) | null = null; @@ -3651,9 +3297,7 @@ export function mutatePersistedConfig( commitBase.diagnostics.config, projectConfigRebaseProvenance(confirmedConfig), ); - const validation = validateConfigCandidate(projected); - if (!validation.ok) throw new ConfigMutationValidationError(validation.error); - if (persistConfigUnlocked(projected, "mutation")) bumpGenerationForCooperatingConfigWrite(); + if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite(); return { status: "committed", value: confirmed.value }; } return { status: "unavailable", reason: "conflict" }; @@ -4008,7 +3652,7 @@ function readPersistedServerBinding( * conflict keeps the live value; * - a provider or custom-model row deleted on disk stays deleted even if stale * live state edited that same row; - * - missing file → save what we have; invalid existing file → fail closed. + * - file missing/unreadable → save what we have, no throw. * * Custom-model rows are merged by their stable `id`, preserving independent * edits and deletions across stale whole-config saves. @@ -4080,10 +3724,10 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { const persistedConfig: OcxConfig = { ...projectedConfig, port: persistedBinding.port }; if (persistedBinding.hostname === undefined) delete persistedConfig.hostname; else persistedConfig.hostname = persistedBinding.hostname; - if (persistConfigUnlocked(persistedConfig, "mutation")) bumpGenerationForCooperatingConfigWrite(); + if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite(); persistedLiveServerBinding.set(config, persistedBinding); } else { - if (persistConfigUnlocked(projectedConfig, "mutation")) bumpGenerationForCooperatingConfigWrite(); + if (persistConfigUnlocked(projectedConfig)) bumpGenerationForCooperatingConfigWrite(); } adoptCustomModelCatalogMigration(config, projectedConfig); if (claudeCodeBaseline.has(config)) { @@ -4489,3 +4133,59 @@ export function backupInvalidConfig(configPath: string): string | null { return null; } } + +export function resolveSubagentCandidates( + config: Pick | OcxConfig, + roleOrModel?: string, +): string[] { + const candidates = config?.subagentCandidates; + if (!candidates) return []; + + const normalizeList = (raw: unknown): string[] => { + if (!Array.isArray(raw)) return []; + const result: string[] = []; + const seen = new Set(); + for (const item of raw) { + if (typeof item !== "string") continue; + const trimmed = item.trim(); + if (!trimmed) continue; + const key = slugEquivalenceKey(trimmed); + if (seen.has(key)) continue; + seen.add(key); + result.push(trimmed); + } + return result; + }; + + if (Array.isArray(candidates)) return normalizeList(candidates); + if (typeof candidates !== "object") return []; + + const record = candidates as Record; + const trimmed = roleOrModel?.trim(); + let selected: unknown; + if (trimmed) { + if (Object.hasOwn(record, trimmed)) { + selected = record[trimmed]; + } else { + const matchingKey = Object.keys(record).find(key => slugsEquivalent(key, trimmed)); + if (matchingKey) selected = record[matchingKey]; + } + } + if (!selected) selected = record.default ?? record["*"]; + return normalizeList(selected); +} + +export function replacePersistedConfig(config: OcxConfig): void { + assertNotRealHomeUnderTest(getConfigDir()); + withConfigMutationLockSync(() => { + const projected = projectCustomModelCatalogMigration( + readRawConfigJson(), + projectConfigRebaseProvenance(config), + ); + if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite(); + adoptCustomModelCatalogMigration(config, projected); + if (projected.configRebaseProvenance === undefined) delete config.configRebaseProvenance; + else config.configRebaseProvenance = structuredClone(projected.configRebaseProvenance); + clearPendingConfigTopLevelDeletions(config); + }); +} diff --git a/src/types/config.ts b/src/types/config.ts index 8fb37f04fb..3f98ae2c51 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -142,8 +142,6 @@ export interface OcxClaudeCodeConfig { * Routing-sidecar alias decoding is unchanged — only the Desktop model list writer. */ desktopNativeModels?: boolean; - /** Claude ingress compatibility gate. Defaults to enforce. */ - compatibility?: "shadow" | "enforce"; } export type OcxClaudeDesktopFamily = "opus" | "fable" | "sonnet" | "haiku"; diff --git a/src/usage/log.ts b/src/usage/log.ts index fd8408cc19..4f53142c1e 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -10,6 +10,8 @@ import type { AttemptTierOutcome, OcxUsage } from "../types"; import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace"; import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label"; import { claudeCompatibilityReason, normalizeClaudeFeatureCodes, type ClaudeFeatureCode } from "../claude/compatibility"; +import type { AgentKind } from "../server/effort-policy"; +import type { TurnProgressTelemetry } from "../types/progress"; export interface PersistedClaudeCompatibilityLog { decision: "shadow"; @@ -29,29 +31,22 @@ export function normalizeClaudeCompatibilityUsageLog(value: unknown): PersistedC } export type UsageStatus = "reported" | "unreported" | "unsupported" | "estimated"; -/** - * A persisted account label: a Codex pool account (`main`/`p`) or a non-Codex OAuth - * provider account (`o`, #2699). - * - * The old name `CodexUsageAccountLogLabel` is kept as an alias because it is exported and used - * across modules; the two predicates below are what callers should choose between. - */ export type UsageAccountLogLabel = "main" | `p${string}` | `o${string}`; export type CodexUsageAccountLogLabel = UsageAccountLogLabel; -/** - * Accepts EITHER label family. This is the predicate the persistence writers use, so widening - * it here is what stops six separate call sites from silently dropping an `o`-label -- including - * two in the live request path (`request-log.ts:972` and `:1187`). - * - * The name is unchanged deliberately: renaming it would touch every call site for no behavior, - * and the widened contract is what every one of those sites wanted. - */ +export const OAUTH_ACCOUNT_LOG_LABEL_RE = /^[a-f0-9]{8,64}(?:-\d+)?$/; + +export function isPersistableAccountLogLabel(value: unknown): value is string { + return ( + typeof value === "string" + && (ACCOUNT_LOG_LABEL_RE.test(value) || OAUTH_ACCOUNT_LOG_LABEL_RE.test(value)) + ); +} + export function isCodexUsageAccountLogLabel(value: unknown): value is UsageAccountLogLabel { - return value === "main" || (typeof value === "string" && ACCOUNT_LOG_LABEL_RE.test(value)); + return typeof value === "string" && ACCOUNT_LOG_LABEL_RE.test(value); } -/** Strictly a Codex pool label. Use when the Codex-only distinction actually matters. */ export function isCodexPoolAccountLogLabel(value: unknown): value is "main" | `p${string}` { return value === "main" || (typeof value === "string" && CODEX_ACCOUNT_LOG_LABEL_RE.test(value)); } @@ -68,10 +63,17 @@ export type AttemptRecoveryKind = | "key-429" | "rate-limit-429" | "anthropic-oauth-429" + | "cursor-oauth-auth" + | "cursor-oauth-429" | "oauth-account-429" | "image-413" | "opaque-blob-rejection" - | "empty-completion"; + | "empty-completion" + | "cursor-envelope-echo" + | "cursor-routing-commentary" + | "cursor-duplicate-tool-call" + | "cursor-overflow-remint" + | "cursor-invalid-argument"; /** Request-time upstream credential class, never a credential or account identifier. */ export type UsageCredentialSource = "grok-oauth" | "xai-api-key"; @@ -103,8 +105,8 @@ export interface PersistedUsageAttempt { * one of them that was missed. Absent on ordinary attempts so old rows keep their exact shape. */ locallyAnswered?: boolean; - /** Stable non-PII identity for the Codex pool account that served this attempt. */ - accountLogLabel?: CodexUsageAccountLogLabel; + /** Stable non-PII identity for the account that served this attempt. */ + accountLogLabel?: string; inputTokenEstimate?: number; usage?: OcxUsage; totalTokens?: number; @@ -121,6 +123,8 @@ export interface PersistedUsageAttempt { } export interface PersistedUsageEntry { + /** Responses request origin; absent for unrelated traffic and old rows. */ + agentKind?: AgentKind; requestedAlias?: string; requestId: string; timestamp: number; @@ -133,10 +137,16 @@ export interface PersistedUsageEntry { admissionKind?: "configured" | "environment" | "loopback"; /** The inbound wire, not the client product — see `surface`. */ inboundProtocol?: "responses" | "chat" | "messages"; - /** Stable non-PII identity for Codex Pool usage; absent for Direct/non-Codex traffic. */ - accountLogLabel?: CodexUsageAccountLogLabel; + v2BridgeScope?: "root" | "child"; + v2BridgeDecision?: "active" | "disabled" | "not_v2" | "non_native_route" + | "maintenance_turn" | "no_collaboration_catalog" | "combo" | "compaction" | "shadow_route"; + v2BridgeStateDurability?: "standard" | "encrypted" | "memory-only"; + /** Stable non-PII identity for the account that served this request. */ + accountLogLabel?: string; /** Best-effort chat/session correlation for Logs grouping (#330). */ conversationId?: string; + /** Protocol-only per-call progress counters; contains no text, arguments, or identifiers. */ + turnProgress?: TurnProgressTelemetry; resolvedModel?: string; requestedModel?: string; /** Original bare helper model when the opt-in shadow-call route rewrote this request. */ @@ -206,6 +216,19 @@ const KNOWN_ADMISSION_KINDS = new Set>([ "responses", "chat", "messages", ]); +const KNOWN_V2_BRIDGE_SCOPES = new Set>(["root", "child"]); +const KNOWN_V2_BRIDGE_DECISIONS = new Set>([ + "active", "disabled", "not_v2", "non_native_route", "maintenance_turn", + "no_collaboration_catalog", "combo", "compaction", "shadow_route", +]); +const KNOWN_V2_BRIDGE_DURABILITIES = new Set>([ + "standard", "encrypted", "memory-only", +]); +const KNOWN_AGENT_KINDS = new Set(["main", "subagent", "internal"]); + +export function isKnownAgentKind(value: unknown): value is AgentKind { + return typeof value === "string" && KNOWN_AGENT_KINDS.has(value as AgentKind); +} /** Same closed-set discipline as `isKnownUsageSurface`: an old or corrupted row * carrying an unexpected value drops the field instead of poisoning the enum. */ @@ -217,6 +240,18 @@ export function isKnownInboundProtocol(value: unknown): value is NonNullable); } +export function isKnownV2BridgeScope(value: unknown): value is NonNullable { + return typeof value === "string" && KNOWN_V2_BRIDGE_SCOPES.has(value as NonNullable); +} + +export function isKnownV2BridgeDecision(value: unknown): value is NonNullable { + return typeof value === "string" && KNOWN_V2_BRIDGE_DECISIONS.has(value as NonNullable); +} + +export function isKnownV2BridgeStateDurability(value: unknown): value is NonNullable { + return typeof value === "string" && KNOWN_V2_BRIDGE_DURABILITIES.has(value as NonNullable); +} + export function usageLogPath(configDir?: string): string { return join(configDir ?? getConfigDir(), "usage.jsonl"); } @@ -287,10 +322,17 @@ const ATTEMPT_RECOVERY_KINDS = new Set([ "key-429", "rate-limit-429", "anthropic-oauth-429", + "cursor-oauth-auth", + "cursor-oauth-429", "oauth-account-429", "image-413", "opaque-blob-rejection", "empty-completion", + "cursor-envelope-echo", + "cursor-routing-commentary", + "cursor-duplicate-tool-call", + "cursor-overflow-remint", + "cursor-invalid-argument", ]); const USAGE_STATUSES = new Set([ "reported", @@ -441,7 +483,7 @@ function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null { sendCount: attempt.sendCount as number, recoveryKinds, usageStatus: attempt.usageStatus as UsageStatus, - ...(isCodexUsageAccountLogLabel(attempt.accountLogLabel) + ...(isPersistableAccountLogLabel(attempt.accountLogLabel) ? { accountLogLabel: attempt.accountLogLabel } : {}), ...(isNonNegativeFiniteNumber(attempt.inputTokenEstimate) @@ -494,6 +536,64 @@ function normalizedAttempts(raw: unknown): PersistedUsageAttempt[] { .filter((attempt): attempt is PersistedUsageAttempt => attempt !== null); } +function normalizeTurnProgress(raw: unknown): TurnProgressTelemetry | undefined { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; + const value = raw as Record; + const integer = (key: keyof TurnProgressTelemetry): number | undefined => { + const candidate = value[key]; + return typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate >= 0 + ? candidate + : undefined; + }; + const required = { + logicalCallOrdinal: integer("logicalCallOrdinal"), + consecutive429sBeforeCall: integer("consecutive429sBeforeCall"), + logicalCallsSinceToolCompletion: integer("logicalCallsSinceToolCompletion"), + textDeltaCount: integer("textDeltaCount"), + textBytes: integer("textBytes"), + commentaryTextBytes: integer("commentaryTextBytes"), + finalTextBytes: integer("finalTextBytes"), + thinkingDeltaCount: integer("thinkingDeltaCount"), + toolCallsStarted: integer("toolCallsStarted"), + toolCallsCompleted: integer("toolCallsCompleted"), + assistantBoundaries: integer("assistantBoundaries"), + terminalEvents: integer("terminalEvents"), + }; + if (Object.values(required).some(candidate => candidate === undefined)) return undefined; + return { + logicalCallOrdinal: required.logicalCallOrdinal!, + consecutive429sBeforeCall: required.consecutive429sBeforeCall!, + logicalCallsSinceToolCompletion: required.logicalCallsSinceToolCompletion!, + textDeltaCount: required.textDeltaCount!, + textBytes: required.textBytes!, + commentaryTextBytes: required.commentaryTextBytes!, + finalTextBytes: required.finalTextBytes!, + preToolTextBytes: integer("preToolTextBytes") ?? 0, + thinkingDeltaCount: required.thinkingDeltaCount!, + toolCallsStarted: required.toolCallsStarted!, + toolCallsCompleted: required.toolCallsCompleted!, + assistantBoundaries: required.assistantBoundaries!, + terminalEvents: required.terminalEvents!, + ...(typeof value.exactOutputRepeat === "boolean" ? { exactOutputRepeat: value.exactOutputRepeat } : {}), + ...(typeof value.normalizedCommentaryRepeat === "boolean" + ? { normalizedCommentaryRepeat: value.normalizedCommentaryRepeat } + : {}), + ...(typeof value.normalizedPreToolTextRepeat === "boolean" + ? { normalizedPreToolTextRepeat: value.normalizedPreToolTextRepeat } + : {}), + ...(typeof value.repeatedPreToolNarration === "boolean" + ? { repeatedPreToolNarration: value.repeatedPreToolNarration } + : {}), + ...(typeof value.suppressedRepeatedPreToolText === "boolean" + ? { suppressedRepeatedPreToolText: value.suppressedRepeatedPreToolText } + : {}), + ...(typeof value.commentaryOnlyRound === "boolean" ? { commentaryOnlyRound: value.commentaryOnlyRound } : {}), + ...(typeof value.emptyProtocolRound === "boolean" ? { emptyProtocolRound: value.emptyProtocolRound } : {}), + ...(typeof value.rateLimitCircuitOpen === "boolean" ? { rateLimitCircuitOpen: value.rateLimitCircuitOpen } : {}), + ...(typeof value.repetitionCircuitOpen === "boolean" ? { repetitionCircuitOpen: value.repetitionCircuitOpen } : {}), + }; +} + const MAX_METADATA_STRING_LEN = 64; function capMetadataString(s: string): string { return s.length > MAX_METADATA_STRING_LEN ? s.slice(0, MAX_METADATA_STRING_LEN) : s; @@ -514,11 +614,13 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { const routeDecision = entry.routeDecision ? normalizeRouteDecisionTrace(entry.routeDecision) : undefined; + const turnProgress = normalizeTurnProgress(entry.turnProgress); return { requestId: entry.requestId, timestamp: entry.timestamp, provider: entry.provider, model: entry.model, + ...(isKnownAgentKind(entry.agentKind) ? { agentKind: entry.agentKind } : {}), ...(isKnownUsageSurface(entry.surface) ? { surface: entry.surface } : {}), ...(typeof entry.apiKeyId === "string" && entry.apiKeyId.trim() // Deliberately NOT capped. `capMetadataString` protects free-form metadata @@ -529,12 +631,18 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry { : {}), ...(isKnownAdmissionKind(entry.admissionKind) ? { admissionKind: entry.admissionKind } : {}), ...(isKnownInboundProtocol(entry.inboundProtocol) ? { inboundProtocol: entry.inboundProtocol } : {}), - ...(isCodexUsageAccountLogLabel(entry.accountLogLabel) + ...(isKnownV2BridgeScope(entry.v2BridgeScope) ? { v2BridgeScope: entry.v2BridgeScope } : {}), + ...(isKnownV2BridgeDecision(entry.v2BridgeDecision) ? { v2BridgeDecision: entry.v2BridgeDecision } : {}), + ...(isKnownV2BridgeStateDurability(entry.v2BridgeStateDurability) + ? { v2BridgeStateDurability: entry.v2BridgeStateDurability } + : {}), + ...(isPersistableAccountLogLabel(entry.accountLogLabel) ? { accountLogLabel: entry.accountLogLabel } : {}), ...(typeof entry.conversationId === "string" && entry.conversationId.trim() ? { conversationId: entry.conversationId.trim().slice(0, 128) } : {}), + ...(turnProgress ? { turnProgress } : {}), ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}), ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}), ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}), @@ -663,6 +771,15 @@ let managementUsageReadInflight: { abort: AbortController; } | null = null; +/** Test seam for the same-ledger shrink branch without mutating an open Windows file. */ +export function setManagementUsageReadOpenedSizeForTests(openedSize: number): void { + if (!Number.isSafeInteger(openedSize) || openedSize < 0) { + throw new RangeError("management usage opened size must be a non-negative safe integer"); + } + if (!managementUsageReadInflight) throw new Error("no management usage read is in flight"); + managementUsageReadInflight.openedSize = openedSize; +} + /** * Append-tolerant snapshot of the last management read. * From 310bbe63dfe5bc60cbfbb61f4b26b44186da3289 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 06:14:10 +0000 Subject: [PATCH 98/99] fix(sync): take upstream docker bootstrap and compose contract Align docker/ and compose.yaml with vendor/main so container-bootstrap tests match the upstream v2.46.0 deployment surface. Co-authored-by: SB Yoon --- compose.yaml | 3 -- docker/bootstrap-token.ts | 6 ++-- docker/verify-compatibility.ts | 66 ++++++---------------------------- 3 files changed, 14 insertions(+), 61 deletions(-) diff --git a/compose.yaml b/compose.yaml index 7692d1691c..b54795692a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,9 +14,6 @@ services: CODEX_HOME: /home/bun/.codex ports: - "${OPENCODEX_BIND_ADDRESS:-127.0.0.1}:${OPENCODEX_PORT:-10100}:10100" - environment: - OCX_CONTAINER_PUBLIC_PORT: "${OPENCODEX_PORT:-10100}" - OCX_CONTAINER_PUBLIC_ORIGIN: "${OPENCODEX_PUBLIC_ORIGIN:-}" volumes: - ocx-state:/home/bun/.opencodex - codex-state:/home/bun/.codex diff --git a/docker/bootstrap-token.ts b/docker/bootstrap-token.ts index 8160c6e8e7..2c647cd9dd 100644 --- a/docker/bootstrap-token.ts +++ b/docker/bootstrap-token.ts @@ -1,6 +1,6 @@ import { writeServiceApiTokenFile } from "../src/lib/service-secrets"; -const MAX_TOKEN_BYTES = 512; +const MAX_TOKEN_BYTES = 4096; const MAX_INPUT_BYTES = MAX_TOKEN_BYTES + 2; export async function readBoundedToken(stream: ReadableStream): Promise { @@ -14,7 +14,7 @@ export async function readBoundedToken(stream: ReadableStream): Prom const { done, value } = await reader.read(); if (done) break; bytes += value.byteLength; - if (bytes > MAX_INPUT_BYTES) throw new Error("token input exceeds 512 bytes"); + if (bytes > MAX_INPUT_BYTES) throw new Error("token input exceeds 4096 bytes"); raw += decoder.decode(value, { stream: true }); } raw += decoder.decode(); @@ -27,7 +27,7 @@ export async function readBoundedToken(stream: ReadableStream): Prom const token = line.trim(); if (!token) throw new Error("token input is empty"); - if (Buffer.byteLength(token) > MAX_TOKEN_BYTES) throw new Error("token input exceeds 512 bytes"); + if (Buffer.byteLength(token) > MAX_TOKEN_BYTES) throw new Error("token input exceeds 4096 bytes"); return token; } diff --git a/docker/verify-compatibility.ts b/docker/verify-compatibility.ts index 00427fd883..affd0af7f1 100644 --- a/docker/verify-compatibility.ts +++ b/docker/verify-compatibility.ts @@ -3,29 +3,8 @@ import { lstatSync, readFileSync, readdirSync } from "node:fs"; import { join, posix, resolve } from "node:path"; // Match the canonical generator without importing any not-yet-verified source. -const REQUIRED_COMPATIBILITY_FILES = [ - ".dockerignore", - "Dockerfile", - "bun.lock", - "compose.yaml", - "docker/bootstrap-tls.ts", - "docker/bootstrap-token.ts", - "docker/config.json", - "docker/healthcheck.ts", - "docker/verify-compatibility.ts", - "gui/bun.lock", - "gui/package.json", - "package.json", - "scripts/model-metadata.source.json", -]; +const REQUIRED_ROOT_FILES = ["package.json", "bun.lock", "scripts/model-metadata.source.json"]; const MANIFEST_PATH = "src/generated/compatibility-version.json"; -const BUILD_CONTEXT_ONLY_FILES = new Set([".dockerignore", "Dockerfile", "compose.yaml"]); - -function isBuildContextOnly(path: string): boolean { - // The build stage verifies every tracked GUI input before Vite derives gui/dist. - // The runtime stage intentionally contains only that derived output, not its sources. - return BUILD_CONTEXT_ONLY_FILES.has(path) || path.startsWith("gui/"); -} interface ManifestRow { path: string; @@ -52,9 +31,7 @@ function parseRows(raw: unknown): ManifestRow[] { const path = row.path; if (!path || /[\\\0]/.test(path) || posix.normalize(path) !== path || path.split("/").some(part => !part || part === "." || part === "..") - || (!path.startsWith("src/") && !path.startsWith("docker/") - && !path.startsWith("gui/") - && !REQUIRED_COMPATIBILITY_FILES.includes(path)) + || (!path.startsWith("src/") && !REQUIRED_ROOT_FILES.includes(path)) || path === MANIFEST_PATH) { throw new Error(`Invalid compatibility manifest path: ${JSON.stringify(path)}`); } @@ -80,55 +57,36 @@ function regularFile(root: string, path: string): string { return current; } -function treeFiles(root: string, path: string, label: string): string[] { +function sourceFiles(root: string, path = "src"): string[] { const stat = lstatSync(join(root, path)); - if (stat.isSymbolicLink()) throw new Error(`Symlink in ${label} tree: ${JSON.stringify(path)}`); + if (stat.isSymbolicLink()) throw new Error(`Symlink in source tree: ${JSON.stringify(path)}`); if (stat.isFile()) return [path]; - if (!stat.isDirectory()) throw new Error(`Non-regular ${label} entry: ${JSON.stringify(path)}`); - return readdirSync(join(root, path)).flatMap(name => treeFiles(root, `${path}/${name}`, label)); + if (!stat.isDirectory()) throw new Error(`Non-regular source entry: ${JSON.stringify(path)}`); + return readdirSync(join(root, path)).flatMap(name => sourceFiles(root, `${path}/${name}`)); } -/** Validate a Git-free build snapshot against the host-generated tracked-authority manifest. */ -export function verifyCompatibilitySnapshot( - snapshotRoot: string, - options: { runtime?: boolean } = {}, -): void { +/** Validate a Git-free build snapshot against the host-generated tracked-source manifest. */ +export function verifyCompatibilitySnapshot(snapshotRoot: string): void { const root = resolve(snapshotRoot); const stat = lstatSync(root); if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("Invalid compatibility snapshot root"); const manifestFile = regularFile(root, MANIFEST_PATH); const rows = parseRows(JSON.parse(readFileSync(manifestFile, "utf8"))); const expected = new Set(rows.map(row => row.path)); - for (const required of REQUIRED_COMPATIBILITY_FILES) { + for (const required of REQUIRED_ROOT_FILES) { if (!expected.has(required)) throw new Error(`Missing required manifest entry: ${required}`); } if (!rows.some(row => row.path.startsWith("src/"))) { throw new Error("Compatibility manifest contains no source files"); } - if (!rows.some(row => row.path.startsWith("gui/"))) { - throw new Error("Compatibility manifest contains no GUI build inputs"); - } // Inspect the full tree, including symlinks to files/directories not named in the manifest. - for (const path of treeFiles(root, "src", "source")) { + for (const path of sourceFiles(root)) { if (path !== MANIFEST_PATH && !expected.has(path)) { throw new Error(`Source file absent from compatibility manifest: ${JSON.stringify(path)}`); } } - for (const path of treeFiles(root, "docker", "container authority")) { - if (!expected.has(path)) { - throw new Error(`Container authority file absent from compatibility manifest: ${JSON.stringify(path)}`); - } - } - if (!options.runtime) { - for (const path of treeFiles(root, "gui", "GUI build input")) { - if (!expected.has(path)) { - throw new Error(`GUI build input absent from compatibility manifest: ${JSON.stringify(path)}`); - } - } - } for (const row of rows) { - if (options.runtime && isBuildContextOnly(row.path)) continue; const bytes = readFileSync(regularFile(root, row.path)); const actual = createHash("sha256").update(bytes).digest("hex"); if (actual !== row.sha256) { @@ -138,7 +96,5 @@ export function verifyCompatibilitySnapshot( } if (import.meta.main) { - const runtime = process.argv.includes("--runtime"); - const rootArg = process.argv.slice(2).find(arg => arg !== "--runtime"); - verifyCompatibilitySnapshot(rootArg ?? resolve(import.meta.dir, ".."), { runtime }); + verifyCompatibilitySnapshot(process.argv[2] ?? resolve(import.meta.dir, "..")); } From b8c6c20f1993b49c10272963576bbd1f34c06f7d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 7 Sep 2026 06:39:59 +0000 Subject: [PATCH 99/99] fix(sync): merge fork and upstream v2.46 i18n locales Restore dev locale catalogs and append upstream-only v2.46 strings. Fix ci-workflows audit assertion for fork package.json recipe. Co-authored-by: SB Yoon --- .../workflows/cleanup-closed-pr-branches.yml | 2 +- .github/workflows/dev-version-bump.yml | 254 ++++++-------- .github/workflows/enforce-issue-quality.yml | 8 +- .github/workflows/enforce-pr-target.yml | 255 +------------- .github/workflows/issue-quality-tests.yml | 22 +- .github/workflows/issue-triage.yml | 4 +- .github/workflows/pr-hygiene.yml | 2 +- .github/workflows/pr-labeler.yml | 2 +- .github/workflows/release.yml | 319 +++++------------- .github/workflows/service-lifecycle.yml | 17 +- gui/src/i18n/de.ts | 248 ++++++++++++-- gui/src/i18n/en.ts | 250 ++++++++++++-- gui/src/i18n/fr.ts | 248 ++++++++++++-- gui/src/i18n/ja.ts | 248 ++++++++++++-- gui/src/i18n/ko.ts | 248 ++++++++++++-- gui/src/i18n/ru.ts | 248 ++++++++++++-- gui/src/i18n/tr.ts | 248 ++++++++++++-- gui/src/i18n/zh-TW.ts | 248 ++++++++++++-- gui/src/i18n/zh.ts | 248 ++++++++++++-- tests/ci-workflows/ci-workflows.test.ts | 4 +- 20 files changed, 2271 insertions(+), 852 deletions(-) diff --git a/.github/workflows/cleanup-closed-pr-branches.yml b/.github/workflows/cleanup-closed-pr-branches.yml index 0429e6deb1..4b0c1229a9 100644 --- a/.github/workflows/cleanup-closed-pr-branches.yml +++ b/.github/workflows/cleanup-closed-pr-branches.yml @@ -34,7 +34,7 @@ jobs: pull-requests: read steps: - name: Checkout trusted default-branch code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false sparse-checkout: .github/scripts diff --git a/.github/workflows/dev-version-bump.yml b/.github/workflows/dev-version-bump.yml index 21a7f5c79d..f02c0e89f4 100644 --- a/.github/workflows/dev-version-bump.yml +++ b/.github/workflows/dev-version-bump.yml @@ -1,52 +1,41 @@ -name: Dev version bump (dormant fallback) +name: Dev version bump -# Dormant upstream fallback for manually prepared recovery. The fork's live release -# path must not call this workflow: `promote-dev.yml` is the sole post-release writer -# and advances `dev` through an exact-SHA, tag-verified App update. Keeping two callers -# produced both a direct 2.40.1 bump and a conflicting 2.41.0 PR after v2.40.0. -# -# If explicitly restored as the sole authority in a future design, open a pull request -# that moves `dev` past the published -# version. Without this, `dev` keeps carrying a version that is at or behind a released -# one, and `tests/ci-workflows/release-version-line.test.ts` fails on `dev` and on every pull request -# opened against it - inherited red a contributor cannot fix from their own diff. +# Before a release publishes, open a pull request that moves `dev` past the intended +# version. Merge that pull request before promoting and publishing so `dev` and pull +# requests based on it never inherit a version-line failure from the new tag. # # That has been repaired by hand four times: 32529c2b2, e4a85d134, 076ad3036, befcac3e1. -# The second of those ADDED the detector and two more repairs followed it, so more -# visibility was never the missing piece; a prepared change was. +# The workflow now prepares the move before publication. Explicit repair mode retains +# the old catch-up capability if a release somehow publishes without the pre-move. # # WHAT THIS DOES NOT DO. It does not push to `dev`. It opens a pull request and a human # merges it, because ruleset `Protect dev` requires an approving review and code-owner -# sign-off that a bot cannot supply. Until that merge the red persists. This converts a -# forgotten chore into a queued, reviewable change - not into an automatic repair. -# -# WHY THIS IS CALLED, NOT TRIGGERED. It used to listen for `release: published`, and in -# that form it ran ZERO times across v2.37.0, v2.38.0 and v2.39.0 - every one of those -# bumps was still opened by hand (#3045, #3076, #3127). The workflow was not broken; the -# event never existed. `release.yml` creates the GitHub release with -# `GH_TOKEN: ${{ github.token }}`, and GitHub does not start workflow runs from events -# raised by the default `GITHUB_TOKEN`. A `release: published` listener therefore cannot -# observe a release this repository publishes itself, no matter which branch it sits on. -# -# The historical upstream fix kept the credential surface unchanged: no PAT or app -# token. It called this workflow from `release.yml`; the fork deliberately does not, -# because its verified promotion controller already owns that mutation. +# sign-off that a bot cannot supply. `release.yml` independently refuses publication +# until `dev` already outranks the intended version. # -# A `workflow_call` body resolves from the CALLER's ref. This file would have to exist on -# whichever release branch deliberately restored it as the sole authority before it -# could take effect. +# WHY THIS IS DISPATCHED. The intended version is known before publication, and this +# workflow's purpose is to queue the reviewed `dev` move first. It is not called by the +# release workflow after an irreversible publish, and it does not react to release events. # -# There is deliberately no `workflow_dispatch`: a branch-selected manual run executes -# THAT branch body with `contents: write`. Re-drive a missed run by running -# `bun scripts/bump-dev-version.ts package.json` locally and opening the pull -# request normally. +# A branch-selected dispatch executes that branch's workflow body with write permission. +# The in-job guard therefore rejects accidental non-default-ref dispatches. It is an early +# warning, not a security boundary: a writer could remove it on their branch. Protected +# release branches and the required review on `dev` remain the enforcement boundaries. on: - workflow_call: + workflow_dispatch: inputs: - released-version: - description: "The tag that just published, e.g. v2.39.0" + intended-version: + description: "Version about to be released (pre-move), or one already published (repair)" required: true type: string + mode: + description: "pre-move (default) or repair — repair allows an already-published version" + required: false + default: pre-move + type: choice + options: + - pre-move + - repair permissions: {} @@ -87,17 +76,59 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Refuse a dispatch from a non-default ref + run: | + test "$GITHUB_REF" = "refs/heads/${{ github.event.repository.default_branch }}" || { + echo "::error::this workflow may only be dispatched from the default branch" + exit 1 + } + + - name: Resolve the target version + id: target + env: + INTENDED: ${{ inputs.intended-version }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + target="${INTENDED:-}" + if [ -z "$target" ]; then + echo "::error::intended-version was not supplied" + exit 1 + fi + echo "version=${target}" >> "$GITHUB_OUTPUT" + if [ "${MODE:-pre-move}" = "repair" ]; then + echo "mode=repair" >> "$GITHUB_OUTPUT" + else + echo "mode=pre-move" >> "$GITHUB_OUTPUT" + fi + - name: Decide the version dev should carry id: decide env: - RELEASED_VERSION: ${{ inputs.released-version }} + RELEASED_VERSION: ${{ steps.target.outputs.version }} run: | set -euo pipefail bun scripts/bump-dev-version.ts "${RELEASED_VERSION}" package.json + - name: Prove the intended version is not already released + if: ${{ steps.target.outputs.mode == 'pre-move' }} + env: + INTENDED: ${{ steps.target.outputs.version }} + run: | + set -euo pipefail + git fetch --force --tags origin + if git rev-parse -q --verify "refs/tags/v${INTENDED#v}" >/dev/null; then + echo "::error::v${INTENDED#v} already exists; this is a catch-up, not a pre-move" + exit 1 + fi + if npm view "@bitkyc08/opencodex@${INTENDED#v}" version >/dev/null 2>&1; then + echo "::error::${INTENDED#v} is already on npm" + exit 1 + fi + - name: Prove the chosen version is unused if: ${{ steps.decide.outputs.changed == 'true' }} - # The script decides the candidate from the released version SHAPE, which is all + # The script decides the candidate from the target version SHAPE, which is all # a pure function can see. Whether that candidate is actually FREE is a property # of the tag set, so it is settled here by the detector that already owns the # question. If this fails, no pull request is opened and the job goes red asking @@ -108,21 +139,34 @@ jobs: if: ${{ steps.decide.outputs.changed == 'true' }} env: GH_TOKEN: ${{ github.token }} + MODE: ${{ steps.target.outputs.mode }} NEXT_VERSION: ${{ steps.decide.outputs.version }} - RELEASED_VERSION: ${{ inputs.released-version }} + TARGET_VERSION: ${{ steps.target.outputs.version }} run: | set -euo pipefail branch="codex/dev-version-${NEXT_VERSION}" + if [ "${MODE}" = "repair" ]; then + subject="fix(release): move dev to ${NEXT_VERSION} after ${TARGET_VERSION}" + reason="\`${TARGET_VERSION}\` has published, so \`dev\` is carrying a version at or behind a released one and \`tests/ci-workflows/release-version-line.test.ts\` fails on \`dev\` and on every pull request opened against it. This is the post-publish repair." + freeness="\`bun test tests/ci-workflows/release-version-line.test.ts\` proved the chosen development version is unused." + else + subject="chore(release): open dev at ${NEXT_VERSION} before releasing ${TARGET_VERSION}" + reason="\`${TARGET_VERSION}\` is about to be released. Merging this first means \`dev\` already outranks the new tag when it lands, so neither \`dev\` nor any open pull request ever inherits the version-line failure. \`release.yml\` refuses to publish until this has merged." + freeness="The workflow proved \`${TARGET_VERSION}\` has neither a Git tag nor an npm publication, and \`bun test tests/ci-workflows/release-version-line.test.ts\` proved the chosen development version is unused." + fi - # Idempotent: a second publish, a re-run, or a manual repair must not turn a - # successful release into a red job. + # Idempotent: a repeated dispatch, a re-run, or a manual repair must not turn + # an already-queued version move into a red job. # # Check the PULL REQUEST as well as the branch, not just the branch. A security # review caught that: an open bump pull request whose head branch was deleted # leaves the branch check passing, so the job would recreate the branch and then - # fail on `gh pr create` with "already exists" — turning a successful release red - # for a repair that was already queued. + # fail on `gh pr create` with "already exists" — turning a successful run red + # for a move that was already queued. + # Apply the repository owner and branch filter on the server. Filtering a + # paginated `gh pr list` result locally can miss this repository's pull request + # when newer same-named fork pull requests fill the fetched page (#3325). open_prs="$( gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls" \ -f state=open \ @@ -137,15 +181,12 @@ jobs: fi # An existing branch is NOT terminal. If a previous run pushed the branch and then - # failed at `gh pr create`, exiting here would leave the repair permanently unqueued. - # Validate that the name still belongs to this automation, then rebuild it from the - # CURRENT dev head. Reusing an old commit would open an immediately stale PR and could - # reintroduce files that moved on dev while the failed run was waiting for a retry. - existing_branch_sha="" + # failed at `gh pr create`, exiting here would leave the move permanently unqueued + # while every rerun reports success - the exact failure mode a reviewer caught. So + # reuse the branch and fall through to pull-request creation instead. if git ls-remote --exit-code --heads origin "${branch}" >/dev/null 2>&1; then echo "::notice::${branch} exists without an open pull request; validating it" git fetch origin "${branch}" - existing_branch_sha="$(git rev-parse "origin/${branch}")" # Fail closed on unexpected content. The branch carries the bot's own one-line # bump, so anything else on it means a human or another job is using that name and @@ -160,41 +201,34 @@ jobs: echo "::error::${branch} carries ${branch_version}, expected ${NEXT_VERSION}" exit 1 fi - fi - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -B "${branch}" origin/dev - git add package.json - git commit -m "fix(release): move dev to ${NEXT_VERSION} after ${RELEASED_VERSION}" - if [ -n "${existing_branch_sha}" ]; then - # Exact lease: fail if anything moved the validated branch after our fetch. - git push --force-with-lease="refs/heads/${branch}:${existing_branch_sha}" origin "${branch}" + git checkout -B "${branch}" "origin/${branch}" else + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "${branch}" + git add package.json + git commit -m "${subject}" git push origin "${branch}" fi gh pr create \ --base dev \ --head "${branch}" \ - --title "fix(release): move dev to ${NEXT_VERSION} after ${RELEASED_VERSION}" \ + --title "${subject}" \ --body "$(cat < !issue.pull_request && String(issue.body || "").includes(marker)); - const runUrl = process.env.RUN_URL; - - if (process.env.BUMP_RESULT !== "failure") { - if (!existing) return; - await github.rest.issues.createComment({ - owner, repo, issue_number: existing.number, - body: `The dev-version bump automation recovered successfully: ${runUrl}`, - }); - await github.rest.issues.update({ - owner, repo, issue_number: existing.number, state: "closed", state_reason: "completed", - }); - return; - } - - if (existing) { - await github.rest.issues.createComment({ - owner, repo, issue_number: existing.number, - body: `The dev-version bump automation failed again: ${runUrl}`, - }); - return; - } - - const labels = { - "agent:jules": ["8250df", "Trusted Jules implementation request"], - "agent:generated": ["0969da", "Trusted generated maintenance issue"], - "agent:queued": ["d4c5f9", "Maintenance task queued"], - }; - const known = new Set((await github.paginate( - github.rest.issues.listLabelsForRepo, { owner, repo, per_page: 100 } - )).map(label => label.name)); - for (const [name, [color, description]] of Object.entries(labels)) { - if (!known.has(name)) { - await github.rest.issues.createLabel({ owner, repo, name, color, description }); - } - } - - await github.rest.issues.create({ - owner, repo, - title: "[agent:release] Dev version bump automation failed", - labels: Object.keys(labels), - body: [ - marker, - "### Area", "", "Installation or packaging", "", - "### What are you trying to accomplish?", "", - "Keep the dev package version strictly ahead of every published release so inherited CI stays green.", "", - "### What prevents this today?", "", - `The trusted dev-version bump workflow failed. Exact run: ${runUrl}`, "", - "### What should OpenCodex do?", "", - "Diagnose the exact failed run, prepare the smallest safe repair, and preserve the human-reviewed pull-request boundary for changes to dev.", "", - "### Example usage or interface", "", - `Inspect ${runUrl}, reproduce the failed gate locally, and open a template-complete PR against dev.`, "", - "### Alternatives or workarounds", "", - "A maintainer can run scripts/bump-dev-version.ts locally and open the bump PR manually while the automation is repaired.", "", - "### Additional context", "", - "Generated by the trusted release-version supervisor. Workflow and release changes remain protected and require explicit security review.", "", - "### Checks", "", - "- [x] I searched existing issues and documentation.", - "- [x] This request describes a concrete OpenCodex workflow rather than merely naming a desired technology.", - "- [x] I removed secrets and personal data.", - ].join("\n"), - }); diff --git a/.github/workflows/enforce-issue-quality.yml b/.github/workflows/enforce-issue-quality.yml index bf1e2cb9d2..a983da5d78 100644 --- a/.github/workflows/enforce-issue-quality.yml +++ b/.github/workflows/enforce-issue-quality.yml @@ -53,7 +53,7 @@ jobs: copilot-requests: write steps: - name: Checkout trusted workflow code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false @@ -458,7 +458,7 @@ jobs: copilot-requests: write steps: - name: Checkout trusted workflow code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false @@ -764,7 +764,7 @@ jobs: steps: - name: Checkout trusted workflow code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: # Always load validator scripts from the repository default branch so # a branch-selected workflow_dispatch cannot execute untrusted code @@ -1196,7 +1196,7 @@ jobs: issues: write steps: - name: Checkout trusted workflow code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index f449b55547..6743730206 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -15,17 +15,6 @@ on: # branch, so a PR cannot suppress or rewrite this signal path. The status is # only a wake-up signal; the gate re-reads live reviews before any write. status: - check_run: - types: [completed] - workflow_run: - workflows: ["Cross-platform CI"] - types: [completed] - workflow_dispatch: - inputs: - pull_number: - description: Pull request to reconcile against live GitHub state - required: true - type: number # pull-requests:write covers title/comment/label updates. # contents:write is required for convertPullRequestToDraft / @@ -33,7 +22,6 @@ on: # (otherwise: "Resource not accessible by integration"). This workflow # never checks out PR head code. permissions: - checks: read contents: write pull-requests: write @@ -48,15 +36,6 @@ jobs: github.event.state == 'success' && github.event.sender.login == 'coderabbitai[bot]' && github.event.sender.id == 136622811) || - github.event_name == 'check_run' || - (github.event_name == 'workflow_run' && - github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && - github.event.workflow_run.name == 'Cross-platform CI' && - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.status == 'completed' && - github.event.workflow_run.repository.full_name == github.repository) || - (github.event_name == 'workflow_dispatch' && - github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) || (github.event_name == 'pull_request_target' && ((github.event.action != 'labeled' && github.event.action != 'unlabeled') || github.event.label.name == 'gui-screenshot-waived' || @@ -65,11 +44,9 @@ jobs: github.event.label.name == 'test-exception-approved' || github.event.label.name == 'suppression-approved' || github.event.label.name == 'generated-change-approved' || - github.event.label.name == 'dependency-change-approved' || - github.event.label.name == 'review-bot-waived')) + github.event.label.name == 'dependency-change-approved')) runs-on: ubuntu-latest permissions: - checks: read contents: read pull-requests: read outputs: @@ -78,105 +55,11 @@ jobs: - name: Resolve trusted gate event to PR id: resolve uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 - env: - CURSOR_BUGBOT_APP_ID: ${{ vars.CURSOR_BUGBOT_APP_ID }} with: script: | const { owner, repo } = context.repo; let pullNumber = context.payload.pull_request?.number ?? null; - if (context.eventName === "workflow_dispatch") { - const defaultBranch = context.payload.repository?.default_branch; - if (!defaultBranch || context.ref !== `refs/heads/${defaultBranch}`) { - core.info("Manual reconciliation is only permitted from the default branch; skipping."); - return; - } - const requested = Number(context.payload.inputs?.pull_number); - if (!Number.isSafeInteger(requested) || requested <= 0) { - core.info("Manual reconciliation requires a positive pull request number; skipping."); - return; - } - const live = (await github.rest.pulls.get({ - owner, repo, pull_number: requested - })).data; - if (live.number !== requested || live.state !== "open" || live.merged || live.merged_at) { - core.info(`Pull request #${requested} is not an open, unmerged PR; skipping.`); - return; - } - pullNumber = live.number; - } - - if (context.eventName === "check_run") { - const check = context.payload.check_run; - const expectedAppId = Number(process.env.CURSOR_BUGBOT_APP_ID); - const trustedBugbot = - check?.name === "Cursor Bugbot" && - Number.isSafeInteger(expectedAppId) && - check.app?.id === expectedAppId; - const trustedBaseline = - ["ci", "hygiene"].includes(check?.name) && - check.app?.id === 15368; - if (!trustedBugbot && !trustedBaseline) { - core.info("Check producer is not a trusted sync readiness check; skipping."); - return; - } - const openPrs = await github.paginate(github.rest.pulls.list, { - owner, - repo, - state: "open", - per_page: 100 - }); - const candidates = openPrs.filter( - candidate => candidate.head?.sha === check.head_sha - ); - if (candidates.length !== 1) { - core.info( - `Cursor Bugbot check ${check.head_sha} maps to ${candidates.length} open current-head PRs; skipping ambiguous/stale revalidation.` - ); - return; - } - pullNumber = candidates[0].number; - } - - if (context.eventName === "workflow_run") { - const workflowRun = context.payload.workflow_run; - const expectedRepository = `${owner}/${repo}`; - if ( - workflowRun?.name !== "Cross-platform CI" || - workflowRun?.event !== "pull_request" || - workflowRun?.status !== "completed" || - workflowRun?.repository?.full_name !== expectedRepository - ) { - core.info("Workflow completion is not a trusted same-repository Cross-platform CI pull_request run; skipping."); - return; - } - - const headSha = workflowRun.head_sha; - if (typeof headSha !== "string" || headSha.length === 0) { - core.info("Cross-platform CI workflow completion has no head SHA; skipping."); - return; - } - const openPrs = await github.paginate(github.rest.pulls.list, { - owner, - repo, - state: "open", - per_page: 100 - }); - const candidates = openPrs.filter( - candidate => - candidate.state === "open" && - !candidate.merged && - candidate.head?.sha === headSha - ); - if (candidates.length !== 1) { - core.info( - `Cross-platform CI workflow ${headSha} maps to ${candidates.length} open current-head PRs; skipping ambiguous/stale revalidation.` - ); - return; - } - pullNumber = candidates[0].number; - } - if (context.eventName === "status") { const sender = context.payload.sender; const trustedCodeRabbit = @@ -259,7 +142,6 @@ jobs: runs-on: ubuntu-latest # Job-scoped permissions replace, rather than extend, the workflow default. permissions: - checks: read contents: write pull-requests: write concurrency: @@ -270,7 +152,7 @@ jobs: steps: - name: Checkout trusted PR-quality scripts - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: # Source trusted scripts from an integration branch, never from the # PR's own base commit. A stacked child PR's base is another open @@ -283,7 +165,7 @@ jobs: # `main`-targeting PR must take its scripts from `main`, or the gate # runs a `main` workflow definition against `dev` scripts. Every # other base, including a stacked child's, resolves to `dev`. - ref: ${{ github.event_name != 'pull_request_target' && github.event.repository.default_branch || (github.event.pull_request.base.ref == 'main' && 'main' || 'dev') }} + ref: ${{ github.event_name == 'status' && github.event.repository.default_branch || (github.event.pull_request.base.ref == 'main' && 'main' || 'dev') }} persist-credentials: false sparse-checkout: | .github/scripts @@ -292,8 +174,6 @@ jobs: - name: Enforce PR target, ancestry, and description uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: - CURSOR_BUGBOT_APP_ID: ${{ vars.CURSOR_BUGBOT_APP_ID }} - CURSOR_BUGBOT_POLICY: ${{ vars.CURSOR_BUGBOT_POLICY }} RESOLVED_PULL_NUMBER: ${{ needs.resolve-pr.outputs.pull-number }} with: script: | @@ -306,11 +186,10 @@ jobs: isChangedFileListTruncated, extractReviewReadiness, appendReviewReadinessSection, - stripReviewReadinessSection, + stripReviewReadinessSection, uncheckReviewReadinessBoxes, REVIEW_READINESS_CLAIM_INDEX, - resetReviewReadinessSection, - isPromotionPr + resetReviewReadinessSection } = require( path.join(process.cwd(), ".github", "scripts", "pr-quality.cjs"), ); @@ -374,21 +253,8 @@ jobs: "pr-maintainers.cjs" ), ); - const { - exactHeadBugbotEvidence, - generatedSyncBaselineDisposition, - hasExactHeadMaintainerWaiver - } = require( - path.join( - process.cwd(), - ".github", - "scripts", - "agent-maintenance.cjs" - ), - ); - const ALLOWED_BASES = - context.repo.owner === "lidge-jun" ? ["dev"] : ["dev", "main"]; + const ALLOWED_BASES = ["dev"]; const DEFAULT_BASE = "dev"; const TITLE_PREFIX = "[WRONG BRANCH] "; const LEGACY_COMMENT_MARKER = ""; @@ -413,7 +279,7 @@ jobs: // Defense in depth: the resolver job is the primary event gate, but // the write-capable script also rejects event classes this workflow // never intends to mutate from. - if (!["pull_request_target", "status", "check_run", "workflow_run", "workflow_dispatch"].includes(context.eventName)) { + if (!["pull_request_target", "status"].includes(context.eventName)) { core.info(`Unsupported gate event ${context.eventName}; skipping.`); return; } @@ -826,74 +692,6 @@ jobs: }), ]; - const bugbotPolicy = process.env.CURSOR_BUGBOT_POLICY || "shadow"; - if (!["shadow", "required"].includes(bugbotPolicy)) { - throw new Error(`Invalid CURSOR_BUGBOT_POLICY: ${bugbotPolicy}`); - } - const bugbotAppId = Number(process.env.CURSOR_BUGBOT_APP_ID); - let bugbotEvidence = null; - let bugbotWaived = false; - const promotionPr = isPromotionPr(pr.base.ref, pr.title); - const syncGenerated = - pr.base.ref === "dev" && - /^sync\/upstream-[A-Za-z0-9._-]+-[0-9a-f]{7,64}$/i.test(pr.head.ref ?? ""); - let syncBaselineReady = !syncGenerated; - let syncBaselineFailed = false; - if (bugbotPolicy === "required" || bugbotPolicy === "shadow") { - try { - const checkRuns = await github.paginate( - github.rest.checks.listForRef, - { owner, repo, ref: pr.head.sha, per_page: 100 } - ); - bugbotEvidence = exactHeadBugbotEvidence({ - checkRuns, - liveHeadSha: pr.head.sha, - expectedAppId: bugbotAppId - }); - if (syncGenerated) { - // This job is the enforce-target baseline check. Requiring - // its previous result here would deadlock a draft after the - // first run fails only because CI/hygiene were incomplete; - // this run becomes the successful enforce-target result. - const syncBaselineStatus = generatedSyncBaselineDisposition({ - syncGenerated, - checkRuns, - headSha: pr.head.sha, - expectedAppId: 15368, - }); - syncBaselineReady = syncBaselineStatus === "success"; - syncBaselineFailed = syncBaselineStatus === "failed"; - } - } catch (error) { - core.warning(`Could not verify Cursor Bugbot: ${error.message}`); - syncBaselineFailed = syncGenerated; - } - if (!bugbotEvidence && labelNames.includes("review-bot-waived")) { - try { - const waiverReviews = await github.paginate( - github.rest.pulls.listReviews, - { owner, repo, pull_number, per_page: 100 } - ); - bugbotWaived = hasExactHeadMaintainerWaiver({ - labels: labelNames, - reviews: waiverReviews, - maintainers: readMaintainerLogins(), - headSha: pr.head.sha - }); - } catch (error) { - core.warning(`Could not verify Cursor Bugbot waiver: ${error.message}`); - } - } - if (!bugbotEvidence && !bugbotWaived && bugbotPolicy === "required") { - failures.push({ code: "bugbot_review" }); - } else if (!bugbotEvidence && !bugbotWaived) { - core.info("Cursor Bugbot exact-head evidence is absent (shadow mode)."); - } - } - if (syncGenerated && syncBaselineFailed) { - failures.push({ code: "sync_baseline" }); - } - // A maintainer issue comment saying the change does not touch // the GUI waives the screenshot gate. The flag is what tells the // author the screenshot is not required, even though the failure @@ -967,7 +765,7 @@ jobs: // lookup fails closed — the PR is treated as a contributor PR. const authorIsMaintainer = !permissionLookupFailed && authorHasPushPermission(authorPermission); - const checklistRequired = !authorIsMaintainer && !syncGenerated && !promotionPr; + const checklistRequired = !authorIsMaintainer; // A confirmed maintainer does not need the bot's checklist: retire // the injected section from the body so it stops rendering as a @@ -1240,14 +1038,10 @@ jobs: } } - const syncReady = syncGenerated && syncBaselineReady; - // A contributor PR stays a draft while the checklist is open, even // when every quality gate already passes. const mustDraft = - failures.length > 0 || - (checklistRequired && !checklistComplete) || - (syncGenerated && !syncReady); + failures.length > 0 || (checklistRequired && !checklistComplete); // Which reset notice (head drift vs claim check) accompanies the // draft path; only one can be active because the claim check is @@ -1280,16 +1074,6 @@ jobs: "Add a screenshot of the UI change to the PR description." ); } - if (failures.some(failure => failure.code === "bugbot_review")) { - actions.push( - "Wait for a successful Cursor Bugbot check on the current head, or obtain the exact-head outage waiver." - ); - } - if (failures.some(failure => failure.code === "sync_baseline")) { - actions.push( - "Wait for successful exact-head `ci`, `enforce-target`, and `hygiene` checks." - ); - } for (const failure of failures) { const hint = HYGIENE_FAILURE_HINTS[failure.code]; if (!hint) continue; @@ -1315,10 +1099,7 @@ jobs: // to labeled PRs (maintainer PRs never carry this label), so the // label is kept as a visible status marker only. const readyMoment = - failures.length === 0 && - ((checklistRequired && checklistComplete) || - (syncGenerated && syncReady) || - promotionPr); + checklistRequired && checklistComplete && failures.length === 0; const reviewReadyDesired = readyMoment; const hasReviewReadyLabel = (pr.labels ?? []).some( label => label.name === REVIEW_READY_LABEL @@ -1566,9 +1347,7 @@ jobs: let readyConverted = false; const shouldMarkReady = (gateState.active && gateState.autoDraftedByBot) || - (checklistRequired && checklistComplete) || - (syncGenerated && syncReady) || - promotionPr; + (checklistRequired && checklistComplete); if (shouldMarkReady && pr.draft) { try { await markReadyForReview(); @@ -1585,11 +1364,7 @@ jobs: ...gateState, active: readyConversionFailed ? true : false }; - if ( - (checklistRequired && checklistComplete) || - (syncGenerated && syncReady) || - promotionPr - ) { + if (checklistRequired && checklistComplete) { const maintainers = readMaintainerLogins().filter( login => login !== pr.user.login ); @@ -1624,11 +1399,7 @@ jobs: await upsertGateComment(readyState, { status: "READY", - statusReason: promotionPr - ? "all promotion PR quality gates passed; ready for human review and merge." - : (syncGenerated - ? "all exact-head sync PR quality gates passed; ready for human merge." - : "all PR quality gates passed; the review readiness checklist is complete."), + statusReason: "all PR quality gates passed; the review readiness checklist is complete.", actions: [], readiness, checklistRequired, diff --git a/.github/workflows/issue-quality-tests.yml b/.github/workflows/issue-quality-tests.yml index ae4a6e2819..0b6529f667 100644 --- a/.github/workflows/issue-quality-tests.yml +++ b/.github/workflows/issue-quality-tests.yml @@ -28,14 +28,6 @@ on: - ".github/scripts/run-copilot-inference*.cjs" - ".github/scripts/parse-issue-translation-response.cjs" - ".github/scripts/parse-issue-translation-response.test.cjs" - - ".github/scripts/pr-automation*.cjs" - - ".github/scripts/agent-maintenance*.cjs" - - ".github/scripts/automation-health*.cjs" - - ".github/scripts/closed-pr-branch-cleanup.cjs" - - ".github/scripts/closed-pr-branch-cleanup.test.cjs" - - ".github/workflows/pr-automation.yml" - - ".github/workflows/agent-maintenance.yml" - - ".github/workflows/automation-health.yml" - ".github/workflows/enforce-issue-quality.yml" - ".github/workflows/enforce-pr-target.yml" - ".github/workflows/pr-labeler.yml" @@ -69,14 +61,6 @@ on: - ".github/scripts/run-copilot-inference*.cjs" - ".github/scripts/parse-issue-translation-response.cjs" - ".github/scripts/parse-issue-translation-response.test.cjs" - - ".github/scripts/pr-automation*.cjs" - - ".github/scripts/agent-maintenance*.cjs" - - ".github/scripts/automation-health*.cjs" - - ".github/scripts/closed-pr-branch-cleanup.cjs" - - ".github/scripts/closed-pr-branch-cleanup.test.cjs" - - ".github/workflows/pr-automation.yml" - - ".github/workflows/agent-maintenance.yml" - - ".github/workflows/automation-health.yml" - ".github/workflows/enforce-issue-quality.yml" - ".github/workflows/enforce-pr-target.yml" - ".github/workflows/pr-labeler.yml" @@ -93,7 +77,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: persist-credentials: false @@ -112,10 +96,6 @@ jobs: node --test .github/scripts/copilot-workflows.test.cjs node --test .github/scripts/run-copilot-inference.test.cjs node --test .github/scripts/parse-issue-translation-response.test.cjs - node --test .github/scripts/pr-automation*.test.cjs - node --test .github/scripts/agent-maintenance*.test.cjs - node --test .github/scripts/automation-health.test.cjs - node --test .github/scripts/closed-pr-branch-cleanup.test.cjs - name: Validate issue-form YAML run: | diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 3bda0b7454..c3485054b7 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -20,7 +20,7 @@ jobs: matches: ${{ steps.parse.outputs.matches }} steps: - name: Checkout trusted triage scripts - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: # Issue events load this workflow from the default branch; keep scripts # aligned with that same trusted ref. @@ -169,7 +169,7 @@ jobs: issues: write steps: - name: Checkout trusted triage scripts - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false diff --git a/.github/workflows/pr-hygiene.yml b/.github/workflows/pr-hygiene.yml index cfb3fdbb9a..b60943b93a 100644 --- a/.github/workflows/pr-hygiene.yml +++ b/.github/workflows/pr-hygiene.yml @@ -34,7 +34,7 @@ jobs: pull-requests: write steps: - name: Checkout trusted hygiene script - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: # Source trusted scripts from an integration branch, never from the # PR's own base commit. A stacked child PR's base is another open diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index 5b358f118a..309fd40b8f 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout labeler script (default-branch trusted code) - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5c9ec66505..685a13876b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,9 +1,9 @@ name: Release -# Publish opencodex to npm — triggered from the Actions tab with explicit inputs, or by the -# fork's audited stable-release dispatcher. Bump package.json on main BEFORE dispatching (or -# use `bun run release `, which does the bump+commit+push+dispatch for you); the workflow -# verifies the version and exact commit before publishing. +# Publish opencodex to npm — jawcode-style: triggered from the Actions tab with an explicit +# version, dist-tag, and a dry-run-first default. Bump package.json on main BEFORE dispatching +# (or use `bun run release `, which does the bump+commit+push+dispatch for you); the +# workflow verifies the version matches before publishing. on: workflow_dispatch: inputs: @@ -18,7 +18,6 @@ on: options: - latest - preview - - dev default: latest dry-run: description: "Dry run (build + pack, no actual publish)" @@ -29,27 +28,9 @@ on: description: "Immutable release commit this dispatch must publish (fail if the branch moved)" required: true type: string - candidate-run-id: - description: "Successful Build release candidate workflow run ID" - required: false - type: string - candidate-artifact-id: - description: "Immutable release candidate artifact ID from that run" - required: false - type: string - repository_dispatch: - types: [fork-auto-release] permissions: {} -env: - DISPATCH_VERSION: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version || inputs.version }} - DISPATCH_TAG: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.tag || inputs.tag }} - DISPATCH_EXPECTED_SHA: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.expected_sha || inputs.expected-sha }} - DISPATCH_DRY_RUN: ${{ github.event_name == 'repository_dispatch' && 'false' || inputs.dry-run }} - DISPATCH_CANDIDATE_RUN_ID: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.candidate_run_id || inputs.candidate-run-id }} - DISPATCH_CANDIDATE_ARTIFACT_ID: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.candidate_artifact_id || inputs.candidate-artifact-id }} - concurrency: group: release cancel-in-progress: false @@ -63,37 +44,24 @@ jobs: - name: Checkout trusted dispatch guard uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ github.sha }} + ref: ${{ github.event.repository.default_branch }} persist-credentials: false path: trusted-dispatch - name: Validate release dispatch env: - EXPECTED_SHA: ${{ env.DISPATCH_EXPECTED_SHA }} - DISPATCH_TAG: ${{ env.DISPATCH_TAG }} - DISPATCH_DRY_RUN: ${{ env.DISPATCH_DRY_RUN }} - CANDIDATE_RUN_ID: ${{ env.DISPATCH_CANDIDATE_RUN_ID }} - CANDIDATE_ARTIFACT_ID: ${{ env.DISPATCH_CANDIDATE_ARTIFACT_ID }} - EVENT_ACTION: ${{ github.event.action || '' }} + EXPECTED_SHA: ${{ inputs.expected-sha }} run: | node - <<'NODE' - const fs = require("node:fs"); const { validateReleaseDispatch } = require( "./trusted-dispatch/.github/scripts/release-dispatch-guard.cjs", ); - const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); const failure = validateReleaseDispatch({ eventName: process.env.GITHUB_EVENT_NAME, - eventAction: process.env.EVENT_ACTION, ref: process.env.GITHUB_REF, expectedSha: process.env.EXPECTED_SHA, actualSha: process.env.GITHUB_SHA, - tag: process.env.DISPATCH_TAG, - dryRun: process.env.DISPATCH_DRY_RUN, - candidateRunId: process.env.CANDIDATE_RUN_ID, - candidateArtifactId: process.env.CANDIDATE_ARTIFACT_ID, - clientPayload: event.client_payload, }); if (failure) { @@ -114,48 +82,11 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: - ref: ${{ env.DISPATCH_EXPECTED_SHA }} fetch-depth: 0 - persist-credentials: false - - - name: Verify and download immutable release candidate - if: ${{ env.DISPATCH_CANDIDATE_RUN_ID != '' }} - env: - GH_TOKEN: ${{ github.token }} - EXPECTED_SHA: ${{ env.DISPATCH_EXPECTED_SHA }} - DISPATCH_TAG: ${{ env.DISPATCH_TAG }} - CANDIDATE_RUN_ID: ${{ env.DISPATCH_CANDIDATE_RUN_ID }} - CANDIDATE_ARTIFACT_ID: ${{ env.DISPATCH_CANDIDATE_ARTIFACT_ID }} - shell: bash - run: | - set -euo pipefail - run_json="$(gh api "/repos/${GITHUB_REPOSITORY}/actions/runs/${CANDIDATE_RUN_ID}")" - jq -e --arg repo "$GITHUB_REPOSITORY" --arg sha "$EXPECTED_SHA" --arg release_event "$GITHUB_EVENT_NAME" --arg release_tag "$DISPATCH_TAG" \ - '(.repository.full_name == $repo) and (.head_sha == $sha) and (.status == "completed") and (.conclusion == "success") and (.name == "Build release candidate") and (.path == ".github/workflows/release-candidate.yml") and ((.event == "workflow_run") or (.event == "workflow_dispatch")) and (($release_event != "repository_dispatch") or ((.event == "workflow_run") and (.head_branch == "main"))) and (($release_tag != "latest") or (.head_branch == "main"))' \ - <<<"$run_json" >/dev/null || { echo "::error::candidate run is not a successful Build release candidate for the expected repository/SHA"; exit 1; } - repo_id="$(jq -r '.repository.id' <<<"$run_json")" - run_attempt="$(jq -r '.run_attempt' <<<"$run_json")" - test "$run_attempt" -ge 1 || { echo "::error::candidate run attempt is invalid"; exit 1; } - echo "CANDIDATE_RUN_ATTEMPT=$run_attempt" >> "$GITHUB_ENV" - artifact_json="$(gh api "/repos/${GITHUB_REPOSITORY}/actions/artifacts/${CANDIDATE_ARTIFACT_ID}")" - expected_name="release-candidate-${EXPECTED_SHA}" - jq -e --arg repo_id "$repo_id" --arg sha "$EXPECTED_SHA" --arg name "$expected_name" --arg run "$CANDIDATE_RUN_ID" \ - '(.name == $name) and (.expired == false) and ((.workflow_run.id|tostring) == $run) and ((.workflow_run.repository_id|tostring) == $repo_id) and (.workflow_run.head_sha == $sha)' \ - <<<"$artifact_json" >/dev/null || { echo "::error::candidate artifact is not the unexpired expected artifact belonging to the verified run"; exit 1; } - - - name: Download immutable release candidate - if: ${{ env.DISPATCH_CANDIDATE_RUN_ID != '' }} - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - artifact-ids: ${{ env.DISPATCH_CANDIDATE_ARTIFACT_ID }} - github-token: ${{ github.token }} - repository: ${{ github.repository }} - run-id: ${{ env.DISPATCH_CANDIDATE_RUN_ID }} - path: release-candidate - name: Verify dispatched SHA env: - EXPECTED_SHA: ${{ env.DISPATCH_EXPECTED_SHA }} + EXPECTED_SHA: ${{ inputs.expected-sha }} run: | if [ -z "$EXPECTED_SHA" ]; then echo "::error::expected-sha is required; refusing to publish without an audited commit" @@ -169,33 +100,6 @@ jobs: - name: Setup project Bun uses: ./.github/actions/setup-project-bun - - name: Verify candidate manifest, package bytes, and inputs - id: candidate-package - if: ${{ env.DISPATCH_CANDIDATE_RUN_ID != '' }} - env: - EXPECTED_SHA: ${{ env.DISPATCH_EXPECTED_SHA }} - RELEASE_VERSION: ${{ env.DISPATCH_VERSION }} - CANDIDATE_RUN_ID: ${{ env.DISPATCH_CANDIDATE_RUN_ID }} - CANDIDATE_RUN_ATTEMPT: ${{ env.CANDIDATE_RUN_ATTEMPT }} - shell: bash - run: | - set -euo pipefail - manifest="release-candidate/release-candidate.json" - package_file="$(find release-candidate -maxdepth 1 -type f -name '*.tgz' -print)" - test -f "$manifest" && test "$(printf '%s\n' "$package_file" | sed '/^$/d' | wc -l | tr -d ' ')" = 1 || { echo "::error::candidate must contain one manifest and one tarball"; exit 1; } - tree_sha="$(git rev-parse 'HEAD^{tree}')" - bun scripts/release-candidate.ts verify "$manifest" "$package_file" \ - --repository "$GITHUB_REPOSITORY" --sha "$EXPECTED_SHA" --tree "$tree_sha" --input-root "$GITHUB_WORKSPACE" - node -e ' - const m=require("./release-candidate/release-candidate.json"); - const [name,version]=[m.package.name,m.package.version]; - if (version !== process.env.RELEASE_VERSION) throw new Error(`candidate version ${version} != ${process.env.RELEASE_VERSION}`); - if (m.builder.workflow !== "Build release candidate" || m.builder.runId !== process.env.CANDIDATE_RUN_ID || String(m.builder.runAttempt) !== process.env.CANDIDATE_RUN_ATTEMPT) throw new Error("candidate builder provenance mismatch"); - if (!/^@[a-z0-9._~-]+\/[a-z0-9._~-]+$/.test(name)) throw new Error(`unexpected package name ${name}`); - if (require("./package.json").name !== name) throw new Error("candidate package name differs from checked-out package.json"); - ' - echo "path=$package_file" >> "$GITHUB_OUTPUT" - # node + npm perform the actual publish. registry-url points npm at the public registry. - name: Setup Node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -227,32 +131,22 @@ jobs: - name: Verify version matches package.json env: - RELEASE_VERSION: ${{ env.DISPATCH_VERSION }} + RELEASE_VERSION: ${{ inputs.version }} run: | PKG=$(node -p "require('./package.json').version") echo "package.json=$PKG input=${RELEASE_VERSION}" - if [ -n "$DISPATCH_CANDIDATE_RUN_ID" ]; then - test "$PKG" = "$RELEASE_VERSION" || { - echo "::error::package.json ($PKG) != requested (${RELEASE_VERSION}) — build a candidate from a commit carrying the exact version"; - exit 1; - } - elif [ "$GITHUB_REF" = "refs/heads/dev" ] && [ "$PKG" != "$RELEASE_VERSION" ]; then - echo "Setting package.json version to $RELEASE_VERSION for transitional dev publish" - npm version "$RELEASE_VERSION" --no-git-tag-version --allow-same-version - else - test "$PKG" = "$RELEASE_VERSION" || { - echo "::error::package.json ($PKG) != requested (${RELEASE_VERSION}) — bump package.json first"; - exit 1; - } - fi + test "$PKG" = "$RELEASE_VERSION" || { + echo "::error::package.json ($PKG) != requested (${RELEASE_VERSION}) — bump package.json on main first"; + exit 1; + } # The exact-SHA CI gate includes the hosted Linux, Windows, and macOS # keyring smoke matrix. Do not duplicate its Linux bootstrap here. - name: Require successful Cross-platform CI for this commit env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ env.DISPATCH_VERSION }} - NPM_DIST_TAG: ${{ env.DISPATCH_TAG }} + RELEASE_VERSION: ${{ inputs.version }} + NPM_DIST_TAG: ${{ inputs.tag }} run: | set -euo pipefail @@ -271,15 +165,8 @@ jobs: exit 1 fi ;; - refs/heads/dev) - expected_tag="dev" - if [[ "$RELEASE_VERSION" != *-dev.* ]]; then - echo "::error::dev releases must use a dev prerelease version; got ${RELEASE_VERSION}" - exit 1 - fi - ;; *) - echo "::error::Release must run from main, preview, or dev; got ${GITHUB_REF}" + echo "::error::Release must run from main or preview; got ${GITHUB_REF}" exit 1 ;; esac @@ -352,16 +239,26 @@ jobs: echo "Service lifecycle passed for ${GITHUB_SHA}: ${service_url}" fi - # Tokenless publish via Trusted Publishing (OIDC) — NO NPM_TOKEN secret. Publish the verified - # candidate tarball directly; npm must not repack or execute lifecycle scripts here. + - name: Require dev to be ready for this release + env: + RELEASE_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + git fetch --force --tags origin +refs/heads/dev:refs/remotes/origin/dev + dev_version="$(git show origin/dev:package.json | bun -e 'console.log(JSON.parse(await Bun.stdin.text()).version)')" + bun scripts/version-line.ts assert-ahead "$dev_version" "$RELEASE_VERSION" + + # Tokenless publish via Trusted Publishing (OIDC) — NO NPM_TOKEN secret. npm auto-detects the + # OIDC environment (`id-token: write` above) and generates provenance automatically, so neither a + # token nor `--provenance` is needed. `npm publish` runs prepublishOnly first (typecheck + build + # the GUI into gui/dist), so even a dry-run fully verifies the build. # PREREQUISITE: configure the Trusted Publisher for this repo + workflow on npmjs.com — possible # only AFTER the package's first version exists (do the first publish locally, see the runbook). - name: Preflight release metadata - id: release-metadata env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ env.DISPATCH_VERSION }} - DRY_RUN: ${{ env.DISPATCH_DRY_RUN }} + RELEASE_VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry-run }} run: | set -euo pipefail @@ -372,62 +269,56 @@ jobs: git fetch --force --tags origin existing_tag_sha="$(git rev-parse -q --verify "refs/tags/${release_tag}^{commit}" || true)" - release_exists=false - gh release view "$release_tag" >/dev/null 2>&1 && release_exists=true - - npm_exists=false - npm_git_head="" - if npm_metadata="$(npm view "${pkg_name}@${RELEASE_VERSION}" version gitHead --json 2>/dev/null)"; then - npm_exists=true - npm_git_head="$(jq -r '.gitHead // empty' <<<"$npm_metadata")" + if [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" != "$GITHUB_SHA" ]; then + echo "::error::${release_tag} already points at ${existing_tag_sha}, not ${GITHUB_SHA}" + exit 1 fi - decision="$( - EXPECTED_SHA="$GITHUB_SHA" \ - NPM_EXISTS="$npm_exists" \ - NPM_GIT_HEAD="$npm_git_head" \ - TAG_SHA="$existing_tag_sha" \ - RELEASE_EXISTS="$release_exists" \ - RELEASE_DRY_RUN="$dry_run" \ - node - <<'NODE' - const { decideReleasePostpublish } = require("./.github/scripts/release-postpublish.cjs"); - const result = decideReleasePostpublish({ - expectedSha: process.env.EXPECTED_SHA, - npmExists: process.env.NPM_EXISTS === "true", - npmGitHead: process.env.NPM_GIT_HEAD, - tagSha: process.env.TAG_SHA, - releaseExists: process.env.RELEASE_EXISTS === "true", - dryRun: process.env.RELEASE_DRY_RUN === "true", - }); - process.stdout.write(JSON.stringify(result)); - NODE - )" - action="$(jq -r .action <<<"$decision")" - echo "publish-needed=$(jq -r .publish <<<"$decision")" >> "$GITHUB_OUTPUT" - echo "tag-needed=$(jq -r .createTag <<<"$decision")" >> "$GITHUB_OUTPUT" - echo "release-needed=$(jq -r .createRelease <<<"$decision")" >> "$GITHUB_OUTPUT" - echo "::notice::Release metadata disposition: ${action}" + if [ -n "$existing_tag_sha" ]; then + if [ "$dry_run" = "true" ]; then + echo "::notice::${release_tag} already exists at this commit; dry-run only" + else + echo "::error::${release_tag} already exists. Refusing to publish a version with pre-existing Git metadata." + exit 1 + fi + fi + + if gh release view "$release_tag" >/dev/null 2>&1; then + if [ "$dry_run" = "true" ]; then + echo "::notice::GitHub Release ${release_tag} already exists; dry-run only" + else + echo "::error::GitHub Release ${release_tag} already exists. Choose the next unused patch version." + exit 1 + fi + fi + + if npm view "${pkg_name}@${RELEASE_VERSION}" version >/dev/null 2>&1; then + if [ "$dry_run" = "true" ]; then + echo "::notice::${pkg_name}@${RELEASE_VERSION} already exists on npm; dry-run only" + else + echo "::error::${pkg_name}@${RELEASE_VERSION} already exists on npm. Choose the next unused patch version." + exit 1 + fi + fi - name: Refuse a release the current tag set already outranks env: - RELEASE_VERSION: ${{ env.DISPATCH_VERSION }} + RELEASE_VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry-run }} run: | set -euo pipefail - allow=() + allow="" existing_tag_sha="$(git rev-parse -q --verify "refs/tags/v${RELEASE_VERSION}^{commit}" || true)" - # release-postpublish.cjs already proved that any existing public state - # belongs to this exact source commit. Permit an exact-tag resume while - # still rejecting a candidate outranked by any other release tag. - if [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" = "$GITHUB_SHA" ]; then - allow=(--allow-existing-tag "v${RELEASE_VERSION}") + if [ "$DRY_RUN" = "true" ] && [ -n "$existing_tag_sha" ] && [ "$existing_tag_sha" = "$GITHUB_SHA" ]; then + allow="--allow-existing-tag-at-head" fi - git tag --list 'v*' | bun scripts/version-line.ts assert-releasable "$RELEASE_VERSION" "${allow[@]}" + git tag --list 'v*' | bun scripts/version-line.ts assert-releasable "$RELEASE_VERSION" $allow - name: Build and validate release changelog env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ env.DISPATCH_VERSION }} - NPM_DIST_TAG: ${{ env.DISPATCH_TAG }} + RELEASE_VERSION: ${{ inputs.version }} + NPM_DIST_TAG: ${{ inputs.tag }} run: | set -euo pipefail notes_file="$GITHUB_WORKSPACE/.release-notes.md" @@ -444,61 +335,42 @@ jobs: - name: Publish (or dry-run) env: - DRY_RUN: ${{ env.DISPATCH_DRY_RUN }} - NPM_DIST_TAG: ${{ env.DISPATCH_TAG }} - CANDIDATE_PACKAGE_PATH: ${{ steps.candidate-package.outputs.path }} - PUBLISH_NEEDED: ${{ steps.release-metadata.outputs.publish-needed }} + DRY_RUN: ${{ inputs.dry-run }} + NPM_DIST_TAG: ${{ inputs.tag }} run: | - set -euo pipefail - package_file="" - if [ -n "$DISPATCH_CANDIDATE_RUN_ID" ]; then - package_file="./${CANDIDATE_PACKAGE_PATH#./}" - test -f "$package_file" || { echo "::error::verified candidate package path is unavailable"; exit 1; } - fi - if [ -z "$DISPATCH_CANDIDATE_RUN_ID" ] && [ "$DRY_RUN" = "true" ]; then - echo "::notice::TRANSITIONAL DRY RUN — building locally; automatic main releases always consume an immutable candidate" + if [ "$DRY_RUN" = "true" ]; then + echo "::notice::DRY RUN — building + packing, not publishing" npm run prepublishOnly npm pack --dry-run - elif [ "$PUBLISH_NEEDED" != "true" ]; then - echo "::notice::Exact npm version is already published from ${GITHUB_SHA}; resuming post-publish metadata only" - elif [ -z "$DISPATCH_CANDIDATE_RUN_ID" ]; then - echo "::warning::transitional manual release is building locally; migrate this caller to candidate IDs" - npm publish --tag "$NPM_DIST_TAG" --access public - elif [ "$DRY_RUN" = "true" ]; then - echo "::notice::DRY RUN — validating exact candidate tarball, not publishing" - npm publish "$package_file" --tag "$NPM_DIST_TAG" --access public --ignore-scripts --dry-run else - npm publish "$package_file" --tag "$NPM_DIST_TAG" --access public --ignore-scripts + npm publish --tag "$NPM_DIST_TAG" --access public fi # Confirm the registry actually has the new version (real publishes only). - name: Post-publish registry smoke - if: ${{ env.DISPATCH_DRY_RUN != 'true' }} + if: ${{ inputs.dry-run != true }} env: - RELEASE_VERSION: ${{ env.DISPATCH_VERSION }} + RELEASE_VERSION: ${{ inputs.version }} run: | - pkg_name="$(node -p "require('./package.json').name")" for attempt in $(seq 1 30); do - if VERSION=$(npm view "${pkg_name}@${RELEASE_VERSION}" version 2>/dev/null); then + if VERSION=$(npm view "@bitkyc08/opencodex@${RELEASE_VERSION}" version 2>/dev/null); then echo "registry version=$VERSION" test "$VERSION" = "$RELEASE_VERSION" - npm dist-tag ls "$pkg_name" + npm dist-tag ls @bitkyc08/opencodex exit 0 fi - echo "::notice::${pkg_name}@${RELEASE_VERSION} not visible in npm registry yet (attempt $attempt/30)" + echo "::notice::@bitkyc08/opencodex@${RELEASE_VERSION} not visible in npm registry yet (attempt $attempt/30)" sleep 10 done echo "::error::npm registry smoke failed after 30 attempts" - npm view "$pkg_name" versions dist-tags --json || true + npm view @bitkyc08/opencodex versions dist-tags --json || true exit 1 - name: Create GitHub release - if: ${{ env.DISPATCH_DRY_RUN != 'true' }} + if: ${{ inputs.dry-run != true }} env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ env.DISPATCH_VERSION }} - TAG_NEEDED: ${{ steps.release-metadata.outputs.tag-needed }} - RELEASE_NEEDED: ${{ steps.release-metadata.outputs.release-needed }} + RELEASE_VERSION: ${{ inputs.version }} run: | set -euo pipefail @@ -518,33 +390,14 @@ jobs: fi prerelease_flag="" - if [[ "$RELEASE_VERSION" == *-preview.* || "$RELEASE_VERSION" == *-dev.* ]]; then + if [[ "$RELEASE_VERSION" == *-preview.* ]]; then prerelease_flag="--prerelease" fi - if [ "$TAG_NEEDED" = "true" ]; then - # Checkout deliberately leaves no credential in .git/config. Create - # the lightweight tag through the authenticated API instead of an - # unauthenticated git push, then verify the public ref before release. - if ! gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ - -f ref="refs/tags/${release_tag}" -f sha="$GITHUB_SHA" >/dev/null; then - echo "::notice::Tag creation raced another writer; verifying the resulting ref" - fi - fi - - git fetch --force --tags origin - published_tag_sha="$(git rev-parse -q --verify "refs/tags/${release_tag}^{commit}" || true)" - if [ "$published_tag_sha" != "$GITHUB_SHA" ]; then - echo "::error::${release_tag} resolved to ${published_tag_sha:-nothing}, not ${GITHUB_SHA}" - exit 1 + if [ -z "$existing_tag_sha" ]; then + git tag "$release_tag" "$GITHUB_SHA" + git push origin "refs/tags/${release_tag}" fi - if [ "$RELEASE_NEEDED" = "true" ]; then - if ! gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" \ - --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag}; then - echo "::notice::Release creation raced another writer; verifying the resulting release" - gh release view "$release_tag" --json tagName,targetCommitish >/dev/null - fi - else - echo "::notice::GitHub Release ${release_tag} already exists at the verified tag" - fi + gh release create "$release_tag" --target "$GITHUB_SHA" --title "$release_tag" \ + --notes-file "$notes_file" ${prerelease_flag:+$prerelease_flag} diff --git a/.github/workflows/service-lifecycle.yml b/.github/workflows/service-lifecycle.yml index 26991ed0f3..df37f60561 100644 --- a/.github/workflows/service-lifecycle.yml +++ b/.github/workflows/service-lifecycle.yml @@ -19,10 +19,19 @@ on: # produced no run and the gate dead-ended until a manual dispatch. - ".github/workflows/release.yml" push: - # Release eligibility requires exact-head lifecycle evidence on every - # integration push, including commits whose diff does not touch service - # files. Do not add a push path filter here. - branches: [main, preview, dev] + paths: + - "src/service.ts" + # Keep in sync with the release.yml service-gate regex (see above). + - "src/cli.ts" + - "src/cli/index.ts" + - "src/lib/bun-runtime.ts" + - "package.json" + - "bun.lock" + - ".github/workflows/service-lifecycle.yml" + # release.yml gates on THIS workflow having run for the release SHA. A release-branch + # commit that touches only release.yml (e.g. the v2.40.0 permissions carry, #3263/#3264) + # produced no run and the gate dead-ended until a manual dispatch. + - ".github/workflows/release.yml" workflow_dispatch: permissions: diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 5faab4b356..4f933b7923 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -5,20 +5,6 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { - "models.pickerOrder.label": "Modellreihenfolge", - "models.pickerOrder.default": "Standard", - "models.pickerOrder.alphabetical": "A–Z nach Modell", - "models.pickerOrder.provider": "Nach Anbieter", - "models.pickerOrder.mostUsed": "Nutzungsschnappschuss", - "models.pickerOrder.custom": "Eigene Reihenfolge", - "models.pickerOrder.apply": "Reihenfolge anwenden", - "models.pickerOrder.applying": "Wird angewendet…", - "models.pickerOrder.saved": "Modellreihenfolge gespeichert. Clients mit altem Katalog erneut öffnen.", - "models.pickerOrder.pending": "Reihenfolge gespeichert; Katalogaktualisierung ausstehend.", - "models.pickerOrder.usageFailed": "Modellnutzung konnte nicht geladen werden.", - "models.pickerOrder.loadFailed": "Auswahleinstellungen konnten nicht geladen werden.", - "models.pickerOrder.retry": "Erneut versuchen", - "models.pickerOrder.hint": "Speichert geroutete Modelle für Codex- und Claude-Listen. Prioritätsbereiche bevorzugter/nativer Modelle bleiben erhalten. Nutzung ist eine Momentaufnahme; nativ angebotene Optionen können sich ändern.", "codexAuth.quotaAutoRefreshAllHint": "Schaltet die unterstützten 5-Stunden- und Wochenfenster aller aktuellen Konten gemeinsam um. Im Pool-Modus wird nach jedem Reset eine kleine Anfrage gesendet, die Kontingent verbraucht.", "codexAuth.quotaAutoRefreshMixed": "Einige Fenster sind aktiviert.", "codexAuth.quotaAutoRefreshEmpty": "Keine unterstützten Kontingentfenster. Aktualisieren Sie die Kontingente der Konten.", @@ -574,7 +560,7 @@ export const de: Record = { "models.setAllHint": "Schaltet das Standardfenster {value} für alle gerouteten Anbieter ein. Fehlen context_window / context_length, wird dieser Wert das tatsächliche Codex-Fenster. Für ein einzelnes Modell nutzen Sie «Eigene Fenster» in derselben Zeile. Native Anbieter bleiben unberührt.", "models.collapseAll": "Alle einklappen", "models.expandAll": "Alle ausklappen", - "models.orderHint": "Die Standardreihenfolge folgt bevorzugten Modellen und der Katalogpriorität; geroutete Modelle mit gleichem Rang folgen Anbieter und Modell. Eine gespeicherte Reihenfolge kann die Anzeige ändern. Sichtbarkeitsschalter filtern die Zeilen. Clients können bis zum erneuten Öffnen einen älteren Katalog anzeigen.", + "models.orderHint": "Reihenfolge in der Modellauswahl: Subagents-Auswahl (in der festgelegten Reihenfolge) → übrige geroutete Modelle alphabetisch nach Anbieter, dann Modell-ID → native Modelle. Sichtbarkeitsschalter filtern nur; sie ändern diese Reihenfolge nicht.", "models.custom": "Benutzerdefiniert…", "models.customApply": "Anwenden", "models.customPlaceholder": "Tokens (z. B. 420000)", @@ -1093,7 +1079,6 @@ export const de: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", - "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside-Profile", "integrations.aside.profilesHint": "Wähle, welche Profile die ausgewählten Modelle erhalten. Das aktive Aside-Profil bleibt unverändert.", "integrations.aside.all": "Alle Profile synchronisieren", @@ -1253,10 +1238,6 @@ export const de: Record = { "integrations.semantics.zcode": "Verwaltet nur provider.opencodex in ~/.zcode/v2/config.json. Z.ai-Anmeldung und andere Provider bleiben unverändert. ZCode nach Änderungen neu starten.", "integrations.semantics.prime": "Verwaltet nur providers.opencodex in der models.json von Prime Agent — ~/.prime/agent, sofern PRIME_AGENT_CODING_AGENT_DIR sie nicht umleitet. Andere Provider und Modell-Overrides bleiben unverändert. Gilt für neue Sitzungen.", "integrations.semantics.aside": "Verwaltet nur providers.opencodex in der ~/.aside/u//models.json dieses Profils. Andere Provider bleiben unverändert. Beende Aside nach dem Anwenden vollständig und öffne es erneut.", - "integrations.semantics.raycast": "Fügt einen OpenCodex-Provider-Eintrag in die providers.yaml von Raycast ein, damit jedes geroutete Modell in der Modellauswahl von Raycast AI erscheint. Raycast Pro erforderlich.", - "integrations.raycast.proRequired": "Custom Providers ist eine Funktion von Raycast Pro. Die Datei wird geschrieben, aber Raycast ignoriert sie, bis ein Pro-Abonnement aktiv ist.", - "integrations.raycast.planUnknown": "Es konnte nicht festgestellt werden, ob Raycast Pro aktiv ist; Custom Providers erfordert Raycast Pro.", - "integrations.raycast.revealConfig": "Öffnen Sie Raycast → Einstellungen → AI und klicken Sie einmal auf „Reveal Providers Config“, damit der Providers-Ordner existiert.", "codexAuth.mainAccount": "Hauptkonto", "codexAuth.logLabel": "Log-Kennung", "codexAuth.codexApp": "Codex App", @@ -1585,7 +1566,6 @@ export const de: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", - "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Konfiguration kopieren", "api.clientConfig.download": "Herunterladen", "api.clientConfig.loading": "Client-Konfiguration wird erstellt…", @@ -1713,6 +1693,7 @@ export const de: Record = { "common.close": "Schließen", "common.ok": "OK", "app.logoAria": "opencodex-Logo", + "app.pageTitle": "{page} — opencodex", "app.claudeOn": "Claude AN", "app.claudeOff": "Claude AUS", "usage.dayMon": "Mo", @@ -2554,6 +2535,231 @@ export const de: Record = { "integrations.cursor.colReasoning": "Reasoning-Aufwand", "integrations.cursor.colContext": "Kontext", "integrations.cursor.guide": "Anleitung zu Cursor Private Inference öffnen", + + // Fork-preserved dashboard surfaces. + "dash.activity.title": "Aktivität der letzten 30 Tage", + "dash.activity.requests": "Anfragen", + "dash.activity.tokens": "Token", + "dash.activity.chartLabel": "Aktivität der letzten 30 Tage. Mit Links und Rechts einen Tag auswählen; Pos1 und Ende springen zu den Bereichsgrenzen.", + "dash.activity.viewUsage": "Nutzung anzeigen", + "sub.roles": "Rollen", + "sub.roles.hint": "Benannte Spezialisten, die das Eltern-Agent spawnen kann. Jede Rolle legt ein Modell und den Kind-Prompt fest. Das Eltern-Agent entscheidet weiterhin, wann delegiert wird.", + "sub.roles.add": "Rolle hinzufügen", + "sub.roles.empty": "Noch keine Rollen. Fügen Sie einen Spezialisten für Review, Suche oder andere delegierte Arbeit hinzu.", + "sub.roles.full": "Rollenkatalog ist voll (max. 8)", + "sub.roles.id": "Rollenname", + "sub.roles.description": "Wann verwenden", + "sub.roles.descriptionHint": "Wird dem Eltern-Agent gezeigt, damit es weiß, wann dieser Spezialist gespawnt werden soll.", + "sub.roles.model": "Modell", + "sub.roles.effort": "Aufwand", + "sub.roles.effortNone": "Modellstandard", + "sub.roles.instructions": "Kind-Anweisungen", + "sub.roles.instructionsHint": "Developer-Prompt, den das Kind-Agent lädt.", + "sub.roles.enabled": "Aktiv", + "sub.roles.delete": "Rolle {id} löschen", + "sub.roles.save": "Rollen speichern", + "sub.roles.saved": "Rollen gespeichert. Gilt für neue Codex-Sitzungen.", + "sub.roles.saveFailed": "Rollen konnten nicht gespeichert werden", + "sub.roles.loadFail": "Rollen konnten nicht geladen werden — läuft der Proxy?", + "sub.roles.routedV2Warning": "Diese Rolle nutzt ein geroutetes Modell auf der v2-Oberfläche, ohne ChatGPT auf v1 zu halten. Native v2-Eltern verschlüsseln Kind-Aufgaben.", + "sub.roles.sync": "Mit Codex-Agent-Dateien synchronisieren", + "sub.roles.syncHint": "Schreibt aktivierte Rollen in marker-eigene ocx-*.toml-Dateien. Aus bereinigt OpenCodex-eigene Dateien und lässt Ihre unangetastet.", + "cursorPool.title": "Cursor-Kontenpool (experimentell)", + "cursorPool.enabledDesc": "Bei 429 oder Auth-Fehler wird das Konto gekühlt und für diese Konversation umgeschaltet. Jede Konversation bleibt auf einem Konto, bis ein Rate-Limit- oder Anmeldefehler auftritt.", + "cursorPool.disabledDesc": "Nutzt nur das aktive Cursor-Konto. Nur aktivieren, wenn experimentelles Routing akzeptabel ist.", + "cursorPool.experimentalWarning": "Experimentell und nicht kampferprobt. Die Cursor-Nutzungsrichtlinie verbietet das Umgehen von Rate-Limits und die Manipulation von Nutzungsmessung. Teams-Nutzung ist pro Sitz und kann nicht übertragen werden. Ausgeschaltet lassen, sofern das Risiko unklar ist.", + "cursorPool.needTwoAccounts": "Füge mindestens zwei Cursor-OAuth-Konten hinzu, bevor du den Pool aktivierst.", + "cursorPool.loadFailed": "Cursor-Pool-Einstellungen konnten nicht geladen werden.", + "cursorPool.saveFailed": "Cursor-Pool-Einstellungen konnten nicht gespeichert werden.", + "cursorPool.on": "An", + "cursorPool.off": "Aus", + "antigravityRouting.failoverOnlyTitle": "Multi-Konto-Routing (nur Failover)", + "antigravityRouting.failoverOnlyDesc": "Jede Konversation bleibt auf einem Antigravity-Konto. Bei Rate-Limit-429 oder Anmeldefehler kann opencodex für diese Konversation auf ein anderes geeignetes Konto umschalten — ohne neue Sitzungen zu verteilen oder das globale aktive Konto für andere laufende Konversationen zu ändern.", + "usage.chart.dayDetail": "{date}: {requests} Anfragen, {tokens} Token", + "replitGateway.title": "Replit-Gateway-Begleiter", + "replitGateway.subtitle": "Verbinden Sie Ihr selbst bereitgestelltes Replit-Gateway und installieren Sie die gepaarten OpenAI-Chat- und Anthropic-Anbieter gemeinsam.", + "replitGateway.companionNote": "Dies ist ein benutzerdefinierter Begleit-Workflow, kein kanonisches Registry-Preset.", + "replitGateway.originLabel": "Bereitstellungs-Ursprung", + "replitGateway.originHint": "HTTPS-Ursprung Ihrer Replit-Bereitstellung (z. B. https://my-app.replit.app).", + "replitGateway.keyLabel": "Gateway-Schlüssel", + "replitGateway.keyHint": "Das REPLIT_GATEWAY_KEY-Geheimnis aus Ihrer Bereitstellung. Es wird in Ihrer lokalen Anbieterkonfiguration gespeichert und authentifiziert den Gateway-Datenverkehr.", + "replitGateway.keyTooShort": "Der Gateway-Schlüssel ist zu kurz (mindestens 32 Zeichen).", + "replitGateway.keyTooLong": "Der Gateway-Schlüssel darf höchstens 512 Zeichen lang sein.", + "replitGateway.keyInvalidChars": "Der Gateway-Schlüssel darf nur druckbare ASCII-Zeichen enthalten.", + "replitGateway.errorNetwork": "Der Proxy konnte nicht erreicht werden. Prüfen Sie, ob er läuft, und versuchen Sie es erneut.", + "replitGateway.errorMalformed": "Der Proxy hat eine ungültige Antwort zurückgegeben. Versuchen Sie es in Kürze erneut.", + "replitGateway.customDomainLabel": "Benutzerdefinierte Domain zulassen", + "replitGateway.customDomainHint": "Nur aktivieren, wenn Ihr Gateway nicht auf .replit.app gehostet wird.", + "replitGateway.setDefaultLabel": "replit nach der Installation als Standardanbieter festlegen", + "replitGateway.install": "Paar installieren", + "replitGateway.installing": "Installation…", + "replitGateway.close": "Schließen", + "replitGateway.openButton": "Replit-Gateway…", + "replitGateway.replaceTitle": "Bestehendes Replit-Paar ersetzen?", + "replitGateway.replaceBody": "Die Anbieter replit und replit-anthropic existieren bereits. Beim Ersetzen werden Gateway-Ursprung und Schlüssel überschrieben; Ihre Overlays bleiben erhalten.", + "replitGateway.replaceConfirm": "Paar ersetzen", + "replitGateway.replaceCancel": "Bestehendes behalten", + "replitGateway.probeHealth": "healthz: HTTP {status} ({ms} ms)", + "replitGateway.probeModels": "models: {count} gefunden ({ms} ms)", + "replitGateway.successTitle": "Das Replit-Gateway-Paar wurde installiert.", + "replitGateway.errorGeneric": "Das Replit-Gateway-Paar konnte nicht installiert werden.", + "replitGateway.errorCollision": "Das Replit-Anbieterpaar existiert bereits.", + "replitGateway.errorBusy": "Die Konfiguration ist ausgelastet. Versuchen Sie es in Kürze erneut.", + "replitGateway.originRequired": "Der Bereitstellungs-Ursprung ist erforderlich.", + "replitGateway.keyRequired": "Der Gateway-Schlüssel ist erforderlich.", + // Fork-preserved dashboard behavior catalog. + "common.copy": "Kopieren", + "common.copied": "Kopiert", + "auth.adminTokenDesc": "Gib dein OPENCODEX_ADMIN_AUTH_TOKEN ein, um fortzufahren. Dein Browser kann es für das nächste Mal speichern.", + "auth.adminTokenPlaceholder": "Admin-Token einfügen", + "auth.adminTokenShow": "Anzeigen", + "auth.adminTokenHide": "Verbergen", + "dash.connectionReconnecting": "Verbindung unterbrochen — erneuter Verbindungsaufbau…", + "dash.serverModeLabel": "Fernzugriffsmodus", + "dash.serverModeLoopback": "Loopback + SSH", + "dash.serverModeRemote": "Remote TLS", + "dash.serverModeLoopbackHint": "Für LAN empfohlen. Behält 127.0.0.1 bei und nutzt SSH-Weiterleitung – keine Zertifikatsvertrauensstellung nötig.", + "dash.serverModeRemoteHint": "Direkter TLS-Bind (0.0.0.0). Erfordert ein vertrauenswürdiges Zertifikat.", + "dash.serverLoopbackCardTitle": "Loopback – SSH-Weiterleitung", + "dash.serverLoopbackCardDesc": "Führen Sie auf dem Computer, auf dem Sie das Dashboard öffnen möchten, diesen temporären Tunnel aus und lassen Sie das Terminal geöffnet.", + "dash.serverLoopbackCardNote": "Öffnen Sie die weitergeleitete HTTP-URL im Browser. SSH verschlüsselt die Verbindung; verwenden Sie niemals ssh -g.", + "dash.serverPersistentTunnelDocs": "Dauerhaften macOS-Tunnel einrichten", + "dash.serverRemoteCardTitle": "Remote-TLS-Bind", + "dash.serverRemoteCardDesc": "Proxy lauscht auf allen Schnittstellen mit TLS. Codex verbindet sich direkt mit https://:. Das Zertifikat muss vom Codex-Rechner (rustls) vertraut werden – ein selbstsigniertes Zertifikat zeigt UnknownIssuer bis zur Installation.", + "dash.serverRemoteSanHint": "Das Zertifikat-SAN muss den Hostnamen oder die IP aus publicOrigin enthalten. Verwende eine vertrauenswürdige CA oder installiere deine private CA auf dem Codex-Rechner.", + "dash.serverAdvanced": "Erweitert (Hostname)", + "dash.serverCopyUnavailable": "Kopieren nicht verfügbar", + "dash.serverRemoteCredentialRequired": "Füge vor dem Aktivieren von Remote-TLS einen Data-Plane-API-Schlüssel hinzu.", + "dash.serverRestartNotice": "Listener-Änderungen gespeichert. Starte den Proxy neu, um sie anzuwenden.", + "dash.serverSettingsTitle": "Einstellungen des Server-Listeners", + "dash.serverSaveFailed": "Server-Listener-Einstellungen konnten nicht gespeichert werden.", + "dash.serverTlsOff": "deaktiviert", + "dash.serverCredentialConfigured": "konfiguriert", + "dash.serverCredentialMissing": "fehlt", + "dash.serverRestartRequired": "ja", + "prov.cooldownCleared": "Abklingzeit gelöscht", + "prov.cooldownClearFailed": "Abklingzeit konnte nicht gelöscht werden", + "models.contextMetadataSource": "Quelle", + "models.contextMetadataLive": "erkannt", + "models.contextMetadataRegistry": "Registry", + "models.contextMetadataCursorStatic": "Cursor-kuratiert", + "models.contextMetadataSnapshot": "gespeichert", + "models.contextMetadataFallback": "Fallback", + "models.contextMetadataDerived": "abgeleitet", + "models.contextMetadataUnknown": "unbekannt", + "models.contextMetadataStale": "veraltet", + "models.contextTooltipDetected": "{value} · erkannt", + "models.contextTooltipCapped": "{detected} erkannt · begrenzt auf {cap}", + "models.contextTooltipStale": "{value} · {when}", + "models.contextTooltipUnknown": "Kontextfenster unbekannt", + "models.contextTooltipSource": "{value} · {source}", + "models.contextLastChecked": "zuletzt geprüft {when}", + "models.contextYesterday": "gestern", + "models.customEditNamed": "{name} bearbeiten", + "models.customDeleteNamed": "{name} löschen", + "sub.nativeParentOverride": "Native V2-Eltern weiterleiten", + "sub.nativeParentOverrideHint": "Codex kann das ursprüngliche native Modell anzeigen, während OpenCodex den Root auf dem ausgewählten weitergeleiteten Ziel ausführt.", + "sub.nativeParentOverridePrivacyWarning": "Prompts, Repository-Kontext, Gesprächsverlauf und Tool-Ergebnisse gehen an den ausgewählten Anbieter und übernehmen dessen Verfügbarkeit, Kontext, Latenz, Verhalten, Kosten und Datenschutzeigenschaften.", + "sub.nativeParentOverrideModel": "Zielmodell für native Eltern", + "sub.nativeParentOverrideV2Required": "Erfordert explizites V2, das Upstream-V2-Flag und deaktiviertes „ChatGPT auf v1 behalten“.", + "sub.nativeParentOverrideSaveFail": "Native Elternweiterleitung konnte nicht gespeichert werden", + "sub.nativeParentOverrideSaved": "Native Elternweiterleitung gespeichert. Gilt für nachfolgende Elternrunden und die Komprimierung.", + "sub.agentTaskRecovery": "Verschlüsselte V2-Aufgaben wiederherstellen", + "sub.agentTaskRecoveryHint": "Entschlüsselt OpenAI Fernet-verschlüsselte Aufgaben über ChatGPT, damit Nicht-OpenAI-Subagenten weitergeleitete V2-Aufgaben lesen können.", + "sub.agentTaskRecoveryModel": "Wiederherstellungsmodell (Standard: gpt-5.6-luna)", + "sub.agentTaskRecoveryDefault": "Standardmodell (gpt-5.6-luna)", + "sub.agentTaskRecoverySaveFail": "Wiederherstellung verschlüsselter Aufgaben konnte nicht gespeichert werden", + "sub.agentTaskRecoverySaved": "Einstellung für die Wiederherstellung verschlüsselter Aufgaben gespeichert.", + "sub.injectionPrompt": "Benutzerdefinierte Eltern-Anleitung", + "sub.injectionPromptHint": "Ersetzt den eingebauten v2-Spawn-Hinweis. Leer lassen für den Standardtext. Platzhalter einfügen, wenn Live-Werte nötig sind:", + "sub.injectionPromptSave": "Eltern-Anleitung speichern", + "sub.injectionPromptSaved": "Eltern-Anleitung gespeichert. Gilt für neue Codex-Sitzungen.", + "sub.injectionPromptSaveFailed": "Eltern-Anleitung konnte nicht gespeichert werden", + "sub.childInstructions": "Globale Kind-Anweisungen", + "sub.childInstructionsHint": "Gilt für jedes gespawnte Kind. Rollenanweisungen gelten nach der Synchronisierung nur für diesen Spezialisten.", + "sub.childInstructionsSave": "Kind-Anweisungen speichern", + "sub.childInstructionsSaved": "Kind-Anweisungen gespeichert. Gilt für neue Codex-Sitzungen.", + "sub.childInstructionsSaveFailed": "Kind-Anweisungen konnten nicht gespeichert werden", + "logs.detail.attempt.recovery.cursorEnvelopeEcho": "Wiederherstellung nach Cursor-Replay-Hülle", + "logs.detail.attempt.recovery.cursorRoutingCommentary": "Wiederherstellung nach Cursor-Routing-Kommentar", + "logs.detail.attempt.recovery.cursorDuplicateToolCall": "Wiederherstellung nach doppeltem Cursor-Tool-Aufruf", + "logs.detail.attempt.recovery.cursorOverflowRemint": "Cursor-Überlaufwiederherstellung", + "logs.detail.attempt.recovery.cursorInvalidArgument": "Cursor-Wiederherstellung nach ungültigem Argument", + "modal.catalogTabs": "Anbieterkategorien", + "modal.searchLabel": "Anbieter suchen", + "usage.heatmap.keyboardLabel": "Mit Hoch und Runter tageweise, mit Links und Rechts wochenweise navigieren.", + "modal.catalogLoadFailed": "Anbieterkatalog konnte nicht geladen werden.", + "pws.allAccounts": "Alle Konten", + "pws.replayTransientFailures": "Vorübergehende Fehler wiederholen", + "pws.replayTransientFailuresDesc": "Vor dem Stream erkannte vorübergehende Fehler mindestens einmal wiederholen; Anfragen können mehrfach zugestellt werden.", + "pws.clearCooldown": "Abklingzeit löschen", + "pws.pacingJitter": "Zufällige Streuung (ms)", + "pws.antigravityTlsTitle": "Antigravity-Browser-TLS-Profil", + "pws.antigravityTlsEnable": "Experimentelle TLS/HTTP2-Emulation aktivieren", + "pws.antigravityTlsWarning": "Inoffizielle Kompatibilitätsfunktion. Sie gewährleistet keine Einhaltung der Nutzungsbedingungen und verhindert keine Sperrung, kann den Datenverkehr auffälliger machen und bei Initialisierungsfehlern auf Bun zurückfallen. Offizielle Gemini-API-Key-, Vertex- oder dokumentierte Code-Assist-Routen sind sicherere Optionen.", + "pws.antigravityTlsConfirm": "Das experimentelle, inoffizielle Antigravity-TLS-Profil aktivieren? Es gewährleistet keine Richtlinienkonformität und verhindert keine Sperrung. Fortfahren?", + "pws.antigravityTlsStatus": "TLS-Profilstatus: {status}", + "pws.antigravityTlsDisabled": "Deaktiviert", + "pws.antigravityTlsActive": "Aktiv", + "pws.antigravityTlsFallback": "Fallback", + "pws.aiStudio": "AI Studio", + "pws.aiStudio.connected": "Verbunden", + "pws.aiStudio.checking": "Sitzung wird geprüft", + "pws.aiStudio.unsupported": "Native Anmeldung auf dieser Plattform nicht unterstützt", + "pws.aiStudio.needsReauth": "Erneute Anmeldung erforderlich", + "pws.aiStudio.reauthenticated": "Erfolgreich erneut angemeldet", + "pws.aiStudio.loginFailed": "Native Anmeldung fehlgeschlagen", + "pws.aiStudio.connect": "Verbinden", + "claudeDesktop.catalogChanged": "Der Modellkatalog hat sich während der Bearbeitung geändert. Das nicht verfügbare Modell wurde wiederhergestellt; prüfen Sie das Profil und speichern Sie erneut.", + "claudeDesktop.mappingDetails": "Erweiterte Modellzuordnungen", + "sub.routedDelegationBridge": "Geroutete V2-Delegationsbrücke", + "sub.routedDelegationBridgeHint": "Experimentell. Solange aktiv, sind Spawn-, Sende- und Follow-up-Nachrichten Klartext, auch bei nativer Delegation. Codex kann weiterhin das ursprüngliche Modell anzeigen; geroutete Inhalte und Datenschutz folgen dem ausgewählten Anbieter.", + "sub.routedDelegationBridgeInactive": "Bereitgestellt; wird für geeignete native V2-Roots und gespawnte Kind-Turns aktiviert.", + "sub.routedDelegationBridgeSaveFail": "Geroutete V2-Delegationsbrücke konnte nicht gespeichert werden", + "sub.routedDelegationBridgeSaved": "Geroutete V2-Delegationsbrücke gespeichert. Das Deaktivieren wirkt sofort.", + "sub.prefer": "Bevorzugen", + "sub.preferred": "Bevorzugt", + "sub.preferredOutsideAdvertised": "Das bevorzugte Modell ist nicht in den fünf angekündigten Überschreibungen. Es bleibt über seine vollständige ID ausführbar und wird nicht automatisch eingefügt.", + "sub.catalogState.label": "Codex-Katalog", + "sub.catalogState.fresh": "Aktuell", + "sub.catalogState.stale": "Veraltet — starte Codex neu, um die Modellauswahl zu aktualisieren.", + "sub.catalogState.not_running": "Kein Codex-App-Server aktiv", + "sub.catalogState.unknown": "Unbekannt", + "sub.nativeDefaultState.active": "Aktiv: Sub-Agenten ohne Modell verwenden diese Präferenz nach der Synchronisierung.", + "sub.nativeDefaultState.disabled": "Synchronisierung für Sub-Agenten ohne Modell ist deaktiviert; diese Präferenz ist kein natives Standardmodell.", + "sub.nativeDefaultState.pending": "Ausstehend: Synchronisierung oder Neustart erforderlich, bevor das Verhalten aktiv ist.", + "sub.nativeDefaultState.blocked": "Blockiert: Eine benutzereigene oder inkompatible Codex-Einstellung verhindert die native Synchronisierung.", + "logs.filter.agent.label": "Agent", + "logs.filter.agent.all": "Alle", + "logs.filter.agent.main": "Hauptagent", + "logs.filter.agent.subagent": "Sub-Agent", + "logs.filter.agent.internal": "Intern", + "logs.filter.agent.unknown": "Unbekannt", + "logs.agent.main": "Hauptagent", + "logs.agent.subagent": "Sub-Agent", + "logs.agent.internal": "Intern", + "logs.agent.unknown": "Unbekannt", + "logs.agent.badgeTitle": "Anfrageherkunft", + "models.pickerOrder.label": "Modellreihenfolge", + "models.pickerOrder.default": "Standard", + "models.pickerOrder.alphabetical": "A–Z nach Modell", + "models.pickerOrder.provider": "Nach Anbieter", + "models.pickerOrder.mostUsed": "Nutzungsschnappschuss", + "models.pickerOrder.custom": "Eigene Reihenfolge", + "models.pickerOrder.apply": "Reihenfolge anwenden", + "models.pickerOrder.applying": "Wird angewendet…", + "models.pickerOrder.saved": "Modellreihenfolge gespeichert. Clients mit altem Katalog erneut öffnen.", + "models.pickerOrder.pending": "Reihenfolge gespeichert; Katalogaktualisierung ausstehend.", + "models.pickerOrder.usageFailed": "Modellnutzung konnte nicht geladen werden.", + "models.pickerOrder.loadFailed": "Auswahleinstellungen konnten nicht geladen werden.", + "models.pickerOrder.retry": "Erneut versuchen", + "models.pickerOrder.hint": "Speichert geroutete Modelle für Codex- und Claude-Listen. Prioritätsbereiche bevorzugter/nativer Modelle bleiben erhalten. Nutzung ist eine Momentaufnahme; nativ angebotene Optionen können sich ändern.", + "integrations.tab.raycast": "Raycast", + "integrations.semantics.raycast": "Fügt einen OpenCodex-Provider-Eintrag in die providers.yaml von Raycast ein, damit jedes geroutete Modell in der Modellauswahl von Raycast AI erscheint. Raycast Pro erforderlich.", + "integrations.raycast.proRequired": "Custom Providers ist eine Funktion von Raycast Pro. Die Datei wird geschrieben, aber Raycast ignoriert sie, bis ein Pro-Abonnement aktiv ist.", + "integrations.raycast.planUnknown": "Es konnte nicht festgestellt werden, ob Raycast Pro aktiv ist; Custom Providers erfordert Raycast Pro.", + "integrations.raycast.revealConfig": "Öffnen Sie Raycast → Einstellungen → AI und klicken Sie einmal auf „Reveal Providers Config“, damit der Providers-Ordner existiert.", + "api.clientConfig.clientRaycast": "Raycast", "models.displayNameSavedRefreshFailed": "Die Änderung wurde gespeichert, aber die Modellliste konnte nicht aktualisiert werden. Versuchen Sie es erneut.", "models.displayNameOutcomeUnknown": "Die Anfrage wurde nicht abgeschlossen. Die Änderung wurde möglicherweise gespeichert. Prüfen Sie den aktuellen Namen durch erneutes Versuchen, bevor Sie ihn weiter ändern.", "models.displayNameCurrentUnavailable": "Aktueller Name erst nach Aktualisierung verfügbar", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c71208942f..8c22b7e920 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -6,20 +6,6 @@ * `{var}` are plain interpolations. */ export const en = { - "models.pickerOrder.label": "Picker order", - "models.pickerOrder.default": "Default", - "models.pickerOrder.alphabetical": "A–Z by model", - "models.pickerOrder.provider": "Group by provider", - "models.pickerOrder.mostUsed": "Most used snapshot", - "models.pickerOrder.custom": "Custom order", - "models.pickerOrder.apply": "Apply order", - "models.pickerOrder.applying": "Applying…", - "models.pickerOrder.saved": "Picker order saved. Reopen clients that still show the old catalog.", - "models.pickerOrder.pending": "Order saved; catalog refresh is pending.", - "models.pickerOrder.usageFailed": "Could not load model usage.", - "models.pickerOrder.loadFailed": "Could not load picker settings.", - "models.pickerOrder.retry": "Retry", - "models.pickerOrder.hint": "Saves routed order for Codex and Claude discovery. Featured/native bands stay in place; Most used is a snapshot. Native advertised choices may change.", "codexAuth.quotaAutoRefreshAllHint": "Controls the supported 5-hour and weekly windows for all current accounts together. In Pool mode, a small request is sent after each reset and uses quota.", "codexAuth.quotaAutoRefreshMixed": "Some windows are enabled.", "codexAuth.quotaAutoRefreshEmpty": "No supported quota windows. Refresh account quotas to check again.", @@ -63,6 +49,7 @@ export const en = { "auth.adminTokenHelp": "This is the OpenCodex management admin token, not a provider API key. The proxy writes it to ~/.opencodex/admin-api-token (or $OPENCODEX_HOME/admin-api-token) on first start, and OPENCODEX_ADMIN_AUTH_TOKEN overrides it.", "auth.adminTokenDocsLink": "How to find it", "app.logoAria": "opencodex logo", + "app.pageTitle": "{page} — opencodex", "app.claudeOn": "Claude ON", "app.claudeOff": "Claude OFF", "theme.label": "Theme", @@ -599,7 +586,7 @@ export const en = { "models.setAllHint": "Turn on the {value} default window for every routed provider. Relays that omit context_window / context_length get this as the actual Codex window. Use Custom windows on a provider row to set one model by hand. Native providers are unaffected.", "models.collapseAll": "Collapse all", "models.expandAll": "Expand all", - "models.orderHint": "Default order follows featured selections and catalog priority, with provider/model ordering for tied routed rows. A saved picker order can override display order; visibility switches filter which rows appear. Clients may keep an older catalog until reopened.", + "models.orderHint": "Picker order: Subagents picks (in the selected order) → remaining routed models alphabetically by provider, then model ID → native models. Visibility switches only filter models; they do not change this order.", "models.custom": "Custom…", "models.customApply": "Apply", "models.customPlaceholder": "Tokens (e.g. 420000)", @@ -1600,7 +1587,6 @@ export const en = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", - "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside profiles", "integrations.aside.profilesHint": "Choose which profiles receive the selected models. Aside’s active profile stays unchanged.", "integrations.aside.all": "Sync all profiles", @@ -1800,10 +1786,6 @@ export const en = { "integrations.semantics.zcode": "Manages only provider.opencodex in ~/.zcode/v2/config.json. Your Z.ai login and other providers stay unchanged. Restart ZCode after changes.", "integrations.semantics.prime": "Manages only providers.opencodex in Prime Agent's models.json — ~/.prime/agent unless PRIME_AGENT_CODING_AGENT_DIR redirects it. Your other providers and model overrides stay unchanged. Applies to new sessions.", "integrations.semantics.aside": "Manages only providers.opencodex in this profile’s ~/.aside/u//models.json. Your other providers stay unchanged. Fully quit and reopen Aside after applying.", - "integrations.semantics.raycast": "Adds an OpenCodex provider entry to Raycast's providers.yaml so every routed model appears in the Raycast AI model picker. Raycast Pro required.", - "integrations.raycast.proRequired": "Custom Providers is a Raycast Pro feature. The file will be written, but Raycast ignores it until a Pro subscription is active.", - "integrations.raycast.planUnknown": "Could not determine whether Raycast Pro is active; Custom Providers requires Raycast Pro.", - "integrations.raycast.revealConfig": "Open Raycast → Settings → AI and click Reveal Providers Config once so the providers folder exists.", "codexAuth.mainAccount": "Main Account", "codexAuth.logLabel": "Log label", "codexAuth.codexApp": "Codex App", @@ -1930,7 +1912,7 @@ export const en = { "anthropicPool.title": "Claude account pool (experimental)", "anthropicPool.enabledDesc": "Sticky sessions, and new sessions prefer usage under {threshold}% ({window}).", "anthropicPool.enabledNoProactiveDesc": "Sticky sessions. Proactive usage-based switching is off at threshold 0, but new-session selection uses the {window} window.", - "anthropicPool.disabledDesc": "One account per session. A 429 still fails over to another logged-in account — that cannot be turned off.", + "anthropicPool.disabledDesc": "One account per session. 429 failover is off.", "anthropicPool.experimentalWarning": "Experimental and not battle-tested. Anthropic may restrict accounts that look like automated multi-account rotation. Same organization can share quota — pooling those accounts will not help. Keep this off unless you understand the risk.", "anthropicPool.needTwoAccounts": "Add at least two Claude OAuth accounts before enabling the pool.", "anthropicPool.threshold": "New-session usage threshold", @@ -2143,7 +2125,6 @@ export const en = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", - "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Copy config", "api.clientConfig.download": "Download", "api.clientConfig.loading": "Building client config…", @@ -2588,6 +2569,231 @@ export const en = { "usage.scope.machine": "This machine", "usage.scope.hub": "Hub-wide", "usage.hubOffline": "Hub usage is unavailable. Local usage was not substituted.", + + // Fork-preserved dashboard surfaces. + "dash.activity.title": "30-day activity", + "dash.activity.requests": "Requests", + "dash.activity.tokens": "Tokens", + "dash.activity.chartLabel": "30-day activity. Use Left and Right to select a day; Home and End jump to the range bounds.", + "dash.activity.viewUsage": "View usage", + "sub.roles": "Roles", + "sub.roles.hint": "Named specialists the parent can spawn. Each role pins a model and the child's prompt. The parent still decides when to delegate.", + "sub.roles.add": "Add role", + "sub.roles.empty": "No roles yet. Add a specialist for review, search, or other delegated work.", + "sub.roles.full": "Role catalog is full (max 8)", + "sub.roles.id": "Role name", + "sub.roles.description": "When to use", + "sub.roles.descriptionHint": "Shown to the parent so it knows when to spawn this specialist.", + "sub.roles.model": "Model", + "sub.roles.effort": "Effort", + "sub.roles.effortNone": "Model default", + "sub.roles.instructions": "Child instructions", + "sub.roles.instructionsHint": "Developer prompt the child agent loads.", + "sub.roles.enabled": "Enabled", + "sub.roles.delete": "Delete role {id}", + "sub.roles.save": "Save roles", + "sub.roles.saved": "Roles saved. Applies to new Codex sessions.", + "sub.roles.saveFailed": "Failed to save roles", + "sub.roles.loadFail": "Failed to load roles — is the proxy running?", + "sub.roles.routedV2Warning": "This role uses a routed model on the v2 surface without keeping ChatGPT on v1. Native v2 parents encrypt child tasks.", + "sub.roles.sync": "Sync to Codex agent files", + "sub.roles.syncHint": "Write enabled roles to marker-owned ocx-*.toml files. Off prunes OpenCodex-owned files and leaves yours untouched.", + "usage.chart.dayDetail": "{date}: {requests} requests, {tokens} tokens", + "replitGateway.title": "Replit gateway companion", + "replitGateway.subtitle": "Connect your user-deployed Replit gateway and install the paired OpenAI Chat and Anthropic providers together.", + "replitGateway.companionNote": "This is a custom companion workflow, not a canonical registry preset.", + "replitGateway.originLabel": "Deployment origin", + "replitGateway.originHint": "HTTPS origin of your Replit deployment (for example https://my-app.replit.app).", + "replitGateway.keyLabel": "Gateway key", + "replitGateway.keyHint": "The REPLIT_GATEWAY_KEY secret from your deployment. It is stored in your local provider configuration and used to authenticate gateway traffic.", + "replitGateway.keyTooShort": "Gateway key is too short (minimum 32 characters).", + "replitGateway.keyTooLong": "Gateway key must be at most 512 characters.", + "replitGateway.keyInvalidChars": "Gateway key must contain only printable ASCII characters.", + "replitGateway.errorNetwork": "Could not reach the proxy. Check that it is running and try again.", + "replitGateway.errorMalformed": "The proxy returned an invalid response. Try again shortly.", + "replitGateway.customDomainLabel": "Allow custom domain", + "replitGateway.customDomainHint": "Only enable when your gateway is not hosted on .replit.app.", + "replitGateway.setDefaultLabel": "Set replit as the default provider after install", + "replitGateway.install": "Install pair", + "replitGateway.installing": "Installing…", + "replitGateway.close": "Close", + "replitGateway.openButton": "Replit gateway…", + "replitGateway.replaceTitle": "Replace the existing Replit pair?", + "replitGateway.replaceBody": "Providers replit and replit-anthropic already exist. Replacing overwrites the gateway origin and key while preserving your overlays.", + "replitGateway.replaceConfirm": "Replace pair", + "replitGateway.replaceCancel": "Keep existing", + "replitGateway.probeHealth": "healthz: HTTP {status} ({ms} ms)", + "replitGateway.probeModels": "models: {count} discovered ({ms} ms)", + "replitGateway.successTitle": "Installed the Replit gateway pair.", + "replitGateway.errorGeneric": "Could not install the Replit gateway pair.", + "replitGateway.errorCollision": "The Replit provider pair already exists.", + "replitGateway.errorBusy": "Configuration is busy. Retry shortly.", + "replitGateway.originRequired": "Deployment origin is required.", + "replitGateway.keyRequired": "Gateway key is required.", + "cursorPool.title": "Cursor account pool (experimental)", + "cursorPool.enabledDesc": "On 429 or auth failure, cools the account and fails over for this conversation. Each conversation stays on one account until a rate-limit or credential failure.", + "cursorPool.disabledDesc": "Uses only the active Cursor account. Enable only if you accept experimental routing.", + "cursorPool.experimentalWarning": "Experimental and not battle-tested. Cursor's acceptable-use policy forbids circumventing rate limits and manipulating usage metering. Teams included usage is per seat and may not transfer. Keep this off unless you understand the risk.", + "cursorPool.needTwoAccounts": "Add at least two Cursor OAuth accounts before enabling the pool.", + "cursorPool.loadFailed": "Cursor pool settings could not be loaded.", + "cursorPool.saveFailed": "Cursor pool settings could not be saved.", + "cursorPool.on": "On", + "cursorPool.off": "Off", + "antigravityRouting.failoverOnlyTitle": "Multi-account routing (failover only)", + "antigravityRouting.failoverOnlyDesc": "Each conversation stays on one Antigravity account. On rate-limit 429 or credential failure, opencodex may fail over to another eligible account for that conversation — without load-balancing new sessions across accounts or changing the global active account for other live conversations.", + // Fork-preserved dashboard behavior catalog. + "common.copy": "Copy", + "common.copied": "Copied", + "auth.adminTokenDesc": "Enter your OPENCODEX_ADMIN_AUTH_TOKEN to continue. Your browser may offer to save it for next time.", + "auth.adminTokenPlaceholder": "Paste admin token", + "auth.adminTokenShow": "Show", + "auth.adminTokenHide": "Hide", + "dash.connectionReconnecting": "Connection interrupted — reconnecting…", + "dash.serverSettingsTitle": "Server listener settings", + "dash.serverModeLabel": "Remote access mode", + "dash.serverModeLoopback": "Loopback + SSH", + "dash.serverModeRemote": "Remote TLS", + "dash.serverModeLoopbackHint": "Recommended for LAN. Keep 127.0.0.1 and use SSH forwarding — no certificate trust needed.", + "dash.serverModeRemoteHint": "Direct TLS bind (0.0.0.0). Requires a trusted certificate.", + "dash.serverLoopbackCardTitle": "Loopback — SSH forwarding", + "dash.serverLoopbackCardDesc": "On the computer where you want to open the dashboard, run this temporary tunnel and keep the terminal open.", + "dash.serverLoopbackCardNote": "Open the forwarded HTTP URL in your browser. SSH encrypts the connection; never use ssh -g.", + "dash.serverPersistentTunnelDocs": "Persistent macOS tunnel setup", + "dash.serverRemoteCardTitle": "Remote TLS bind", + "dash.serverRemoteCardDesc": "Proxy listens on all interfaces with TLS. Codex connects directly to https://:. The certificate must be trusted by the Codex machine (rustls) — a self-signed cert will show UnknownIssuer until installed.", + "dash.serverRemoteSanHint": "The certificate SAN must include the hostname or IP in publicOrigin. Use a trusted CA or install your private CA on the Codex machine.", + "dash.serverAdvanced": "Advanced (hostname)", + "dash.serverCopyUnavailable": "Copy unavailable", + "dash.serverRemoteCredentialRequired": "Add a data-plane API key before enabling remote TLS.", + "dash.serverRestartNotice": "Listener changes are saved. Restart the proxy to apply them.", + "dash.serverSaveFailed": "Failed to save server listener settings.", + "dash.serverTlsOff": "off", + "dash.serverCredentialConfigured": "configured", + "dash.serverCredentialMissing": "missing", + "dash.serverRestartRequired": "true", + "prov.cooldownCleared": "Cooldown cleared", + "prov.cooldownClearFailed": "Could not clear cooldown", + "models.contextMetadataSource": "Source", + "models.contextMetadataLive": "detected", + "models.contextMetadataRegistry": "registry", + "models.contextMetadataCursorStatic": "Cursor curated", + "models.contextMetadataSnapshot": "saved", + "models.contextMetadataFallback": "fallback", + "models.contextMetadataDerived": "derived", + "models.contextMetadataUnknown": "unknown", + "models.contextMetadataStale": "stale", + "models.contextTooltipDetected": "{value} · detected", + "models.contextTooltipCapped": "{detected} detected · capped at {cap}", + "models.contextTooltipStale": "{value} · {when}", + "models.contextTooltipUnknown": "Context window unknown", + "models.contextTooltipSource": "{value} · {source}", + "models.contextLastChecked": "last checked {when}", + "models.contextYesterday": "yesterday", + "models.customEditNamed": "Edit {name}", + "models.customDeleteNamed": "Delete {name}", + "sub.prefer": "Prefer", + "sub.preferred": "Preferred", + "sub.preferredOutsideAdvertised": "The preferred model is not in the five advertised overrides. It remains executable by exact id and is not inserted automatically.", + "sub.catalogState.label": "Codex catalog", + "sub.catalogState.fresh": "Fresh", + "sub.catalogState.stale": "Stale — restart Codex to refresh its model picker.", + "sub.catalogState.not_running": "No Codex app-server running", + "sub.catalogState.unknown": "Unknown", + "sub.nativeDefaultState.active": "Live: omitted-model subagents use this preference after sync.", + "sub.nativeDefaultState.disabled": "Omitted-model sync is off; this preference is not a native default.", + "sub.nativeDefaultState.pending": "Pending: sync/restart is required before omitted-model behavior is live.", + "sub.nativeDefaultState.blocked": "Blocked: a user-owned or incompatible Codex setting prevents native sync.", + "sub.nativeParentOverride": "Route native V2 parents", + "sub.nativeParentOverrideHint": "Codex may display the original native model while OpenCodex executes the root on the selected routed target.", + "sub.nativeParentOverridePrivacyWarning": "Prompts, repository context, conversation history, and tool results go to the selected provider and inherit its availability, context, latency, behavior, cost, and privacy characteristics.", + "sub.nativeParentOverrideModel": "Native parent target model", + "sub.nativeParentOverrideV2Required": "Requires explicit V2, the upstream V2 flag, and Keep ChatGPT on v1 turned off.", + "sub.nativeParentOverrideSaveFail": "Failed to save native parent routing", + "sub.nativeParentOverrideSaved": "Native parent routing saved. Applies to subsequent parent turns and compaction.", + "sub.routedDelegationBridge": "Routed V2 delegation bridge", + "sub.routedDelegationBridgeHint": "Experimental. While active, spawn, send, and follow-up messages are plaintext, including native-to-native delegation. Codex may still show the original model; routed content and privacy follow the selected provider.", + "sub.routedDelegationBridgeInactive": "Armed; activates for eligible native V2 roots and spawned-child turns.", + "sub.routedDelegationBridgeSaveFail": "Failed to save routed V2 delegation bridge", + "sub.routedDelegationBridgeSaved": "Routed V2 delegation bridge saved. Disabling applies immediately.", + "sub.agentTaskRecovery": "Recover Encrypted V2 Tasks", + "sub.agentTaskRecoveryHint": "Unwraps OpenAI Fernet-encrypted task messages via ChatGPT so non-OpenAI subagents can read and execute routed V2 tasks.", + "sub.agentTaskRecoveryModel": "Recovery model (default: gpt-5.6-luna)", + "sub.agentTaskRecoveryDefault": "Model default (gpt-5.6-luna)", + "sub.agentTaskRecoverySaveFail": "Failed to save encrypted task recovery", + "sub.agentTaskRecoverySaved": "Encrypted task recovery setting saved.", + "sub.injectionPrompt": "Custom parent guidance", + "sub.injectionPromptHint": "Replaces the built-in v2 spawn advice. Leave empty for the built-in text. Include placeholders if you want live values:", + "sub.injectionPromptSave": "Save parent guidance", + "sub.injectionPromptSaved": "Parent guidance saved. Applies to new Codex sessions.", + "sub.injectionPromptSaveFailed": "Failed to save parent guidance", + "sub.childInstructions": "Global child instructions", + "sub.childInstructionsHint": "Applies to every spawned child. Named role instructions, when synced to Codex, apply only to that specialist.", + "sub.childInstructionsSave": "Save child instructions", + "sub.childInstructionsSaved": "Child instructions saved. Applies to new Codex sessions.", + "sub.childInstructionsSaveFailed": "Failed to save child instructions", + "logs.filter.agent.label": "Agent", + "logs.filter.agent.all": "All", + "logs.filter.agent.main": "Main", + "logs.filter.agent.subagent": "Subagent", + "logs.filter.agent.internal": "Internal", + "logs.filter.agent.unknown": "Unknown", + "logs.agent.main": "Main", + "logs.agent.subagent": "Subagent", + "logs.agent.internal": "Internal", + "logs.agent.unknown": "Unknown", + "logs.agent.badgeTitle": "Request origin", + "logs.detail.attempt.recovery.cursorEnvelopeEcho": "Cursor replay envelope recovery", + "logs.detail.attempt.recovery.cursorRoutingCommentary": "Cursor routing commentary recovery", + "logs.detail.attempt.recovery.cursorDuplicateToolCall": "Cursor duplicate tool-call recovery", + "logs.detail.attempt.recovery.cursorOverflowRemint": "Cursor overflow conversation recovery", + "logs.detail.attempt.recovery.cursorInvalidArgument": "Cursor invalid-argument recovery", + "usage.heatmap.keyboardLabel": "Use Up and Down to move by day; Left and Right to move by week.", + "modal.catalogTabs": "Provider categories", + "modal.searchLabel": "Search providers", + "modal.catalogLoadFailed": "Could not load provider catalog.", + "pws.allAccounts": "All accounts", + "pws.replayTransientFailures": "Replay transient failures", + "pws.replayTransientFailuresDesc": "Retry pre-stream transient failures at least once; requests may be delivered more than once.", + "pws.clearCooldown": "Clear cooldown", + "pws.pacingJitter": "Random jitter (ms)", + "pws.antigravityTlsTitle": "Antigravity browser TLS profile", + "pws.antigravityTlsEnable": "Enable experimental TLS/HTTP2 emulation", + "pws.antigravityTlsWarning": "Unofficial compatibility behavior. It does not ensure Terms-of-Service compliance or prevent suspension, may make traffic more distinctive, and can fall back to Bun when initialization fails. Official Gemini API-key, Vertex, or documented Code Assist routes are safer choices.", + "pws.antigravityTlsConfirm": "Enable the experimental unofficial Antigravity TLS profile? It does not ensure policy compliance or prevent suspension and may make traffic more distinctive. Continue?", + "pws.antigravityTlsStatus": "TLS profile status: {status}", + "pws.antigravityTlsDisabled": "Disabled", + "pws.antigravityTlsActive": "Active", + "pws.antigravityTlsFallback": "Fallback", + "pws.aiStudio": "AI Studio", + "pws.aiStudio.connected": "Connected", + "pws.aiStudio.checking": "Checking session", + "pws.aiStudio.unsupported": "Native login unsupported on this platform", + "pws.aiStudio.needsReauth": "Reauthentication required", + "pws.aiStudio.reauthenticated": "Reauthenticated successfully", + "pws.aiStudio.loginFailed": "Native login failed", + "pws.aiStudio.connect": "Connect", + "claudeDesktop.catalogChanged": "The model catalog changed while you were editing. The unavailable model was restored; review the profile and save again.", + "claudeDesktop.mappingDetails": "Advanced model mappings", + "models.pickerOrder.label": "Picker order", + "models.pickerOrder.default": "Default", + "models.pickerOrder.alphabetical": "A–Z by model", + "models.pickerOrder.provider": "Group by provider", + "models.pickerOrder.mostUsed": "Most used snapshot", + "models.pickerOrder.custom": "Custom order", + "models.pickerOrder.apply": "Apply order", + "models.pickerOrder.applying": "Applying…", + "models.pickerOrder.saved": "Picker order saved. Reopen clients that still show the old catalog.", + "models.pickerOrder.pending": "Order saved; catalog refresh is pending.", + "models.pickerOrder.usageFailed": "Could not load model usage.", + "models.pickerOrder.loadFailed": "Could not load picker settings.", + "models.pickerOrder.retry": "Retry", + "models.pickerOrder.hint": "Saves routed order for Codex and Claude discovery. Featured/native bands stay in place; Most used is a snapshot. Native advertised choices may change.", + "integrations.tab.raycast": "Raycast", + "integrations.semantics.raycast": "Adds an OpenCodex provider entry to Raycast's providers.yaml so every routed model appears in the Raycast AI model picker. Raycast Pro required.", + "integrations.raycast.proRequired": "Custom Providers is a Raycast Pro feature. The file will be written, but Raycast ignores it until a Pro subscription is active.", + "integrations.raycast.planUnknown": "Could not determine whether Raycast Pro is active; Custom Providers requires Raycast Pro.", + "integrations.raycast.revealConfig": "Open Raycast → Settings → AI and click Reveal Providers Config once so the providers folder exists.", + "api.clientConfig.clientRaycast": "Raycast", "models.displayNameSavedRefreshFailed": "The change was saved, but the model list could not be refreshed. Retry to refresh it.", "models.displayNameOutcomeUnknown": "The request did not finish. The change may have been saved. Retry to check the current name before making another change.", "models.displayNameCurrentUnavailable": "Current name unavailable until refresh", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index e1b3519ef1..69226d7e12 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4,20 +4,6 @@ import type { TKey } from "./en"; * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { - "models.pickerOrder.label": "Ordre des modèles", - "models.pickerOrder.default": "Par défaut", - "models.pickerOrder.alphabetical": "A–Z par modèle", - "models.pickerOrder.provider": "Par fournisseur", - "models.pickerOrder.mostUsed": "Instantané des usages", - "models.pickerOrder.custom": "Ordre personnalisé", - "models.pickerOrder.apply": "Appliquer l’ordre", - "models.pickerOrder.applying": "Application…", - "models.pickerOrder.saved": "Ordre enregistré. Rouvrez les clients affichant encore l’ancien catalogue.", - "models.pickerOrder.pending": "Ordre enregistré ; actualisation du catalogue en attente.", - "models.pickerOrder.usageFailed": "Impossible de charger les usages.", - "models.pickerOrder.loadFailed": "Impossible de charger les réglages du sélecteur.", - "models.pickerOrder.retry": "Réessayer", - "models.pickerOrder.hint": "Enregistre l’ordre des modèles routés pour Codex et la découverte Claude. Les plages prioritaires et natives sont conservées. Les usages sont un instantané ; les choix annoncés nativement peuvent changer.", "codexAuth.quotaAutoRefreshAllHint": "Active ou désactive ensemble, pour tous les comptes actuels, les fenêtres de quota prises en charge par chaque compte : 5 heures et hebdomadaire. En mode Groupe, une petite requête consommant du quota est envoyée après chaque réinitialisation.", "codexAuth.quotaAutoRefreshMixed": "Certaines fenêtres sont activées.", "codexAuth.quotaAutoRefreshEmpty": "Aucune fenêtre de quota prise en charge. Actualisez les quotas des comptes.", @@ -60,6 +46,7 @@ export const fr: Record = { "auth.adminTokenHelp": "Il s’agit du jeton d’administration de l’API de gestion OpenCodex, pas d’une clé API de fournisseur. Au premier démarrage, le proxy l’écrit dans ~/.opencodex/admin-api-token (ou $OPENCODEX_HOME/admin-api-token), et OPENCODEX_ADMIN_AUTH_TOKEN a la priorité.", "auth.adminTokenDocsLink": "Où le trouver", "app.logoAria": "Logo opencodex", + "app.pageTitle": "{page} — opencodex", "app.claudeOn": "Claude ACTIVÉ", "app.claudeOff": "Claude DÉSACTIVÉ", "theme.label": "Thème", @@ -584,7 +571,7 @@ export const fr: Record = { "models.setAllHint": "Active la fenêtre par défaut {value} pour chaque fournisseur routé. Si un relais omet context_window / context_length, cette valeur devient la fenêtre Codex réelle. Pour un seul modèle, utilisez « Fenêtres perso » sur la même ligne. Les fournisseurs natifs ne sont pas affectés.", "models.collapseAll": "Tout réduire", "models.expandAll": "Tout développer", - "models.orderHint": "L’ordre par défaut suit les modèles prioritaires et la priorité du catalogue ; les modèles routés à égalité suivent le fournisseur puis le modèle. Un ordre enregistré peut modifier l’affichage. Les options de visibilité filtrent les lignes. Les clients peuvent conserver un ancien catalogue jusqu’à leur réouverture.", + "models.orderHint": "Ordre du sélecteur : choix des sous-agents (dans l’ordre sélectionné) → autres modèles routés, classés par ordre alphabétique du fournisseur puis par ID de modèle → modèles natifs. Les options de visibilité ne font que filtrer les modèles ; elles ne modifient pas cet ordre.", "models.custom": "Personnalisé…", "models.customApply": "Appliquer", "models.customPlaceholder": "Jetons (p. ex. 420000)", @@ -1572,7 +1559,6 @@ export const fr: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", - "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Profils Aside", "integrations.aside.profilesHint": "Choisissez les profils qui recevront les modèles sélectionnés. Le profil actif dans Aside reste inchangé.", "integrations.aside.all": "Synchroniser tous les profils", @@ -1732,10 +1718,6 @@ export const fr: Record = { "integrations.semantics.zcode": "Gère uniquement provider.opencodex dans ~/.zcode/v2/config.json. Votre connexion Z.ai et les autres fournisseurs restent inchangés. Redémarrez ZCode après toute modification.", "integrations.semantics.prime": "Gère uniquement providers.opencodex dans le models.json de Prime Agent — ~/.prime/agent, sauf si PRIME_AGENT_CODING_AGENT_DIR le redirige. Vos autres fournisseurs et surcharges de modèles restent inchangés. S'applique aux nouvelles sessions.", "integrations.semantics.aside": "Gère uniquement providers.opencodex dans le fichier ~/.aside/u//models.json de ce profil. Vos autres fournisseurs restent inchangés. Quittez complètement Aside et relancez-le après application.", - "integrations.semantics.raycast": "Ajoute une entrée de fournisseur OpenCodex dans le providers.yaml de Raycast afin que chaque modèle routé apparaisse dans le sélecteur de modèles de Raycast AI. Raycast Pro requis.", - "integrations.raycast.proRequired": "Custom Providers est une fonctionnalité Raycast Pro. Le fichier sera écrit, mais Raycast l'ignore tant qu'un abonnement Pro n'est pas actif.", - "integrations.raycast.planUnknown": "Impossible de déterminer si Raycast Pro est actif ; Custom Providers nécessite Raycast Pro.", - "integrations.raycast.revealConfig": "Ouvrez Raycast → Réglages → AI et cliquez une fois sur « Reveal Providers Config » pour que le dossier des fournisseurs existe.", "codexAuth.mainAccount": "Compte principal", "codexAuth.logLabel": "Libellé du journal", "codexAuth.codexApp": "Application Codex", @@ -2062,7 +2044,6 @@ export const fr: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", - "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Copier la configuration", "api.clientConfig.download": "Télécharger", "api.clientConfig.loading": "Génération de la configuration du client…", @@ -2541,6 +2522,231 @@ export const fr: Record = { "integrations.cursor.colReasoning": "Raisonnement", "integrations.cursor.colContext": "Contexte", "integrations.cursor.guide": "Ouvrir le guide de Cursor Private Inference", + + // Fork-preserved dashboard surfaces. + "dash.activity.title": "Activité sur 30 jours", + "dash.activity.requests": "Requêtes", + "dash.activity.tokens": "Jetons", + "dash.activity.chartLabel": "Activité sur 30 jours. Utilisez Gauche et Droite pour choisir un jour ; Début et Fin accèdent aux limites.", + "dash.activity.viewUsage": "Voir l’utilisation", + "sub.roles": "Rôles", + "sub.roles.hint": "Spécialistes nommés que le parent peut lancer. Chaque rôle fixe un modèle et le prompt de l’enfant. Le parent décide toujours quand déléguer.", + "sub.roles.add": "Ajouter un rôle", + "sub.roles.empty": "Aucun rôle pour l’instant. Ajoutez un spécialiste pour la revue, la recherche ou d’autres tâches déléguées.", + "sub.roles.full": "Le catalogue de rôles est plein (max. 8)", + "sub.roles.id": "Nom du rôle", + "sub.roles.description": "Quand l’utiliser", + "sub.roles.descriptionHint": "Montré au parent pour qu’il sache quand lancer ce spécialiste.", + "sub.roles.model": "Modèle", + "sub.roles.effort": "Effort de raisonnement", + "sub.roles.effortNone": "Défaut du modèle", + "sub.roles.instructions": "Instructions enfant", + "sub.roles.instructionsHint": "Prompt développeur chargé par l’agent enfant.", + "sub.roles.enabled": "Activé", + "sub.roles.delete": "Supprimer le rôle {id}", + "sub.roles.save": "Enregistrer les rôles", + "sub.roles.saved": "Rôles enregistrés. S’applique aux nouvelles sessions Codex.", + "sub.roles.saveFailed": "Échec de l’enregistrement des rôles", + "sub.roles.loadFail": "Impossible de charger les rôles — le proxy tourne-t-il ?", + "sub.roles.routedV2Warning": "Ce rôle utilise un modèle routé sur la surface v2 sans garder ChatGPT en v1. Les parents v2 natifs chiffrent les tâches enfants.", + "sub.roles.sync": "Synchroniser vers les fichiers d’agent Codex", + "sub.roles.syncHint": "Écrit les rôles activés dans des fichiers ocx-*.toml marqués. Désactivé, cela élague les fichiers OpenCodex et laisse les vôtres.", + "usage.chart.dayDetail": "{date} : {requests} requêtes, {tokens} jetons", + "replitGateway.title": "Compagnon passerelle Replit", + "replitGateway.subtitle": "Connectez votre passerelle Replit auto-hébergée et installez ensemble les fournisseurs OpenAI Chat et Anthropic associés.", + "replitGateway.companionNote": "Il s’agit d’un flux compagnon personnalisé, et non d’un préréglage canonique du registre.", + "replitGateway.originLabel": "Origine du déploiement", + "replitGateway.originHint": "Origine HTTPS de votre déploiement Replit (par exemple https://my-app.replit.app).", + "replitGateway.keyLabel": "Clé de passerelle", + "replitGateway.keyHint": "Secret REPLIT_GATEWAY_KEY de votre déploiement. Il est enregistré dans votre configuration locale des fournisseurs et sert à authentifier le trafic vers la passerelle.", + "replitGateway.keyTooShort": "La clé de passerelle est trop courte (32 caractères minimum).", + "replitGateway.keyTooLong": "La clé de passerelle ne doit pas dépasser 512 caractères.", + "replitGateway.keyInvalidChars": "La clé de passerelle ne doit contenir que des caractères ASCII imprimables.", + "replitGateway.errorNetwork": "Impossible de joindre le proxy. Vérifiez qu’il est en cours d’exécution, puis réessayez.", + "replitGateway.errorMalformed": "Le proxy a renvoyé une réponse invalide. Réessayez dans un instant.", + "replitGateway.customDomainLabel": "Autoriser un domaine personnalisé", + "replitGateway.customDomainHint": "Activez cette option uniquement si votre passerelle n’est pas hébergée sur .replit.app.", + "replitGateway.setDefaultLabel": "Définir replit comme fournisseur par défaut après l’installation", + "replitGateway.install": "Installer la paire", + "replitGateway.installing": "Installation…", + "replitGateway.close": "Fermer", + "replitGateway.openButton": "Passerelle Replit…", + "replitGateway.replaceTitle": "Remplacer la paire Replit existante ?", + "replitGateway.replaceBody": "Les fournisseurs replit et replit-anthropic existent déjà. Le remplacement écrase l’origine et la clé de la passerelle tout en conservant vos surcharges.", + "replitGateway.replaceConfirm": "Remplacer la paire", + "replitGateway.replaceCancel": "Conserver l’existant", + "replitGateway.probeHealth": "healthz : HTTP {status} ({ms} ms)", + "replitGateway.probeModels": "models : {count} découvert(s) ({ms} ms)", + "replitGateway.successTitle": "La paire passerelle Replit a été installée.", + "replitGateway.errorGeneric": "Impossible d’installer la paire passerelle Replit.", + "replitGateway.errorCollision": "La paire de fournisseurs Replit existe déjà.", + "replitGateway.errorBusy": "La configuration est occupée. Réessayez dans un instant.", + "replitGateway.originRequired": "L’origine du déploiement est obligatoire.", + "replitGateway.keyRequired": "La clé de passerelle est obligatoire.", + "cursorPool.title": "Groupe de comptes Cursor (expérimental)", + "cursorPool.enabledDesc": "En cas de 429 ou d'échec d'authentification, met le compte en délai de récupération et bascule pour cette conversation. Chaque conversation reste sur un compte jusqu'à une limite de débit ou un échec d'identifiants.", + "cursorPool.disabledDesc": "Utilise uniquement le compte Cursor actif. Activez cette option seulement si vous acceptez le routage expérimental.", + "cursorPool.experimentalWarning": "Fonctionnalité expérimentale et peu éprouvée. La politique d'utilisation de Cursor interdit de contourner les limites de débit et de manipuler la mesure d'utilisation. L'utilisation Teams incluse est par siège et peut ne pas être transférable. Laissez cette option désactivée si vous n'en comprenez pas les risques.", + "cursorPool.needTwoAccounts": "Ajoutez au moins deux comptes OAuth Cursor avant d'activer le groupe.", + "cursorPool.loadFailed": "Impossible de charger les paramètres du groupe Cursor.", + "cursorPool.saveFailed": "Impossible d'enregistrer les paramètres du groupe Cursor.", + "cursorPool.on": "Activé", + "cursorPool.off": "Désactivé", + "antigravityRouting.failoverOnlyTitle": "Routage multi-comptes (failover uniquement)", + "antigravityRouting.failoverOnlyDesc": "Chaque conversation reste sur un compte Antigravity. En cas de limite de débit 429 ou d'échec d'identifiants, opencodex peut basculer vers un autre compte éligible pour cette conversation — sans équilibrer les nouvelles sessions entre comptes ni changer le compte actif global pour d'autres conversations en cours.", + // Fork-preserved dashboard behavior catalog. + "common.copy": "Copier", + "common.copied": "Copié", + "auth.adminTokenDesc": "Saisissez votre OPENCODEX_ADMIN_AUTH_TOKEN pour continuer. Votre navigateur pourra l’enregistrer pour la prochaine fois.", + "auth.adminTokenPlaceholder": "Coller le jeton d’administration", + "auth.adminTokenShow": "Afficher", + "auth.adminTokenHide": "Masquer", + "dash.connectionReconnecting": "Connexion interrompue — reconnexion…", + "dash.serverModeLabel": "Mode d’accès distant", + "dash.serverModeLoopback": "Boucle locale + SSH", + "dash.serverModeRemote": "TLS distant", + "dash.serverModeLoopbackHint": "Recommandé pour le LAN. Conserve 127.0.0.1 et utilise la redirection SSH — aucun certificat à approuver n’est nécessaire.", + "dash.serverModeRemoteHint": "Liaison TLS directe (0.0.0.0). Nécessite un certificat de confiance.", + "dash.serverLoopbackCardTitle": "Loopback — Redirection SSH", + "dash.serverLoopbackCardDesc": "Sur l’ordinateur où vous souhaitez ouvrir le tableau de bord, lancez ce tunnel temporaire et gardez le terminal ouvert.", + "dash.serverLoopbackCardNote": "Ouvrez l’URL HTTP redirigée dans le navigateur. SSH chiffre la connexion ; n’utilisez jamais ssh -g.", + "dash.serverPersistentTunnelDocs": "Configurer un tunnel macOS permanent", + "dash.serverRemoteCardTitle": "Liaison TLS distante", + "dash.serverRemoteCardDesc": "Le proxy écoute sur toutes les interfaces avec TLS. Codex se connecte directement à https://:. Le certificat doit être approuvé par la machine Codex (rustls) — un certificat auto-signé affichera UnknownIssuer jusqu’à son installation.", + "dash.serverRemoteSanHint": "Le SAN du certificat doit inclure le nom d’hôte ou l’IP présents dans publicOrigin. Utilisez une CA de confiance ou installez votre CA privée sur la machine Codex.", + "dash.serverAdvanced": "Avancé (nom d’hôte)", + "dash.serverCopyUnavailable": "Copie indisponible", + "dash.serverRemoteCredentialRequired": "Ajoutez une clé API data-plane avant d’activer le TLS distant.", + "dash.serverRestartNotice": "Modifications de l’écouteur enregistrées. Redémarrez le proxy pour les appliquer.", + "dash.serverSettingsTitle": "Paramètres de l’écouteur du serveur", + "dash.serverSaveFailed": "Échec de l’enregistrement des paramètres de l’écouteur du serveur.", + "dash.serverTlsOff": "désactivé", + "dash.serverCredentialConfigured": "configuré", + "dash.serverCredentialMissing": "manquant", + "dash.serverRestartRequired": "oui", + "prov.cooldownCleared": "Délai de récupération effacé", + "prov.cooldownClearFailed": "Impossible d'effacer le délai de récupération", + "models.contextMetadataSource": "Origine", + "models.contextMetadataLive": "détecté", + "models.contextMetadataRegistry": "registre", + "models.contextMetadataCursorStatic": "sélectionné par Cursor", + "models.contextMetadataSnapshot": "enregistré", + "models.contextMetadataFallback": "repli", + "models.contextMetadataDerived": "dérivé", + "models.contextMetadataUnknown": "inconnu", + "models.contextMetadataStale": "périmé", + "models.contextTooltipDetected": "{value} · détecté", + "models.contextTooltipCapped": "{detected} détecté · plafonné à {cap}", + "models.contextTooltipStale": "{value} ({when})", + "models.contextTooltipUnknown": "Fenêtre de contexte inconnue", + "models.contextTooltipSource": "{value} ({source})", + "models.contextLastChecked": "dernière vérif. {when}", + "models.contextYesterday": "hier", + "models.customEditNamed": "Modifier {name}", + "models.customDeleteNamed": "Supprimer {name}", + "sub.nativeParentOverride": "Acheminer les parents V2 natifs", + "sub.nativeParentOverrideHint": "Codex peut afficher le modèle natif d’origine tandis qu’OpenCodex exécute la racine sur la cible routée sélectionnée.", + "sub.nativeParentOverridePrivacyWarning": "Les prompts, le contexte du dépôt, l’historique de conversation et les résultats des outils sont envoyés au fournisseur sélectionné et héritent de sa disponibilité, son contexte, sa latence, son comportement, son coût et ses caractéristiques de confidentialité.", + "sub.nativeParentOverrideModel": "Modèle cible du parent natif", + "sub.nativeParentOverrideV2Required": "Nécessite V2 explicite, le flag V2 amont et la désactivation de « garder ChatGPT sur v1 ».", + "sub.nativeParentOverrideSaveFail": "Impossible d’enregistrer le routage du parent natif", + "sub.nativeParentOverrideSaved": "Routage du parent natif enregistré. S’applique aux tours suivants du parent et à la compaction.", + "sub.agentTaskRecovery": "Récupérer les tâches V2 chiffrées", + "sub.agentTaskRecoveryHint": "Déchiffre les messages de tâche chiffrés Fernet d’OpenAI via ChatGPT afin que les sous-agents non-OpenAI puissent exécuter les tâches V2.", + "sub.agentTaskRecoveryModel": "Modèle de récupération (par défaut : gpt-5.6-luna)", + "sub.agentTaskRecoveryDefault": "Modèle par défaut (gpt-5.6-luna)", + "sub.agentTaskRecoverySaveFail": "Échec de l’enregistrement de la récupération des tâches chiffrées", + "sub.agentTaskRecoverySaved": "Paramètre de récupération des tâches chiffrées enregistré.", + "sub.injectionPrompt": "Conseils parent personnalisés", + "sub.injectionPromptHint": "Remplace le texte v2 intégré. Laissez vide pour le texte par défaut. Incluez des espaces réservés pour les valeurs live :", + "sub.injectionPromptSave": "Enregistrer les conseils parent", + "sub.injectionPromptSaved": "Conseils parent enregistrés. S’applique aux nouvelles sessions Codex.", + "sub.injectionPromptSaveFailed": "Échec de l’enregistrement des conseils parent", + "sub.childInstructions": "Instructions enfant globales", + "sub.childInstructionsHint": "S’applique à chaque enfant lancé. Les instructions de rôle, une fois synchronisées, ne s’appliquent qu’à ce spécialiste.", + "sub.childInstructionsSave": "Enregistrer les instructions enfant", + "sub.childInstructionsSaved": "Instructions enfant enregistrées. S’applique aux nouvelles sessions Codex.", + "sub.childInstructionsSaveFailed": "Échec de l’enregistrement des instructions enfant", + "logs.detail.attempt.recovery.cursorEnvelopeEcho": "Récupération d'enveloppe de rejeu Cursor", + "logs.detail.attempt.recovery.cursorRoutingCommentary": "Récupération de commentaire de routage Cursor", + "logs.detail.attempt.recovery.cursorDuplicateToolCall": "Récupération d’appel d’outil dupliqué Cursor", + "logs.detail.attempt.recovery.cursorOverflowRemint": "Récupération de dépassement Cursor", + "logs.detail.attempt.recovery.cursorInvalidArgument": "Récupération d’argument invalide Cursor", + "usage.heatmap.keyboardLabel": "Utilisez Haut et Bas pour changer de jour ; Gauche et Droite pour changer de semaine.", + "modal.catalogTabs": "Catégories de fournisseurs", + "modal.searchLabel": "Rechercher des fournisseurs", + "modal.catalogLoadFailed": "Impossible de charger le catalogue des fournisseurs.", + "pws.allAccounts": "Tous les comptes", + "pws.replayTransientFailures": "Rejouer les erreurs transitoires", + "pws.replayTransientFailuresDesc": "Réessayer au moins une fois les erreurs transitoires avant le flux ; une requête peut être livrée plusieurs fois.", + "pws.pacingJitter": "Variation aléatoire (ms)", + "pws.antigravityTlsTitle": "Profil TLS du navigateur Antigravity", + "pws.antigravityTlsEnable": "Activer l’émulation TLS/HTTP2 expérimentale", + "pws.antigravityTlsWarning": "Fonction de compatibilité non officielle. Elle ne garantit pas le respect des conditions d’utilisation ni l’absence de suspension, peut rendre le trafic plus distinctif et revenir à Bun si l’initialisation échoue. Les routes officielles Gemini par clé API, Vertex ou Code Assist documentées sont plus sûres.", + "pws.antigravityTlsConfirm": "Activer le profil TLS Antigravity expérimental et non officiel ? Il ne garantit pas la conformité et n’empêche pas une suspension. Continuer ?", + "pws.antigravityTlsStatus": "État du profil TLS : {status}", + "pws.antigravityTlsDisabled": "Désactivé", + "pws.antigravityTlsActive": "Actif", + "pws.antigravityTlsFallback": "Repli", + "pws.clearCooldown": "Effacer le délai de récupération", + "pws.aiStudio": "AI Studio", + "pws.aiStudio.connected": "Connecté", + "pws.aiStudio.checking": "Vérification de la session", + "pws.aiStudio.unsupported": "Connexion native non prise en charge sur cette plateforme", + "pws.aiStudio.needsReauth": "Réauthentification requise", + "pws.aiStudio.reauthenticated": "Réauthentification réussie", + "pws.aiStudio.loginFailed": "Échec de la connexion native", + "pws.aiStudio.connect": "Connecter", + "claudeDesktop.catalogChanged": "Le catalogue de modèles a changé pendant la modification. Le modèle indisponible a été restauré ; vérifiez le profil puis enregistrez-le à nouveau.", + "claudeDesktop.mappingDetails": "Attributions avancées des modèles", + "sub.routedDelegationBridge": "Pont de délégation V2 routé", + "sub.routedDelegationBridgeHint": "Expérimental. Lorsqu’il est actif, les messages de création, d’envoi et de suivi sont en clair, y compris entre agents natifs. Codex peut encore afficher le modèle d’origine ; le contenu routé et la confidentialité suivent le fournisseur sélectionné.", + "sub.routedDelegationBridgeInactive": "Armé ; s’active pour les racines V2 natives éligibles et les tours des enfants générés.", + "sub.routedDelegationBridgeSaveFail": "Échec de l’enregistrement du pont de délégation V2 routé", + "sub.routedDelegationBridgeSaved": "Pont de délégation V2 routé enregistré. La désactivation s’applique immédiatement.", + "sub.prefer": "Préférer", + "sub.preferred": "Préféré", + "sub.preferredOutsideAdvertised": "Le modèle préféré ne figure pas parmi les cinq remplacements annoncés. Il reste exécutable avec son identifiant exact et n’est pas inséré automatiquement.", + "sub.catalogState.label": "Catalogue Codex", + "sub.catalogState.fresh": "À jour", + "sub.catalogState.stale": "Obsolète — redémarrez Codex pour actualiser son sélecteur de modèles.", + "sub.catalogState.not_running": "Aucun app-server Codex en cours", + "sub.catalogState.unknown": "Inconnu", + "sub.nativeDefaultState.active": "Actif : les sous-agents sans modèle utilisent cette préférence après synchronisation.", + "sub.nativeDefaultState.disabled": "La synchronisation des sous-agents sans modèle est désactivée ; cette préférence n’est pas une valeur native par défaut.", + "sub.nativeDefaultState.pending": "En attente : synchronisation ou redémarrage requis avant l’activation du comportement.", + "sub.nativeDefaultState.blocked": "Bloqué : un réglage Codex appartenant à l’utilisateur ou incompatible empêche la synchronisation native.", + "logs.filter.agent.label": "Type d’agent", + "logs.filter.agent.all": "Tous", + "logs.filter.agent.main": "Principal", + "logs.filter.agent.subagent": "Sous-agent", + "logs.filter.agent.internal": "Interne", + "logs.filter.agent.unknown": "Inconnu", + "logs.agent.main": "Principal", + "logs.agent.subagent": "Sous-agent", + "logs.agent.internal": "Interne", + "logs.agent.unknown": "Inconnu", + "logs.agent.badgeTitle": "Origine de la requête", + "models.pickerOrder.label": "Ordre des modèles", + "models.pickerOrder.default": "Par défaut", + "models.pickerOrder.alphabetical": "A–Z par modèle", + "models.pickerOrder.provider": "Par fournisseur", + "models.pickerOrder.mostUsed": "Instantané des usages", + "models.pickerOrder.custom": "Ordre personnalisé", + "models.pickerOrder.apply": "Appliquer l’ordre", + "models.pickerOrder.applying": "Application…", + "models.pickerOrder.saved": "Ordre enregistré. Rouvrez les clients affichant encore l’ancien catalogue.", + "models.pickerOrder.pending": "Ordre enregistré ; actualisation du catalogue en attente.", + "models.pickerOrder.usageFailed": "Impossible de charger les usages.", + "models.pickerOrder.loadFailed": "Impossible de charger les réglages du sélecteur.", + "models.pickerOrder.retry": "Réessayer", + "models.pickerOrder.hint": "Enregistre l’ordre des modèles routés pour Codex et la découverte Claude. Les plages prioritaires et natives sont conservées. Les usages sont un instantané ; les choix annoncés nativement peuvent changer.", + "integrations.tab.raycast": "Raycast", + "integrations.semantics.raycast": "Ajoute une entrée de fournisseur OpenCodex dans le providers.yaml de Raycast afin que chaque modèle routé apparaisse dans le sélecteur de modèles de Raycast AI. Raycast Pro requis.", + "integrations.raycast.proRequired": "Custom Providers est une fonctionnalité Raycast Pro. Le fichier sera écrit, mais Raycast l'ignore tant qu'un abonnement Pro n'est pas actif.", + "integrations.raycast.planUnknown": "Impossible de déterminer si Raycast Pro est actif ; Custom Providers nécessite Raycast Pro.", + "integrations.raycast.revealConfig": "Ouvrez Raycast → Réglages → AI et cliquez une fois sur « Reveal Providers Config » pour que le dossier des fournisseurs existe.", + "api.clientConfig.clientRaycast": "Raycast", "models.displayNameSavedRefreshFailed": "La modification a été enregistrée, mais la liste des modèles n’a pas pu être actualisée. Réessayez.", "models.displayNameOutcomeUnknown": "La requête n’a pas abouti. La modification a peut-être été enregistrée. Réessayez pour vérifier le nom actuel avant toute autre modification.", "models.displayNameCurrentUnavailable": "Nom actuel indisponible avant actualisation", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index cf94831583..25d77df178 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,20 +4,6 @@ import type { TKey } from "./en"; * Japanese i18n catalog; must match the `TKey` set (compile-checked). */ export const ja: Record = { - "models.pickerOrder.label": "モデル選択順", - "models.pickerOrder.default": "デフォルト", - "models.pickerOrder.alphabetical": "モデル名のA–Z順", - "models.pickerOrder.provider": "プロバイダー別", - "models.pickerOrder.mostUsed": "使用量のスナップショット", - "models.pickerOrder.custom": "カスタム順", - "models.pickerOrder.apply": "順序を適用", - "models.pickerOrder.applying": "適用中…", - "models.pickerOrder.saved": "選択順を保存しました。古い一覧が表示される場合はクライアントを開き直してください。", - "models.pickerOrder.pending": "順序を保存しました。カタログの更新は保留中です。", - "models.pickerOrder.usageFailed": "モデル使用量を読み込めませんでした。", - "models.pickerOrder.loadFailed": "モデル選択設定を読み込めませんでした。", - "models.pickerOrder.retry": "再試行", - "models.pickerOrder.hint": "CodexとClaudeの検出一覧のルーティングモデル順を保存します。優先・ネイティブの順位帯は維持されます。使用量順はスナップショットで、ネイティブツールの候補表示は変わる場合があります。", "codexAuth.quotaAutoRefreshAllHint": "現在の全アカウントで、対応する5時間・週間枠をまとめて切り替えます。プールモードではリセット後に少量の利用枠を消費するリクエストを送信します。", "codexAuth.quotaAutoRefreshMixed": "一部の枠が有効です。", "codexAuth.quotaAutoRefreshEmpty": "対応する利用枠がありません。アカウントの利用枠を更新してください。", @@ -130,6 +116,7 @@ export const ja: Record = { "auth.adminTokenHelp": "これは OpenCodex 管理 API の管理者トークンで、プロバイダーの API キーではありません。プロキシは初回起動時に ~/.opencodex/admin-api-token(または $OPENCODEX_HOME/admin-api-token)へ書き込み、OPENCODEX_ADMIN_AUTH_TOKEN を設定するとそちらが優先されます。", "auth.adminTokenDocsLink": "確認方法", "app.logoAria": "opencodex ロゴ", + "app.pageTitle": "{page} — opencodex", "app.claudeOn": "Claude オン", "app.claudeOff": "Claude オフ", "theme.label": "テーマ", @@ -582,7 +569,7 @@ export const ja: Record = { "models.setAllHint": "すべてのルーティング済みプロバイダーに {value} のデフォルトウィンドウをオンにします。中継が context_window / context_length を返さない場合、この値が実際の Codex ウィンドウになります。1 モデルだけ手で書くときは同じ行の「カスタムウィンドウ」を使います。ネイティブプロバイダーには影響しません。", "models.collapseAll": "すべて折りたたむ", "models.expandAll": "すべて展開", - "models.orderHint": "デフォルト順は優先モデルとカタログの優先順位に従い、同順位のルーティングモデルはプロバイダー・モデル順になります。保存した順序で表示順を変更でき、表示スイッチは表示する行を絞り込みます。クライアントを開き直すまで古いカタログが表示される場合があります。", + "models.orderHint": "ピッカーの順序: サブエージェントの選択(選択順) → 残りのルーティングモデルはプロバイダー別、次にモデル ID 別のアルファベット順 → ネイティブモデル。表示切り替えはモデルをフィルタするだけで、この順序は変更しません。", "models.custom": "カスタム…", "models.customApply": "適用", "models.customPlaceholder": "トークン (例: 420000)", @@ -1513,7 +1500,6 @@ export const ja: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", - "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Asideのプロファイル", "integrations.aside.profilesHint": "選択したモデルを同期するプロファイルを選んでください。Asideで使用中のプロファイルは変わりません。", "integrations.aside.all": "すべてのプロファイルを同期", @@ -1673,10 +1659,6 @@ export const ja: Record = { "integrations.semantics.zcode": "~/.zcode/v2/config.json の provider.opencodex のみを管理します。Z.ai ログインと他のプロバイダーは変更しません。変更後は ZCode を再起動してください。", "integrations.semantics.prime": "Prime Agent の models.json 内の providers.opencodex のみを管理します。場所は ~/.prime/agent ですが、PRIME_AGENT_CODING_AGENT_DIR が設定されている場合はそちらが優先されます。他のプロバイダーとモデルオーバーライドは変更しません。新しいセッションから適用されます。", "integrations.semantics.aside": "このプロファイルの ~/.aside/u//models.json 内の providers.opencodex のみを管理します。他のプロバイダーは変更しません。適用後は Aside を完全に終了してから開き直してください。", - "integrations.semantics.raycast": "Raycast の providers.yaml に OpenCodex のプロバイダーエントリを追加し、ルーティングされたすべてのモデルを Raycast AI のモデル選択に表示します。Raycast Pro が必要です。", - "integrations.raycast.proRequired": "Custom Providers は Raycast Pro の機能です。ファイルは書き込まれますが、Pro サブスクリプションが有効になるまで Raycast はこれを無視します。", - "integrations.raycast.planUnknown": "Raycast Pro が有効かどうか確認できませんでした。Custom Providers には Raycast Pro が必要です。", - "integrations.raycast.revealConfig": "Raycast → 設定 → AI を開き、「Reveal Providers Config」を一度クリックして providers フォルダを作成してください。", "codexAuth.mainAccount": "メインアカウント", "codexAuth.logLabel": "ログラベル", "codexAuth.codexApp": "Codex App", @@ -2010,7 +1992,6 @@ export const ja: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", - "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "設定をコピー", "api.clientConfig.download": "ダウンロード", "api.clientConfig.loading": "クライアント設定を生成中…", @@ -2575,6 +2556,231 @@ export const ja: Record = { "integrations.cursor.colReasoning": "推論", "integrations.cursor.colContext": "コンテキスト", "integrations.cursor.guide": "Cursor Private Inference のガイドを開く", + + // Fork-preserved dashboard surfaces. + "dash.activity.title": "30日間のアクティビティ", + "dash.activity.requests": "リクエスト", + "dash.activity.tokens": "トークン", + "dash.activity.chartLabel": "30日間のアクティビティ。左右キーで日を選択し、Home と End で範囲の端へ移動します。", + "dash.activity.viewUsage": "使用状況を表示", + "sub.roles": "ロール", + "sub.roles.hint": "親が spawn できる名前付きスペシャリストです。各ロールはモデルと子プロンプトを固定します。委譲するかどうかは親が決めます。", + "sub.roles.add": "ロールを追加", + "sub.roles.empty": "まだロールがありません。レビューや検索など、委譲したい作業用のスペシャリストを追加してください。", + "sub.roles.full": "ロールカタログがいっぱいです(最大 8)", + "sub.roles.id": "ロール名", + "sub.roles.description": "使うタイミング", + "sub.roles.descriptionHint": "親がこのスペシャリストをいつ spawn すべきか判断するために表示されます。", + "sub.roles.model": "モデル", + "sub.roles.effort": "エフォート", + "sub.roles.effortNone": "モデル既定", + "sub.roles.instructions": "子の指示", + "sub.roles.instructionsHint": "子エージェントが読み込むデベロッパープロンプトです。", + "sub.roles.enabled": "有効", + "sub.roles.delete": "ロール {id} を削除", + "sub.roles.save": "ロールを保存", + "sub.roles.saved": "ロールを保存しました。新しい Codex セッションから適用されます。", + "sub.roles.saveFailed": "ロールの保存に失敗しました", + "sub.roles.loadFail": "ロールを読み込めませんでした — プロキシは起動していますか?", + "sub.roles.routedV2Warning": "このロールは v2 サーフェス上のルーテッドモデルを使い、ChatGPT を v1 に保っていません。ネイティブ v2 の親は子タスクを暗号化します。", + "sub.roles.sync": "Codex エージェントファイルへ同期", + "sub.roles.syncHint": "有効なロールをマーカー所有の ocx-*.toml に書き込みます。オフにすると OpenCodex 所有ファイルだけを削除し、あなたのファイルは触りません。", + "usage.chart.dayDetail": "{date}: {requests} リクエスト、{tokens} トークン", + "replitGateway.title": "Replit ゲートウェイコンパニオン", + "replitGateway.subtitle": "自分でデプロイした Replit ゲートウェイに接続し、OpenAI Chat と Anthropic のペアプロバイダーをまとめてインストールします。", + "replitGateway.companionNote": "これはカスタムのコンパニオンワークフローであり、正規レジストリのプリセットではありません。", + "replitGateway.originLabel": "デプロイメントのオリジン", + "replitGateway.originHint": "Replit デプロイメントの HTTPS オリジン(例: https://my-app.replit.app)。", + "replitGateway.keyLabel": "ゲートウェイキー", + "replitGateway.keyHint": "デプロイメントの REPLIT_GATEWAY_KEY シークレット。ローカルのプロバイダー設定に保存され、ゲートウェイ通信の認証に使われます。", + "replitGateway.keyTooShort": "ゲートウェイキーが短すぎます(最低 32 文字)。", + "replitGateway.keyTooLong": "ゲートウェイキーは 512 文字以内にしてください。", + "replitGateway.keyInvalidChars": "ゲートウェイキーは印字可能な ASCII 文字のみ使用できます。", + "replitGateway.errorNetwork": "プロキシに接続できませんでした。実行中か確認してから再試行してください。", + "replitGateway.errorMalformed": "プロキシから無効な応答が返されました。しばらくしてから再試行してください。", + "replitGateway.customDomainLabel": "カスタムドメインを許可", + "replitGateway.customDomainHint": "ゲートウェイが .replit.app 以外でホストされている場合のみ有効にしてください。", + "replitGateway.setDefaultLabel": "インストール後に replit を既定プロバイダーに設定", + "replitGateway.install": "ペアをインストール", + "replitGateway.installing": "インストール中…", + "replitGateway.close": "閉じる", + "replitGateway.openButton": "Replit ゲートウェイ…", + "replitGateway.replaceTitle": "既存の Replit ペアを置き換えますか?", + "replitGateway.replaceBody": "プロバイダー replit と replit-anthropic は既に存在します。置き換えるとゲートウェイのオリジンとキーが上書きされますが、オーバーレイは保持されます。", + "replitGateway.replaceConfirm": "ペアを置き換え", + "replitGateway.replaceCancel": "既存を保持", + "replitGateway.probeHealth": "healthz: HTTP {status}({ms} ms)", + "replitGateway.probeModels": "models: {count} 件を検出({ms} ms)", + "replitGateway.successTitle": "Replit ゲートウェイペアをインストールしました。", + "replitGateway.errorGeneric": "Replit ゲートウェイペアをインストールできませんでした。", + "replitGateway.errorCollision": "Replit プロバイダーペアは既に存在します。", + "replitGateway.errorBusy": "設定がビジーです。しばらくしてから再試行してください。", + "replitGateway.originRequired": "デプロイメントのオリジンは必須です。", + "replitGateway.keyRequired": "ゲートウェイキーは必須です。", + "cursorPool.title": "Cursor アカウントプール(実験的)", + "cursorPool.enabledDesc": "429 または認証失敗時にアカウントをクールダウンし、この会話でフェイルオーバーします。レート制限または認証失敗が発生するまで、各会話は 1 つのアカウントに留まります。", + "cursorPool.disabledDesc": "アクティブな Cursor アカウントのみを使用します。実験的ルーティングを受け入れる場合のみ有効にしてください。", + "cursorPool.experimentalWarning": "実験的で十分に検証されていません。Cursor の利用規約はレート制限の回避と使用量計測の操作を禁止しています。Teams の含まれる使用量はシート単位で、転送されない場合があります。リスクを理解していない場合はオフのままにしてください。", + "cursorPool.needTwoAccounts": "プールを有効にする前に、Cursor OAuth アカウントを 2 つ以上追加してください。", + "cursorPool.loadFailed": "Cursor プール設定を読み込めませんでした。", + "cursorPool.saveFailed": "Cursor プール設定を保存できませんでした。", + "cursorPool.on": "オン", + "cursorPool.off": "オフ", + "antigravityRouting.failoverOnlyTitle": "マルチアカウントルーティング(フェイルオーバーのみ)", + "antigravityRouting.failoverOnlyDesc": "各会話は 1 つの Antigravity アカウントに留まります。レート制限 429 または認証失敗時に、opencodex はその会話の別の適格アカウントへフェイルオーバーできます — 新規セッションをアカウント間で分散したり、他の進行中会話のグローバルなアクティブアカウントを変更したりしません。", + // Fork-preserved dashboard behavior catalog. + "common.copy": "コピー", + "common.copied": "コピーしました", + "auth.adminTokenDesc": "続行するには OPENCODEX_ADMIN_AUTH_TOKEN を入力してください。ブラウザが次回のために保存を提案する場合があります。", + "auth.adminTokenPlaceholder": "管理者トークンを貼り付け", + "auth.adminTokenShow": "表示", + "auth.adminTokenHide": "非表示", + "dash.connectionReconnecting": "接続が中断されました — 再接続中…", + "dash.serverModeLabel": "リモートアクセスモード", + "dash.serverModeLoopback": "ループバック + SSH", + "dash.serverModeRemote": "リモート TLS", + "dash.serverModeLoopbackHint": "LAN 向けの推奨設定。127.0.0.1 のまま SSH ポートフォワーディングで接続するため、証明書の信頼設定は不要です。", + "dash.serverModeRemoteHint": "直接 TLS バインド(0.0.0.0)。信頼された証明書が必要です。", + "dash.serverLoopbackCardTitle": "ループバック — SSH ポートフォワーディング", + "dash.serverLoopbackCardDesc": "ダッシュボードを開くコンピューターでこの一時トンネルを実行し、ターミナルを開いたままにしてください。", + "dash.serverLoopbackCardNote": "転送された HTTP URL をブラウザーで開きます。接続は SSH で暗号化されます。ssh -g は使用しないでください。", + "dash.serverPersistentTunnelDocs": "永続的な macOS トンネルの設定", + "dash.serverRemoteCardTitle": "リモート TLS バインド", + "dash.serverRemoteCardDesc": "プロキシは TLS で全インターフェースをリッスンします。Codex は https://: に直接接続します。証明書は Codex マシン(rustls)に信頼されている必要があり、自己署名証明書はインストールされるまで UnknownIssuer が表示されます。", + "dash.serverRemoteSanHint": "証明書の SAN には publicOrigin のホスト名または IP を含める必要があります。信頼された CA を使用するか、Codex マシンにプライベート CA をインストールしてください。", + "dash.serverAdvanced": "詳細設定(ホスト名)", + "dash.serverCopyUnavailable": "コピーできません", + "dash.serverRemoteCredentialRequired": "リモート TLS を有効にする前に、データプレーン API キーを追加してください。", + "dash.serverRestartNotice": "リスナー設定を保存しました。適用するにはプロキシを再起動してください。", + "dash.serverSettingsTitle": "サーバーリスナー設定", + "dash.serverSaveFailed": "サーバーリスナー設定の保存に失敗しました。", + "dash.serverTlsOff": "オフ", + "dash.serverCredentialConfigured": "設定済み", + "dash.serverCredentialMissing": "未設定", + "dash.serverRestartRequired": "はい", + "models.contextMetadataSource": "出典", + "models.contextMetadataLive": "検出", + "models.contextMetadataRegistry": "レジストリ", + "models.contextMetadataCursorStatic": "Cursor選定", + "models.contextMetadataSnapshot": "保存済み", + "models.contextMetadataFallback": "フォールバック", + "models.contextMetadataDerived": "派生", + "models.contextMetadataUnknown": "不明", + "models.contextMetadataStale": "古い", + "models.contextTooltipDetected": "{value} · 検出", + "models.contextTooltipCapped": "{detected} 検出 · {cap} に制限", + "models.contextTooltipStale": "{value} · {when}", + "models.contextTooltipUnknown": "コンテキストウィンドウ不明", + "models.contextTooltipSource": "{value} · {source}", + "models.contextLastChecked": "最終確認 {when}", + "models.contextYesterday": "昨日", + "sub.nativeParentOverride": "ネイティブ V2 親をルーティング", + "sub.nativeParentOverrideHint": "Codex には元のネイティブモデルが表示される場合がありますが、OpenCodex は選択したルーティング先でルートを実行します。", + "sub.nativeParentOverridePrivacyWarning": "プロンプト、リポジトリコンテキスト、会話履歴、ツール結果は選択したプロバイダーに送られ、その可用性、コンテキスト、遅延、動作、コスト、プライバシー特性を引き継ぎます。", + "sub.nativeParentOverrideModel": "ネイティブ親の対象モデル", + "sub.nativeParentOverrideV2Required": "明示的な V2、上流の V2 フラグ、「ChatGPT を v1 に保持」の無効化が必要です。", + "sub.nativeParentOverrideSaveFail": "ネイティブ親ルーティングの保存に失敗しました", + "sub.nativeParentOverrideSaved": "ネイティブ親ルーティングを保存しました。以降の親ターンとコンパクションに適用されます。", + "sub.agentTaskRecovery": "暗号化された V2 タスクの復元", + "sub.agentTaskRecoveryHint": "ChatGPT 経由で OpenAI Fernet 暗号化タスクメッセージを復号し、OpenAI 以外のサブエージェントでも V2 タスクを読み取れるようにします。", + "sub.agentTaskRecoveryModel": "復元モデル (デフォルト: gpt-5.6-luna)", + "sub.agentTaskRecoveryDefault": "デフォルトモデル (gpt-5.6-luna)", + "sub.agentTaskRecoverySaveFail": "暗号化タスク復元の保存に失敗しました", + "sub.agentTaskRecoverySaved": "暗号化タスク復元の設定を保存しました。", + "sub.injectionPrompt": "カスタム親ガイダンス", + "sub.injectionPromptHint": "組み込みの v2 spawn 案内を置き換えます。空なら組み込み文です。ライブ値が必要ならプレースホルダを入れてください:", + "sub.injectionPromptSave": "親ガイダンスを保存", + "sub.injectionPromptSaved": "親ガイダンスを保存しました。新しい Codex セッションから適用されます。", + "sub.injectionPromptSaveFailed": "親ガイダンスの保存に失敗しました", + "sub.childInstructions": "グローバルな子の指示", + "sub.childInstructionsHint": "spawn されたすべての子に適用されます。ロール指示は同期後、そのスペシャリストにだけ適用されます。", + "sub.childInstructionsSave": "子の指示を保存", + "sub.childInstructionsSaved": "子の指示を保存しました。新しい Codex セッションから適用されます。", + "sub.childInstructionsSaveFailed": "子の指示の保存に失敗しました", + "logs.detail.attempt.recovery.cursorEnvelopeEcho": "Cursor リプレイエンベロープリカバリ", + "logs.detail.attempt.recovery.cursorRoutingCommentary": "Cursor ルーティング解説リカバリ", + "logs.detail.attempt.recovery.cursorDuplicateToolCall": "Cursor 重複ツール呼び出しリカバリ", + "logs.detail.attempt.recovery.cursorOverflowRemint": "Cursor オーバーフロー復旧", + "logs.detail.attempt.recovery.cursorInvalidArgument": "Cursor 無効引数復旧", + "usage.heatmap.keyboardLabel": "上下キーで日単位、左右キーで週単位に移動します。", + "modal.catalogTabs": "プロバイダーのカテゴリ", + "modal.searchLabel": "プロバイダーを検索", + "modal.catalogLoadFailed": "プロバイダーカタログを読み込めませんでした。", + "pws.allAccounts": "すべてのアカウント", + "pws.replayTransientFailures": "一時的な失敗を再試行", + "pws.replayTransientFailuresDesc": "ストリーム開始前の一時的な失敗を少なくとも一度再試行します。リクエストが複数回配信される可能性があります。", + "pws.clearCooldown": "クールダウンを解除", + "pws.pacingJitter": "ランダムジッター (ms)", + "pws.antigravityTlsTitle": "Antigravity ブラウザー TLS プロファイル", + "pws.antigravityTlsEnable": "実験的な TLS/HTTP2 エミュレーションを有効化", + "pws.antigravityTlsWarning": "非公式の互換機能です。利用規約への準拠や停止防止を保証せず、通信をより特徴的にする可能性があり、初期化に失敗すると Bun にフォールバックします。公式の Gemini API キー、Vertex、または文書化された Code Assist ルートの方が安全です。", + "pws.antigravityTlsConfirm": "実験的で非公式な Antigravity TLS プロファイルを有効にしますか?ポリシー準拠や停止防止は保証されません。続行しますか?", + "pws.antigravityTlsStatus": "TLS プロファイルの状態: {status}", + "pws.antigravityTlsDisabled": "無効", + "pws.antigravityTlsActive": "有効", + "pws.antigravityTlsFallback": "フォールバック", + "pws.aiStudio": "AI Studio", + "pws.aiStudio.connected": "接続済み", + "pws.aiStudio.checking": "セッションを確認中", + "pws.aiStudio.unsupported": "このプラットフォームではネイティブログインに対応していません", + "pws.aiStudio.needsReauth": "再認証が必要です", + "pws.aiStudio.reauthenticated": "再認証に成功しました", + "pws.aiStudio.loginFailed": "ネイティブログインに失敗しました", + "pws.aiStudio.connect": "接続", + "claudeDesktop.catalogChanged": "編集中にモデルカタログが変更されました。利用できないモデルを元に戻したので、プロファイルを確認して再度保存してください。", + "claudeDesktop.mappingDetails": "詳細なモデルマッピング", + "prov.cooldownCleared": "クールダウンが解除されました", + "prov.cooldownClearFailed": "クールダウンを解除できませんでした", + "models.customEditNamed": "{name}を編集", + "models.customDeleteNamed": "{name}を削除", + "sub.routedDelegationBridge": "ルーティングされた V2 委任ブリッジ", + "sub.routedDelegationBridgeHint": "実験的機能です。有効中は、生成・送信・フォローアップのメッセージが、ネイティブ間の委任を含め平文になります。Codex には元のモデルが表示される場合がありますが、ルーティングされた内容とプライバシーは選択したプロバイダーに従います。", + "sub.routedDelegationBridgeInactive": "有効化済みです。対象となるネイティブ V2 ルートと生成された子ターンで動作します。", + "sub.routedDelegationBridgeSaveFail": "ルーティングされた V2 委任ブリッジを保存できませんでした", + "sub.routedDelegationBridgeSaved": "ルーティングされた V2 委任ブリッジを保存しました。無効化は直ちに適用されます。", + "sub.prefer": "優先にする", + "sub.preferred": "優先", + "sub.preferredOutsideAdvertised": "優先モデルは5つの広告オーバーライドに含まれていません。完全なIDで実行できますが、自動的には挿入されません。", + "sub.catalogState.label": "Codex カタログ", + "sub.catalogState.fresh": "最新", + "sub.catalogState.stale": "古い状態 — モデルピッカーを更新するには Codex を再起動してください。", + "sub.catalogState.not_running": "実行中の Codex app-server はありません", + "sub.catalogState.unknown": "不明", + "sub.nativeDefaultState.active": "有効:モデル省略のサブエージェントは同期後にこの優先設定を使用します。", + "sub.nativeDefaultState.disabled": "モデル省略の同期は無効です。この優先設定はネイティブ既定値ではありません。", + "sub.nativeDefaultState.pending": "保留中:モデル省略の動作を有効にするには同期または再起動が必要です。", + "sub.nativeDefaultState.blocked": "ブロック:ユーザー所有または互換性のない Codex 設定がネイティブ同期を妨げています。", + "logs.filter.agent.label": "エージェント", + "logs.filter.agent.all": "すべて", + "logs.filter.agent.main": "メイン", + "logs.filter.agent.subagent": "サブエージェント", + "logs.filter.agent.internal": "内部", + "logs.filter.agent.unknown": "不明", + "logs.agent.main": "メイン", + "logs.agent.subagent": "サブエージェント", + "logs.agent.internal": "内部", + "logs.agent.unknown": "不明", + "logs.agent.badgeTitle": "リクエストの発信元", + "models.pickerOrder.label": "モデル選択順", + "models.pickerOrder.default": "デフォルト", + "models.pickerOrder.alphabetical": "モデル名のA–Z順", + "models.pickerOrder.provider": "プロバイダー別", + "models.pickerOrder.mostUsed": "使用量のスナップショット", + "models.pickerOrder.custom": "カスタム順", + "models.pickerOrder.apply": "順序を適用", + "models.pickerOrder.applying": "適用中…", + "models.pickerOrder.saved": "選択順を保存しました。古い一覧が表示される場合はクライアントを開き直してください。", + "models.pickerOrder.pending": "順序を保存しました。カタログの更新は保留中です。", + "models.pickerOrder.usageFailed": "モデル使用量を読み込めませんでした。", + "models.pickerOrder.loadFailed": "モデル選択設定を読み込めませんでした。", + "models.pickerOrder.retry": "再試行", + "models.pickerOrder.hint": "CodexとClaudeの検出一覧のルーティングモデル順を保存します。優先・ネイティブの順位帯は維持されます。使用量順はスナップショットで、ネイティブツールの候補表示は変わる場合があります。", + "integrations.tab.raycast": "Raycast", + "integrations.semantics.raycast": "Raycast の providers.yaml に OpenCodex のプロバイダーエントリを追加し、ルーティングされたすべてのモデルを Raycast AI のモデル選択に表示します。Raycast Pro が必要です。", + "integrations.raycast.proRequired": "Custom Providers は Raycast Pro の機能です。ファイルは書き込まれますが、Pro サブスクリプションが有効になるまで Raycast はこれを無視します。", + "integrations.raycast.planUnknown": "Raycast Pro が有効かどうか確認できませんでした。Custom Providers には Raycast Pro が必要です。", + "integrations.raycast.revealConfig": "Raycast → 設定 → AI を開き、「Reveal Providers Config」を一度クリックして providers フォルダを作成してください。", + "api.clientConfig.clientRaycast": "Raycast", "models.displayNameSavedRefreshFailed": "変更は保存されましたが、モデル一覧を更新できませんでした。再試行してください。", "models.displayNameOutcomeUnknown": "リクエストが完了しませんでした。変更が保存されている可能性があります。再度変更する前に再試行して現在の名前を確認してください。", "models.displayNameCurrentUnavailable": "更新するまで現在の名前を確認できません", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index c1959482b7..b0c371df01 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,20 +4,6 @@ import type { TKey } from "./en"; * Korean i18n catalog; must match the `TKey` set (compile-checked). */ export const ko: Record = { - "models.pickerOrder.label": "모델 선택 순서", - "models.pickerOrder.default": "기본값", - "models.pickerOrder.alphabetical": "모델 이름순", - "models.pickerOrder.provider": "프로바이더별", - "models.pickerOrder.mostUsed": "사용량순 스냅샷", - "models.pickerOrder.custom": "사용자 지정 순서", - "models.pickerOrder.apply": "순서 적용", - "models.pickerOrder.applying": "적용 중…", - "models.pickerOrder.saved": "모델 선택 순서를 저장했습니다. 이전 목록이 보이면 클라이언트를 다시 열어 주세요.", - "models.pickerOrder.pending": "순서를 저장했습니다. 카탈로그 갱신은 아직 완료되지 않았습니다.", - "models.pickerOrder.usageFailed": "모델 사용량을 불러오지 못했습니다.", - "models.pickerOrder.loadFailed": "모델 선택 설정을 불러오지 못했습니다.", - "models.pickerOrder.retry": "다시 시도", - "models.pickerOrder.hint": "Codex·Claude 검색 목록의 라우팅 모델 순서를 저장합니다. 지정 모델·네이티브 모델의 우선순위 구간은 유지됩니다. 사용량순은 스냅샷이며, 네이티브 도구에 표시되는 후보는 달라질 수 있습니다.", "codexAuth.quotaAutoRefreshAllHint": "현재 등록된 모든 계정의 5시간·주간 할당량을 한 번에 켜거나 끕니다. 지원하는 창에만 적용하며, 풀 모드에서 리셋 후 소량의 할당량을 쓰는 요청을 보냅니다.", "codexAuth.quotaAutoRefreshMixed": "일부만 켜져 있습니다.", "codexAuth.quotaAutoRefreshEmpty": "지원하는 할당량 창이 없습니다. 계정 할당량을 새로고침해 주세요.", @@ -585,7 +571,7 @@ export const ko: Record = { "models.setAllHint": "라우팅된 모든 프로바이더에 {value} 기본 창을 켭니다. 중계가 context_window / context_length 를 주지 않으면 이 값이 실제 Codex 창이 됩니다. 모델 하나만 손으로 쓰려면 같은 줄의 「사용자 지정 창」을 쓰세요. 네이티브 프로바이더는 영향을 받지 않습니다.", "models.collapseAll": "모두 접기", "models.expandAll": "모두 펼치기", - "models.orderHint": "기본 순서는 지정 모델과 카탈로그 우선순위를 따르며, 우선순위가 같은 라우팅 모델은 프로바이더·모델 순으로 정렬됩니다. 저장된 피커 순서는 표시 순서를 바꿀 수 있고, 노출 토글은 표시할 행을 필터링합니다. 클라이언트를 다시 열기 전까지 이전 카탈로그가 보일 수 있습니다.", + "models.orderHint": "피커 순서: Subagents에서 지정한 순서 → 나머지 라우팅 모델(프로바이더, 모델 ID 순 알파벳 정렬) → 네이티브 모델. 노출 토글은 모델을 필터링할 뿐 이 순서를 바꾸지 않습니다.", "models.custom": "직접 입력…", "models.customApply": "적용", "models.customPlaceholder": "토큰 (예: 420000)", @@ -1117,7 +1103,6 @@ export const ko: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", - "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside 프로필", "integrations.aside.profilesHint": "선택한 모델을 동기화할 프로필을 고르세요. Aside에서 사용 중인 프로필은 바뀌지 않습니다.", "integrations.aside.all": "모든 프로필 동기화", @@ -1277,10 +1262,6 @@ export const ko: Record = { "integrations.semantics.zcode": "~/.zcode/v2/config.json의 provider.opencodex만 관리하며 Z.ai 로그인과 다른 프로바이더는 변경하지 않습니다. 변경 후 ZCode를 재시작하세요.", "integrations.semantics.prime": "Prime Agent의 models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.prime/agent이며 PRIME_AGENT_CODING_AGENT_DIR가 설정되면 그쪽이 우선합니다. 다른 프로바이더와 모델 오버라이드는 변경하지 않습니다. 새 세션부터 적용됩니다.", "integrations.semantics.aside": "이 프로필의 ~/.aside/u//models.json에서 providers.opencodex만 관리합니다. 다른 프로바이더는 그대로 유지됩니다. 적용 후 Aside를 완전히 종료하고 다시 여세요.", - "integrations.semantics.raycast": "Raycast의 providers.yaml에 OpenCodex 프로바이더 항목을 추가해 라우팅된 모든 모델이 Raycast AI 모델 선택기에 표시되도록 합니다. Raycast Pro가 필요합니다.", - "integrations.raycast.proRequired": "Custom Providers는 Raycast Pro 기능입니다. 파일은 기록되지만 Pro 구독이 활성화될 때까지 Raycast는 이를 무시합니다.", - "integrations.raycast.planUnknown": "Raycast Pro 활성 여부를 확인할 수 없습니다. Custom Providers에는 Raycast Pro가 필요합니다.", - "integrations.raycast.revealConfig": "Raycast → 설정 → AI를 열고 「Reveal Providers Config」를 한 번 클릭해 providers 폴더를 만드세요.", "codexAuth.mainAccount": "메인 계정", "codexAuth.logLabel": "로그 라벨", "codexAuth.codexApp": "Codex App", @@ -1612,7 +1593,6 @@ export const ko: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", - "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "설정 복사", "api.clientConfig.download": "다운로드", "api.clientConfig.loading": "클라이언트 설정 생성 중…", @@ -1740,6 +1720,7 @@ export const ko: Record = { "common.close": "닫기", "common.ok": "확인", "app.logoAria": "opencodex 로고", + "app.pageTitle": "{page} — opencodex", "app.claudeOn": "Claude ON", "app.claudeOff": "Claude OFF", "usage.dayMon": "월", @@ -2576,6 +2557,231 @@ export const ko: Record = { "integrations.cursor.colReasoning": "추론", "integrations.cursor.colContext": "컨텍스트", "integrations.cursor.guide": "Cursor Private Inference 가이드 열기", + + // Fork-preserved dashboard surfaces. + "dash.activity.title": "30일 활동", + "dash.activity.requests": "요청", + "dash.activity.tokens": "토큰", + "dash.activity.chartLabel": "30일 활동입니다. 왼쪽과 오른쪽 화살표로 날짜를 선택하고 Home과 End로 범위 끝으로 이동합니다.", + "dash.activity.viewUsage": "사용량 보기", + "sub.roles": "역할", + "sub.roles.hint": "부모가 spawn할 수 있는 이름 있는 전문가입니다. 각 역할은 모델과 자식 프롬프트를 고정합니다. 위임 여부는 부모가 결정합니다.", + "sub.roles.add": "역할 추가", + "sub.roles.empty": "아직 역할이 없습니다. 리뷰, 검색 등 위임할 작업용 전문가를 추가하세요.", + "sub.roles.full": "역할 카탈로그가 가득 찼습니다(최대 8)", + "sub.roles.id": "역할 이름", + "sub.roles.description": "사용할 때", + "sub.roles.descriptionHint": "부모가 이 전문가를 언제 spawn할지 알 수 있도록 표시됩니다.", + "sub.roles.model": "모델", + "sub.roles.effort": "에포트", + "sub.roles.effortNone": "모델 기본값", + "sub.roles.instructions": "자식 지시", + "sub.roles.instructionsHint": "자식 에이전트가 불러오는 개발자 프롬프트입니다.", + "sub.roles.enabled": "사용", + "sub.roles.delete": "역할 {id} 삭제", + "sub.roles.save": "역할 저장", + "sub.roles.saved": "역할이 저장되었습니다. 새 Codex 세션부터 적용됩니다.", + "sub.roles.saveFailed": "역할을 저장하지 못했습니다", + "sub.roles.loadFail": "역할을 불러오지 못했습니다 — 프록시가 실행 중인가요?", + "sub.roles.routedV2Warning": "이 역할은 ChatGPT를 v1에 유지하지 않은 v2 표면의 라우팅된 모델을 사용합니다. 네이티브 v2 부모는 자식 작업을 암호화합니다.", + "sub.roles.sync": "Codex 에이전트 파일로 동기화", + "sub.roles.syncHint": "사용 중인 역할을 마커 소유 ocx-*.toml에 씁니다. 끄면 OpenCodex 소유 파일만 정리하고 사용자 파일은 건드리지 않습니다.", + "cursorPool.title": "Cursor 계정 풀(실험적)", + "cursorPool.enabledDesc": "429 또는 인증 실패 시 계정을 쿨다운하고 이 대화에서 장애 조치합니다. 각 대화는 레이트 제한 또는 인증 실패가 발생할 때까지 한 계정에 유지됩니다.", + "cursorPool.disabledDesc": "활성 Cursor 계정만 사용합니다. 실험적 라우팅을 감수할 때만 켜세요.", + "cursorPool.experimentalWarning": "실험적이며 충분히 검증되지 않았습니다. Cursor 이용 규정은 레이트 제한 우회와 사용량 측정 조작을 금지합니다. Teams 포함 사용량은 좌석 단위이며 이전되지 않을 수 있습니다. 위험을 이해하지 못하면 꺼 두세요.", + "cursorPool.needTwoAccounts": "풀을 켜기 전에 Cursor OAuth 계정을 두 개 이상 추가하세요.", + "cursorPool.loadFailed": "Cursor 풀 설정을 불러오지 못했습니다.", + "cursorPool.saveFailed": "Cursor 풀 설정을 저장하지 못했습니다.", + "cursorPool.on": "켜짐", + "cursorPool.off": "꺼짐", + "antigravityRouting.failoverOnlyTitle": "다중 계정 라우팅(장애 조치만)", + "antigravityRouting.failoverOnlyDesc": "각 대화는 하나의 Antigravity 계정에 유지됩니다. 레이트 제한 429 또는 인증 실패 시 opencodex는 해당 대화의 다른 적격 계정으로 장애 조치할 수 있습니다 — 새 세션을 계정 간에 분산하거나 다른 진행 중 대화의 전역 활성 계정을 변경하지 않습니다.", + "usage.chart.dayDetail": "{date}: 요청 {requests}개, 토큰 {tokens}개", + "replitGateway.title": "Replit 게이트웨이 동반", + "replitGateway.subtitle": "직접 배포한 Replit 게이트웨이에 연결하고 OpenAI Chat 및 Anthropic 페어 프로바이더를 함께 설치합니다.", + "replitGateway.companionNote": "이것은 사용자 지정 동반 워크플로이며, 정식 레지스트리 프리셋이 아닙니다.", + "replitGateway.originLabel": "배포 origin", + "replitGateway.originHint": "Replit 배포의 HTTPS origin(예: https://my-app.replit.app).", + "replitGateway.keyLabel": "게이트웨이 키", + "replitGateway.keyHint": "배포의 REPLIT_GATEWAY_KEY 시크릿입니다. 로컬 프로바이더 구성에 저장되며 게이트웨이 트래픽 인증에 사용됩니다.", + "replitGateway.keyTooShort": "게이트웨이 키가 너무 짧습니다(최소 32자).", + "replitGateway.keyTooLong": "게이트웨이 키는 512자 이하여야 합니다.", + "replitGateway.keyInvalidChars": "게이트웨이 키는 인쇄 가능한 ASCII 문자만 포함해야 합니다.", + "replitGateway.errorNetwork": "프록시에 연결할 수 없습니다. 실행 중인지 확인한 뒤 다시 시도하세요.", + "replitGateway.errorMalformed": "프록시가 잘못된 응답을 반환했습니다. 잠시 후 다시 시도하세요.", + "replitGateway.customDomainLabel": "사용자 지정 도메인 허용", + "replitGateway.customDomainHint": "게이트웨이가 .replit.app에 호스트되지 않은 경우에만 활성화하세요.", + "replitGateway.setDefaultLabel": "설치 후 replit을 기본 프로바이더로 설정", + "replitGateway.install": "페어 설치", + "replitGateway.installing": "설치 중…", + "replitGateway.close": "닫기", + "replitGateway.openButton": "Replit 게이트웨이…", + "replitGateway.replaceTitle": "기존 Replit 페어를 교체할까요?", + "replitGateway.replaceBody": "replit 및 replit-anthropic 프로바이더가 이미 있습니다. 교체하면 게이트웨이 origin과 키가 덮어쓰여지지만 오버레이는 유지됩니다.", + "replitGateway.replaceConfirm": "페어 교체", + "replitGateway.replaceCancel": "기존 유지", + "replitGateway.probeHealth": "healthz: HTTP {status} ({ms} ms)", + "replitGateway.probeModels": "models: {count}개 발견 ({ms} ms)", + "replitGateway.successTitle": "Replit 게이트웨이 페어를 설치했습니다.", + "replitGateway.errorGeneric": "Replit 게이트웨이 페어를 설치할 수 없습니다.", + "replitGateway.errorCollision": "Replit 프로바이더 페어가 이미 있습니다.", + "replitGateway.errorBusy": "구성이 사용 중입니다. 잠시 후 다시 시도하세요.", + "replitGateway.originRequired": "배포 origin은 필수입니다.", + "replitGateway.keyRequired": "게이트웨이 키는 필수입니다.", + // Fork-preserved dashboard behavior catalog. + "common.copy": "복사", + "common.copied": "복사됨", + "auth.adminTokenDesc": "계속하려면 OPENCODEX_ADMIN_AUTH_TOKEN을 입력하세요. 브라우저에서 다음 방문을 위해 저장할 수 있습니다.", + "auth.adminTokenPlaceholder": "관리자 토큰을 붙여넣으세요", + "auth.adminTokenShow": "표시", + "auth.adminTokenHide": "숨기기", + "dash.connectionReconnecting": "연결이 중단되었습니다 — 다시 연결하는 중…", + "dash.serverModeLabel": "원격 접속 모드", + "dash.serverModeLoopback": "루프백 + SSH", + "dash.serverModeRemote": "원격 TLS", + "dash.serverModeLoopbackHint": "LAN에 권장. 127.0.0.1을 유지하고 SSH 포워딩으로 연결하므로 인증서 신뢰 설정이 필요 없습니다.", + "dash.serverModeRemoteHint": "직접 TLS 바인드(0.0.0.0). 신뢰할 수 있는 인증서가 필요합니다.", + "dash.serverLoopbackCardTitle": "루프백 — SSH 포워딩", + "dash.serverLoopbackCardDesc": "대시보드를 열 컴퓨터에서 이 임시 터널을 실행하고 터미널을 열어 두세요.", + "dash.serverLoopbackCardNote": "브라우저에서 포워딩된 HTTP URL을 여세요. SSH가 연결을 암호화합니다. ssh -g는 절대 사용하지 마세요.", + "dash.serverPersistentTunnelDocs": "영구 macOS 터널 설정", + "dash.serverRemoteCardTitle": "원격 TLS 바인드", + "dash.serverRemoteCardDesc": "프록시가 TLS로 모든 인터페이스에서 수신합니다. Codex는 https://:에 직접 연결합니다. 인증서는 Codex 머신(rustls)에서 신뢰되어야 하며, 자체 서명 인증서는 설치될 때까지 UnknownIssuer가 표시됩니다.", + "dash.serverRemoteSanHint": "인증서 SAN에는 publicOrigin의 호스트 이름 또는 IP가 포함되어야 합니다. 신뢰할 수 있는 CA를 사용하거나 Codex 머신에 프라이빗 CA를 설치하세요.", + "dash.serverAdvanced": "고급(호스트 이름)", + "dash.serverCopyUnavailable": "복사할 수 없음", + "dash.serverRemoteCredentialRequired": "원격 TLS를 활성화하기 전에 데이터 플레인 API 키를 추가하세요.", + "dash.serverRestartNotice": "리스너 설정이 저장되었습니다. 적용하려면 프록시를 다시 시작하세요.", + "dash.serverSettingsTitle": "서버 리스너 설정", + "dash.serverSaveFailed": "서버 리스너 설정을 저장하지 못했습니다.", + "dash.serverTlsOff": "꺼짐", + "dash.serverCredentialConfigured": "설정됨", + "dash.serverCredentialMissing": "없음", + "dash.serverRestartRequired": "예", + "prov.cooldownCleared": "쿨다운이 해제되었습니다", + "prov.cooldownClearFailed": "쿨다운을 해제하지 못했습니다", + "models.contextMetadataSource": "출처", + "models.contextMetadataLive": "감지됨", + "models.contextMetadataRegistry": "레지스트리", + "models.contextMetadataCursorStatic": "Cursor 정적 큐레이션", + "models.contextMetadataSnapshot": "저장됨", + "models.contextMetadataFallback": "폴백", + "models.contextMetadataDerived": "파생", + "models.contextMetadataUnknown": "알 수 없음", + "models.contextMetadataStale": "오래됨", + "models.contextTooltipDetected": "{value} · 감지됨", + "models.contextTooltipCapped": "{detected} 감지됨 · {cap}으로 제한", + "models.contextTooltipStale": "{value} · {when}", + "models.contextTooltipUnknown": "컨텍스트 윈도우를 알 수 없음", + "models.contextTooltipSource": "{value} · {source}", + "models.contextLastChecked": "마지막 확인 {when}", + "models.contextYesterday": "어제", + "models.customEditNamed": "{name} 편집", + "models.customDeleteNamed": "{name} 삭제", + "sub.nativeParentOverride": "네이티브 V2 부모 라우팅", + "sub.nativeParentOverrideHint": "Codex에는 원래 네이티브 모델이 표시될 수 있지만 OpenCodex는 선택한 라우팅 대상으로 루트를 실행합니다.", + "sub.nativeParentOverridePrivacyWarning": "프롬프트, 저장소 컨텍스트, 대화 기록, 도구 결과가 선택한 제공업체로 전송되며 해당 제공업체의 가용성, 컨텍스트, 지연 시간, 동작, 비용 및 개인정보 보호 특성을 따릅니다.", + "sub.nativeParentOverrideModel": "네이티브 부모 대상 모델", + "sub.nativeParentOverrideV2Required": "명시적 V2, 업스트림 V2 플래그, 그리고 ‘ChatGPT를 v1에 유지’ 끄기가 필요합니다.", + "sub.nativeParentOverrideSaveFail": "네이티브 부모 라우팅을 저장하지 못했습니다", + "sub.nativeParentOverrideSaved": "네이티브 부모 라우팅이 저장되었습니다. 이후 부모 턴과 컴팩션에 적용됩니다.", + "sub.agentTaskRecovery": "암호화된 V2 작업 복구", + "sub.agentTaskRecoveryHint": "ChatGPT를 통해 OpenAI Fernet 암호화된 작업 메시지를 복호화하여 OpenAI 외 하위 에이전트도 라우팅된 V2 작업을 읽고 실행할 수 있도록 합니다.", + "sub.agentTaskRecoveryModel": "복구 모델 (기본값: gpt-5.6-luna)", + "sub.agentTaskRecoveryDefault": "기본 모델 (gpt-5.6-luna)", + "sub.agentTaskRecoverySaveFail": "암호화된 작업 복구 설정 저장 실패", + "sub.agentTaskRecoverySaved": "암호화된 작업 복구 설정이 저장되었습니다.", + "sub.injectionPrompt": "사용자 지정 부모 안내", + "sub.injectionPromptHint": "내장 v2 spawn 안내를 바꿉니다. 비우면 기본 문장입니다. 실시간 값이 필요하면 플레이스홀더를 넣으세요:", + "sub.injectionPromptSave": "부모 안내 저장", + "sub.injectionPromptSaved": "부모 안내가 저장되었습니다. 새 Codex 세션부터 적용됩니다.", + "sub.injectionPromptSaveFailed": "부모 안내를 저장하지 못했습니다", + "sub.childInstructions": "전역 자식 지시", + "sub.childInstructionsHint": "spawn된 모든 자식에 적용됩니다. 역할 지시는 동기화 후 해당 전문가에만 적용됩니다.", + "sub.childInstructionsSave": "자식 지시 저장", + "sub.childInstructionsSaved": "자식 지시가 저장되었습니다. 새 Codex 세션부터 적용됩니다.", + "sub.childInstructionsSaveFailed": "자식 지시를 저장하지 못했습니다", + "logs.detail.attempt.recovery.cursorEnvelopeEcho": "Cursor 리플레이 엔벨로프 복구", + "logs.detail.attempt.recovery.cursorRoutingCommentary": "Cursor 라우팅 해설 복구", + "logs.detail.attempt.recovery.cursorDuplicateToolCall": "Cursor 중복 도구 호출 복구", + "logs.detail.attempt.recovery.cursorOverflowRemint": "Cursor 오버플로 복구", + "logs.detail.attempt.recovery.cursorInvalidArgument": "Cursor 잘못된 인수 복구", + "modal.catalogTabs": "프로바이더 카테고리", + "modal.searchLabel": "프로바이더 검색", + "usage.heatmap.keyboardLabel": "위아래 화살표로 하루씩, 좌우 화살표로 일주일씩 이동합니다.", + "modal.catalogLoadFailed": "프로바이더 카탈로그를 불러올 수 없습니다.", + "pws.allAccounts": "모든 계정", + "pws.replayTransientFailures": "일시적 실패 재시도", + "pws.replayTransientFailuresDesc": "스트리밍 전에 발생한 일시적 실패를 한 번 이상 재시도합니다. 요청이 여러 번 전달될 수 있습니다.", + "pws.clearCooldown": "쿨다운 해제", + "pws.pacingJitter": "무작위 지터(ms)", + "pws.antigravityTlsTitle": "Antigravity 브라우저 TLS 프로필", + "pws.antigravityTlsEnable": "실험적 TLS/HTTP2 에뮬레이션 사용", + "pws.antigravityTlsWarning": "비공식 호환 기능입니다. 서비스 약관 준수나 정지를 보장하지 않으며 트래픽을 더 식별하기 쉽게 만들 수 있고 초기화 실패 시 Bun으로 대체됩니다. 공식 Gemini API 키, Vertex 또는 문서화된 Code Assist 경로가 더 안전합니다.", + "pws.antigravityTlsConfirm": "실험적이고 비공식적인 Antigravity TLS 프로필을 사용하시겠습니까? 정책 준수나 정지 방지는 보장되지 않습니다. 계속할까요?", + "pws.antigravityTlsStatus": "TLS 프로필 상태: {status}", + "pws.antigravityTlsDisabled": "사용 안 함", + "pws.antigravityTlsActive": "활성", + "pws.antigravityTlsFallback": "대체 사용", + "pws.aiStudio": "AI Studio", + "pws.aiStudio.connected": "연결됨", + "pws.aiStudio.checking": "세션 확인 중", + "pws.aiStudio.unsupported": "이 플랫폼에서는 네이티브 로그인을 지원하지 않습니다", + "pws.aiStudio.needsReauth": "재인증이 필요합니다", + "pws.aiStudio.reauthenticated": "재인증에 성공했습니다", + "pws.aiStudio.loginFailed": "네이티브 로그인에 실패했습니다", + "pws.aiStudio.connect": "연결", + "claudeDesktop.catalogChanged": "편집하는 동안 모델 카탈로그가 변경되었습니다. 사용할 수 없는 모델을 원래대로 되돌렸습니다. 프로필을 확인한 다음 다시 저장하세요.", + "claudeDesktop.mappingDetails": "고급 모델 매핑", + "sub.routedDelegationBridge": "라우팅된 V2 위임 브리지", + "sub.routedDelegationBridgeHint": "실험 기능입니다. 활성화 중에는 생성, 전송, 후속 메시지가 네이티브 간 위임을 포함해 평문입니다. Codex에는 원래 모델이 표시될 수 있으며, 라우팅된 내용과 개인정보 보호는 선택한 제공업체를 따릅니다.", + "sub.routedDelegationBridgeInactive": "설정됨: 적격 네이티브 V2 루트와 생성된 자식 턴에서 활성화됩니다.", + "sub.routedDelegationBridgeSaveFail": "라우팅된 V2 위임 브리지를 저장하지 못했습니다", + "sub.routedDelegationBridgeSaved": "라우팅된 V2 위임 브리지를 저장했습니다. 비활성화는 즉시 적용됩니다.", + "sub.prefer": "우선 모델로 지정", + "sub.preferred": "우선 모델", + "sub.preferredOutsideAdvertised": "선호 모델이 5개의 광고된 오버라이드에 없습니다. 전체 ID로 실행할 수 있으며 자동으로 삽입하지 않습니다.", + "sub.catalogState.label": "Codex 카탈로그", + "sub.catalogState.fresh": "최신", + "sub.catalogState.stale": "오래됨 — Codex를 다시 시작해 모델 피커를 갱신하세요.", + "sub.catalogState.not_running": "실행 중인 Codex app-server 없음", + "sub.catalogState.unknown": "알 수 없음", + "sub.nativeDefaultState.active": "활성: 동기화 후 모델을 생략한 서브에이전트가 이 우선 설정을 사용합니다.", + "sub.nativeDefaultState.disabled": "모델 생략 동기화가 꺼져 있어 이 우선 설정은 네이티브 기본값이 아닙니다.", + "sub.nativeDefaultState.pending": "대기 중: 모델 생략 동작을 활성화하려면 동기화 또는 재시작이 필요합니다.", + "sub.nativeDefaultState.blocked": "차단됨: 사용자 소유 또는 호환되지 않는 Codex 설정이 네이티브 동기화를 막습니다.", + "logs.filter.agent.label": "에이전트", + "logs.filter.agent.all": "모두", + "logs.filter.agent.main": "메인", + "logs.filter.agent.subagent": "서브에이전트", + "logs.filter.agent.internal": "내부", + "logs.filter.agent.unknown": "알 수 없음", + "logs.agent.main": "메인", + "logs.agent.subagent": "서브에이전트", + "logs.agent.internal": "내부", + "logs.agent.unknown": "알 수 없음", + "logs.agent.badgeTitle": "요청 출처", + "models.pickerOrder.label": "모델 선택 순서", + "models.pickerOrder.default": "기본값", + "models.pickerOrder.alphabetical": "모델 이름순", + "models.pickerOrder.provider": "프로바이더별", + "models.pickerOrder.mostUsed": "사용량순 스냅샷", + "models.pickerOrder.custom": "사용자 지정 순서", + "models.pickerOrder.apply": "순서 적용", + "models.pickerOrder.applying": "적용 중…", + "models.pickerOrder.saved": "모델 선택 순서를 저장했습니다. 이전 목록이 보이면 클라이언트를 다시 열어 주세요.", + "models.pickerOrder.pending": "순서를 저장했습니다. 카탈로그 갱신은 아직 완료되지 않았습니다.", + "models.pickerOrder.usageFailed": "모델 사용량을 불러오지 못했습니다.", + "models.pickerOrder.loadFailed": "모델 선택 설정을 불러오지 못했습니다.", + "models.pickerOrder.retry": "다시 시도", + "models.pickerOrder.hint": "Codex·Claude 검색 목록의 라우팅 모델 순서를 저장합니다. 지정 모델·네이티브 모델의 우선순위 구간은 유지됩니다. 사용량순은 스냅샷이며, 네이티브 도구에 표시되는 후보는 달라질 수 있습니다.", + "integrations.tab.raycast": "Raycast", + "integrations.semantics.raycast": "Raycast의 providers.yaml에 OpenCodex 프로바이더 항목을 추가해 라우팅된 모든 모델이 Raycast AI 모델 선택기에 표시되도록 합니다. Raycast Pro가 필요합니다.", + "integrations.raycast.proRequired": "Custom Providers는 Raycast Pro 기능입니다. 파일은 기록되지만 Pro 구독이 활성화될 때까지 Raycast는 이를 무시합니다.", + "integrations.raycast.planUnknown": "Raycast Pro 활성 여부를 확인할 수 없습니다. Custom Providers에는 Raycast Pro가 필요합니다.", + "integrations.raycast.revealConfig": "Raycast → 설정 → AI를 열고 「Reveal Providers Config」를 한 번 클릭해 providers 폴더를 만드세요.", + "api.clientConfig.clientRaycast": "Raycast", "models.displayNameSavedRefreshFailed": "변경 사항은 저장되었지만 모델 목록을 새로 고치지 못했습니다. 다시 시도해 주세요.", "models.displayNameOutcomeUnknown": "요청이 완료되지 않았습니다. 변경 사항이 저장되었을 수 있습니다. 다시 변경하기 전에 재시도하여 현재 이름을 확인하세요.", "models.displayNameCurrentUnavailable": "새로 고침 전까지 현재 이름을 확인할 수 없음", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 0109f5ebdc..34b30d8535 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,20 +4,6 @@ import type { TKey } from "./en"; * Russian i18n catalog; must match the `TKey` set (compile-checked). */ export const ru: Record = { - "models.pickerOrder.label": "Порядок моделей", - "models.pickerOrder.default": "По умолчанию", - "models.pickerOrder.alphabetical": "По имени A–Z", - "models.pickerOrder.provider": "По провайдеру", - "models.pickerOrder.mostUsed": "Снимок использования", - "models.pickerOrder.custom": "Свой порядок", - "models.pickerOrder.apply": "Применить порядок", - "models.pickerOrder.applying": "Применение…", - "models.pickerOrder.saved": "Порядок сохранён. Перезапустите клиенты, показывающие старый каталог.", - "models.pickerOrder.pending": "Порядок сохранён; обновление каталога ожидается.", - "models.pickerOrder.usageFailed": "Не удалось загрузить статистику моделей.", - "models.pickerOrder.loadFailed": "Не удалось загрузить настройки выбора.", - "models.pickerOrder.retry": "Повторить", - "models.pickerOrder.hint": "Сохраняет порядок маршрутизируемых моделей для Codex и обнаружения Claude. Диапазоны приоритетных и нативных моделей сохраняются. Использование — снимок; нативно объявляемые варианты могут измениться.", "codexAuth.quotaAutoRefreshAllHint": "Общее переключение поддерживаемых 5-часовых и недельных окон всех текущих аккаунтов. В режиме пула после сброса отправляется небольшой запрос, расходующий квоту.", "codexAuth.quotaAutoRefreshMixed": "Включены некоторые окна.", "codexAuth.quotaAutoRefreshEmpty": "Нет поддерживаемых окон квоты. Обновите квоты аккаунтов.", @@ -130,6 +116,7 @@ export const ru: Record = { "auth.adminTokenHelp": "Это административный токен управляющего API OpenCodex, а не ключ API провайдера. При первом запуске прокси записывает его в ~/.opencodex/admin-api-token (или $OPENCODEX_HOME/admin-api-token), а OPENCODEX_ADMIN_AUTH_TOKEN переопределяет это значение.", "auth.adminTokenDocsLink": "Как его найти", "app.logoAria": "Логотип opencodex", + "app.pageTitle": "{page} — opencodex", "app.claudeOn": "Claude ВКЛ", "app.claudeOff": "Claude ВЫКЛ", "theme.label": "Тема", @@ -587,7 +574,7 @@ export const ru: Record = { "models.setAllHint": "Включает окно по умолчанию {value} для всех маршрутизируемых провайдеров. Если релей не отдаёт context_window / context_length, это значение становится реальным окном Codex. Чтобы задать одну модель вручную, используйте «Пользовательские окна» в той же строке. Нативные провайдеры не затрагиваются.", "models.collapseAll": "Свернуть все", "models.expandAll": "Развернуть все", - "models.orderHint": "Порядок по умолчанию следует избранным моделям и приоритету каталога; равные по рангу маршрутизируемые строки сортируются по провайдеру и модели. Сохранённый порядок может менять отображение, а переключатели видимости фильтруют строки. До повторного открытия клиент может показывать старый каталог.", + "models.orderHint": "Порядок в селекторе: модели, выбранные на странице «Подагенты» (в заданном порядке) → остальные маршрутизируемые модели по алфавиту — сначала по провайдеру, затем по ID модели → нативные модели. Переключатели видимости лишь фильтруют модели и не меняют этот порядок.", "models.custom": "Другое…", "models.customApply": "Применить", "models.customPlaceholder": "Токены (напр. 420000)", @@ -1583,7 +1570,6 @@ export const ru: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", - "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Профили Aside", "integrations.aside.profilesHint": "Выберите профили, в которые будут добавлены выбранные модели. Активный профиль Aside не изменится.", "integrations.aside.all": "Синхронизировать все профили", @@ -1743,10 +1729,6 @@ export const ru: Record = { "integrations.semantics.zcode": "Управляет только provider.opencodex в ~/.zcode/v2/config.json. Вход Z.ai и другие провайдеры не меняются. Перезапустите ZCode после изменений.", "integrations.semantics.prime": "Управляет только providers.opencodex в models.json Prime Agent — ~/.prime/agent, если PRIME_AGENT_CODING_AGENT_DIR не переопределяет путь. Другие провайдеры и переопределения моделей не меняются. Применяется к новым сессиям.", "integrations.semantics.aside": "Управляет только providers.opencodex в файле ~/.aside/u//models.json этого профиля. Другие провайдеры остаются без изменений. После применения полностью закройте и снова откройте Aside.", - "integrations.semantics.raycast": "Добавляет запись провайдера OpenCodex в providers.yaml Raycast, чтобы каждая маршрутизируемая модель появилась в выборе моделей Raycast AI. Требуется Raycast Pro.", - "integrations.raycast.proRequired": "Custom Providers — функция Raycast Pro. Файл будет записан, но Raycast игнорирует его, пока не активна подписка Pro.", - "integrations.raycast.planUnknown": "Не удалось определить, активен ли Raycast Pro; для Custom Providers требуется Raycast Pro.", - "integrations.raycast.revealConfig": "Откройте Raycast → Настройки → AI и один раз нажмите «Reveal Providers Config», чтобы папка провайдеров появилась.", "codexAuth.mainAccount": "Основной аккаунт", "codexAuth.logLabel": "Метка журнала", "codexAuth.codexApp": "Codex App", @@ -2080,7 +2062,6 @@ export const ru: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", - "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Копировать конфигурацию", "api.clientConfig.download": "Скачать", "api.clientConfig.loading": "Формируется конфигурация клиента…", @@ -2577,6 +2558,231 @@ export const ru: Record = { "integrations.cursor.colReasoning": "Рассуждения", "integrations.cursor.colContext": "Контекст", "integrations.cursor.guide": "Открыть руководство по Cursor Private Inference", + + // Fork-preserved dashboard surfaces. + "dash.activity.title": "Активность за 30 дней", + "dash.activity.requests": "Запросы", + "dash.activity.tokens": "Токены", + "dash.activity.chartLabel": "Активность за 30 дней. Стрелки влево и вправо выбирают день; Home и End переходят к границам диапазона.", + "dash.activity.viewUsage": "Открыть статистику", + "sub.roles": "Роли", + "sub.roles.hint": "Именованные специалисты, которых родитель может порождать. Каждая роль фиксирует модель и промпт ребёнка. Решение о делегировании остаётся за родителем.", + "sub.roles.add": "Добавить роль", + "sub.roles.empty": "Ролей пока нет. Добавьте специалиста для ревью, поиска или другой делегированной работы.", + "sub.roles.full": "Каталог ролей заполнен (макс. 8)", + "sub.roles.id": "Имя роли", + "sub.roles.description": "Когда использовать", + "sub.roles.descriptionHint": "Показывается родителю, чтобы он знал, когда порождать этого специалиста.", + "sub.roles.model": "Модель", + "sub.roles.effort": "Усилие", + "sub.roles.effortNone": "По умолчанию модели", + "sub.roles.instructions": "Инструкции ребёнка", + "sub.roles.instructionsHint": "Developer-промпт, который загружает дочерний агент.", + "sub.roles.enabled": "Включена", + "sub.roles.delete": "Удалить роль {id}", + "sub.roles.save": "Сохранить роли", + "sub.roles.saved": "Роли сохранены. Применяется к новым сеансам Codex.", + "sub.roles.saveFailed": "Не удалось сохранить роли", + "sub.roles.loadFail": "Не удалось загрузить роли — прокси запущен?", + "sub.roles.routedV2Warning": "Эта роль использует маршрутизируемую модель на поверхности v2, не оставляя ChatGPT на v1. Нативные v2-родители шифруют задачи детей.", + "sub.roles.sync": "Синхронизировать в файлы агентов Codex", + "sub.roles.syncHint": "Пишет включённые роли в marker-owned ocx-*.toml. Выключение удаляет файлы OpenCodex и не трогает ваши.", + "usage.chart.dayDetail": "{date}: {requests} запросов, {tokens} токенов", + "replitGateway.title": "Компаньон шлюза Replit", + "replitGateway.subtitle": "Подключите развернутый вами шлюз Replit и установите пару провайдеров OpenAI Chat и Anthropic вместе.", + "replitGateway.companionNote": "Это пользовательский сценарий-компаньон, а не канонический пресет реестра.", + "replitGateway.originLabel": "Origin развертывания", + "replitGateway.originHint": "HTTPS origin вашего развертывания Replit (например, https://my-app.replit.app).", + "replitGateway.keyLabel": "Ключ шлюза", + "replitGateway.keyHint": "Секрет REPLIT_GATEWAY_KEY из вашего развертывания. Он сохраняется в локальной конфигурации провайдеров и используется для аутентификации трафика к шлюзу.", + "replitGateway.keyTooShort": "Ключ шлюза слишком короткий (минимум 32 символа).", + "replitGateway.keyTooLong": "Ключ шлюза должен содержать не более 512 символов.", + "replitGateway.keyInvalidChars": "Ключ шлюза должен содержать только печатаемые символы ASCII.", + "replitGateway.errorNetwork": "Не удалось связаться с прокси. Проверьте, что он запущен, и повторите попытку.", + "replitGateway.errorMalformed": "Прокси вернул недопустимый ответ. Повторите попытку через некоторое время.", + "replitGateway.customDomainLabel": "Разрешить пользовательский домен", + "replitGateway.customDomainHint": "Включайте только если ваш шлюз не размещен на .replit.app.", + "replitGateway.setDefaultLabel": "Сделать replit провайдером по умолчанию после установки", + "replitGateway.install": "Установить пару", + "replitGateway.installing": "Установка…", + "replitGateway.close": "Закрыть", + "replitGateway.openButton": "Шлюз Replit…", + "replitGateway.replaceTitle": "Заменить существующую пару Replit?", + "replitGateway.replaceBody": "Провайдеры replit и replit-anthropic уже существуют. Замена перезапишет origin и ключ шлюза, сохранив ваши наложения.", + "replitGateway.replaceConfirm": "Заменить пару", + "replitGateway.replaceCancel": "Оставить существующую", + "replitGateway.probeHealth": "healthz: HTTP {status} ({ms} ms)", + "replitGateway.probeModels": "models: обнаружено {count} ({ms} ms)", + "replitGateway.successTitle": "Пара шлюза Replit установлена.", + "replitGateway.errorGeneric": "Не удалось установить пару шлюза Replit.", + "replitGateway.errorCollision": "Пара провайдеров Replit уже существует.", + "replitGateway.errorBusy": "Конфигурация занята. Повторите попытку через некоторое время.", + "replitGateway.originRequired": "Origin развертывания обязателен.", + "replitGateway.keyRequired": "Ключ шлюза обязателен.", + "cursorPool.title": "Пул аккаунтов Cursor (экспериментально)", + "cursorPool.enabledDesc": "При 429 или ошибке аутентификации аккаунт охлаждается и выполняется переключение для этой беседы. Каждая беседа остаётся на одном аккаунте до rate-limit или ошибки учётных данных.", + "cursorPool.disabledDesc": "Используется только активный аккаунт Cursor. Включайте только если принимаете экспериментальную маршрутизацию.", + "cursorPool.experimentalWarning": "Экспериментально и недостаточно проверено. Политика Cursor запрещает обход лимитов запросов и манипуляции с учётом использования. Включённое использование Teams рассчитывается на место и может не переноситься. Оставляйте выключенным, если не понимаете риск.", + "cursorPool.needTwoAccounts": "Перед включением пула добавьте минимум два OAuth-аккаунта Cursor.", + "cursorPool.loadFailed": "Не удалось загрузить настройки пула Cursor.", + "cursorPool.saveFailed": "Не удалось сохранить настройки пула Cursor.", + "cursorPool.on": "Вкл", + "cursorPool.off": "Выкл", + "antigravityRouting.failoverOnlyTitle": "Маршрутизация нескольких аккаунтов (только failover)", + "antigravityRouting.failoverOnlyDesc": "Каждая беседа остаётся на одном аккаунте Antigravity. При rate-limit 429 или ошибке учётных данных opencodex может переключиться на другой подходящий аккаунт для этой беседы — без распределения новых сессий между аккаунтами и без изменения глобального активного аккаунта для других живых бесед.", + // Fork-preserved dashboard behavior catalog. + "common.copy": "Копировать", + "common.copied": "Скопировано", + "auth.adminTokenDesc": "Введите ваш OPENCODEX_ADMIN_AUTH_TOKEN, чтобы продолжить. Браузер может предложить сохранить его для следующего раза.", + "auth.adminTokenPlaceholder": "Вставьте токен администратора", + "auth.adminTokenShow": "Показать", + "auth.adminTokenHide": "Скрыть", + "dash.connectionReconnecting": "Соединение прервано — повторное подключение…", + "dash.serverModeLabel": "Режим удалённого доступа", + "dash.serverModeLoopback": "Loopback + SSH", + "dash.serverModeRemote": "Удалённый TLS", + "dash.serverModeLoopbackHint": "Рекомендуется для локальной сети. Оставьте 127.0.0.1 и используйте проброс через SSH — доверие к сертификату не требуется.", + "dash.serverModeRemoteHint": "Прямая привязка TLS (0.0.0.0). Требуется доверенный сертификат.", + "dash.serverLoopbackCardTitle": "Loopback — проброс через SSH", + "dash.serverLoopbackCardDesc": "На компьютере, где вы хотите открыть панель, запустите этот временный туннель и не закрывайте терминал.", + "dash.serverLoopbackCardNote": "Откройте перенаправленный HTTP-адрес в браузере. SSH шифрует соединение; никогда не используйте ssh -g.", + "dash.serverPersistentTunnelDocs": "Настроить постоянный туннель macOS", + "dash.serverRemoteCardTitle": "Удалённая TLS-привязка", + "dash.serverRemoteCardDesc": "Прокси слушает на всех интерфейсах с TLS. Codex подключается напрямую по https://:. Сертификат должен быть доверенным на машине Codex (rustls) — самоподписанный сертификат будет вызывать UnknownIssuer, пока не будет установлен.", + "dash.serverRemoteSanHint": "SAN сертификата должен включать имя хоста или IP из publicOrigin. Используйте доверенный CA или установите свой приватный CA на машину Codex.", + "dash.serverAdvanced": "Дополнительно (имя хоста)", + "dash.serverCopyUnavailable": "Копирование недоступно", + "dash.serverRemoteCredentialRequired": "Добавьте ключ API data-plane, прежде чем включать удалённый TLS.", + "dash.serverRestartNotice": "Изменения слушателя сохранены. Перезапустите прокси, чтобы применить их.", + "dash.serverSettingsTitle": "Настройки серверного listener", + "dash.serverSaveFailed": "Не удалось сохранить настройки серверного listener.", + "dash.serverTlsOff": "выкл.", + "dash.serverCredentialConfigured": "настроен", + "dash.serverCredentialMissing": "отсутствует", + "dash.serverRestartRequired": "да", + "prov.cooldownCleared": "Пауза сброшена", + "prov.cooldownClearFailed": "Не удалось сбросить паузу", + "models.contextMetadataSource": "Источник", + "models.contextMetadataLive": "обнаружено", + "models.contextMetadataRegistry": "реестр", + "models.contextMetadataCursorStatic": "подборка Cursor", + "models.contextMetadataSnapshot": "сохранено", + "models.contextMetadataFallback": "запасной", + "models.contextMetadataDerived": "производный", + "models.contextMetadataUnknown": "неизвестно", + "models.contextMetadataStale": "устарело", + "models.contextTooltipDetected": "{value} · обнаружено", + "models.contextTooltipCapped": "{detected} обнаружено · ограничено до {cap}", + "models.contextTooltipStale": "{value} · {when}", + "models.contextTooltipUnknown": "Контекстное окно неизвестно", + "models.contextTooltipSource": "{value} · {source}", + "models.contextLastChecked": "последняя проверка {when}", + "models.contextYesterday": "вчера", + "models.customEditNamed": "Изменить {name}", + "models.customDeleteNamed": "Удалить {name}", + "sub.nativeParentOverride": "Маршрутизировать нативных родителей V2", + "sub.nativeParentOverrideHint": "Codex может показывать исходную нативную модель, а OpenCodex выполняет корневой запрос на выбранной маршрутизируемой цели.", + "sub.nativeParentOverridePrivacyWarning": "Промпты, контекст репозитория, история разговора и результаты инструментов отправляются выбранному провайдеру и наследуют его доступность, контекст, задержку, поведение, стоимость и характеристики конфиденциальности.", + "sub.nativeParentOverrideModel": "Целевая модель нативного родителя", + "sub.nativeParentOverrideV2Required": "Требуются явный V2, флаг V2 провайдера и отключённая настройка «Оставлять ChatGPT на v1».", + "sub.nativeParentOverrideSaveFail": "Не удалось сохранить маршрутизацию нативного родителя", + "sub.nativeParentOverrideSaved": "Маршрутизация нативного родителя сохранена. Применяется к последующим ходам родителя и компакции.", + "sub.agentTaskRecovery": "Восстановление зашифрованных задач V2", + "sub.agentTaskRecoveryHint": "Расшифровывает сообщения задач OpenAI Fernet через ChatGPT, чтобы субагенты сторонних провайдеров могли читать и выполнять задачи V2.", + "sub.agentTaskRecoveryModel": "Модель восстановления (по умолчанию: gpt-5.6-luna)", + "sub.agentTaskRecoveryDefault": "Модель по умолчанию (gpt-5.6-luna)", + "sub.agentTaskRecoverySaveFail": "Не удалось сохранить восстановление зашифрованных задач", + "sub.agentTaskRecoverySaved": "Настройка восстановления зашифрованных задач сохранена.", + "sub.injectionPrompt": "Пользовательские подсказки родителю", + "sub.injectionPromptHint": "Заменяет встроенный v2-текст spawn. Оставьте пустым для стандартного текста. Добавьте плейсхолдеры для живых значений:", + "sub.injectionPromptSave": "Сохранить подсказки родителю", + "sub.injectionPromptSaved": "Подсказки родителю сохранены. Применяется к новым сеансам Codex.", + "sub.injectionPromptSaveFailed": "Не удалось сохранить подсказки родителю", + "sub.childInstructions": "Глобальные инструкции ребёнка", + "sub.childInstructionsHint": "Применяется к каждому порождённому ребёнку. Инструкции роли после синхронизации действуют только на этого специалиста.", + "sub.childInstructionsSave": "Сохранить инструкции ребёнка", + "sub.childInstructionsSaved": "Инструкции ребёнка сохранены. Применяется к новым сеансам Codex.", + "sub.childInstructionsSaveFailed": "Не удалось сохранить инструкции ребёнка", + "logs.detail.attempt.recovery.cursorEnvelopeEcho": "Восстановление после эхо-конверта Cursor", + "logs.detail.attempt.recovery.cursorRoutingCommentary": "Восстановление после комментария маршрутизации Cursor", + "logs.detail.attempt.recovery.cursorDuplicateToolCall": "Восстановление после дублированного вызова инструмента Cursor", + "logs.detail.attempt.recovery.cursorOverflowRemint": "Восстановление переполнения Cursor", + "logs.detail.attempt.recovery.cursorInvalidArgument": "Восстановление после недопустимого аргумента Cursor", + "usage.heatmap.keyboardLabel": "Стрелки вверх и вниз перемещают по дням, влево и вправо — по неделям.", + "modal.catalogTabs": "Категории провайдеров", + "modal.searchLabel": "Поиск провайдеров", + "modal.catalogLoadFailed": "Не удалось загрузить каталог провайдеров.", + "pws.allAccounts": "Все аккаунты", + "pws.replayTransientFailures": "Повторять временные сбои", + "pws.replayTransientFailuresDesc": "Повторять временные ошибки до начала потока хотя бы один раз; запрос может быть доставлен повторно.", + "pws.clearCooldown": "Сбросить паузу", + "pws.pacingJitter": "Случайная задержка (мс)", + "pws.antigravityTlsTitle": "TLS-профиль браузера Antigravity", + "pws.antigravityTlsEnable": "Включить экспериментальную эмуляцию TLS/HTTP2", + "pws.antigravityTlsWarning": "Неофициальная функция совместимости. Она не гарантирует соблюдение условий использования или отсутствие блокировки, может сделать трафик более отличимым и при ошибке инициализации перейти на Bun. Официальные маршруты Gemini с API-ключом, Vertex или документированный Code Assist безопаснее.", + "pws.antigravityTlsConfirm": "Включить экспериментальный неофициальный TLS-профиль Antigravity? Он не гарантирует соблюдение политики и не предотвращает блокировку. Продолжить?", + "pws.antigravityTlsStatus": "Состояние TLS-профиля: {status}", + "pws.antigravityTlsDisabled": "Отключён", + "pws.antigravityTlsActive": "Активен", + "pws.antigravityTlsFallback": "Резервный режим", + "pws.aiStudio": "AI Studio", + "pws.aiStudio.connected": "Подключено", + "pws.aiStudio.checking": "Проверка сессии", + "pws.aiStudio.unsupported": "Нативный вход не поддерживается на этой платформе", + "pws.aiStudio.needsReauth": "Требуется повторная аутентификация", + "pws.aiStudio.reauthenticated": "Повторная аутентификация выполнена", + "pws.aiStudio.loginFailed": "Не удалось выполнить нативный вход", + "pws.aiStudio.connect": "Подключить", + "claudeDesktop.catalogChanged": "Каталог моделей изменился во время редактирования. Недоступная модель восстановлена; проверьте профиль и сохраните его снова.", + "claudeDesktop.mappingDetails": "Расширенные сопоставления моделей", + "sub.routedDelegationBridge": "Мост маршрутизированной делегации V2", + "sub.routedDelegationBridgeHint": "Экспериментальная функция. Пока она активна, сообщения создания, отправки и продолжения передаются открытым текстом, включая делегирование между нативными агентами. Codex может показывать исходную модель; маршрутизированный контент и конфиденциальность зависят от выбранного провайдера.", + "sub.routedDelegationBridgeInactive": "Подготовлен; включается для подходящих нативных корней V2 и порождённых дочерних ходов.", + "sub.routedDelegationBridgeSaveFail": "Не удалось сохранить мост маршрутизированной делегации V2", + "sub.routedDelegationBridgeSaved": "Мост маршрутизированной делегации V2 сохранён. Отключение применяется сразу.", + "sub.prefer": "Выбрать предпочтительной", + "sub.preferred": "Предпочтительная", + "sub.preferredOutsideAdvertised": "Предпочтительная модель не входит в пять рекламируемых переопределений. Она остаётся доступной по точному идентификатору и не вставляется автоматически.", + "sub.catalogState.label": "Каталог Codex", + "sub.catalogState.fresh": "Актуален", + "sub.catalogState.stale": "Устарел — перезапустите Codex, чтобы обновить список моделей.", + "sub.catalogState.not_running": "Запущенного app-server Codex нет", + "sub.catalogState.unknown": "Неизвестно", + "sub.nativeDefaultState.active": "Активно: субагенты без модели используют это предпочтение после синхронизации.", + "sub.nativeDefaultState.disabled": "Синхронизация субагентов без модели отключена; это предпочтение не является нативным значением по умолчанию.", + "sub.nativeDefaultState.pending": "Ожидает: для активации поведения нужна синхронизация или перезапуск.", + "sub.nativeDefaultState.blocked": "Заблокировано: пользовательская или несовместимая настройка Codex препятствует нативной синхронизации.", + "logs.filter.agent.label": "Агент", + "logs.filter.agent.all": "Все", + "logs.filter.agent.main": "Основной", + "logs.filter.agent.subagent": "Субагент", + "logs.filter.agent.internal": "Внутренний", + "logs.filter.agent.unknown": "Неизвестно", + "logs.agent.main": "Основной", + "logs.agent.subagent": "Субагент", + "logs.agent.internal": "Внутренний", + "logs.agent.unknown": "Неизвестно", + "logs.agent.badgeTitle": "Источник запроса", + "models.pickerOrder.label": "Порядок моделей", + "models.pickerOrder.default": "По умолчанию", + "models.pickerOrder.alphabetical": "По имени A–Z", + "models.pickerOrder.provider": "По провайдеру", + "models.pickerOrder.mostUsed": "Снимок использования", + "models.pickerOrder.custom": "Свой порядок", + "models.pickerOrder.apply": "Применить порядок", + "models.pickerOrder.applying": "Применение…", + "models.pickerOrder.saved": "Порядок сохранён. Перезапустите клиенты, показывающие старый каталог.", + "models.pickerOrder.pending": "Порядок сохранён; обновление каталога ожидается.", + "models.pickerOrder.usageFailed": "Не удалось загрузить статистику моделей.", + "models.pickerOrder.loadFailed": "Не удалось загрузить настройки выбора.", + "models.pickerOrder.retry": "Повторить", + "models.pickerOrder.hint": "Сохраняет порядок маршрутизируемых моделей для Codex и обнаружения Claude. Диапазоны приоритетных и нативных моделей сохраняются. Использование — снимок; нативно объявляемые варианты могут измениться.", + "integrations.tab.raycast": "Raycast", + "integrations.semantics.raycast": "Добавляет запись провайдера OpenCodex в providers.yaml Raycast, чтобы каждая маршрутизируемая модель появилась в выборе моделей Raycast AI. Требуется Raycast Pro.", + "integrations.raycast.proRequired": "Custom Providers — функция Raycast Pro. Файл будет записан, но Raycast игнорирует его, пока не активна подписка Pro.", + "integrations.raycast.planUnknown": "Не удалось определить, активен ли Raycast Pro; для Custom Providers требуется Raycast Pro.", + "integrations.raycast.revealConfig": "Откройте Raycast → Настройки → AI и один раз нажмите «Reveal Providers Config», чтобы папка провайдеров появилась.", + "api.clientConfig.clientRaycast": "Raycast", "models.displayNameSavedRefreshFailed": "Изменение сохранено, но список моделей не удалось обновить. Повторите попытку.", "models.displayNameOutcomeUnknown": "Запрос не завершён. Изменение могло сохраниться. Повторите попытку, чтобы проверить текущее имя перед следующим изменением.", "models.displayNameCurrentUnavailable": "Текущее имя недоступно до обновления", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index fa8b8e9c25..e502d9a686 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -5,20 +5,6 @@ import type { TKey } from "./en"; * Turkish i18n catalog. Must match the `TKey` set (compile-checked). */ export const tr: Record = { - "models.pickerOrder.label": "Model sırası", - "models.pickerOrder.default": "Varsayılan", - "models.pickerOrder.alphabetical": "Model adına göre A–Z", - "models.pickerOrder.provider": "Sağlayıcıya göre", - "models.pickerOrder.mostUsed": "Kullanım anlık görüntüsü", - "models.pickerOrder.custom": "Özel sıra", - "models.pickerOrder.apply": "Sırayı uygula", - "models.pickerOrder.applying": "Uygulanıyor…", - "models.pickerOrder.saved": "Model sırası kaydedildi. Eski kataloğu gösteren istemcileri yeniden açın.", - "models.pickerOrder.pending": "Sıra kaydedildi; katalog yenilemesi bekleniyor.", - "models.pickerOrder.usageFailed": "Model kullanımı yüklenemedi.", - "models.pickerOrder.loadFailed": "Seçici ayarları yüklenemedi.", - "models.pickerOrder.retry": "Yeniden dene", - "models.pickerOrder.hint": "Codex ve Claude keşfi için yönlendirilen model sırasını kaydeder. Öne çıkan/yerel öncelik aralıkları korunur. Kullanım bir anlık görüntüdür; yerel araçta sunulan seçenekler değişebilir.", "codexAuth.quotaAutoRefreshAllHint": "Mevcut tüm hesapların desteklenen 5 saatlik ve haftalık pencerelerini birlikte açıp kapatır. Havuz modunda her sıfırlamadan sonra az miktarda kota kullanan bir istek gönderilir.", "codexAuth.quotaAutoRefreshMixed": "Bazı pencereler etkin.", "codexAuth.quotaAutoRefreshEmpty": "Desteklenen kota penceresi yok. Hesap kotalarını yenileyin.", @@ -62,6 +48,7 @@ export const tr: Record = { "auth.adminTokenHelp": "Bu, sağlayıcı API anahtarı değil, OpenCodex yönetim API’sinin yönetici jetonudur. Proxy ilk açılışta bunu ~/.opencodex/admin-api-token (veya $OPENCODEX_HOME/admin-api-token) dosyasına yazar; OPENCODEX_ADMIN_AUTH_TOKEN bu değeri geçersiz kılar.", "auth.adminTokenDocsLink": "Nasıl bulunur", "app.logoAria": "opencodex logosu", + "app.pageTitle": "{page} — opencodex", "app.claudeOn": "Claude AÇIK", "app.claudeOff": "Claude KAPALI", "theme.label": "Tema", @@ -590,7 +577,7 @@ export const tr: Record = { "models.setAllHint": "Her yönlendirilen sağlayıcıda {value} varsayılan pencereyi açar. Röle context_window / context_length vermezse bu değer gerçek Codex penceresi olur. Tek bir modeli elle yazmak için aynı satırdaki «Özel pencereler»i kullanın.", "models.collapseAll": "Tümünü daralt", "models.expandAll": "Tümünü genişlet", - "models.orderHint": "Varsayılan sıra öne çıkan seçimleri ve katalog önceliğini izler; eşit öncelikli yönlendirilmiş satırlar sağlayıcı ve modele göre sıralanır. Kaydedilmiş sıra görünümü değiştirebilir; görünürlük anahtarları satırları filtreler. İstemci yeniden açılana kadar eski kataloğu gösterebilir.", + "models.orderHint": "Seçici sırası: Alt ajan seçimleri → kalan modeller.", "models.custom": "Özel…", "models.customApply": "Uygula", "models.customPlaceholder": "Jetonlar (örn. 420000)", @@ -1590,7 +1577,6 @@ export const tr: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", - "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside profilleri", "integrations.aside.profilesHint": "Seçili modellerin hangi profillere aktarılacağını seçin. Aside’ın etkin profili değişmez.", "integrations.aside.all": "Tüm profilleri eşitle", @@ -1749,10 +1735,6 @@ export const tr: Record = { "integrations.semantics.zcode": "Yalnızca ~/.zcode/v2/config.json içindeki provider.opencodex bölümünü yönetir. Z.ai oturumu ve diğer sağlayıcılar değişmez. Değişikliklerden sonra ZCode'u yeniden başlatın.", "integrations.semantics.prime": "Yalnızca Prime Agent'ın models.json dosyasındaki providers.opencodex bölümünü yönetir — PRIME_AGENT_CODING_AGENT_DIR ayarlı değilse ~/.prime/agent. Diğer sağlayıcılar ve model geçersiz kılmaları değişmez. Yeni oturumlarda geçerli olur.", "integrations.semantics.aside": "Yalnızca bu profilin ~/.aside/u//models.json dosyasındaki providers.opencodex bölümünü yönetir. Diğer sağlayıcılarınız değişmez. Uyguladıktan sonra Aside’ı tamamen kapatıp yeniden açın.", - "integrations.semantics.raycast": "Raycast'in providers.yaml dosyasına bir OpenCodex sağlayıcı girdisi ekler; böylece yönlendirilen her model Raycast AI model seçicisinde görünür. Raycast Pro gerekir.", - "integrations.raycast.proRequired": "Custom Providers bir Raycast Pro özelliğidir. Dosya yazılır, ancak bir Pro aboneliği etkin olana kadar Raycast bunu yok sayar.", - "integrations.raycast.planUnknown": "Raycast Pro’nun etkin olup olmadığı belirlenemedi; Custom Providers için Raycast Pro gerekir.", - "integrations.raycast.revealConfig": "Raycast → Ayarlar → AI bölümünü açıp sağlayıcı klasörünün oluşması için „Reveal Providers Config“ seçeneğine bir kez tıklayın.", "integrations.semantics.omp": "Kataloğu yüklemek için OMP'yi yeniden başlatın.", "codexAuth.mainAccount": "Ana Hesap", "codexAuth.logLabel": "Günlük etiketi", @@ -2087,7 +2069,6 @@ export const tr: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", - "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "JSON Kopyala", "api.clientConfig.download": "İndir", "api.clientConfig.loading": "İstemci konfigürasyonu oluşturuluyor…", @@ -2577,6 +2558,231 @@ export const tr: Record = { "integrations.cursor.colReasoning": "Akıl yürütme", "integrations.cursor.colContext": "Bağlam", "integrations.cursor.guide": "Cursor Private Inference kılavuzunu aç", + + // Fork-preserved dashboard surfaces. + "dash.activity.title": "30 günlük etkinlik", + "dash.activity.requests": "İstekler", + "dash.activity.tokens": "Jetonlar", + "dash.activity.chartLabel": "30 günlük etkinlik. Gün seçmek için Sol ve Sağ, aralık sınırlarına gitmek için Home ve End tuşlarını kullanın.", + "dash.activity.viewUsage": "Kullanımı görüntüle", + "sub.roles": "Roller", + "sub.roles.hint": "Ebeveynin spawn edebileceği adlı uzmanlar. Her rol bir model ve çocuk istemini sabitler. Yetki devrine ebeveyn karar verir.", + "sub.roles.add": "Rol ekle", + "sub.roles.empty": "Henüz rol yok. İnceleme, arama veya başka yetki devri için bir uzman ekleyin.", + "sub.roles.full": "Rol kataloğu dolu (en fazla 8)", + "sub.roles.id": "Rol adı", + "sub.roles.description": "Ne zaman kullanılır", + "sub.roles.descriptionHint": "Ebeveynin bu uzmanı ne zaman spawn edeceğini bilmesi için gösterilir.", + "sub.roles.model": "Model", + "sub.roles.effort": "Efor", + "sub.roles.effortNone": "Model varsayılanı", + "sub.roles.instructions": "Çocuk yönergeleri", + "sub.roles.instructionsHint": "Çocuk aracının yüklediği geliştirici istemi.", + "sub.roles.enabled": "Etkin", + "sub.roles.delete": "{id} rolünü sil", + "sub.roles.save": "Rolleri kaydet", + "sub.roles.saved": "Roller kaydedildi. Yeni Codex oturumlarına uygulanır.", + "sub.roles.saveFailed": "Roller kaydedilemedi", + "sub.roles.loadFail": "Roller yüklenemedi — vekil çalışıyor mu?", + "sub.roles.routedV2Warning": "Bu rol, ChatGPT’yi v1’de tutmadan v2 yüzeyinde yönlendirilmiş bir model kullanıyor. Yerel v2 ebeveynler çocuk görevlerini şifreler.", + "sub.roles.sync": "Codex aracı dosyalarına eşitle", + "sub.roles.syncHint": "Etkin rolleri işaretli ocx-*.toml dosyalarına yazar. Kapalıyken OpenCodex dosyalarını budar, sizinkilere dokunmaz.", + "usage.chart.dayDetail": "{date}: {requests} istek, {tokens} jeton", + "replitGateway.title": "Replit ağ geçidi yardımcısı", + "replitGateway.subtitle": "Kendi dağıttığınız Replit ağ geçidine bağlanın ve eşleşen OpenAI Chat ile Anthropic sağlayıcılarını birlikte kurun.", + "replitGateway.companionNote": "Bu, kanonik bir kayıt ön ayarı değil, özel bir yardımcı iş akışıdır.", + "replitGateway.originLabel": "Dağıtım origin'i", + "replitGateway.originHint": "Replit dağıtımınızın HTTPS origin'i (örneğin https://my-app.replit.app).", + "replitGateway.keyLabel": "Ağ geçidi anahtarı", + "replitGateway.keyHint": "Dağıtımınızdaki REPLIT_GATEWAY_KEY gizli değeri. Yerel sağlayıcı yapılandırmanızda saklanır ve ağ geçidi trafiğini doğrulamak için kullanılır.", + "replitGateway.keyTooShort": "Ağ geçidi anahtarı çok kısa (en az 32 karakter).", + "replitGateway.keyTooLong": "Ağ geçidi anahtarı en fazla 512 karakter olabilir.", + "replitGateway.keyInvalidChars": "Ağ geçidi anahtarı yalnızca yazdırılabilir ASCII karakterler içermelidir.", + "replitGateway.errorNetwork": "Proxy'ye ulaşılamadı. Çalıştığını doğrulayıp tekrar deneyin.", + "replitGateway.errorMalformed": "Proxy geçersiz bir yanıt döndürdü. Kısa süre sonra tekrar deneyin.", + "replitGateway.customDomainLabel": "Özel etki alanına izin ver", + "replitGateway.customDomainHint": "Yalnızca ağ geçidiniz .replit.app üzerinde barındırılmıyorsa etkinleştirin.", + "replitGateway.setDefaultLabel": "Kurulumdan sonra replit'i varsayılan sağlayıcı yap", + "replitGateway.install": "Eşi kur", + "replitGateway.installing": "Kuruluyor…", + "replitGateway.close": "Kapat", + "replitGateway.openButton": "Replit ağ geçidi…", + "replitGateway.replaceTitle": "Mevcut Replit eşi değiştirilsin mi?", + "replitGateway.replaceBody": "replit ve replit-anthropic sağlayıcıları zaten var. Değiştirmek ağ geçidi origin'ini ve anahtarını üzerine yazar; katmanlarınız korunur.", + "replitGateway.replaceConfirm": "Eşi değiştir", + "replitGateway.replaceCancel": "Mevcut olanı koru", + "replitGateway.probeHealth": "healthz: HTTP {status} ({ms} ms)", + "replitGateway.probeModels": "models: {count} bulundu ({ms} ms)", + "replitGateway.successTitle": "Replit ağ geçidi eşi kuruldu.", + "replitGateway.errorGeneric": "Replit ağ geçidi eşi kurulamadı.", + "replitGateway.errorCollision": "Replit sağlayıcı eşi zaten mevcut.", + "replitGateway.errorBusy": "Yapılandırma meşgul. Kısa süre sonra tekrar deneyin.", + "replitGateway.originRequired": "Dağıtım origin'i gerekli.", + "replitGateway.keyRequired": "Ağ geçidi anahtarı gerekli.", + "cursorPool.title": "Cursor hesap havuzu (deneysel)", + "cursorPool.enabledDesc": "429 veya kimlik doğrulama hatasında hesabı soğutur ve bu konuşma için yedeklemeye geçer. Her konuşma, bir oran sınırı veya kimlik bilgisi hatası oluşana kadar tek bir hesapta kalır.", + "cursorPool.disabledDesc": "Yalnızca aktif Cursor hesabını kullanır. Deneysel yönlendirmeyi kabul ediyorsanız etkinleştirin.", + "cursorPool.experimentalWarning": "Deneysel ve yeterince test edilmemiş. Cursor kullanım politikası oran sınırlarını aşmayı ve kullanım ölçümünü manipüle etmeyi yasaklar. Teams dahil kullanım koltuk bazlıdır ve aktarılmayabilir. Riskleri anlamıyorsanız kapalı tutun.", + "cursorPool.needTwoAccounts": "Havuzu etkinleştirmeden önce en az iki Cursor OAuth hesabı ekleyin.", + "cursorPool.loadFailed": "Cursor havuz ayarları yüklenemedi.", + "cursorPool.saveFailed": "Cursor havuz ayarları kaydedilemedi.", + "cursorPool.on": "Açık", + "cursorPool.off": "Kapalı", + "antigravityRouting.failoverOnlyTitle": "Çoklu hesap yönlendirme (yalnızca yedekleme)", + "antigravityRouting.failoverOnlyDesc": "Her konuşma tek bir Antigravity hesabında kalır. Oran sınırı 429 veya kimlik bilgisi hatasında opencodex, bu konuşma için başka bir uygun hesaba geçebilir — yeni oturumları hesaplar arasında dağıtmadan veya diğer canlı konuşmaların genel aktif hesabını değiştirmeden.", + // Fork-preserved dashboard behavior catalog. + "common.copy": "Kopyala", + "common.copied": "Kopyalandı", + "auth.adminTokenDesc": "Devam etmek için OPENCODEX_ADMIN_AUTH_TOKEN değerinizi girin. Tarayıcınız bir sonraki sefer için kaydetmeyi teklif edebilir.", + "auth.adminTokenPlaceholder": "Yönetici jetonunu yapıştırın", + "auth.adminTokenShow": "Göster", + "auth.adminTokenHide": "Gizle", + "dash.connectionReconnecting": "Bağlantı kesildi — yeniden bağlanılıyor…", + "dash.serverModeLabel": "Uzaktan erişim modu", + "dash.serverModeLoopback": "Loopback + SSH", + "dash.serverModeRemote": "Uzak TLS", + "dash.serverModeLoopbackHint": "LAN için önerilir. 127.0.0.1 adresinde kalın ve SSH tünelleme kullanın — sertifika güveni gerekmez.", + "dash.serverModeRemoteHint": "Doğrudan TLS bağlantısı (0.0.0.0). Güvenilir bir sertifika gerektirir.", + "dash.serverLoopbackCardTitle": "Loopback — SSH tünelleme", + "dash.serverLoopbackCardDesc": "Panoyu açmak istediğiniz bilgisayarda bu geçici tüneli çalıştırın ve terminali açık tutun.", + "dash.serverLoopbackCardNote": "Yönlendirilen HTTP URL’sini tarayıcıda açın. SSH bağlantıyı şifreler; ssh -g seçeneğini asla kullanmayın.", + "dash.serverPersistentTunnelDocs": "Kalıcı macOS tüneli kurulumu", + "dash.serverRemoteCardTitle": "Uzak TLS bağlantısı", + "dash.serverRemoteCardDesc": "Proxy tüm arayüzlerde TLS ile dinler. Codex doğrudan https://: adresine bağlanır. Sertifika Codex makinesi (rustls) tarafından güvenilir olmalıdır — kendinden imzalı sertifika yüklenene kadar UnknownIssuer gösterir.", + "dash.serverRemoteSanHint": "Sertifika SAN değeri publicOrigin içindeki ana bilgisayar adını veya IP'yi içermelidir. Güvenilir bir CA kullanın veya özel CA'nızı Codex makinesine yükleyin.", + "dash.serverAdvanced": "Gelişmiş (ana bilgisayar adı)", + "dash.serverCopyUnavailable": "Kopyalama kullanılamıyor", + "dash.serverRemoteCredentialRequired": "Uzak TLS'yi etkinleştirmeden önce bir data-plane API anahtarı ekleyin.", + "dash.serverRestartNotice": "Dinleyici değişiklikleri kaydedildi. Uygulamak için proxy'yi yeniden başlatın.", + "dash.serverSettingsTitle": "Sunucu dinleyicisi ayarları", + "dash.serverSaveFailed": "Sunucu dinleyicisi ayarları kaydedilemedi.", + "dash.serverTlsOff": "kapalı", + "dash.serverCredentialConfigured": "yapılandırıldı", + "dash.serverCredentialMissing": "eksik", + "dash.serverRestartRequired": "evet", + "prov.cooldownCleared": "Soğuma süresi temizlendi", + "prov.cooldownClearFailed": "Soğuma süresi temizlenemedi", + "models.contextMetadataSource": "Kaynak", + "models.contextMetadataLive": "tespit", + "models.contextMetadataRegistry": "kayıt", + "models.contextMetadataCursorStatic": "Cursor seçkisi", + "models.contextMetadataSnapshot": "kayıtlı", + "models.contextMetadataFallback": "yedek", + "models.contextMetadataDerived": "türetilmiş", + "models.contextMetadataUnknown": "bilinmiyor", + "models.contextMetadataStale": "eski", + "models.contextTooltipDetected": "{value} · tespit", + "models.contextTooltipCapped": "{detected} tespit · {cap} ile sınırlı", + "models.contextTooltipStale": "{value} · {when}", + "models.contextTooltipUnknown": "Bağlam penceresi bilinmiyor", + "models.contextTooltipSource": "{value} · {source}", + "models.contextLastChecked": "son kontrol {when}", + "models.contextYesterday": "dün", + "models.customEditNamed": "{name} modelini düzenle", + "models.customDeleteNamed": "{name} modelini sil", + "sub.nativeParentOverride": "Yerel V2 üst ajanlarını yönlendir", + "sub.nativeParentOverrideHint": "Codex özgün yerel modeli gösterebilir; OpenCodex ise kökü seçilen yönlendirilmiş hedefte çalıştırır.", + "sub.nativeParentOverridePrivacyWarning": "İstemler, depo bağlamı, konuşma geçmişi ve araç sonuçları seçilen sağlayıcıya gider; sağlayıcının kullanılabilirlik, bağlam, gecikme, davranış, maliyet ve gizlilik özelliklerini devralır.", + "sub.nativeParentOverrideModel": "Yerel üst ajan hedef modeli", + "sub.nativeParentOverrideV2Required": "Açık V2, üst akış V2 bayrağı ve ‘ChatGPT’yi v1’de tut’ seçeneğinin kapalı olması gerekir.", + "sub.nativeParentOverrideSaveFail": "Yerel üst ajan yönlendirmesi kaydedilemedi", + "sub.nativeParentOverrideSaved": "Yerel üst ajan yönlendirmesi kaydedildi. Sonraki üst ajan turlarına ve sıkıştırmaya uygulanır.", + "sub.agentTaskRecovery": "Şifreli V2 Görevlerini Kurtar", + "sub.agentTaskRecoveryHint": "OpenAI Fernet şifreli görev iletilerini ChatGPT aracılığıyla çözerek OpenAI dışı alt ajanların yönlendirilen V2 görevlerini okuyup yürütmesini sağlar.", + "sub.agentTaskRecoveryModel": "Kurtarma modeli (varsayılan: gpt-5.6-luna)", + "sub.agentTaskRecoveryDefault": "Varsayılan model (gpt-5.6-luna)", + "sub.agentTaskRecoverySaveFail": "Şifreli görev kurtarma kaydedilemedi", + "sub.agentTaskRecoverySaved": "Şifreli görev kurtarma ayarı kaydedildi.", + "sub.injectionPrompt": "Özel ebeveyn rehberi", + "sub.injectionPromptHint": "Yerleşik v2 spawn metnini değiştirir. Boş bırakırsanız yerleşik metin kullanılır. Canlı değerler için yer tutucuları ekleyin:", + "sub.injectionPromptSave": "Ebeveyn rehberini kaydet", + "sub.injectionPromptSaved": "Ebeveyn rehberi kaydedildi. Yeni Codex oturumlarına uygulanır.", + "sub.injectionPromptSaveFailed": "Ebeveyn rehberi kaydedilemedi", + "sub.childInstructions": "Küresel çocuk yönergeleri", + "sub.childInstructionsHint": "Spawn edilen her çocuğa uygulanır. Rol yönergeleri eşitlenince yalnızca o uzmana uygulanır.", + "sub.childInstructionsSave": "Çocuk yönergelerini kaydet", + "sub.childInstructionsSaved": "Çocuk yönergeleri kaydedildi. Yeni Codex oturumlarına uygulanır.", + "sub.childInstructionsSaveFailed": "Çocuk yönergeleri kaydedilemedi", + "logs.detail.attempt.recovery.cursorEnvelopeEcho": "Cursor yeniden yürütme zarfı kurtarma", + "logs.detail.attempt.recovery.cursorRoutingCommentary": "Cursor yönlendirme yorumu kurtarma", + "logs.detail.attempt.recovery.cursorDuplicateToolCall": "Cursor yinelenen araç çağrısı kurtarma", + "logs.detail.attempt.recovery.cursorOverflowRemint": "Cursor taşma kurtarma", + "logs.detail.attempt.recovery.cursorInvalidArgument": "Cursor geçersiz argüman kurtarma", + "usage.heatmap.keyboardLabel": "Gün gün ilerlemek için Yukarı ve Aşağı, hafta hafta ilerlemek için Sol ve Sağ tuşlarını kullanın.", + "modal.catalogTabs": "Sağlayıcı kategorileri", + "modal.searchLabel": "Sağlayıcı ara", + "modal.catalogLoadFailed": "Sağlayıcı kataloğu yüklenemedi.", + "pws.allAccounts": "Tüm hesaplar", + "pws.replayTransientFailures": "Geçici hataları yeniden oynat", + "pws.replayTransientFailuresDesc": "Akış başlamadan oluşan geçici hataları en az bir kez yeniden dene; istek birden fazla iletilebilir.", + "pws.clearCooldown": "Soğuma süresini temizle", + "pws.pacingJitter": "Rastgele titreşim (ms)", + "pws.antigravityTlsTitle": "Antigravity tarayıcı TLS profili", + "pws.antigravityTlsEnable": "Deneysel TLS/HTTP2 öykünmesini etkinleştir", + "pws.antigravityTlsWarning": "Resmi olmayan uyumluluk özelliğidir. Hizmet şartlarına uyumu veya askıya alınmayı önlemeyi garanti etmez, trafiği daha ayırt edilebilir kılabilir ve başlatma başarısız olursa Bun'a dönebilir. Resmi Gemini API anahtarı, Vertex veya belgelenmiş Code Assist yolları daha güvenlidir.", + "pws.antigravityTlsConfirm": "Deneysel ve resmi olmayan Antigravity TLS profilini etkinleştirelim mi? Politika uyumu veya askıya alınmayı önleme garantisi yoktur. Devam edilsin mi?", + "pws.antigravityTlsStatus": "TLS profili durumu: {status}", + "pws.antigravityTlsDisabled": "Devre dışı", + "pws.antigravityTlsActive": "Etkin", + "pws.antigravityTlsFallback": "Geri dönüş", + "pws.aiStudio": "AI Studio", + "pws.aiStudio.connected": "Bağlı", + "pws.aiStudio.checking": "Oturum kontrol ediliyor", + "pws.aiStudio.unsupported": "Bu platformda yerel giriş desteklenmiyor", + "pws.aiStudio.needsReauth": "Yeniden kimlik doğrulama gerekli", + "pws.aiStudio.reauthenticated": "Yeniden kimlik doğrulama başarılı", + "pws.aiStudio.loginFailed": "Yerel giriş başarısız", + "pws.aiStudio.connect": "Bağlan", + "claudeDesktop.catalogChanged": "Düzenleme sırasında model kataloğu değişti. Kullanılamayan model geri yüklendi; profili kontrol edip yeniden kaydedin.", + "claudeDesktop.mappingDetails": "Gelişmiş model eşlemeleri", + "sub.routedDelegationBridge": "Yönlendirilmiş V2 yetkilendirme köprüsü", + "sub.routedDelegationBridgeHint": "Deneyseldir. Etkinken oluşturma, gönderme ve takip mesajları, yerel ajanlar arası delegasyon dahil düz metindir. Codex özgün modeli gösterebilir; yönlendirilen içerik ve gizlilik seçilen sağlayıcıya bağlıdır.", + "sub.routedDelegationBridgeInactive": "Hazır; uygun yerel V2 kökleri ve oluşturulan alt ajan turları için etkinleşir.", + "sub.routedDelegationBridgeSaveFail": "Yönlendirilmiş V2 yetkilendirme köprüsü kaydedilemedi", + "sub.routedDelegationBridgeSaved": "Yönlendirilmiş V2 yetkilendirme köprüsü kaydedildi. Devre dışı bırakma hemen uygulanır.", + "sub.prefer": "Tercih et", + "sub.preferred": "Tercih edilen", + "sub.preferredOutsideAdvertised": "Tercih edilen model beş duyurulan geçersiz kılma arasında değil. Tam kimliğiyle çalıştırılabilir ve otomatik eklenmez.", + "sub.catalogState.label": "Codex kataloğu", + "sub.catalogState.fresh": "Güncel", + "sub.catalogState.stale": "Güncel değil — model seçiciyi yenilemek için Codex’i yeniden başlatın.", + "sub.catalogState.not_running": "Çalışan Codex app-server yok", + "sub.catalogState.unknown": "Bilinmiyor", + "sub.nativeDefaultState.active": "Etkin: modeli belirtilmeyen alt aracılar senkronizasyondan sonra bu tercihi kullanır.", + "sub.nativeDefaultState.disabled": "Modeli belirtilmeyen alt aracı senkronizasyonu kapalı; bu tercih yerel varsayılan değildir.", + "sub.nativeDefaultState.pending": "Beklemede: modeli belirtilmeyen davranışın etkin olması için senkronizasyon veya yeniden başlatma gerekir.", + "sub.nativeDefaultState.blocked": "Engellendi: kullanıcıya ait veya uyumsuz bir Codex ayarı yerel senkronizasyonu engelliyor.", + "logs.filter.agent.label": "Aracı", + "logs.filter.agent.all": "Tümü", + "logs.filter.agent.main": "Ana", + "logs.filter.agent.subagent": "Alt aracı", + "logs.filter.agent.internal": "Dahili", + "logs.filter.agent.unknown": "Bilinmiyor", + "logs.agent.main": "Ana", + "logs.agent.subagent": "Alt aracı", + "logs.agent.internal": "Dahili", + "logs.agent.unknown": "Bilinmiyor", + "logs.agent.badgeTitle": "İstek kaynağı", + "models.pickerOrder.label": "Model sırası", + "models.pickerOrder.default": "Varsayılan", + "models.pickerOrder.alphabetical": "Model adına göre A–Z", + "models.pickerOrder.provider": "Sağlayıcıya göre", + "models.pickerOrder.mostUsed": "Kullanım anlık görüntüsü", + "models.pickerOrder.custom": "Özel sıra", + "models.pickerOrder.apply": "Sırayı uygula", + "models.pickerOrder.applying": "Uygulanıyor…", + "models.pickerOrder.saved": "Model sırası kaydedildi. Eski kataloğu gösteren istemcileri yeniden açın.", + "models.pickerOrder.pending": "Sıra kaydedildi; katalog yenilemesi bekleniyor.", + "models.pickerOrder.usageFailed": "Model kullanımı yüklenemedi.", + "models.pickerOrder.loadFailed": "Seçici ayarları yüklenemedi.", + "models.pickerOrder.retry": "Yeniden dene", + "models.pickerOrder.hint": "Codex ve Claude keşfi için yönlendirilen model sırasını kaydeder. Öne çıkan/yerel öncelik aralıkları korunur. Kullanım bir anlık görüntüdür; yerel araçta sunulan seçenekler değişebilir.", + "integrations.tab.raycast": "Raycast", + "integrations.semantics.raycast": "Raycast'in providers.yaml dosyasına bir OpenCodex sağlayıcı girdisi ekler; böylece yönlendirilen her model Raycast AI model seçicisinde görünür. Raycast Pro gerekir.", + "integrations.raycast.proRequired": "Custom Providers bir Raycast Pro özelliğidir. Dosya yazılır, ancak bir Pro aboneliği etkin olana kadar Raycast bunu yok sayar.", + "integrations.raycast.planUnknown": "Raycast Pro’nun etkin olup olmadığı belirlenemedi; Custom Providers için Raycast Pro gerekir.", + "integrations.raycast.revealConfig": "Raycast → Ayarlar → AI bölümünü açıp sağlayıcı klasörünün oluşması için „Reveal Providers Config“ seçeneğine bir kez tıklayın.", + "api.clientConfig.clientRaycast": "Raycast", "models.displayNameSavedRefreshFailed": "Değişiklik kaydedildi ancak model listesi yenilenemedi. Yenilemek için tekrar deneyin.", "models.displayNameOutcomeUnknown": "İstek tamamlanmadı. Değişiklik kaydedilmiş olabilir. Başka bir değişiklik yapmadan önce geçerli adı kontrol etmek için tekrar deneyin.", "models.displayNameCurrentUnavailable": "Geçerli ad yenilemeye kadar kullanılamıyor", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 3bc246543a..f2f6138b13 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2,20 +2,6 @@ import type { TKey } from "./en"; /** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */ export const zhTW: Record = { - "models.pickerOrder.label": "模型選擇順序", - "models.pickerOrder.default": "預設", - "models.pickerOrder.alphabetical": "依模型名稱 A–Z", - "models.pickerOrder.provider": "依供應商分組", - "models.pickerOrder.mostUsed": "使用量快照", - "models.pickerOrder.custom": "自訂順序", - "models.pickerOrder.apply": "套用順序", - "models.pickerOrder.applying": "正在套用…", - "models.pickerOrder.saved": "選擇順序已儲存。若仍顯示舊目錄,請重新開啟用戶端。", - "models.pickerOrder.pending": "順序已儲存,目錄更新尚未完成。", - "models.pickerOrder.usageFailed": "無法載入模型使用量。", - "models.pickerOrder.loadFailed": "無法載入模型選擇設定。", - "models.pickerOrder.retry": "重試", - "models.pickerOrder.hint": "儲存 Codex 與 Claude 探索清單中的路由模型順序。保留精選與原生模型的優先級區間;使用量排序是快照,原生工具顯示的候選可能改變。", "codexAuth.quotaAutoRefreshAllHint": "統一切換目前所有帳戶各自支援的 5 小時與每週額度視窗。在帳戶池模式下,重設後會傳送消耗少量額度的請求。", "codexAuth.quotaAutoRefreshMixed": "部分視窗已啟用。", "codexAuth.quotaAutoRefreshEmpty": "沒有支援的額度視窗。請重新整理帳戶額度。", @@ -41,6 +27,7 @@ export const zhTW: Record = { "common.loading": "載入中…", "common.retry": "重試", "app.logoAria": "opencodex 徽標", + "app.pageTitle": "{page} — opencodex", "app.claudeOn": "Claude 開", "app.claudeOff": "Claude 關", "theme.label": "主題", @@ -453,7 +440,7 @@ export const zhTW: Record = { "models.setAllHint": "為所有已路由供應商打開 {value} 預設視窗。中繼站沒回報 context_window / context_length 時,這個值就是 Codex 實際視窗。要幫單一模型手寫,用同一列上的「自訂視窗」。原生供應商不受影響。", "models.collapseAll": "全部摺疊", "models.expandAll": "全部展開", - "models.orderHint": "預設順序遵循精選項目與目錄優先級;同優先級的路由列依供應商、模型排序。儲存的選擇順序可改變顯示順序,可見性開關用於篩選顯示的列。用戶端重新開啟前可能仍顯示舊目錄。", + "models.orderHint": "選擇器順序:Subagents 中的選擇(按所選順序)→ 其餘已路由模型(依次按供應商、模型 ID 字母排序)→ 原生模型。可見性開關僅用於篩選,不會改變此順序。", "models.custom": "自訂…", "models.customApply": "套用", "models.customPlaceholder": "tokens (例如 420000)", @@ -2178,7 +2165,6 @@ export const zhTW: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", - "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside 設定檔", "integrations.aside.profilesHint": "選擇要接收所選模型的設定檔。Aside 目前使用的設定檔不會改變。", "integrations.aside.all": "同步所有設定檔", @@ -2338,10 +2324,6 @@ export const zhTW: Record = { "integrations.semantics.zcode": "僅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不會變更 Z.ai 登入狀態或其他供應商。變更後請重新啟動 ZCode。", "integrations.semantics.prime": "僅管理 Prime Agent 的 models.json 中的 providers.opencodex;預設位於 ~/.prime/agent,若設定 PRIME_AGENT_CODING_AGENT_DIR 則以其為準。不會變更其他供應商或模型覆寫設定。對新工作階段生效。", "integrations.semantics.aside": "僅管理此設定檔的 ~/.aside/u//models.json 中的 providers.opencodex。其他供應商維持不變。套用後請完全結束並重新開啟 Aside。", - "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中新增一個 OpenCodex 供應商項目,讓所有已路由的模型出現在 Raycast AI 模型選擇器中。需要 Raycast Pro。", - "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。檔案會被寫入,但在 Pro 訂閱生效之前 Raycast 會忽略它。", - "integrations.raycast.planUnknown": "無法確認 Raycast Pro 是否已啟用;Custom Providers 需要 Raycast Pro。", - "integrations.raycast.revealConfig": "開啟 Raycast → 設定 → AI,點一次「Reveal Providers Config」,以便建立 providers 資料夾。", "codexAuth.pinned": "已固定", "codexAuth.pinnedHint": "你手動選取了此帳號,因此較高的選擇順序不會越過它。此固定會持續到該帳號用盡、你改選其他帳號,或你變更任一選擇順序為止。", "codexAuth.requestUserInput": "在 Default 模式中要求輸入", @@ -2383,7 +2365,6 @@ export const zhTW: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", - "api.clientConfig.clientRaycast": "Raycast", "cws.tabsLabel": "Combo 詳細區段", "cws.field.nativeAlias": "原生 OpenAI 別名", "cws.field.nativeAliasHint": "讓此 combo 擁有受支援的未限定原生 OpenAI 模型 ID。帶有帳號或供應商限定的 OpenAI 路由仍保持獨立。", @@ -2539,6 +2520,231 @@ export const zhTW: Record = { "integrations.cursor.colReasoning": "推理", "integrations.cursor.colContext": "上下文", "integrations.cursor.guide": "開啟 Cursor Private Inference 指南", + + // Fork-preserved dashboard surfaces. + "dash.activity.title": "30 天活動", + "dash.activity.requests": "請求", + "dash.activity.tokens": "Token", + "dash.activity.chartLabel": "30 天活動。使用左右方向鍵選擇日期,使用 Home 和 End 跳到範圍兩端。", + "dash.activity.viewUsage": "檢視用量", + "sub.roles": "角色", + "sub.roles.hint": "父代理可以 spawn 的具名專家。每個角色固定模型與子代理提示。是否委派仍由父代理決定。", + "sub.roles.add": "新增角色", + "sub.roles.empty": "尚無角色。請為審查、搜尋或其他委派工作新增專家。", + "sub.roles.full": "角色目錄已滿(最多 8 個)", + "sub.roles.id": "角色名稱", + "sub.roles.description": "何時使用", + "sub.roles.descriptionHint": "顯示給父代理,讓它知道何時 spawn 此專家。", + "sub.roles.model": "模型", + "sub.roles.effort": "推理強度", + "sub.roles.effortNone": "模型預設", + "sub.roles.instructions": "子代理說明", + "sub.roles.instructionsHint": "子代理載入的開發者提示。", + "sub.roles.enabled": "啟用", + "sub.roles.delete": "刪除角色 {id}", + "sub.roles.save": "儲存角色", + "sub.roles.saved": "角色已儲存。適用於新的 Codex 會話。", + "sub.roles.saveFailed": "儲存角色失敗", + "sub.roles.loadFail": "無法載入角色 — 代理是否正在執行?", + "sub.roles.routedV2Warning": "此角色在 v2 表面上使用了路由模型,且未將 ChatGPT 維持在 v1。原生 v2 父工作階段會加密子任務。", + "sub.roles.sync": "同步到 Codex 代理檔案", + "sub.roles.syncHint": "將已啟用角色寫入帶標記的 ocx-*.toml。關閉時只清理 OpenCodex 擁有的檔案,不改動你的檔案。", + "usage.chart.dayDetail": "{date}:{requests} 個請求,{tokens} 個 Token", + "replitGateway.title": "Replit 閘道夥伴", + "replitGateway.subtitle": "連線您自行部署的 Replit 閘道,並一次安裝配對的 OpenAI Chat 與 Anthropic 提供者。", + "replitGateway.companionNote": "這是自訂夥伴工作流程,不是正式註冊表預設項目。", + "replitGateway.originLabel": "部署來源", + "replitGateway.originHint": "Replit 部署的 HTTPS 來源(例如 https://my-app.replit.app)。", + "replitGateway.keyLabel": "閘道金鑰", + "replitGateway.keyHint": "部署中的 REPLIT_GATEWAY_KEY 密鑰。它會保存在本機提供者設定中,並用於閘道流量驗證。", + "replitGateway.keyTooShort": "閘道金鑰過短(至少 32 個字元)。", + "replitGateway.keyTooLong": "閘道金鑰最多 512 個字元。", + "replitGateway.keyInvalidChars": "閘道金鑰只能包含可列印的 ASCII 字元。", + "replitGateway.errorNetwork": "無法連線至代理。請確認代理正在執行後再試。", + "replitGateway.errorMalformed": "代理回傳無效回應。請稍後再試。", + "replitGateway.customDomainLabel": "允許自訂網域", + "replitGateway.customDomainHint": "僅在閘道未託管於 .replit.app 時啟用。", + "replitGateway.setDefaultLabel": "安裝後將 replit 設為預設提供者", + "replitGateway.install": "安裝配對", + "replitGateway.installing": "安裝中…", + "replitGateway.close": "關閉", + "replitGateway.openButton": "Replit 閘道…", + "replitGateway.replaceTitle": "要取代現有的 Replit 配對嗎?", + "replitGateway.replaceBody": "提供者 replit 與 replit-anthropic 已存在。取代會覆寫閘道來源與金鑰,但會保留您的覆寫設定。", + "replitGateway.replaceConfirm": "取代配對", + "replitGateway.replaceCancel": "保留現有", + "replitGateway.probeHealth": "healthz:HTTP {status}({ms} 毫秒)", + "replitGateway.probeModels": "models:發現 {count} 個({ms} 毫秒)", + "replitGateway.successTitle": "已安裝 Replit 閘道配對。", + "replitGateway.errorGeneric": "無法安裝 Replit 閘道配對。", + "replitGateway.errorCollision": "Replit 提供者配對已存在。", + "replitGateway.errorBusy": "設定檔忙碌中,請稍後重試。", + "replitGateway.originRequired": "必須填寫部署來源。", + "replitGateway.keyRequired": "必須填寫閘道金鑰。", + "cursorPool.title": "Cursor 帳號池(實驗性)", + "cursorPool.enabledDesc": "遇到 429 或認證失敗時冷卻該帳號並為此對話故障轉移。每個對話會保持一個帳號,直到發生限速或憑證失敗。", + "cursorPool.disabledDesc": "僅使用目前活躍的 Cursor 帳號。僅在接受實驗性路由時啟用。", + "cursorPool.experimentalWarning": "實驗性功能,尚未充分驗證。Cursor 使用政策禁止規避速率限制和操作用量計量。Teams 包含用量按席位計算,可能無法轉移。除非瞭解風險,否則請保持關閉。", + "cursorPool.needTwoAccounts": "啟用帳號池前請至少新增兩個 Cursor OAuth 帳號。", + "cursorPool.loadFailed": "無法載入 Cursor 帳號池設定。", + "cursorPool.saveFailed": "無法儲存 Cursor 帳號池設定。", + "cursorPool.on": "開", + "cursorPool.off": "關", + "antigravityRouting.failoverOnlyTitle": "多帳號路由(僅故障轉移)", + "antigravityRouting.failoverOnlyDesc": "每個對話保持一個 Antigravity 帳號。遇到限速 429 或憑證失敗時,opencodex 可為該對話故障轉移到另一個符合條件的帳號 — 不會在新工作階段間負載平衡,也不會更改其他進行中對話的全域活躍帳號。", + // Fork-preserved dashboard behavior catalog. + "common.copy": "複製", + "common.copied": "已複製", + "dash.connectionReconnecting": "連線中斷 — 正在重新連線…", + "dash.serverModeLabel": "遠端存取模式", + "dash.serverModeLoopback": "回環 + SSH", + "dash.serverModeRemote": "遠端 TLS", + "dash.serverModeLoopbackHint": "建議用於區域網路。保持 127.0.0.1 並透過 SSH 轉送連線,無需憑證信任設定。", + "dash.serverModeRemoteHint": "直接 TLS 綁定(0.0.0.0),需要受信任的憑證。", + "dash.serverLoopbackCardTitle": "回環 — SSH 轉送", + "dash.serverLoopbackCardDesc": "在要開啟儀表板的電腦上執行此臨時隧道,並保持終端機開啟。", + "dash.serverLoopbackCardNote": "在瀏覽器中開啟轉送後的 HTTP URL。SSH 會加密連線;切勿使用 ssh -g。", + "dash.serverPersistentTunnelDocs": "設定永久 macOS 隧道", + "dash.serverRemoteCardTitle": "遠端 TLS 綁定", + "dash.serverRemoteCardDesc": "代理透過 TLS 在所有介面上監聽。Codex 直接連線至 https://:。憑證須被 Codex 機器(rustls)信任,自簽憑證在安裝前會顯示 UnknownIssuer。", + "dash.serverRemoteSanHint": "憑證 SAN 須包含 publicOrigin 中的主機名稱或 IP。請使用受信任的 CA,或在 Codex 機器上安裝私有 CA。", + "dash.serverAdvanced": "進階(主機名稱)", + "dash.serverCopyUnavailable": "無法複製", + "dash.serverRemoteCredentialRequired": "啟用遠端 TLS 前請先新增資料平面 API 金鑰。", + "dash.serverRestartNotice": "監聽器設定已儲存,重新啟動代理後生效。", + "dash.serverSettingsTitle": "伺服器監聽器設定", + "dash.serverSaveFailed": "儲存伺服器監聽器設定失敗。", + "dash.serverTlsOff": "關閉", + "dash.serverCredentialConfigured": "已設定", + "dash.serverCredentialMissing": "缺少", + "dash.serverRestartRequired": "是", + "prov.cooldownCleared": "已清除冷卻", + "prov.cooldownClearFailed": "清除冷卻失敗", + "models.contextMetadataSource": "來源", + "models.contextMetadataLive": "已偵測", + "models.contextMetadataRegistry": "登錄檔", + "models.contextMetadataCursorStatic": "Cursor 精選", + "models.contextMetadataSnapshot": "已儲存", + "models.contextMetadataFallback": "回退", + "models.contextMetadataDerived": "衍生", + "models.contextMetadataUnknown": "未知", + "models.contextMetadataStale": "過期", + "models.contextTooltipDetected": "{value} · 已偵測", + "models.contextTooltipCapped": "{detected} 已偵測 · 上限 {cap}", + "models.contextTooltipStale": "{value}({when})", + "models.contextTooltipUnknown": "上下文視窗未知", + "models.contextTooltipSource": "{value}({source})", + "models.contextLastChecked": "上次檢查 {when}", + "models.contextYesterday": "昨天", + "models.customEditNamed": "編輯{name}", + "models.customDeleteNamed": "刪除{name}", + "sub.nativeParentOverride": "路由原生 V2 父代理", + "sub.nativeParentOverrideHint": "Codex 可能顯示原始原生模型,而 OpenCodex 會在所選路由目標上執行根請求。", + "sub.nativeParentOverridePrivacyWarning": "提示詞、儲存庫內容、對話記錄和工具結果會傳送至所選提供者,並繼承其可用性、內容、延遲、行為、成本和隱私特性。", + "sub.nativeParentOverrideModel": "原生父代理目標模型", + "sub.nativeParentOverrideV2Required": "需要明確 V2、上游 V2 旗標,並關閉「將 ChatGPT 保持在 v1」。", + "sub.nativeParentOverrideSaveFail": "儲存原生父代理路由失敗", + "sub.nativeParentOverrideSaved": "原生父代理路由已儲存。適用於後續父代理回合和壓縮。", + "sub.agentTaskRecovery": "恢復加密的 V2 任務", + "sub.agentTaskRecoveryHint": "透過 ChatGPT 解密 OpenAI Fernet 加密任務訊息,以便非 OpenAI 子代理可以讀取並執行路由的 V2 任務。", + "sub.agentTaskRecoveryModel": "恢復模型(預設:gpt-5.6-luna)", + "sub.agentTaskRecoveryDefault": "預設模型(gpt-5.6-luna)", + "sub.agentTaskRecoverySaveFail": "儲存加密任務恢復設定失敗", + "sub.agentTaskRecoverySaved": "加密任務恢復設定已儲存。", + "sub.injectionPrompt": "自訂父級指引", + "sub.injectionPromptHint": "取代內建 v2 spawn 說明。留空則使用內建文字。若需要即時值,請加入佔位符:", + "sub.injectionPromptSave": "儲存父級指引", + "sub.injectionPromptSaved": "父級指引已儲存。適用於新的 Codex 會話。", + "sub.injectionPromptSaveFailed": "儲存父級指引失敗", + "sub.childInstructions": "全域子代理說明", + "sub.childInstructionsHint": "套用到每個被 spawn 的子代理。角色說明在同步後僅套用到該專家。", + "sub.childInstructionsSave": "儲存子代理說明", + "sub.childInstructionsSaved": "子代理說明已儲存。適用於新的 Codex 會話。", + "sub.childInstructionsSaveFailed": "儲存子代理說明失敗", + "usage.heatmap.keyboardLabel": "使用上下方向鍵按天移動,使用左右方向鍵按週移動。", + "modal.catalogTabs": "供應商類別", + "modal.searchLabel": "搜尋供應商", + "modal.catalogLoadFailed": "無法載入供應商目錄。", + "pws.allAccounts": "全部帳號", + "pws.replayTransientFailures": "重試暫時性失敗", + "pws.replayTransientFailuresDesc": "在串流開始前至少重試一次暫時性失敗;請求可能會傳送多次。", + "pws.clearCooldown": "清除冷卻", + "pws.pacingJitter": "隨機抖動(毫秒)", + "pws.antigravityTlsTitle": "Antigravity 瀏覽器 TLS 設定檔", + "pws.antigravityTlsEnable": "啟用實驗性 TLS/HTTP2 模擬", + "pws.antigravityTlsWarning": "非官方相容功能。不保證符合服務條款或避免停權,可能讓流量更具辨識度;初始化失敗時會回退到 Bun。官方 Gemini API 金鑰、Vertex 或有文件的 Code Assist 路由較安全。", + "pws.antigravityTlsConfirm": "要啟用實驗性且非官方的 Antigravity TLS 設定檔嗎?它不保證符合政策,也無法避免停權。繼續嗎?", + "pws.antigravityTlsStatus": "TLS 設定檔狀態:{status}", + "pws.antigravityTlsDisabled": "已停用", + "pws.antigravityTlsActive": "啟用中", + "pws.antigravityTlsFallback": "回退", + "pws.aiStudio": "AI Studio", + "pws.aiStudio.connected": "已連線", + "pws.aiStudio.checking": "正在檢查工作階段", + "pws.aiStudio.unsupported": "此平台不支援原生登入", + "pws.aiStudio.needsReauth": "需要重新驗證", + "pws.aiStudio.reauthenticated": "重新驗證成功", + "pws.aiStudio.loginFailed": "原生登入失敗", + "pws.aiStudio.connect": "連線", + "claudeDesktop.catalogChanged": "編輯期間模型目錄已變更。已還原不可用的模型;請檢查配置後重新儲存。", + "claudeDesktop.mappingDetails": "進階模型對映", + "auth.adminTokenDesc": "請輸入你的 OPENCODEX_ADMIN_AUTH_TOKEN 以繼續。瀏覽器可能會為你保存以便下次使用。", + "auth.adminTokenPlaceholder": "貼上管理員金鑰", + "auth.adminTokenShow": "顯示", + "auth.adminTokenHide": "隱藏", + "logs.detail.attempt.recovery.cursorEnvelopeEcho": "Cursor 重放信封復原", + "logs.detail.attempt.recovery.cursorRoutingCommentary": "Cursor 路由解說復原", + "logs.detail.attempt.recovery.cursorDuplicateToolCall": "Cursor 重複工具呼叫復原", + "logs.detail.attempt.recovery.cursorOverflowRemint": "Cursor 溢位復原", + "logs.detail.attempt.recovery.cursorInvalidArgument": "Cursor 無效引數復原", + "sub.routedDelegationBridge": "路由 V2 委派橋接", + "sub.routedDelegationBridgeHint": "實驗性功能。啟用期間,建立、傳送和後續訊息皆為明文,包括原生代理之間的委派。Codex 仍可能顯示原始模型;路由內容和隱私依所選提供者而定。", + "sub.routedDelegationBridgeInactive": "已就緒;會為符合資格的原生 V2 根任務與產生的子回合啟用。", + "sub.routedDelegationBridgeSaveFail": "無法儲存路由 V2 委派橋接", + "sub.routedDelegationBridgeSaved": "已儲存路由 V2 委派橋接。停用會立即生效。", + "sub.prefer": "設為偏好", + "sub.preferred": "偏好", + "sub.preferredOutsideAdvertised": "偏好模型不在五個廣告覆寫中。它仍可透過完整 ID 執行,且不會自動插入。", + "sub.catalogState.label": "Codex 目錄", + "sub.catalogState.fresh": "最新", + "sub.catalogState.stale": "過期 — 重新啟動 Codex 以更新模型選擇器。", + "sub.catalogState.not_running": "沒有執行中的 Codex app-server", + "sub.catalogState.unknown": "未知", + "sub.nativeDefaultState.active": "已生效:同步後,省略模型的子代理會使用此偏好。", + "sub.nativeDefaultState.disabled": "已關閉省略模型同步;此偏好不是原生預設值。", + "sub.nativeDefaultState.pending": "等待中:省略模型行為生效前需要同步或重新啟動。", + "sub.nativeDefaultState.blocked": "已封鎖:使用者擁有或不相容的 Codex 設定阻止原生同步。", + "logs.filter.agent.label": "代理", + "logs.filter.agent.all": "全部", + "logs.filter.agent.main": "主代理", + "logs.filter.agent.subagent": "子代理", + "logs.filter.agent.internal": "內部", + "logs.filter.agent.unknown": "未知", + "logs.agent.main": "主代理", + "logs.agent.subagent": "子代理", + "logs.agent.internal": "內部", + "logs.agent.unknown": "未知", + "logs.agent.badgeTitle": "請求來源", + "models.pickerOrder.label": "模型選擇順序", + "models.pickerOrder.default": "預設", + "models.pickerOrder.alphabetical": "依模型名稱 A–Z", + "models.pickerOrder.provider": "依供應商分組", + "models.pickerOrder.mostUsed": "使用量快照", + "models.pickerOrder.custom": "自訂順序", + "models.pickerOrder.apply": "套用順序", + "models.pickerOrder.applying": "正在套用…", + "models.pickerOrder.saved": "選擇順序已儲存。若仍顯示舊目錄,請重新開啟用戶端。", + "models.pickerOrder.pending": "順序已儲存,目錄更新尚未完成。", + "models.pickerOrder.usageFailed": "無法載入模型使用量。", + "models.pickerOrder.loadFailed": "無法載入模型選擇設定。", + "models.pickerOrder.retry": "重試", + "models.pickerOrder.hint": "儲存 Codex 與 Claude 探索清單中的路由模型順序。保留精選與原生模型的優先級區間;使用量排序是快照,原生工具顯示的候選可能改變。", + "integrations.tab.raycast": "Raycast", + "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中新增一個 OpenCodex 供應商項目,讓所有已路由的模型出現在 Raycast AI 模型選擇器中。需要 Raycast Pro。", + "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。檔案會被寫入,但在 Pro 訂閱生效之前 Raycast 會忽略它。", + "integrations.raycast.planUnknown": "無法確認 Raycast Pro 是否已啟用;Custom Providers 需要 Raycast Pro。", + "integrations.raycast.revealConfig": "開啟 Raycast → 設定 → AI,點一次「Reveal Providers Config」,以便建立 providers 資料夾。", + "api.clientConfig.clientRaycast": "Raycast", "models.displayNameSavedRefreshFailed": "變更已儲存,但無法重新整理模型清單。請重試。", "models.displayNameOutcomeUnknown": "請求未完成。變更可能已儲存。再次變更之前,請重試以檢查目前名稱。", "models.displayNameCurrentUnavailable": "重新整理之前無法取得目前名稱", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b10c48688d..cf22e3f9d1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,20 +4,6 @@ import type { TKey } from "./en"; * Chinese i18n catalog; must match the `TKey` set (compile-checked). */ export const zh: Record = { - "models.pickerOrder.label": "模型选择顺序", - "models.pickerOrder.default": "默认", - "models.pickerOrder.alphabetical": "按模型名 A–Z", - "models.pickerOrder.provider": "按提供商分组", - "models.pickerOrder.mostUsed": "使用量快照", - "models.pickerOrder.custom": "自定义顺序", - "models.pickerOrder.apply": "应用顺序", - "models.pickerOrder.applying": "正在应用…", - "models.pickerOrder.saved": "选择顺序已保存。若仍显示旧目录,请重新打开客户端。", - "models.pickerOrder.pending": "顺序已保存,目录刷新尚未完成。", - "models.pickerOrder.usageFailed": "无法加载模型使用量。", - "models.pickerOrder.loadFailed": "无法加载模型选择设置。", - "models.pickerOrder.retry": "重试", - "models.pickerOrder.hint": "保存 Codex 和 Claude 发现列表中的路由模型顺序。保留精选与原生模型的优先级区间;使用量排序是快照,原生工具显示的候选可能变化。", "codexAuth.quotaAutoRefreshAllHint": "统一开关当前所有账户各自支持的 5 小时和每周额度窗口。在账户池模式下,重置后会发送消耗少量额度的请求。", "codexAuth.quotaAutoRefreshMixed": "部分窗口已启用。", "codexAuth.quotaAutoRefreshEmpty": "没有支持的额度窗口。请刷新账户额度。", @@ -582,7 +568,7 @@ export const zh: Record = { "models.setAllHint": "给所有已路由提供方打开 {value} 默认窗口。中转站没报 context_window / context_length 时,这个值就是 Codex 实际窗口。要给单个模型手写,用同一行上的「自定义窗口」。原生提供方不受影响。", "models.collapseAll": "全部折叠", "models.expandAll": "全部展开", - "models.orderHint": "默认顺序遵循精选项和目录优先级;同优先级的路由行按提供商、模型排序。保存的选择顺序可改变显示顺序,可见性开关用于筛选显示的行。客户端重新打开前可能仍显示旧目录。", + "models.orderHint": "选择器顺序:Subagents 中的选择(按所选顺序)→ 其余已路由模型(依次按提供方、模型 ID 字母排序)→ 原生模型。可见性开关仅用于筛选,不会改变此顺序。", "models.custom": "自定义…", "models.customApply": "应用", "models.customPlaceholder": "令牌 (例如 420000)", @@ -1110,7 +1096,6 @@ export const zh: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", - "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside 配置文件", "integrations.aside.profilesHint": "选择要接收所选模型的配置文件。Aside 当前使用的配置文件不会改变。", "integrations.aside.all": "同步所有配置文件", @@ -1270,10 +1255,6 @@ export const zh: Record = { "integrations.semantics.zcode": "仅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不会更改 Z.ai 登录状态或其他提供商。更改后请重启 ZCode。", "integrations.semantics.prime": "仅管理 Prime Agent 的 models.json 中的 providers.opencodex;默认位于 ~/.prime/agent,若设置 PRIME_AGENT_CODING_AGENT_DIR 则以其为准。不会更改其他提供商或模型覆盖设置。对新会话生效。", "integrations.semantics.aside": "仅管理此配置文件的 ~/.aside/u//models.json 中的 providers.opencodex。其他提供商保持不变。应用后请完全退出并重新打开 Aside。", - "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中添加一个 OpenCodex 提供商条目,让所有已路由的模型出现在 Raycast AI 模型选择器中。需要 Raycast Pro。", - "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。文件会被写入,但在 Pro 订阅生效之前 Raycast 会忽略它。", - "integrations.raycast.planUnknown": "无法确定 Raycast Pro 是否已激活;Custom Providers 需要 Raycast Pro。", - "integrations.raycast.revealConfig": "打开 Raycast → 设置 → AI,点击一次“Reveal Providers Config”,以便创建 providers 文件夹。", "codexAuth.mainAccount": "主账号", "codexAuth.logLabel": "日志标签", "codexAuth.codexApp": "Codex App", @@ -1605,7 +1586,6 @@ export const zh: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", - "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "复制配置", "api.clientConfig.download": "下载", "api.clientConfig.loading": "正在生成客户端配置…", @@ -1733,6 +1713,7 @@ export const zh: Record = { "common.close": "关闭", "common.ok": "确定", "app.logoAria": "opencodex 徽标", + "app.pageTitle": "{page} — opencodex", "app.claudeOn": "Claude 开", "app.claudeOff": "Claude 关", "usage.dayMon": "一", @@ -2575,6 +2556,231 @@ export const zh: Record = { "integrations.cursor.colReasoning": "推理", "integrations.cursor.colContext": "上下文", "integrations.cursor.guide": "打开 Cursor Private Inference 指南", + + // Fork-preserved dashboard surfaces. + "dash.activity.title": "30 天活动", + "dash.activity.requests": "请求", + "dash.activity.tokens": "Token", + "dash.activity.chartLabel": "30 天活动。使用左右方向键选择日期,使用 Home 和 End 跳转到范围两端。", + "dash.activity.viewUsage": "查看用量", + "sub.roles": "角色", + "sub.roles.hint": "父代理可以 spawn 的具名专家。每个角色固定模型和子代理提示。是否委派仍由父代理决定。", + "sub.roles.add": "添加角色", + "sub.roles.empty": "还没有角色。为评审、搜索或其他委派工作添加专家。", + "sub.roles.full": "角色目录已满(最多 8 个)", + "sub.roles.id": "角色名", + "sub.roles.description": "何时使用", + "sub.roles.descriptionHint": "展示给父代理,以便它知道何时 spawn 该专家。", + "sub.roles.model": "模型", + "sub.roles.effort": "推理强度", + "sub.roles.effortNone": "模型默认", + "sub.roles.instructions": "子代理说明", + "sub.roles.instructionsHint": "子代理加载的开发者提示。", + "sub.roles.enabled": "启用", + "sub.roles.delete": "删除角色 {id}", + "sub.roles.save": "保存角色", + "sub.roles.saved": "角色已保存。适用于新的 Codex 会话。", + "sub.roles.saveFailed": "保存角色失败", + "sub.roles.loadFail": "无法加载角色 — 代理是否在运行?", + "sub.roles.routedV2Warning": "此角色在 v2 表面上使用了路由模型,且未将 ChatGPT 保持在 v1。原生 v2 父会话会加密子任务。", + "sub.roles.sync": "同步到 Codex 代理文件", + "sub.roles.syncHint": "将已启用角色写入带标记的 ocx-*.toml。关闭时只清理 OpenCodex 拥有的文件,不改动你的文件。", + "cursorPool.title": "Cursor 账户池(实验性)", + "cursorPool.enabledDesc": "遇到 429 或认证失败时冷却该账户并为此对话故障转移。每个对话会保持一个账户,直到发生限速或凭据失败。", + "cursorPool.disabledDesc": "仅使用当前活跃的 Cursor 账户。仅在接受实验性路由时启用。", + "cursorPool.experimentalWarning": "实验性功能,尚未充分验证。Cursor 使用政策禁止规避速率限制和操纵用量计量。Teams 包含用量按席位计算,可能无法转移。除非了解风险,否则请保持关闭。", + "cursorPool.needTwoAccounts": "启用账户池前请至少添加两个 Cursor OAuth 账户。", + "cursorPool.loadFailed": "无法加载 Cursor 账户池设置。", + "cursorPool.saveFailed": "无法保存 Cursor 账户池设置。", + "cursorPool.on": "开", + "cursorPool.off": "关", + "antigravityRouting.failoverOnlyTitle": "多账户路由(仅故障转移)", + "antigravityRouting.failoverOnlyDesc": "每个对话保持一个 Antigravity 账户。遇到限速 429 或凭据失败时,opencodex 可为该对话故障转移到另一个符合条件的账户 — 不会在新会话间负载均衡,也不会更改其他进行中对话的全局活跃账户。", + "usage.chart.dayDetail": "{date}:{requests} 个请求,{tokens} 个 Token", + "replitGateway.title": "Replit 网关配套", + "replitGateway.subtitle": "连接您自行部署的 Replit 网关,并一起安装配对的 OpenAI Chat 与 Anthropic 提供方。", + "replitGateway.companionNote": "这是自定义配套流程,不是规范注册表预设。", + "replitGateway.originLabel": "部署 origin", + "replitGateway.originHint": "Replit 部署的 HTTPS origin(例如 https://my-app.replit.app)。", + "replitGateway.keyLabel": "网关密钥", + "replitGateway.keyHint": "部署中的 REPLIT_GATEWAY_KEY 密钥。它会保存在本地提供方配置中,并用于网关流量认证。", + "replitGateway.keyTooShort": "网关密钥过短(至少 32 个字符)。", + "replitGateway.keyTooLong": "网关密钥最多 512 个字符。", + "replitGateway.keyInvalidChars": "网关密钥只能包含可打印 ASCII 字符。", + "replitGateway.errorNetwork": "无法连接代理。请确认其正在运行后重试。", + "replitGateway.errorMalformed": "代理返回了无效响应。请稍后重试。", + "replitGateway.customDomainLabel": "允许自定义域名", + "replitGateway.customDomainHint": "仅当网关未托管在 .replit.app 上时启用。", + "replitGateway.setDefaultLabel": "安装后将 replit 设为默认提供方", + "replitGateway.install": "安装配对", + "replitGateway.installing": "安装中…", + "replitGateway.close": "关闭", + "replitGateway.openButton": "Replit 网关…", + "replitGateway.replaceTitle": "替换现有 Replit 配对?", + "replitGateway.replaceBody": "提供方 replit 与 replit-anthropic 已存在。替换会覆盖网关 origin 与密钥,但会保留您的覆盖设置。", + "replitGateway.replaceConfirm": "替换配对", + "replitGateway.replaceCancel": "保留现有", + "replitGateway.probeHealth": "healthz:HTTP {status}({ms} ms)", + "replitGateway.probeModels": "models:发现 {count} 个({ms} ms)", + "replitGateway.successTitle": "已安装 Replit 网关配对。", + "replitGateway.errorGeneric": "无法安装 Replit 网关配对。", + "replitGateway.errorCollision": "Replit 提供方配对已存在。", + "replitGateway.errorBusy": "配置繁忙。请稍后重试。", + "replitGateway.originRequired": "必须填写部署 origin。", + "replitGateway.keyRequired": "必须填写网关密钥。", + // Fork-preserved dashboard behavior catalog. + "common.copy": "复制", + "common.copied": "已复制", + "auth.adminTokenDesc": "输入你的 OPENCODEX_ADMIN_AUTH_TOKEN 以继续。浏览器可能会在下次为你保存。", + "auth.adminTokenPlaceholder": "粘贴管理员令牌", + "auth.adminTokenShow": "显示", + "auth.adminTokenHide": "隐藏", + "dash.connectionReconnecting": "连接中断 — 正在重新连接…", + "dash.serverModeLabel": "远程访问模式", + "dash.serverModeLoopback": "回环 + SSH", + "dash.serverModeRemote": "远程 TLS", + "dash.serverModeLoopbackHint": "推荐用于局域网。保持 127.0.0.1 并通过 SSH 转发连接,无需证书信任配置。", + "dash.serverModeRemoteHint": "直接 TLS 绑定(0.0.0.0),需要受信任的证书。", + "dash.serverLoopbackCardTitle": "回环 — SSH 转发", + "dash.serverLoopbackCardDesc": "在要打开控制台的电脑上运行此临时隧道,并保持终端窗口开启。", + "dash.serverLoopbackCardNote": "在浏览器中打开转发后的 HTTP URL。SSH 会加密连接;切勿使用 ssh -g。", + "dash.serverPersistentTunnelDocs": "设置永久 macOS 隧道", + "dash.serverRemoteCardTitle": "远程 TLS 绑定", + "dash.serverRemoteCardDesc": "代理通过 TLS 在所有网卡上监听。Codex 直接连接 https://:。证书须被 Codex 机器(rustls)信任,自签名证书在安装前会显示 UnknownIssuer。", + "dash.serverRemoteSanHint": "证书 SAN 须包含 publicOrigin 中的主机名或 IP。请使用受信任的 CA,或在 Codex 机器上安装私有 CA。", + "dash.serverAdvanced": "高级(主机名)", + "dash.serverCopyUnavailable": "无法复制", + "dash.serverRemoteCredentialRequired": "启用远程 TLS 前请先添加数据面 API 密钥。", + "dash.serverRestartNotice": "监听器设置已保存,重启代理后生效。", + "dash.serverSettingsTitle": "服务器监听器设置", + "dash.serverSaveFailed": "保存服务器监听器设置失败。", + "dash.serverTlsOff": "关闭", + "dash.serverCredentialConfigured": "已配置", + "dash.serverCredentialMissing": "缺失", + "dash.serverRestartRequired": "是", + "prov.cooldownCleared": "已清除冷却", + "prov.cooldownClearFailed": "清除冷却失败", + "models.contextMetadataSource": "来源", + "models.contextMetadataLive": "已检测", + "models.contextMetadataRegistry": "注册表", + "models.contextMetadataCursorStatic": "Cursor 精选", + "models.contextMetadataSnapshot": "已保存", + "models.contextMetadataFallback": "回退", + "models.contextMetadataDerived": "派生", + "models.contextMetadataUnknown": "未知", + "models.contextMetadataStale": "过期", + "models.contextTooltipDetected": "{value} · 已检测", + "models.contextTooltipCapped": "{detected} 已检测 · 上限 {cap}", + "models.contextTooltipStale": "{value} · {when}", + "models.contextTooltipUnknown": "上下文窗口未知", + "models.contextTooltipSource": "{value} · {source}", + "models.contextLastChecked": "上次检查 {when}", + "models.contextYesterday": "昨天", + "models.customEditNamed": "编辑{name}", + "models.customDeleteNamed": "删除{name}", + "sub.nativeParentOverride": "路由原生 V2 父代理", + "sub.nativeParentOverrideHint": "Codex 可能显示原始原生模型,而 OpenCodex 会在所选路由目标上执行根请求。", + "sub.nativeParentOverridePrivacyWarning": "提示词、仓库上下文、对话历史和工具结果会发送到所选提供商,并继承其可用性、上下文、延迟、行为、成本和隐私特征。", + "sub.nativeParentOverrideModel": "原生父代理目标模型", + "sub.nativeParentOverrideV2Required": "需要显式 V2、上游 V2 标志,并关闭“将 ChatGPT 保持在 v1”。", + "sub.nativeParentOverrideSaveFail": "保存原生父代理路由失败", + "sub.nativeParentOverrideSaved": "原生父代理路由已保存。适用于后续父代理轮次和压缩。", + "sub.agentTaskRecovery": "恢复加密的 V2 任务", + "sub.agentTaskRecoveryHint": "通过 ChatGPT 解密 OpenAI Fernet 加密任务消息,以便非 OpenAI 子代理可以读取并执行路由的 V2 任务。", + "sub.agentTaskRecoveryModel": "恢复模型(默认:gpt-5.6-luna)", + "sub.agentTaskRecoveryDefault": "默认模型(gpt-5.6-luna)", + "sub.agentTaskRecoverySaveFail": "保存加密任务恢复设置失败", + "sub.agentTaskRecoverySaved": "加密任务恢复设置已保存。", + "sub.injectionPrompt": "自定义父级指导", + "sub.injectionPromptHint": "替换内置 v2 spawn 说明。留空则使用内置文本。若需要实时值,请加入占位符:", + "sub.injectionPromptSave": "保存父级指导", + "sub.injectionPromptSaved": "父级指导已保存。适用于新的 Codex 会话。", + "sub.injectionPromptSaveFailed": "保存父级指导失败", + "sub.childInstructions": "全局子代理说明", + "sub.childInstructionsHint": "应用于每个被 spawn 的子代理。角色说明在同步后仅作用于该专家。", + "sub.childInstructionsSave": "保存子代理说明", + "sub.childInstructionsSaved": "子代理说明已保存。适用于新的 Codex 会话。", + "sub.childInstructionsSaveFailed": "保存子代理说明失败", + "logs.detail.attempt.recovery.cursorEnvelopeEcho": "Cursor 重放信封恢复", + "logs.detail.attempt.recovery.cursorRoutingCommentary": "Cursor 路由解说恢复", + "logs.detail.attempt.recovery.cursorDuplicateToolCall": "Cursor 重复工具调用恢复", + "logs.detail.attempt.recovery.cursorOverflowRemint": "Cursor 溢出恢复", + "logs.detail.attempt.recovery.cursorInvalidArgument": "Cursor 无效参数恢复", + "modal.catalogTabs": "提供方类别", + "modal.searchLabel": "搜索提供方", + "usage.heatmap.keyboardLabel": "使用上下方向键按天移动,使用左右方向键按周移动。", + "modal.catalogLoadFailed": "无法加载提供商目录。", + "pws.allAccounts": "全部账号", + "pws.replayTransientFailures": "重试暂时性失败", + "pws.replayTransientFailuresDesc": "在流开始前至少重试一次暂时性失败;请求可能会被传递多次。", + "pws.clearCooldown": "清除冷却", + "pws.pacingJitter": "随机抖动(毫秒)", + "pws.antigravityTlsTitle": "Antigravity 浏览器 TLS 配置", + "pws.antigravityTlsEnable": "启用实验性 TLS/HTTP2 模拟", + "pws.antigravityTlsWarning": "非官方兼容功能。它不保证遵守服务条款或避免停用,可能使流量更具特征;初始化失败时会回退到 Bun。官方 Gemini API 密钥、Vertex 或有文档的 Code Assist 路由更安全。", + "pws.antigravityTlsConfirm": "启用实验性且非官方的 Antigravity TLS 配置?它不保证符合政策,也不能避免停用。继续吗?", + "pws.antigravityTlsStatus": "TLS 配置状态:{status}", + "pws.antigravityTlsDisabled": "已停用", + "pws.antigravityTlsActive": "活动", + "pws.antigravityTlsFallback": "回退", + "pws.aiStudio": "AI Studio", + "pws.aiStudio.connected": "已连接", + "pws.aiStudio.checking": "正在检查会话", + "pws.aiStudio.unsupported": "此平台不支持原生登录", + "pws.aiStudio.needsReauth": "需要重新认证", + "pws.aiStudio.reauthenticated": "重新认证成功", + "pws.aiStudio.loginFailed": "原生登录失败", + "pws.aiStudio.connect": "连接", + "claudeDesktop.catalogChanged": "编辑期间模型目录已更改。已还原不可用的模型;请检查配置后重新保存。", + "claudeDesktop.mappingDetails": "高级模型映射", + "sub.routedDelegationBridge": "路由 V2 委派桥接", + "sub.routedDelegationBridgeHint": "实验性功能。启用期间,生成、发送和后续消息均为明文,包括原生代理之间的委派。Codex 仍可能显示原始模型;路由内容和隐私遵循所选提供商。", + "sub.routedDelegationBridgeInactive": "已就绪;对符合条件的原生 V2 根任务和生成的子回合激活。", + "sub.routedDelegationBridgeSaveFail": "无法保存路由 V2 委派桥接", + "sub.routedDelegationBridgeSaved": "已保存路由 V2 委派桥接。禁用会立即生效。", + "sub.prefer": "设为首选", + "sub.preferred": "首选", + "sub.preferredOutsideAdvertised": "首选模型不在五个公开覆盖项中。它仍可通过完整 ID 执行,不会自动插入。", + "sub.catalogState.label": "Codex 目录", + "sub.catalogState.fresh": "最新", + "sub.catalogState.stale": "已过期 — 重启 Codex 以刷新模型选择器。", + "sub.catalogState.not_running": "没有正在运行的 Codex app-server", + "sub.catalogState.unknown": "未知", + "sub.nativeDefaultState.active": "已生效:同步后,省略模型的子代理会使用此首选项。", + "sub.nativeDefaultState.disabled": "省略模型同步已关闭;此首选项不是原生默认值。", + "sub.nativeDefaultState.pending": "等待中:省略模型行为生效前需要同步或重启。", + "sub.nativeDefaultState.blocked": "已阻止:用户拥有或不兼容的 Codex 设置阻止原生同步。", + "logs.filter.agent.label": "代理", + "logs.filter.agent.all": "全部", + "logs.filter.agent.main": "主代理", + "logs.filter.agent.subagent": "子代理", + "logs.filter.agent.internal": "内部", + "logs.filter.agent.unknown": "未知", + "logs.agent.main": "主代理", + "logs.agent.subagent": "子代理", + "logs.agent.internal": "内部", + "logs.agent.unknown": "未知", + "logs.agent.badgeTitle": "请求来源", + "models.pickerOrder.label": "模型选择顺序", + "models.pickerOrder.default": "默认", + "models.pickerOrder.alphabetical": "按模型名 A–Z", + "models.pickerOrder.provider": "按提供商分组", + "models.pickerOrder.mostUsed": "使用量快照", + "models.pickerOrder.custom": "自定义顺序", + "models.pickerOrder.apply": "应用顺序", + "models.pickerOrder.applying": "正在应用…", + "models.pickerOrder.saved": "选择顺序已保存。若仍显示旧目录,请重新打开客户端。", + "models.pickerOrder.pending": "顺序已保存,目录刷新尚未完成。", + "models.pickerOrder.usageFailed": "无法加载模型使用量。", + "models.pickerOrder.loadFailed": "无法加载模型选择设置。", + "models.pickerOrder.retry": "重试", + "models.pickerOrder.hint": "保存 Codex 和 Claude 发现列表中的路由模型顺序。保留精选与原生模型的优先级区间;使用量排序是快照,原生工具显示的候选可能变化。", + "integrations.tab.raycast": "Raycast", + "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中添加一个 OpenCodex 提供商条目,让所有已路由的模型出现在 Raycast AI 模型选择器中。需要 Raycast Pro。", + "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。文件会被写入,但在 Pro 订阅生效之前 Raycast 会忽略它。", + "integrations.raycast.planUnknown": "无法确定 Raycast Pro 是否已激活;Custom Providers 需要 Raycast Pro。", + "integrations.raycast.revealConfig": "打开 Raycast → 设置 → AI,点击一次“Reveal Providers Config”,以便创建 providers 文件夹。", + "api.clientConfig.clientRaycast": "Raycast", "models.displayNameSavedRefreshFailed": "更改已保存,但无法刷新模型列表。请重试以刷新。", "models.displayNameOutcomeUnknown": "请求未完成。更改可能已保存。再次更改之前,请重试以检查当前名称。", "models.displayNameCurrentUnavailable": "刷新之前无法获取当前名称", diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index c967f9d4c0..717621cd2d 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -889,7 +889,7 @@ describe("GitHub Actions hardening", () => { scripts?: Record; }; expect(packageJson.scripts?.["audit:high"]).toBe( - "bun audit --audit-level=high && cd gui && bun audit --audit-level=high", + "bun run scripts/ci/audit-high.ts", ); expect(workflow).toContain("run: bun run audit:high"); expect(workflow).not.toContain("run: bun audit --audit-level=high"); @@ -1621,7 +1621,7 @@ describe("GitHub Actions hardening", () => { * await github.rest.pulls.update({ ...{ base: "main" }, owner, … }) * Object.assign(pr.base, { ref: EXPECTED_BASE }) * if (false) { …the entire body… } - * try { …the entire body… } catch {} + * try { …the entire body… } catch { /* no-op */ } * * A recording client does not care how the call was spelled. It records what * came out. `if (false)` and a swallowed exception show up as an empty call

Ba>p$xU-%CZ15eSXiGGY@+Ad1CW%9 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%0DYP=M4x*Sq0Ks2}hf7Oo=n+dCXl5zSJW~oZja| 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%0DEd-b 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 z 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