diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 67362b177e7..8c9f113168b 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -47,7 +47,7 @@ runs helper features around provider requests. | `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another's request record. Logs carry a hashed account key only. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. | | `spendPoolAliases?` | `Record` | unset | Explicit historical salted pool-alias to canonical provider mappings. Keep this key at the top level, outside `spend`. See [Historical pool continuity](#historical-pool-continuity). | -| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Redirect recognized Codex helper/shadow calls to a chosen model while preserving the request's configured reasoning effort. The default source prefixes are `gpt-6-luna` and `gpt-5.6-luna`; older clients through 0.144.x used `gpt-5.4-mini`, which `sourceModels` can restore. | +| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Redirect recognized Codex helper/shadow calls to a chosen model while preserving the request's configured reasoning effort. The default source prefixes are `gpt-6-luna` and `gpt-5.6-luna`. The dashboard exposes one toggle per source and keeps both Terra generations (`gpt-6.1-terra` and `gpt-5.6-terra`) and the retired `gpt-5.4-mini` helper off until selected. At least one source must stay enabled; disable the main switch to stop all shadow calls. | | `memoryModels?` | `{ extract?: { model: string; reasoningEffort?: string }; consolidation?: { model: string; reasoningEffort?: string } }` | off | Route Codex's two memory phases to a chosen model, with an optional reasoning effort per phase. See [Memory routing](#memory-routing). | | `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | Web-search sidecar options. | | `visionSidecar?` | `OcxVisionSidecarConfig` | on when usable | Image-description sidecar options. | @@ -932,10 +932,25 @@ proxy after editing `config.json` by hand. Dashboard saves apply immediately. Codex uses small helper models for tasks such as titles and commit messages. Enable `shadowCallIntercept` to redirect recognized source-model prefixes to another configured model. The -replacement keeps the request's configured reasoning effort. Set `sourceModels` only when a client -uses different helper ids. A non-empty `sourceModels` replaces the default prefixes instead of -extending them, so include `gpt-6-luna` (and `gpt-5.6-luna` for 0.145.0-0.153.x clients) in the -list when current clients should still be intercepted. +replacement keeps the request's configured reasoning effort. The dashboard lists the sources the +runtime supports and persists one switch per source through +`PUT /api/shadow-call-settings`; `GET` returns both the active `sourceModels` and that +`sourceModelOptions` inventory. A non-empty `sourceModels` replaces the default prefixes instead of +extending them, so include `gpt-6-luna` (and `gpt-5.6-luna` for 0.145.0-0.153.x clients) when +current clients should still be intercepted. The API rejects an empty, duplicate, whitespace-bearing, +or provider-qualified source prefix, caps the list at 32 prefixes of 128 characters each, and it +refuses a target that would intersect a selected source. +A rejected source list returns `code: "shadow_call_source_invalid"` and a conflicting target +returns `code: "shadow_call_target_invalid"`, so the dashboard can show the matching message +instead of treating every `400` or HTTP error as a network failure. +At least one source stays active; use the main switch to stop every shadow call. +Memory consolidation is separate: Codex asks for Terra there, and +`memoryModels.consolidation` controls that phase. Terra is also a normal foreground and subagent +model, so its source toggles stay off by default. Selecting the active Terra generation routes +consolidation through the shadow target only while no consolidation model is configured; a +configured Memory-Routing phase still wins. The dashboard keeps both `gpt-6.1-terra` and +`gpt-5.6-terra` in the source inventory and labels them as `Memory` rather than silently omitting +them. Matching bare model ids are eligible for interception, but explicit user turns keep their selected model: `x-codex-turn-metadata` must contain both `request_kind: "turn"` and `thread_source: "user"`. Spawned children also keep their model, identified by the `x-openai-subagent: collab_spawn` connection header (HTTP request or WebSocket diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index a22b130e072..0ad0aff6c21 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -280,7 +280,7 @@ See [Codex prompt layers](/guides/codex-prompt/) for the layer model and the key | `POST /api/update/run` | Asynchronously check a fresh package version, then start an update job, optionally followed by restart | 400 invalid body; job-specific conflict/error status | | `GET /api/update/status` | Poll an update job by id | 404 unknown job | | `GET, PUT /api/sidecar-settings` | Read or update web-search and vision sidecar model/backend settings | 400 invalid shape, backend, or limit | -| `GET, PUT /api/shadow-call-settings` | Read or update shadow-call interception settings | 400 invalid shape or value | +| `GET, PUT /api/shadow-call-settings` | Read or update shadow-call interception settings, including `sourceModels`; GET also returns the runtime-owned `sourceModelOptions` inventory | 400 invalid shape or value, including an empty, duplicate, whitespace-bearing, or provider-qualified source prefix, or more than 32 prefixes / 128 characters per prefix; source-list failures carry `code: shadow_call_source_invalid`, target conflicts `code: shadow_call_target_invalid` | ### Logs, usage, and storage diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a61b7a2c575..4b28c780394 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -580,13 +580,27 @@ export const de: Record = { "memoryModels.saved": "Memory-Einstellungen gespeichert.", "memoryModels.saveFailed": "Speichern fehlgeschlagen. Deine Änderungen stehen noch da; versuch es erneut.", "dash.shadowCallIntercept": "Shadow-Call-Abfangen", - "dash.shadowCallInterceptHint": "Fängt die Hintergrund-Hilfsaufrufe der Codex-App ({models}) ab und leitet sie an das gewählte Modell um.", - "dash.shadowCallWarning": "⚠ Bei Aktivierung werden ALLE Anfragen an {models} durch das gewählte Modell ersetzt.", + "shadowCall.memoryLabel": "Memory: Terra", + "shadowCall.sourceCoveredShort": "Abgedeckt durch {prefix}", + "shadowCall.sourceRequired": "Muss aktiv bleiben", + "shadowCall.sources": "Abgefangene Quellen", + "shadowCall.sourceHelper": "Hilfsmodell", + "shadowCall.sourceMemory": "Memory", + "shadowCall.sourceLegacy": "Legacy", + "shadowCall.sourceCustom": "Benutzerdefiniert", + "shadowCall.sourceHint": "Nur aktivierte Quellen werden abgefangen. Mindestens eine muss aktiv bleiben; der Hauptschalter schaltet das Abfangen aller Shadow-Calls aus. Memory-Routing hat bei konfigurierter Konsolidierung weiterhin Vorrang.", + "shadowCall.sourceCovered": "Wird vom benutzerdefinierten Präfix {prefix} abgedeckt. Entferne oder verenge ihn in config.json, um diese Quelle separat zu schalten.", + "shadowCall.sourceLastRequired": "Mindestens eine Quelle muss aktiv bleiben. Der Hauptschalter schaltet das Abfangen aller Shadow-Calls aus.", + "dash.shadowCallInterceptHint": "Fängt die Hintergrund-Hilfsaufrufe der Codex-App für Titel, Commit-Nachrichten und Skill-Orchestrierung ab. Wähle unten die Quellmodelle. Die Memory-Konsolidierung läuft normalerweise mit Terra und folgt bei Konfiguration dem Memory-Routing.", + "dash.shadowCallWarning": "⚠ Bei Aktivierung werden Anfragen an jede ausgewählte Quelle ({models}) durch das gewählte Modell ersetzt. Terra ist Opt-in; bei der Konsolidierung hat Memory-Routing weiterhin Vorrang.", "dash.shadowCallOriginal": "Original", "dash.shadowCallModel": "Ersatzmodell", - "dash.shadowCallTooltip": "Die Codex-App ruft im Hintergrund ein Hilfsmodell für Titelgenerierung, Commit-Nachrichten und Skill-Orchestrierung auf. Das Modell wechselt zwischen Client-Versionen, daher fängt opencodex diesen Satz ab: {models}.", + "dash.shadowCallTooltip": "Die Codex-App ruft im Hintergrund Hilfsmodelle für Titelgenerierung, Commit-Nachrichten und Skill-Orchestrierung auf. Das Modell wechselt zwischen Client-Versionen, daher kann opencodex mehrere Quellen abfangen. Wähle unten jede Quelle aus, die umgeleitet werden soll. Terra ist das Modell für die Memory-Konsolidierung und standardmäßig aus, weil Codex es auch für normale Arbeit wählt; ist ein Konsolidierungsmodell im Memory-Routing gesetzt, gewinnt diese Route. Mindestens eine Quelle muss aktiv bleiben; der Hauptschalter schaltet das Abfangen aller Shadow-Calls aus.", "models.shadowCallIntercept": "Shadow-Call-Abfangen", - "models.shadowCallInterceptHint": "Fängt die Hintergrund-Hilfsaufrufe der Codex-App ({models}) ab und leitet sie an das gewählte Modell um.", + "models.shadowCallInterceptHint": "Fängt die Hintergrund-Hilfsaufrufe der Codex-App für Titel, Commit-Nachrichten und Skill-Orchestrierung ab. Wähle unten die Quellmodelle. Die Memory-Konsolidierung läuft normalerweise mit Terra und folgt bei Konfiguration dem Memory-Routing.", + "models.shadowCallSaveFailed": "Shadow-Call-Einstellungen konnten nicht gespeichert werden.", + "models.shadowCallTargetInvalid": "Das Ersatzmodell überschneidet sich mit einer aktiven Quelle. Wähle ein anderes Ersatzmodell.", + "models.shadowCallSourceInvalid": "Diese Quellenauswahl ist ungültig. Wähle mindestens eine Quelle ohne Leerzeichen oder Provider-Präfix.", "models.autoReviewOverride": "Auto-Review-Überschreibung", "models.autoReviewOverrideHint": "Legt fest, welches Modell Codex für die automatische Prüfung (Approval-Reviewer) verwendet. Wird beim Katalog-Abgleich auf alle Zeilen angewendet; ein autoReviewModel auf Anbieterebene hat weiterhin Vorrang.", "models.autoReviewPending": "Ausstehend", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index b29633e01b6..d6529728972 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -598,13 +598,27 @@ export const en = { "memoryModels.saved": "Memory settings saved.", "memoryModels.saveFailed": "Could not save. Your changes are still here; try again.", "dash.shadowCallIntercept": "Shadow Call Intercept", - "dash.shadowCallInterceptHint": "Intercepts Codex App's background helper calls ({models}) for title generation and commit messages and redirects them to your chosen model.", - "dash.shadowCallWarning": "⚠ When enabled, ALL requests for {models} will be replaced with the selected model.", + "shadowCall.memoryLabel": "Memory: Terra", + "shadowCall.sourceCoveredShort": "Covered by {prefix}", + "shadowCall.sourceRequired": "Must stay on", + "dash.shadowCallInterceptHint": "Intercepts Codex App's background helper calls for titles, commit messages, and skill orchestration. Choose the source models below. Memory consolidation normally runs on Terra and follows Memory-Routing when configured.", + "dash.shadowCallWarning": "⚠ When enabled, requests for every selected source ({models}) are replaced with the chosen model. Terra is opt-in; Memory-Routing still wins for consolidation.", "dash.shadowCallOriginal": "Original", "dash.shadowCallModel": "Replacement model", - "dash.shadowCallTooltip": "Codex App makes background helper calls for thread title generation, commit message generation, and skill orchestration. The helper model changed across client versions, so opencodex intercepts every model in this set: {models}. Enable this to redirect those calls to your chosen model.", + "dash.shadowCallTooltip": "Codex App makes background helper calls for thread title generation, commit message generation, and skill orchestration. The helper model changed across client versions, so opencodex can intercept several sources. Select each source you want to redirect below. Terra is the memory-consolidation model and stays off by default because Codex also selects it for ordinary work; when a Memory-Routing consolidation model is configured, that route wins. At least one source must stay enabled; use the main switch to turn off shadow-call interception.", + "shadowCall.sources": "Intercepted sources", + "shadowCall.sourceHelper": "Helper", + "shadowCall.sourceMemory": "Memory", + "shadowCall.sourceLegacy": "Legacy", + "shadowCall.sourceCustom": "Custom", + "shadowCall.sourceHint": "Only enabled sources are intercepted. At least one must stay on; use the main switch to turn off shadow-call interception. Memory-Routing still wins for consolidation when configured.", + "shadowCall.sourceCovered": "Covered by the custom prefix {prefix}. Remove or narrow it in config.json to toggle this source separately.", + "shadowCall.sourceLastRequired": "At least one source must stay enabled. Use the main switch to turn off shadow-call interception.", "models.shadowCallIntercept": "Shadow Call Intercept", - "models.shadowCallInterceptHint": "Intercepts Codex App's background helper calls ({models}) for titles and commit messages and redirects them to your chosen model.", + "models.shadowCallInterceptHint": "Intercepts Codex App's background helper calls for titles, commit messages, and skill orchestration. Choose the source models below. Memory consolidation normally runs on Terra and follows Memory-Routing when configured.", + "models.shadowCallSaveFailed": "Could not save the shadow-call settings.", + "models.shadowCallTargetInvalid": "The replacement target overlaps an active source. Choose another replacement model.", + "models.shadowCallSourceInvalid": "This source selection is invalid. Choose at least one source without whitespace or a provider prefix.", "models.autoReviewOverride": "Auto-review override", "models.autoReviewOverrideHint": "Chooses the model Codex uses for auto-review (the approval reviewer). Applied to every catalog row at sync; a provider-scoped autoReviewModel still takes precedence on its own rows.", "models.autoReviewPending": "Pending", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 8cb9f5701cb..856b968a068 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -588,13 +588,27 @@ export const fr: Record = { "memoryModels.saved": "Réglages de mémoire enregistrés.", "memoryModels.saveFailed": "Échec de l'enregistrement. Vos modifications sont conservées ; réessayez.", "dash.shadowCallIntercept": "Interception des appels fantômes", - "dash.shadowCallInterceptHint": "Intercepte les appels auxiliaires en arrière-plan de l’application Codex ({models}) pour générer les titres et les messages de commit, puis les redirige vers le modèle choisi.", - "dash.shadowCallWarning": "⚠ Lorsque cette option est activée, TOUTES les requêtes destinées à {models} sont remplacées par le modèle sélectionné.", + "shadowCall.memoryLabel": "Mémoire : Terra", + "shadowCall.sourceCoveredShort": "Couvert par {prefix}", + "shadowCall.sourceRequired": "Doit rester actif", + "shadowCall.sources": "Sources interceptées", + "shadowCall.sourceHelper": "Auxiliaire", + "shadowCall.sourceMemory": "Mémoire", + "shadowCall.sourceLegacy": "Hérité", + "shadowCall.sourceCustom": "Personnalisé", + "shadowCall.sourceHint": "Seules les sources activées sont interceptées. Au moins une doit rester active ; utilisez l’interrupteur principal pour désactiver l’interception des appels fantômes. Le routage de la mémoire reste prioritaire pour la consolidation lorsqu’il est configuré.", + "shadowCall.sourceCovered": "Couvert par le préfixe personnalisé {prefix}. Supprimez-le ou réduisez-le dans config.json pour activer cette source séparément.", + "shadowCall.sourceLastRequired": "Au moins une source doit rester active. Utilisez l’interrupteur principal pour désactiver l’interception des appels fantômes.", + "dash.shadowCallInterceptHint": "Intercepte les appels auxiliaires en arrière-plan de l’application Codex pour les titres, les messages de commit et l’orchestration des compétences. Choisissez les modèles sources ci-dessous. La consolidation de la mémoire utilise normalement Terra et suit le routage de la mémoire lorsqu’il est configuré.", + "dash.shadowCallWarning": "⚠ Lorsque cette option est activée, les requêtes de chaque source sélectionnée ({models}) sont remplacées par le modèle choisi. Terra est optionnel ; le routage de la mémoire reste prioritaire pour la consolidation.", "dash.shadowCallOriginal": "Original", "dash.shadowCallModel": "Modèle de remplacement", - "dash.shadowCallTooltip": "L’application Codex effectue des appels auxiliaires en arrière-plan pour générer les titres de fils, les messages de commit et orchestrer les compétences. Le modèle auxiliaire ayant changé selon les versions du client, opencodex intercepte tous les modèles de cet ensemble : {models}. Activez cette option pour rediriger ces appels vers le modèle choisi.", + "dash.shadowCallTooltip": "L’application Codex effectue des appels auxiliaires en arrière-plan pour générer les titres de fils, les messages de commit et orchestrer les compétences. Le modèle auxiliaire ayant changé selon les versions du client, opencodex peut intercepter plusieurs sources. Sélectionnez ci-dessous chaque source à rediriger. Terra est le modèle de consolidation de la mémoire et reste désactivé par défaut, car Codex le choisit aussi pour le travail ordinaire ; lorsqu’un modèle de consolidation est configuré dans le routage de la mémoire, cette route l’emporte. Au moins une source doit rester active ; utilisez l’interrupteur principal pour désactiver l’interception des appels fantômes.", "models.shadowCallIntercept": "Interception des appels fantômes", - "models.shadowCallInterceptHint": "Intercepte les appels auxiliaires en arrière-plan de l’application Codex ({models}) pour les titres et les messages de commit, puis les redirige vers le modèle choisi.", + "models.shadowCallInterceptHint": "Intercepte les appels auxiliaires en arrière-plan de l’application Codex pour les titres, les messages de commit et l’orchestration des compétences. Choisissez les modèles sources ci-dessous. La consolidation de la mémoire utilise normalement Terra et suit le routage de la mémoire lorsqu’il est configuré.", + "models.shadowCallSaveFailed": "Impossible d’enregistrer les paramètres des appels fantômes.", + "models.shadowCallTargetInvalid": "Le modèle de remplacement recouvre une source active. Choisissez un autre modèle de remplacement.", + "models.shadowCallSourceInvalid": "Cette sélection de sources n’est pas valide. Choisissez au moins une source sans espace ni préfixe de fournisseur.", "models.autoReviewOverride": "Remplacement de la revue automatique", "models.autoReviewOverrideHint": "Choisit le modèle que Codex utilise pour la revue automatique (le modèle de revue des demandes d'approbation). Appliqué à chaque ligne du catalogue à la synchronisation ; un autoReviewModel défini au niveau du fournisseur reste prioritaire sur ses lignes.", "models.autoReviewPending": "En attente", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c465fa086b5..440a762b8a1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -589,13 +589,27 @@ export const ja: Record = { "memoryModels.saved": "メモリ設定を保存しました。", "memoryModels.saveFailed": "保存できませんでした。変更は残っています。もう一度お試しください。", "dash.shadowCallIntercept": "シャドウコール傍受", - "dash.shadowCallInterceptHint": "Codex App のバックグラウンドヘルパー呼び出し({models}: タイトル生成、コミットメッセージ)を傍受し、選択したモデルにリダイレクトします。", - "dash.shadowCallWarning": "⚠ オンにすると、{models} へのリクエストがすべて選択したモデルに置き換えられます。", + "shadowCall.memoryLabel": "メモリ: Terra", + "shadowCall.sourceCoveredShort": "{prefix} に含まれる", + "shadowCall.sourceRequired": "オンのまま", + "shadowCall.sources": "傍受するソース", + "shadowCall.sourceHelper": "ヘルパー", + "shadowCall.sourceMemory": "メモリ", + "shadowCall.sourceLegacy": "レガシー", + "shadowCall.sourceCustom": "カスタム", + "shadowCall.sourceHint": "有効なソースだけが傍受されます。少なくとも 1 つを有効にしておく必要があります。すべてのシャドウコールの傍受を止めるにはメインスイッチを使います。メモリの統合では、設定済みのメモリルーティングが引き続き優先されます。", + "shadowCall.sourceCovered": "カスタムプレフィックス {prefix} に含まれています。このソースを個別に切り替えるには、config.json でそのプレフィックスを削除するか範囲を狭めてください。", + "shadowCall.sourceLastRequired": "少なくとも 1 つのソースを有効にしておく必要があります。すべてのシャドウコールの傍受を止めるにはメインスイッチを使います。", + "dash.shadowCallInterceptHint": "Codex App のバックグラウンドヘルパー呼び出し(タイトル、コミットメッセージ、スキルオーケストレーション)を傍受します。下でソースモデルを選択してください。メモリの統合は通常 Terra で実行され、設定されている場合はメモリルーティングに従います。", + "dash.shadowCallWarning": "⚠ オンにすると、選択した各ソース({models})へのリクエストが選択したモデルに置き換えられます。Terra はオプトインです。統合では、設定済みのメモリルーティングが引き続き優先されます。", "dash.shadowCallOriginal": "元のモデル", "dash.shadowCallModel": "差し替えモデル", - "dash.shadowCallTooltip": "Codex App はスレッドタイトル生成、コミットメッセージ生成、スキルオーケストレーションをバックグラウンドで呼び出します。使われるモデルはクライアントのバージョンによって変わるため、opencodex は {models} をまとめて傍受します。これをオンにすると、それらの呼び出しを選択したモデルにリダイレクトします。", + "dash.shadowCallTooltip": "Codex App はスレッドタイトル生成、コミットメッセージ生成、スキルオーケストレーションをバックグラウンドで呼び出します。使われるモデルはクライアントのバージョンによって変わるため、opencodex は複数のソースを傍受できます。下でリダイレクトするソースを個別に選択してください。Terra はメモリ統合用のモデルで、Codex が通常の作業でも選択するため既定ではオフです。メモリルーティングに統合モデルが設定されている場合は、そのルートが優先されます。少なくとも 1 つのソースを有効にしておく必要があります。すべてのシャドウコールの傍受を止めるにはメインスイッチを使います。", "models.shadowCallIntercept": "シャドウコール傍受", - "models.shadowCallInterceptHint": "Codex App のバックグラウンドヘルパー呼び出し({models}: タイトル、コミットメッセージ)を傍受し、選択したモデルにリダイレクトします。", + "models.shadowCallInterceptHint": "Codex App のバックグラウンドヘルパー呼び出し(タイトル、コミットメッセージ、スキルオーケストレーション)を傍受します。下でソースモデルを選択してください。メモリの統合は通常 Terra で実行され、設定されている場合はメモリルーティングに従います。", + "models.shadowCallSaveFailed": "シャドウコール設定を保存できませんでした。", + "models.shadowCallTargetInvalid": "代替モデルが有効なソースと重なっています。別の代替モデルを選んでください。", + "models.shadowCallSourceInvalid": "このソース選択は無効です。空白やプロバイダープレフィックスを含まないソースを 1 つ以上選んでください。", "models.autoReviewOverride": "自動レビューの上書き", "models.autoReviewOverrideHint": "Codex の自動レビュー(承認レビュアー)が使うモデルを指定します。カタログ同期時にすべての行へ適用され、プロバイダー固有の autoReviewModel がその行では引き続き優先されます。", "models.autoReviewPending": "保留中", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 3e421dbacf7..616053154d7 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -584,13 +584,27 @@ export const ko: Record = { "memoryModels.saved": "메모리 설정을 저장했습니다.", "memoryModels.saveFailed": "저장하지 못했습니다. 변경 사항은 그대로 있습니다. 다시 시도하세요.", "dash.shadowCallIntercept": "쉐도우 호출 가로채기", - "dash.shadowCallInterceptHint": "Codex 앱이 제목·커밋 메시지 생성에 쓰는 백그라운드 호출({models})을 가로채 선택한 모델로 바꿉니다.", - "dash.shadowCallWarning": "⚠ 활성화하면 {models} 요청이 모두 선택한 모델로 대체됩니다.", + "shadowCall.memoryLabel": "메모리: Terra", + "shadowCall.sourceCoveredShort": "{prefix}에 포함됨", + "shadowCall.sourceRequired": "켜 둬야 함", + "shadowCall.sources": "가로챌 소스", + "shadowCall.sourceHelper": "헬퍼", + "shadowCall.sourceMemory": "메모리", + "shadowCall.sourceLegacy": "레거시", + "shadowCall.sourceCustom": "사용자 지정", + "shadowCall.sourceHint": "활성화된 소스만 가로챕니다. 최소 하나는 켜 두어야 하며, 모든 shadow 호출 가로채기를 끄려면 기본 스위치를 사용하세요. 메모리 통합은 설정된 경우 메모리 라우팅이 계속 우선합니다.", + "shadowCall.sourceCovered": "사용자 지정 접두사 {prefix}에 포함됩니다. 이 소스를 따로 전환하려면 config.json에서 해당 접두사를 제거하거나 범위를 좁히세요.", + "shadowCall.sourceLastRequired": "최소 하나의 소스는 켜 두어야 합니다. 모든 shadow 호출 가로채기를 끄려면 기본 스위치를 사용하세요.", + "dash.shadowCallInterceptHint": "Codex 앱이 제목·커밋 메시지·스킬 오케스트레이션에 쓰는 백그라운드 호출을 가로챕니다. 아래에서 소스 모델을 선택하세요. 메모리 통합은 보통 Terra로 실행되며 설정된 경우 메모리 라우팅을 따릅니다.", + "dash.shadowCallWarning": "⚠ 활성화하면 선택한 각 소스({models})에 대한 요청이 선택한 모델로 대체됩니다. Terra는 선택 사항이며, 통합에서는 설정된 메모리 라우팅이 계속 우선합니다.", "dash.shadowCallOriginal": "원본", "dash.shadowCallModel": "대체 모델", - "dash.shadowCallTooltip": "Codex 앱은 스레드 제목 자동 생성, 커밋 메시지 생성, 스킬 오케스트레이션 같은 내부 작업을 백그라운드로 호출합니다. 이때 쓰는 모델은 클라이언트 버전마다 달라서 opencodex는 {models}를 모두 가로챕니다. 이 설정을 켜면 해당 호출이 선택한 모델로 넘어갑니다.", + "dash.shadowCallTooltip": "Codex 앱은 스레드 제목 생성, 커밋 메시지 생성, 스킬 오케스트레이션 같은 내부 작업을 백그라운드로 호출합니다. 이때 쓰는 모델은 클라이언트 버전마다 달라서 opencodex는 여러 소스를 가로챌 수 있습니다. 아래에서 리디렉션할 소스를 각각 선택하세요. Terra는 메모리 통합 모델이며 Codex가 일반 작업에도 선택하므로 기본적으로 꺼져 있습니다. 메모리 라우팅에 통합 모델이 설정되어 있으면 해당 경로가 우선합니다. 최소 하나의 소스는 켜 두어야 하며, 모든 shadow 호출 가로채기를 끄려면 기본 스위치를 사용하세요.", "models.shadowCallIntercept": "쉐도우 호출 가로채기", - "models.shadowCallInterceptHint": "Codex 앱의 백그라운드 호출({models}, 제목·커밋 메시지)을 가로채 선택한 모델로 바꿉니다.", + "models.shadowCallInterceptHint": "Codex 앱이 제목·커밋 메시지·스킬 오케스트레이션에 쓰는 백그라운드 호출을 가로챕니다. 아래에서 소스 모델을 선택하세요. 메모리 통합은 보통 Terra로 실행되며 설정된 경우 메모리 라우팅을 따릅니다.", + "models.shadowCallSaveFailed": "shadow 호출 설정을 저장하지 못했습니다.", + "models.shadowCallTargetInvalid": "대체 모델이 활성 소스와 겹칩니다. 다른 대체 모델을 선택하세요.", + "models.shadowCallSourceInvalid": "이 소스 선택은 유효하지 않습니다. 공백이나 공급자 접두사가 없는 소스를 하나 이상 선택하세요.", "models.autoReviewOverride": "자동 검토 재정의", "models.autoReviewOverrideHint": "Codex의 자동 검토(승인 검토자)에 사용할 모델을 지정합니다. 카탈로그 동기화 시 모든 행에 적용되며, 공급자별 autoReviewModel이 해당 행에서 계속 우선합니다.", "models.autoReviewPending": "보류 중", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index b87192b6f31..0fe1e35f2a4 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -602,13 +602,27 @@ export const pt: Record = { "memoryModels.saved": "Configurações de memória salvas.", "memoryModels.saveFailed": "Não foi possível salvar. Suas alterações continuam aqui; tente novamente.", "dash.shadowCallIntercept": "Shadow Call Intercept", - "dash.shadowCallInterceptHint": "Intercepta as chamadas auxiliares em segundo plano do Codex App ({models}) para geração de títulos e mensagens de commit e as redireciona ao modelo escolhido.", - "dash.shadowCallWarning": "⚠ Quando ativado, TODAS as requisições para {models} serão substituídas pelo modelo selecionado.", + "shadowCall.memoryLabel": "Memória: Terra", + "shadowCall.sourceCoveredShort": "Coberto por {prefix}", + "shadowCall.sourceRequired": "Deve ficar ativo", + "shadowCall.sources": "Fontes interceptadas", + "shadowCall.sourceHelper": "Auxiliar", + "shadowCall.sourceMemory": "Memória", + "shadowCall.sourceLegacy": "Legado", + "shadowCall.sourceCustom": "Personalizado", + "shadowCall.sourceHint": "Somente as fontes ativadas são interceptadas. Pelo menos uma deve permanecer ativa; use o interruptor principal para desativar a interceptação de todas as chamadas shadow. O roteamento de memória ainda vence na consolidação quando configurado.", + "shadowCall.sourceCovered": "Coberto pelo prefixo personalizado {prefix}. Remova-o ou restrinja-o em config.json para alternar esta fonte separadamente.", + "shadowCall.sourceLastRequired": "Pelo menos uma fonte deve permanecer ativa. Use o interruptor principal para desativar a interceptação de todas as chamadas shadow.", + "dash.shadowCallInterceptHint": "Intercepta as chamadas auxiliares em segundo plano do Codex App para títulos, mensagens de commit e orquestração de skills. Escolha os modelos de origem abaixo. A consolidação de memória normalmente usa Terra e segue o roteamento de memória quando configurado.", + "dash.shadowCallWarning": "⚠ Quando ativado, as requisições para cada fonte selecionada ({models}) são substituídas pelo modelo escolhido. Terra é opcional; o roteamento de memória ainda vence na consolidação.", "dash.shadowCallOriginal": "Original", "dash.shadowCallModel": "Modelo substituto", - "dash.shadowCallTooltip": "O Codex App faz chamadas auxiliares em segundo plano para geração de título de thread, geração de mensagem de commit e orquestração de skills. O modelo auxiliar mudou entre versões do cliente, então o opencodex intercepta todos os modelos deste conjunto: {models}. Ative para redirecionar essas chamadas ao modelo escolhido.", + "dash.shadowCallTooltip": "O Codex App faz chamadas auxiliares em segundo plano para geração de título de thread, geração de mensagem de commit e orquestração de skills. O modelo auxiliar mudou entre versões do cliente, então o opencodex pode interceptar várias fontes. Selecione abaixo cada fonte que deseja redirecionar. Terra é o modelo de consolidação de memória e fica desativado por padrão porque o Codex também o seleciona para trabalho comum; quando um modelo de consolidação está configurado no roteamento de memória, essa rota vence. Pelo menos uma fonte deve permanecer ativa; use o interruptor principal para desativar a interceptação de todas as chamadas shadow.", "models.shadowCallIntercept": "Shadow Call Intercept", - "models.shadowCallInterceptHint": "Intercepta as chamadas auxiliares em segundo plano do Codex App ({models}) para títulos e mensagens de commit e as redireciona ao modelo escolhido.", + "models.shadowCallInterceptHint": "Intercepta as chamadas auxiliares em segundo plano do Codex App para títulos, mensagens de commit e orquestração de skills. Escolha os modelos de origem abaixo. A consolidação de memória normalmente usa Terra e segue o roteamento de memória quando configurado.", + "models.shadowCallSaveFailed": "Não foi possível salvar as configurações de chamadas shadow.", + "models.shadowCallTargetInvalid": "O modelo substituto sobrepõe uma fonte ativa. Escolha outro modelo substituto.", + "models.shadowCallSourceInvalid": "Esta seleção de fontes é inválida. Escolha pelo menos uma fonte sem espaços ou prefixo de provedor.", "models.autoReviewOverride": "Substituição da revisão automática", "models.autoReviewOverrideHint": "Define o modelo que o Codex usa na revisão automática (revisor de aprovação). Aplicado a todas as linhas do catálogo na sincronização; um autoReviewModel no provedor continua tendo prioridade nas próprias linhas.", "models.autoReviewPending": "Pendente", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 24d05e13f3a..672805f5956 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -589,13 +589,27 @@ export const ru: Record = { "memoryModels.saved": "Настройки памяти сохранены.", "memoryModels.saveFailed": "Не удалось сохранить. Ваши изменения на месте; попробуйте снова.", "dash.shadowCallIntercept": "Перехват теневых вызовов", - "dash.shadowCallInterceptHint": "Перехватывает фоновые служебные вызовы Codex App ({models}: генерация заголовков, сообщений коммитов) и перенаправляет их на выбранную вами модель.", - "dash.shadowCallWarning": "⚠ Когда функция включена, ВСЕ запросы к {models} будут заменены выбранной моделью.", + "shadowCall.memoryLabel": "Память: Terra", + "shadowCall.sourceCoveredShort": "Покрыт {prefix}", + "shadowCall.sourceRequired": "Должен остаться", + "shadowCall.sources": "Перехватываемые источники", + "shadowCall.sourceHelper": "Вспомогательная", + "shadowCall.sourceMemory": "Память", + "shadowCall.sourceLegacy": "Устаревшая", + "shadowCall.sourceCustom": "Пользовательская", + "shadowCall.sourceHint": "Перехватываются только включённые источники. Хотя бы один должен оставаться включённым; чтобы отключить перехват всех shadow-вызовов, используйте главный переключатель. Если настроена маршрутизация памяти, она по-прежнему имеет приоритет для консолидации.", + "shadowCall.sourceCovered": "Покрывается пользовательским префиксом {prefix}. Удалите или сузьте его в config.json, чтобы переключать этот источник отдельно.", + "shadowCall.sourceLastRequired": "Хотя бы один источник должен оставаться включённым. Чтобы отключить перехват всех shadow-вызовов, используйте главный переключатель.", + "dash.shadowCallInterceptHint": "Перехватывает фоновые служебные вызовы Codex App для заголовков, сообщений коммитов и оркестрации навыков. Выберите исходные модели ниже. Консолидация памяти обычно выполняется на Terra и следует маршрутизации памяти, если она настроена.", + "dash.shadowCallWarning": "⚠ Когда функция включена, запросы к каждому выбранному источнику ({models}) заменяются выбранной моделью. Terra включается отдельно; при консолидации маршрутизация памяти по-прежнему имеет приоритет.", "dash.shadowCallOriginal": "Оригинал", "dash.shadowCallModel": "Модель-замена", - "dash.shadowCallTooltip": "Codex App в фоновом режиме вызывает служебную модель для генерации заголовков тредов, сообщений коммитов и оркестрации навыков. Эта модель менялась между версиями клиента, поэтому opencodex перехватывает весь набор: {models}. Включите функцию, чтобы перенаправлять такие вызовы на выбранную вами модель.", + "dash.shadowCallTooltip": "Codex App в фоновом режиме вызывает служебные модели для генерации заголовков тредов, сообщений коммитов и оркестрации навыков. Модель менялась между версиями клиента, поэтому opencodex может перехватывать несколько источников. Выберите ниже каждый источник, который нужно перенаправлять. Terra — модель консолидации памяти; по умолчанию она выключена, потому что Codex также выбирает её для обычной работы. Если в маршрутизации памяти задана модель консолидации, этот маршрут имеет приоритет. Хотя бы один источник должен оставаться включённым; чтобы отключить перехват всех shadow-вызовов, используйте главный переключатель.", "models.shadowCallIntercept": "Перехват теневых вызовов", - "models.shadowCallInterceptHint": "Перехватывает фоновые служебные вызовы Codex App ({models}: заголовки, сообщения коммитов) и перенаправляет их на выбранную вами модель.", + "models.shadowCallInterceptHint": "Перехватывает фоновые служебные вызовы Codex App для заголовков, сообщений коммитов и оркестрации навыков. Выберите исходные модели ниже. Консолидация памяти обычно выполняется на Terra и следует маршрутизации памяти, если она настроена.", + "models.shadowCallSaveFailed": "Не удалось сохранить настройки теневых вызовов.", + "models.shadowCallTargetInvalid": "Модель-замена пересекается с активным источником. Укажите другую модель-замену.", + "models.shadowCallSourceInvalid": "Этот набор источников недопустим. Укажите хотя бы один источник без пробелов и префикса провайдера.", "models.autoReviewOverride": "Переопределение авто-проверки", "models.autoReviewOverrideHint": "Задаёт модель, которую Codex использует для автоматической проверки (ревьюера подтверждений). Применяется ко всем строкам каталога при синхронизации; autoReviewModel на уровне провайдера по-прежнему имеет приоритет.", "models.autoReviewPending": "Ожидание", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index c2a550b18d7..6b345e1c571 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -590,13 +590,27 @@ export const tr: Record = { "memoryModels.saved": "Bellek ayarları kaydedildi.", "memoryModels.saveFailed": "Kaydedilemedi. Değişiklikleriniz duruyor; tekrar deneyin.", "dash.shadowCallIntercept": "Gölge Çağrı Yakalama", - "dash.shadowCallInterceptHint": "Codex App'in arka plan yardımcı çağrılarını ({models}) başlık oluşturma ve commit mesajları için yakalar ve seçtiğiniz modele yönlendirir.", - "dash.shadowCallWarning": "⚠ Etkinleştirildiğinde, {models} için olan TÜM istekler seçilen modelle değiştirilecektir.", + "shadowCall.memoryLabel": "Bellek: Terra", + "shadowCall.sourceCoveredShort": "{prefix} kapsıyor", + "shadowCall.sourceRequired": "Açık kalmalı", + "shadowCall.sources": "Yakalanan kaynaklar", + "shadowCall.sourceHelper": "Yardımcı", + "shadowCall.sourceMemory": "Bellek", + "shadowCall.sourceLegacy": "Eski", + "shadowCall.sourceCustom": "Özel", + "shadowCall.sourceHint": "Yalnızca etkin kaynaklar yakalanır. En az biri açık kalmalıdır; tüm shadow çağrılarının yakalanmasını durdurmak için ana anahtarı kullanın. Bellek yönlendirmesi yapılandırıldığında birleştirme için yine önceliklidir.", + "shadowCall.sourceCovered": "Özel {prefix} öneki tarafından kapsanıyor. Bu kaynağı ayrı ayrı değiştirmek için config.json içinde öneki kaldırın veya daraltın.", + "shadowCall.sourceLastRequired": "En az bir kaynak açık kalmalıdır. Tüm shadow çağrılarının yakalanmasını durdurmak için ana anahtarı kullanın.", + "dash.shadowCallInterceptHint": "Codex App'in başlık, commit mesajı ve beceri orkestrasyonu için yaptığı arka plan yardımcı çağrılarını yakalar. Aşağıdan kaynak modelleri seçin. Bellek birleştirme normalde Terra ile çalışır ve yapılandırıldığında Bellek yönlendirmesini izler.", + "dash.shadowCallWarning": "⚠ Etkinleştirildiğinde, seçilen her kaynağa ({models}) yapılan istekler seçilen modelle değiştirilir. Terra isteğe bağlıdır; birleştirme için yapılandırılmış Bellek yönlendirmesi yine önceliklidir.", "dash.shadowCallOriginal": "Orijinal", "dash.shadowCallModel": "Yedek model", - "dash.shadowCallTooltip": "Codex App arka planda başlık ve commit mesajı oluşturmak için yardımcı çağrılar yapar (orijinal {models}). Etkinleştirerek bunları seçtiğiniz modele yönlendirebilirsiniz.", + "dash.shadowCallTooltip": "Codex App arka planda başlık, commit mesajı ve beceri orkestrasyonu için yardımcı çağrılar yapar. Yardımcı model istemci sürümleri arasında değiştiği için opencodex birden çok kaynağı yakalayabilir. Aşağıdan yönlendirmek istediğiniz her kaynağı seçin. Terra bellek birleştirme modelidir ve varsayılan olarak kapalıdır çünkü Codex onu normal işler için de seçer; Bellek yönlendirmesinde bir birleştirme modeli yapılandırıldığında o rota önceliklidir. En az bir kaynak açık kalmalıdır; tüm shadow çağrılarının yakalanmasını durdurmak için ana anahtarı kullanın.", "models.shadowCallIntercept": "Gölge Çağrı Yakalama", - "models.shadowCallInterceptHint": "Codex App'in başlıklar ve commit mesajları için yaptığı arka plan çağrılarını ({models}) yakalar ve seçtiğiniz modele yönlendirir.", + "models.shadowCallInterceptHint": "Codex App'in başlık, commit mesajı ve beceri orkestrasyonu için yaptığı arka plan yardımcı çağrılarını yakalar. Aşağıdan kaynak modelleri seçin. Bellek birleştirme normalde Terra ile çalışır ve yapılandırıldığında Bellek yönlendirmesini izler.", + "models.shadowCallSaveFailed": "Gölge çağrı ayarları kaydedilemedi.", + "models.shadowCallTargetInvalid": "Yedek model etkin bir kaynakla çakışıyor. Başka bir yedek model seçin.", + "models.shadowCallSourceInvalid": "Bu kaynak seçimi geçersiz. Boşluk veya sağlayıcı öneki içermeyen en az bir kaynak seçin.", "models.autoReviewOverride": "Otomatik inceleme geçersiz kılma", "models.autoReviewOverrideHint": "Codex’in otomatik incelemesi (onay gözden geçiren) için kullanılacak modeli seçer. Katalog eşitlemesinde tüm satırlara uygulanır; sağlayıcıya özel autoReviewModel kendi satırlarında yine önceliklidir.", "models.autoReviewPending": "Beklemede", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 53c8d3330c0..a42b9cceea2 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -588,13 +588,27 @@ export const vi: Record = { "memoryModels.saved": "Đã lưu cài đặt bộ nhớ.", "memoryModels.saveFailed": "Không lưu được. Thay đổi của bạn vẫn còn; hãy thử lại.", "dash.shadowCallIntercept": "Shadow Call Intercept", - "dash.shadowCallInterceptHint": "Chặn các lệnh gọi helper nền ({models}) của ứng dụng Codex để tạo tiêu đề và commit messages, sau đó chuyển hướng chúng đến model bạn đã chọn.", - "dash.shadowCallWarning": "⚠ Khi được bật, TẤT CẢ yêu cầu đối với {models} sẽ được thay thế bằng model được chọn.", + "shadowCall.memoryLabel": "Bộ nhớ: Terra", + "shadowCall.sourceCoveredShort": "Bị {prefix} bao phủ", + "shadowCall.sourceRequired": "Phải giữ bật", + "shadowCall.sources": "Nguồn được đánh chặn", + "shadowCall.sourceHelper": "Trợ lý", + "shadowCall.sourceMemory": "Bộ nhớ", + "shadowCall.sourceLegacy": "Cũ", + "shadowCall.sourceCustom": "Tùy chỉnh", + "shadowCall.sourceHint": "Chỉ các nguồn được bật mới được đánh chặn. Phải giữ ít nhất một nguồn; dùng công tắc chính để tắt việc đánh chặn mọi lệnh gọi shadow. Định tuyến bộ nhớ vẫn được ưu tiên cho hợp nhất khi đã cấu hình.", + "shadowCall.sourceCovered": "Được bao phủ bởi tiền tố tùy chỉnh {prefix}. Hãy xóa hoặc thu hẹp tiền tố đó trong config.json để bật/tắt riêng nguồn này.", + "shadowCall.sourceLastRequired": "Phải giữ ít nhất một nguồn. Dùng công tắc chính để tắt việc đánh chặn mọi lệnh gọi shadow.", + "dash.shadowCallInterceptHint": "Đánh chặn các lệnh gọi helper nền của ứng dụng Codex để tạo tiêu đề, commit message và điều phối kĩ năng. Chọn model nguồn bên dưới. Hợp nhất bộ nhớ thường chạy trên Terra và tuân theo Định tuyến bộ nhớ khi đã cấu hình.", + "dash.shadowCallWarning": "⚠ Khi được bật, yêu cầu cho mọi nguồn đã chọn ({models}) sẽ được thay thế bằng model đã chọn. Terra là tùy chọn; Định tuyến bộ nhớ vẫn được ưu tiên cho hợp nhất.", "dash.shadowCallOriginal": "Gốc", "dash.shadowCallModel": "Model thay thế", - "dash.shadowCallTooltip": "Ứng dụng Codex thực hiện các lệnh gọi helper ngầm để tạo tiêu đề chuỗi, tạo commit message và điều phối các kĩ năng. Helper model có thể thay đổi giữa các phiên bản client, do đó opencodex chặn mọi model trong tập hợp này: {models}. Hãy bật tính năng này để chuyển hướng các lệnh gọi đó đến model bạn chọn.", + "dash.shadowCallTooltip": "Ứng dụng Codex thực hiện các lệnh gọi helper ngầm để tạo tiêu đề chuỗi, tạo commit message và điều phối các kĩ năng. Helper model thay đổi giữa các phiên bản client, nên opencodex có thể đánh chặn nhiều nguồn. Chọn từng nguồn bạn muốn chuyển hướng bên dưới. Terra là model hợp nhất bộ nhớ và mặc định tắt vì Codex cũng chọn nó cho công việc thường; khi đã cấu hình model hợp nhất trong Định tuyến bộ nhớ, tuyến đó được ưu tiên. Phải giữ ít nhất một nguồn; dùng công tắc chính để tắt việc đánh chặn mọi lệnh gọi shadow.", "models.shadowCallIntercept": "Shadow Call Intercept", - "models.shadowCallInterceptHint": "Chặn các lệnh gọi helper nền ({models}) của ứng dụng Codex để tạo tiêu đề và commit messages, và chuyển hướng chúng đến model bạn chọn.", + "models.shadowCallInterceptHint": "Đánh chặn các lệnh gọi helper nền của ứng dụng Codex để tạo tiêu đề, commit message và điều phối kĩ năng. Chọn model nguồn bên dưới. Hợp nhất bộ nhớ thường chạy trên Terra và tuân theo Định tuyến bộ nhớ khi đã cấu hình.", + "models.shadowCallSaveFailed": "Không thể lưu cài đặt lệnh gọi shadow.", + "models.shadowCallTargetInvalid": "Model thay thế trùng với một nguồn đang bật. Hãy chọn model thay thế khác.", + "models.shadowCallSourceInvalid": "Lựa chọn nguồn này không hợp lệ. Hãy chọn ít nhất một nguồn không có khoảng trắng hoặc tiền tố nhà cung cấp.", "models.autoReviewOverride": "Ghi đè duyệt tự động", "models.autoReviewOverrideHint": "Chọn model Codex dùng cho việc duyệt tự động (mô hình đánh giá phê duyệt). Áp dụng cho mọi hàng trong catalog khi đồng bộ; autoReviewModel ở cấp nhà cung cấp vẫn được ưu tiên trên các hàng đó.", "models.autoReviewPending": "Đang chờ", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index af32759f376..e0789fe9658 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -461,13 +461,27 @@ export const zhTW: Record = { "memoryModels.saved": "記憶設定已儲存。", "memoryModels.saveFailed": "儲存失敗。你的變更還在,請再試一次。", "dash.shadowCallIntercept": "影子呼叫攔截", - "dash.shadowCallInterceptHint": "攔截 Codex 應用的背景 helper 呼叫({models})以生成標題與提交訊息,並將它們重定向到您選擇的模型。", - "dash.shadowCallWarning": "⚠ 啟用後,{models} 的所有請求將被替換為所選模型。", + "shadowCall.memoryLabel": "記憶: Terra", + "shadowCall.sourceCoveredShort": "由 {prefix} 涵蓋", + "shadowCall.sourceRequired": "必須保留", + "shadowCall.sources": "攔截來源", + "shadowCall.sourceHelper": "輔助模型", + "shadowCall.sourceMemory": "記憶", + "shadowCall.sourceLegacy": "舊版", + "shadowCall.sourceCustom": "自訂", + "shadowCall.sourceHint": "只有已啟用的來源會被攔截。至少保留一個來源;使用主開關關閉所有影子呼叫的攔截。設定記憶路由時,記憶合併仍優先。", + "shadowCall.sourceCovered": "已被自訂前綴 {prefix} 覆蓋。請在 config.json 中刪除或縮小該前綴,以便單獨切換此來源。", + "shadowCall.sourceLastRequired": "至少保留一個來源。使用主開關關閉所有影子呼叫的攔截。", + "dash.shadowCallInterceptHint": "攔截 Codex 應用的背景 helper 呼叫(標題、提交訊息、技能編排)。請在下方選擇來源模型。記憶合併通常使用 Terra 執行,設定後遵循記憶路由。", + "dash.shadowCallWarning": "⚠ 啟用後,發往每個所選來源({models})的請求都會被替換為所選模型。Terra 需要手動啟用;記憶合併仍優先使用已設定的記憶路由。", "dash.shadowCallOriginal": "原始", "dash.shadowCallModel": "替代模型", - "dash.shadowCallTooltip": "Codex 應用會為執行緒標題生成、提交訊息生成與技能編排發出背景 helper 呼叫。helper 模型因客戶端版本而異,因此 opencodex 攔截此集合中的每個模型:{models}。啟用此選項可將這些呼叫重定向到您選擇的模型。", + "dash.shadowCallTooltip": "Codex 應用會為執行緒標題生成、提交訊息生成與技能編排發出背景 helper 呼叫。helper 模型因客戶端版本而異,因此 opencodex 可以攔截多個來源。請在下方選擇要重定向的每個來源。Terra 是記憶合併模型,預設關閉,因為 Codex 也會在一般工作中選擇它;若在記憶路由中設定了合併模型,該路由優先。至少保留一個來源;使用主開關關閉所有影子呼叫的攔截。", "models.shadowCallIntercept": "影子呼叫攔截", - "models.shadowCallInterceptHint": "攔截 Codex 應用的背景 helper 呼叫({models})以生成標題與提交訊息,並將它們重定向到您選擇的模型。", + "models.shadowCallInterceptHint": "攔截 Codex 應用的背景 helper 呼叫(標題、提交訊息、技能編排)。請在下方選擇來源模型。記憶合併通常使用 Terra 執行,設定後遵循記憶路由。", + "models.shadowCallSaveFailed": "無法儲存影子呼叫設定。", + "models.shadowCallTargetInvalid": "替代模型與已啟用的來源重疊。請選擇其他替代模型。", + "models.shadowCallSourceInvalid": "此來源選擇無效。請至少選擇一個不含空白或提供者前置字元的來源。", "models.autoReviewOverride": "自動審查覆寫", "models.autoReviewOverrideHint": "指定 Codex 自動審查(核准審查模型)所使用的模型。同步型錄時套用到所有資料列;提供者層級的 autoReviewModel 在這些資料列上仍具優先權。", "models.autoReviewPending": "待定", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 6668c3e89b9..7d1d472bbd0 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -584,13 +584,27 @@ export const zh: Record = { "memoryModels.saved": "记忆设置已保存。", "memoryModels.saveFailed": "保存失败。你的改动仍在,请重试。", "dash.shadowCallIntercept": "影子调用拦截", - "dash.shadowCallInterceptHint": "拦截 Codex 应用的后台辅助调用({models}:标题生成、提交消息)并重定向到所选模型。", - "dash.shadowCallWarning": "⚠ 启用后,所有对 {models} 的请求都将被替换为所选模型。", + "shadowCall.memoryLabel": "记忆: Terra", + "shadowCall.sourceCoveredShort": "由 {prefix} 覆盖", + "shadowCall.sourceRequired": "必须保留", + "shadowCall.sources": "拦截来源", + "shadowCall.sourceHelper": "辅助模型", + "shadowCall.sourceMemory": "记忆", + "shadowCall.sourceLegacy": "旧版", + "shadowCall.sourceCustom": "自定义", + "shadowCall.sourceHint": "只有已启用的来源会被拦截。至少保留一个来源;使用主开关关闭所有影子调用的拦截。配置了记忆路由时,记忆整合仍优先。", + "shadowCall.sourceCovered": "已被自定义前缀 {prefix} 覆盖。请在 config.json 中删除或缩小该前缀,以便单独切换此来源。", + "shadowCall.sourceLastRequired": "至少保留一个来源。使用主开关关闭所有影子调用的拦截。", + "dash.shadowCallInterceptHint": "拦截 Codex 应用的后台辅助调用(标题生成、提交消息、技能编排)。在下方选择来源模型。记忆整合通常由 Terra 运行,配置后遵循记忆路由。", + "dash.shadowCallWarning": "⚠ 启用后,发往每个所选来源({models})的请求都会替换为所选模型。Terra 需要手动启用;记忆整合仍优先使用已配置的记忆路由。", "dash.shadowCallOriginal": "原始", "dash.shadowCallModel": "替代模型", - "dash.shadowCallTooltip": "Codex 应用会在后台调用辅助模型来生成线程标题、提交消息以及进行技能编排。该模型随客户端版本变化,因此 opencodex 会同时拦截这些模型:{models}。启用此选项可将这些调用重定向到您选择的模型。", + "dash.shadowCallTooltip": "Codex 应用会在后台调用辅助模型来生成线程标题、提交消息以及进行技能编排。该模型随客户端版本变化,因此 opencodex 可以拦截多个来源。请在下方选择要重定向的每个来源。Terra 是记忆整合模型,默认关闭,因为 Codex 也会在普通工作中选择它;如果在记忆路由中配置了整合模型,该路由优先。至少保留一个来源;使用主开关关闭所有影子调用的拦截。", "models.shadowCallIntercept": "影子调用拦截", - "models.shadowCallInterceptHint": "拦截 Codex 应用的后台辅助调用({models})并重定向到所选模型。", + "models.shadowCallInterceptHint": "拦截 Codex 应用的后台辅助调用(标题生成、提交消息、技能编排)。在下方选择来源模型。记忆整合通常由 Terra 运行,配置后遵循记忆路由。", + "models.shadowCallSaveFailed": "无法保存影子调用设置。", + "models.shadowCallTargetInvalid": "替代模型与已启用的来源重叠。请选择其他替代模型。", + "models.shadowCallSourceInvalid": "此来源选择无效。请至少选择一个不含空白或提供方前缀的来源。", "models.autoReviewOverride": "自动审查覆盖", "models.autoReviewOverrideHint": "指定 Codex 自动审查(审批审查模型)使用的模型。同步目录时应用于所有行;提供方级的 autoReviewModel 在这些行上仍然优先。", "models.autoReviewPending": "待定", diff --git a/gui/src/pages/Models.tsx b/gui/src/pages/Models.tsx index 96ddcc9e432..fbd988d98b2 100644 --- a/gui/src/pages/Models.tsx +++ b/gui/src/pages/Models.tsx @@ -83,18 +83,18 @@ import { type ModelPresetView, type ModelRow, type ProviderContextCapsResponse, - type ShadowCallData, type V2Status, } from "./models-shared"; import { DiscoveryDependencyHint, EmptyProviderHint } from "./models-provider-hints"; import SubagentSurfaceWarningModal from "../components/SubagentSurfaceWarningModal"; import { SUBAGENT_SURFACE_GUIDE_URL, readSubagentSurfaceAdvisory } from "../subagent-surface"; import { shadowCallModelOptions } from "./dashboard-shared"; -import { shadowSourceModelBadge, shadowSourceModelLabel } from "./shadow-call-source"; +import { ShadowCallSettingsRow } from "./shadow-call-settings-row"; import { ModelCatalogDelivery } from "./models-catalog-state"; import { CustomModelsSummary, InfoHint, ModelsSettingsPanel, SettingsRow } from "./models-settings-panel"; import { modelsSettingsSummary } from "./models-settings-summary"; import { useAutoReviewSettings } from "./use-auto-review-settings"; +import { useShadowCallSettings } from "./use-shadow-call-settings"; type CachedModelsPage = { models: ModelRow[]; @@ -444,8 +444,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c const [contextError, setContextError] = useState(""); const [hoveredModel, setHoveredModel] = useState<{ namespaced: string; rect: DOMRect } | null>(null); const hoverTimerRef = useRef | null>(null); - const [shadowCall, setShadowCall] = useState(null); - const [shadowCallSaving, setShadowCallSaving] = useState(false); + const { shadowCall, shadowCallSaving, loadShadowCall, saveShadowCall } = useShadowCallSettings({ apiBase, publishFeedback, t }); // App owns the in-session view mode; fallback to persisted mode for isolated renders/tests. const [selectedProvider, setSelectedProvider] = useState(null); @@ -472,16 +471,6 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c [models, shadowModelOptions, autoReview?.model], ); - const loadShadowCall = useCallback(async () => { - const bounded = createBoundedFetch(15_000); - try { - const r = await fetch(`${apiBase}/api/shadow-call-settings`, { signal: bounded.signal }); - const data = await readJsonIfOk(r); - if (data) setShadowCall(data); - } catch { /* old server / network: keep the section disabled */ } - finally { bounded.clear(); } - }, [apiBase]); - const loadV2 = useCallback(async () => { // Never let a toggle in flight be clobbered by the poll (same single-flight rule as models). if (v2BusyRef.current) return; @@ -1075,21 +1064,6 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c const setAll = () => { void putCap({ setAll: !allCapped }); }; - const saveShadowCall = async (patch: Partial) => { - if (!shadowCall || shadowCallSaving) return; - setShadowCallSaving(true); - setShadowCall({ ...shadowCall, ...patch }); - try { - await fetch(`${apiBase}/api/shadow-call-settings`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(patch), - }); - } finally { - setShadowCallSaving(false); - } - }; - /** * Both v2 surface writes adopt the response directly instead of calling * `loadV2()`. `loadV2` returns early while `v2BusyRef` is still held by the @@ -1970,13 +1944,7 @@ export default function Models({ apiBase, restartEpoch = 0, connected = false, c void setDefaultAliases(!aliases.defaults.global)} label={t("models.useDefaultAliasesGlobal")} /> - ⓘ} busy={!shadowCall}> - {t("models.shadowCallOriginal", { models: shadowSourceModelBadge(shadowCall?.sourceModels) })} - void saveShadowCall({ enabled: !shadowCall?.enabled })} disabled={!shadowCall || shadowCallSaving} label={t("models.shadowCallIntercept")} /> -
- onSave({ model })} + disabled={!shadowCall || saving || !shadowCall.enabled} + label={t("models.shadowCallIntercept")} + /> +
+
+ onSave({ sourceModels })} + /> + + ); +} diff --git a/gui/src/pages/shadow-call-source-toggles.tsx b/gui/src/pages/shadow-call-source-toggles.tsx new file mode 100644 index 00000000000..7ab742e142d --- /dev/null +++ b/gui/src/pages/shadow-call-source-toggles.tsx @@ -0,0 +1,82 @@ +import { useId } from "react"; +import { useT, type TFn } from "../i18n/shared"; +import { Switch } from "../ui"; +import { + shadowSourceModelBadge, + shadowSourceOptionActive, + toggleShadowCallSource, + type ShadowCallSourceOption, + type ShadowCallSourceRole, +} from "./shadow-call-source"; + +function roleLabel(t: TFn, role: ShadowCallSourceRole): string { + switch (role) { + case "helper": return t("shadowCall.sourceHelper"); + case "memory": return t("shadowCall.sourceMemory"); + case "legacy": return t("shadowCall.sourceLegacy"); + case "custom": return t("shadowCall.sourceCustom"); + } +} + +/** + * One switch per selectable source model. The runtime supplies the options so + * this component never carries a second copy of the model list. + */ +export function ShadowCallSourceToggles({ + options, + sourceModels, + disabled = false, + saving = false, + onChange, + className, +}: { + options?: ShadowCallSourceOption[]; + sourceModels?: string[]; + disabled?: boolean; + saving?: boolean; + onChange: (sourceModels: string[]) => void; + className?: string; +}) { + const t = useT(); + const hintId = useId(); + if (!options || options.length === 0) return null; + return ( +
+
+ {options.map(option => { + const active = shadowSourceOptionActive(option.id, sourceModels); + const result = toggleShadowCallSource(option, sourceModels); + const covered = "blocked" in result && result.blocked === "covered" ? result.prefix : undefined; + const last = "blocked" in result && result.blocked === "last"; + const title = covered + ? t("shadowCall.sourceCovered", { prefix: covered }) + : last ? t("shadowCall.sourceLastRequired") : undefined; + const reason = covered + ? t("shadowCall.sourceCoveredShort", { prefix: covered }) + : last ? t("shadowCall.sourceRequired") : undefined; + return ( + + { if ("sourceModels" in result) onChange(result.sourceModels); }} + disabled={disabled || saving || covered !== undefined || last} + label={`${option.id} (${roleLabel(t, option.role)})`} + title={title} + /> + {shadowSourceModelBadge([option.id])} + {roleLabel(t, option.role)} + {reason ? {reason} : null} + + ); + })} +
+ {t("shadowCall.sourceHint")} +
+ ); +} + diff --git a/gui/src/pages/shadow-call-source.ts b/gui/src/pages/shadow-call-source.ts index a75fcb47443..20327494051 100644 --- a/gui/src/pages/shadow-call-source.ts +++ b/gui/src/pages/shadow-call-source.ts @@ -4,16 +4,35 @@ * Codex 0.154.0+ sends gpt-6-luna for helper calls; 0.145.0-0.153.x sent * gpt-5.6-luna, which the runtime still intercepts by default. Clients through * 0.144.x used gpt-5.4-mini, which operators can restore through `sourceModels`. + * Memory consolidation is a separate background path: Codex asks for Terra + * there, and `memoryModels.consolidation` owns that route. Terra is also a + * normal foreground/subagent model, so it stays out of the shadow source set. + * The Terra generation changes with Codex; both `gpt-5.6-terra` and + * `gpt-6.1-terra` are recognized here so the badge does not claim the memory + * model is outside the sources after an operator opts one in. * The GUI renders whatever the runtime reports rather than a baked-in label; * this fallback only covers a runtime too old to send `sourceModels`. */ const FALLBACK_SOURCE_MODELS = ["gpt-6-luna", "gpt-5.6-luna"]; +export type ShadowCallSourceRole = "helper" | "memory" | "legacy" | "custom"; + +/** One toggle the runtime offered; `sourceModelOptions` is the only source of truth. */ +export interface ShadowCallSourceOption { + id: string; + role: ShadowCallSourceRole; + defaultIntercepted: boolean; +} + export function shadowSourceModelList(sourceModels?: string[]): string[] { - const cleaned = Array.isArray(sourceModels) + const raw = Array.isArray(sourceModels) ? sourceModels.filter(v => typeof v === "string" && v.trim() !== "").map(v => v.trim()) : []; - return cleaned.length > 0 ? cleaned : FALLBACK_SOURCE_MODELS; + if (raw.length === 0) return FALLBACK_SOURCE_MODELS; + // A routed model id can never match: the runtime hard-excludes slashes before + // prefix comparison. Keeping one visible would let the next save send a value + // the API rejects, so the effective list drops it here as well. + return raw.filter(id => !id.includes("/")); } /** Comma-joined source models for inline badges and warning text. */ @@ -27,3 +46,59 @@ export function shadowSourceModelBadge(sourceModels?: string[]): string { .map(id => id.replace(/^gpt-/, "")) .join(", "); } + +/** True when any configured prefix intercepts this option's model id. */ +export function shadowSourceOptionActive(optionId: string, sourceModels?: string[]): boolean { + return shadowSourceModelList(sourceModels).some(prefix => optionId.startsWith(prefix)); +} + +/** The configured prefix that covers this option, if any. */ +export function shadowSourceOptionCoveringPrefix(optionId: string, sourceModels?: string[]): string | undefined { + return shadowSourceModelList(sourceModels).find(prefix => optionId.startsWith(prefix)); +} + +export type ShadowCallSourceToggleResult = + | { sourceModels: string[] } + | { blocked: "last" } + | { blocked: "covered"; prefix: string }; + +/** + * Compute the next source list for one toggle. + * + * A broader custom prefix can cover several options. Removing that prefix here + * would also switch off the siblings the operator did not touch, so the covered + * option is blocked and the explanation names the prefix. The last active + * source is never removed; stopping everything is the main switch's job. + */ +export function toggleShadowCallSource( + option: ShadowCallSourceOption, + sourceModels?: string[], +): ShadowCallSourceToggleResult { + const selected = shadowSourceModelList(sourceModels); + const broader = selected.find(prefix => prefix !== option.id && option.id.startsWith(prefix)); + if (broader !== undefined) return { blocked: "covered", prefix: broader }; + if (!selected.includes(option.id)) return { sourceModels: [...selected, option.id] }; + const next = selected.filter(prefix => prefix !== option.id); + return next.length === 0 ? { blocked: "last" } : { sourceModels: next }; +} + +/** + * Compact inventory for the shadow-call row: the intercepted helper models plus + * the memory-consolidation model, which is controlled by Memory-Routing rather + * than by the shadow-call switch. The caller localizes `memoryLabel`. + * + * Terra is normally outside the source set, but an explicit `sourceModels` + * entry can opt it in. In that case the source list already names Terra, and + * appending the memory label would list the same model twice. + */ +export function shadowBackgroundModelBadge( + sourceModels: string[] | undefined, + memoryLabel: string, + memoryModelIds: readonly string[] = ["gpt-6.1-terra", "gpt-5.6-terra"], +): string { + const selected = shadowSourceModelList(sourceModels); + const memoryAlreadyIntercepted = memoryModelIds.some(id => id !== "" + && selected.some(prefix => id.startsWith(prefix))); + const sourceLabel = shadowSourceModelBadge(sourceModels); + return memoryAlreadyIntercepted ? sourceLabel : `${sourceLabel} (+ ${memoryLabel})`; +} diff --git a/gui/src/pages/use-dashboard-data.ts b/gui/src/pages/use-dashboard-data.ts index b46deba84c7..995f674a6cf 100644 --- a/gui/src/pages/use-dashboard-data.ts +++ b/gui/src/pages/use-dashboard-data.ts @@ -56,6 +56,7 @@ import { visionModelOptions, useModalDialog, } from "./dashboard-shared"; +import { shadowCallSaveErrorKey } from "./models-shared"; const CONTROLS_CACHE_PREFIX = "ocx.dash.controls.v1:"; const OVERVIEW_CACHE_PREFIX = "ocx.dash.overview.v1:"; @@ -183,6 +184,7 @@ export function useDashboardData(apiBase: string, refreshEpoch = 0) { const [sidecarSaving, setSidecarSaving] = useState(false); const [sidecarCodexApply, setSidecarCodexApply] = useState(); const [shadowCallSaving, setShadowCallSaving] = useState(false); + const [shadowCallError, setShadowCallError] = useState(null); const [modelsLoading, setModelsLoading] = useState(false); const [syncing, setSyncing] = useState(false); const [maMode, setMaMode] = useState(() => cachedMaMode ?? "default"); @@ -614,6 +616,7 @@ const [maBusy, setMaBusy] = useState(false); const previous = shadowCall; const updated = { ...shadowCall, ...patch }; setShadowCallSaving(true); + setShadowCallError(null); shadowCallMutationInFlightRef.current = true; setShadowCall(updated); try { @@ -622,10 +625,16 @@ const [maBusy, setMaBusy] = useState(false); headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); - if (!res.ok) throw new Error("shadow-call save failed"); + if (!res.ok) { + setShadowCall(previous); + const body = await res.json().catch(() => null) as { code?: unknown } | null; + setShadowCallError(t(shadowCallSaveErrorKey(body?.code))); + return; + } shadowCallMutationEpochRef.current += 1; } catch { setShadowCall(previous); + setShadowCallError(t("models.networkError")); } finally { shadowCallMutationInFlightRef.current = false; setShadowCallSaving(false); @@ -932,7 +941,7 @@ const [maBusy, setMaBusy] = useState(false); health, startupHealth, providers, models, settings, sidecar, shadowCall, usage30d, usageLoading: usagePoll.loading && !usage30d, healthLoading: overviewPoll.loading && !health, - sidecarSaving, shadowCallSaving, modelsLoading, settingsSaving, syncing, + sidecarSaving, shadowCallSaving, shadowCallError, modelsLoading, settingsSaving, syncing, maMode, maModeResolved, maBusy, setMaHelpOpen, maHelpOpen, maError, maAdvisory, maAdvisoryOpen, pendingMaMode, keepMaMode, chooseMaV1, dismissMaSurfaceDialog, diff --git a/gui/src/pages/use-shadow-call-settings.ts b/gui/src/pages/use-shadow-call-settings.ts new file mode 100644 index 00000000000..d6a4d271ec5 --- /dev/null +++ b/gui/src/pages/use-shadow-call-settings.ts @@ -0,0 +1,56 @@ +import { useCallback, useState } from "react"; +import { createBoundedFetch } from "../bounded-fetch"; +import { readJsonIfOk } from "../fetch-json"; +import type { TFn } from "../i18n/shared"; +import { shadowCallSaveErrorKey, type ShadowCallData } from "./models-shared"; + +/** Load and persist the Models workspace's shadow-call settings. */ +export function useShadowCallSettings({ + apiBase, publishFeedback, t, +}: { + apiBase: string; + publishFeedback: (ok: boolean, message: string) => void; + t: TFn; +}) { + const [shadowCall, setShadowCall] = useState(null); + const [shadowCallSaving, setShadowCallSaving] = useState(false); + + const loadShadowCall = useCallback(async () => { + const bounded = createBoundedFetch(15_000); + try { + const r = await fetch(`${apiBase}/api/shadow-call-settings`, { signal: bounded.signal }); + const data = await readJsonIfOk(r); + if (data) setShadowCall(data); + } catch { /* old server / network: keep the section disabled */ } + finally { bounded.clear(); } + }, [apiBase]); + + const saveShadowCall = async (patch: Partial) => { + if (!shadowCall || shadowCallSaving) return; + const previous = shadowCall; + setShadowCallSaving(true); + setShadowCall({ ...shadowCall, ...patch }); + try { + const res = await fetch(`${apiBase}/api/shadow-call-settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + setShadowCall(previous); + const body = await res.json().catch(() => null) as { code?: unknown } | null; + publishFeedback(false, t(shadowCallSaveErrorKey(body?.code))); + return; + } + const data = await res.json().catch(() => null) as ShadowCallData | null; + if (data && typeof data.enabled === "boolean") setShadowCall(data); + } catch { + setShadowCall(previous); + publishFeedback(false, t("models.networkError")); + } finally { + setShadowCallSaving(false); + } + }; + + return { shadowCall, shadowCallSaving, loadShadowCall, saveShadowCall }; +} diff --git a/gui/src/styles-models-workspace.css b/gui/src/styles-models-workspace.css index be036cab5a2..089f8009bdf 100644 --- a/gui/src/styles-models-workspace.css +++ b/gui/src/styles-models-workspace.css @@ -235,6 +235,51 @@ white-space: nowrap; } +/* ── Shadow-call source toggles (Dashboard + Models) ───── */ +.shadow-source-toggles { + display: flex; + flex-direction: column; + gap: var(--space-1); + min-width: 0; + margin-top: var(--space-2); +} + +.shadow-source-toggle-list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-1) var(--space-2); + min-width: 0; +} + +.shadow-source-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-1); + min-width: 0; + padding: 2px 6px 2px 2px; + border: 1px solid var(--border); + border-radius: var(--radius-pill); + background: var(--surface); +} + +.shadow-source-toggle.on { + border-color: color-mix(in srgb, var(--green) 35%, var(--border)); +} + +.shadow-source-toggle .switch { flex: 0 0 auto; } +.shadow-source-id { font-size: 11.5px; white-space: nowrap; } +.shadow-source-role { white-space: nowrap; } +.shadow-source-reason { white-space: normal; line-height: 1.2; } +.shadow-source-hint { display: block; line-height: 1.4; } +.models-shadow-sources { grid-column: 1 / -1; margin: 0 0 var(--space-3); } +.dash-shadow-sources { margin-top: var(--space-2); } + +@media (max-width: 560px) { + .shadow-source-toggle-list { gap: var(--space-1); } + .shadow-source-toggle { padding-right: 4px; } +} + .models-v2-detail-row { gap: var(--space-2); margin-bottom: var(--space-2); diff --git a/gui/tests/shadow-call-dashboard-feedback.test.tsx b/gui/tests/shadow-call-dashboard-feedback.test.tsx new file mode 100644 index 00000000000..4f5419104df --- /dev/null +++ b/gui/tests/shadow-call-dashboard-feedback.test.tsx @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { en } from "../src/i18n/en"; +import { LanguageProvider } from "../src/i18n/provider"; +import { useDashboardData } from "../src/pages/use-dashboard-data"; + +const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +let testWindow: Window; +let host: HTMLElement; +let root: Root | null = null; +let originalFetch: typeof fetch; + +type Dash = ReturnType; + +beforeEach(() => { + originalFetch = globalThis.fetch; + previousGlobals = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + root = null; + 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 }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + host = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + } + root = null; + globalThis.fetch = originalFetch; + testWindow.close(); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +function shadowSettings() { + return { + enabled: true, + model: "xai/grok-4.5", + sourceModels: ["gpt-6-luna"], + sourceModelOptions: [{ id: "gpt-6-luna", role: "helper", defaultIntercepted: true }], + }; +} + +function mockDashboardFetch(put: () => Promise): typeof fetch { + return (async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path.endsWith("/api/shadow-call-settings")) { + if (init?.method === "PUT") return put(); + return Response.json(shadowSettings()); + } + if (path.endsWith("/api/sidecar-settings")) { + return Response.json({ + webSearch: { model: "gpt-5.6-luna", streamRoutedModelOutput: false }, + vision: { + model: "gpt-5.6-luna", backend: "openai", reasoning: "low", + enabled: true, maxDescriptionsPerTurn: 12, timeoutMs: 30_000, + }, + }); + } + if (path.endsWith("/api/settings")) { + return Response.json({ port: 10100, hostname: "127.0.0.1" }); + } + if (path.endsWith("/api/providers")) return Response.json({ providers: [] }); + return Response.json({}); + }) as typeof fetch; +} + +async function mountDashboard(fetchImpl: typeof fetch): Promise<() => Dash> { + globalThis.fetch = fetchImpl; + let latest: Dash | undefined; + function Harness() { + const data = useDashboardData("/shadow-feedback"); + useEffect(() => { latest = data; }, [data]); + return null; + } + await act(async () => { + root = createRoot(host); + root.render(); + }); + if (!latest) throw new Error("dashboard hook did not mount"); + return () => latest!; +} + +test("a rejected dashboard source save restores the previous list and publishes localized feedback", async () => { + const data = await mountDashboard(mockDashboardFetch(async () => + Response.json({ error: "bad source", code: "shadow_call_source_invalid" }, { status: 400 }))); + + await act(async () => { await data().saveShadowCall({ sourceModels: ["gpt-6-luna", "gpt-6.1-terra"] }); }); + + expect(data().shadowCall?.sourceModels).toEqual(["gpt-6-luna"]); + expect(data().shadowCallError).toBe(en["models.shadowCallSourceInvalid"]); +}); + +test("a failed dashboard fetch reports a network error instead of a validation message", async () => { + const data = await mountDashboard(mockDashboardFetch(async () => { + throw new Error("offline"); + })); + + await act(async () => { await data().saveShadowCall({ sourceModels: ["gpt-6-luna", "gpt-6.1-terra"] }); }); + + expect(data().shadowCall?.sourceModels).toEqual(["gpt-6-luna"]); + expect(data().shadowCallError).toBe(en["models.networkError"]); +}); diff --git a/gui/tests/shadow-call-source-toggles.test.tsx b/gui/tests/shadow-call-source-toggles.test.tsx new file mode 100644 index 00000000000..f75fdb5f03b --- /dev/null +++ b/gui/tests/shadow-call-source-toggles.test.tsx @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import { ShadowCallSourceToggles } from "../src/pages/shadow-call-source-toggles"; +import type { ShadowCallSourceOption } from "../src/pages/shadow-call-source"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +let testWindow: Window; +let host: HTMLElement; +let root: Root | null = null; + +const options: ShadowCallSourceOption[] = [ + { id: "gpt-6-luna", role: "helper", defaultIntercepted: true }, + { id: "gpt-5.6-luna", role: "helper", defaultIntercepted: true }, + { id: "gpt-5.6-terra", role: "memory", defaultIntercepted: false }, +]; + +beforeEach(() => { + previousGlobals = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + root = null; + 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 }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + host = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(host as never); +}); + +afterEach(async () => { + if (root) { + const current = root; + await act(async () => { current.unmount(); }); + } + root = null; + 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 render(sourceModels: string[], disabled = false) { + const changes: string[][] = []; + await act(async () => { + root = createRoot(host); + root.render( + + changes.push(next)} + /> + , + ); + }); + return changes; +} + +function sourceSwitch(id: string, role: string): HTMLButtonElement { + const button = host.querySelector(`button[aria-label="${id} (${role})"]`); + if (!button) throw new Error(`missing switch for ${id}`); + return button as HTMLButtonElement; +} + +test("clicking an inactive source adds its exact prefix", async () => { + const changes = await render(["gpt-6-luna", "gpt-5.6-luna"]); + act(() => sourceSwitch("gpt-5.6-terra", "Memory").click()); + expect(changes).toEqual([["gpt-6-luna", "gpt-5.6-luna", "gpt-5.6-terra"]]); +}); + +test("the last active source is disabled", async () => { + const changes = await render(["gpt-5.6-terra"]); + const button = sourceSwitch("gpt-5.6-terra", "Memory"); + expect(button.disabled).toBe(true); + expect(host.textContent).toContain("Must stay on"); + act(() => button.click()); + expect(changes).toEqual([]); +}); + +test("the main switch disables every source toggle", async () => { + await render(["gpt-6-luna", "gpt-5.6-luna"], true); + expect(sourceSwitch("gpt-6-luna", "Helper").disabled).toBe(true); + expect(sourceSwitch("gpt-5.6-terra", "Memory").disabled).toBe(true); +}); + +test("a broader custom prefix keeps covered toggles disabled with an explanation", async () => { + await render(["gpt-5.6"]); + const button = sourceSwitch("gpt-5.6-terra", "Memory"); + expect(button.disabled).toBe(true); + expect(button.title).toContain("gpt-5.6"); + expect(host.textContent).toContain("Covered by gpt-5.6"); +}); diff --git a/gui/tests/shadow-call-source.test.ts b/gui/tests/shadow-call-source.test.ts index 21e3c085232..3b305c6c2c8 100644 --- a/gui/tests/shadow-call-source.test.ts +++ b/gui/tests/shadow-call-source.test.ts @@ -1,9 +1,39 @@ import { describe, expect, test } from "bun:test"; import { + shadowBackgroundModelBadge, + shadowSourceOptionActive, shadowSourceModelBadge, shadowSourceModelLabel, shadowSourceModelList, + toggleShadowCallSource, + type ShadowCallSourceOption, } from "../src/pages/shadow-call-source"; +import { shadowCallSaveErrorKey } from "../src/pages/models-shared"; + +/** + * The settings endpoint answers several different 400s with the same status, so the message + * has to come from the stable `code`. These cases pin the three operator-visible outcomes and + * keep an unknown code (older runtime, proxy error page) on the neutral text rather than + * blaming a target the operator may not have touched. + */ +describe("shadowCallSaveErrorKey", () => { + test("maps a target conflict to the replacement target message", () => { + expect(shadowCallSaveErrorKey("shadow_call_target_invalid")) + .toBe("models.shadowCallTargetInvalid"); + }); + + test("maps an invalid source selection to the source message", () => { + expect(shadowCallSaveErrorKey("shadow_call_source_invalid")) + .toBe("models.shadowCallSourceInvalid"); + }); + + test.each([undefined, null, "", "upstream_error"])( + "falls back to the neutral save message for %p", + code => { + expect(shadowCallSaveErrorKey(code)).toBe("models.shadowCallSaveFailed"); + }, + ); +}); /** * The GUI used to spell the intercepted slug into six locale files, which went @@ -41,6 +71,10 @@ describe("shadowSourceModelList", () => { expect(shadowSourceModelList("gpt-5.6-luna" as unknown as string[])) .toEqual(["gpt-6-luna", "gpt-5.6-luna"]); }); + + test("drops a routed prefix that can never match a bare helper id", () => { + expect(shadowSourceModelList(["openai/gpt-5.6-luna"])).toEqual([]); + }); }); describe("shadow source model rendering", () => { @@ -63,3 +97,82 @@ describe("shadow source model rendering", () => { expect(shadowSourceModelBadge(undefined)).toBe("6-luna, 5.6-luna"); }); }); + +describe("shadowBackgroundModelBadge", () => { + test("adds the separately routed memory model to the shadow source list", () => { + expect(shadowBackgroundModelBadge(["gpt-6-luna", "gpt-5.6-luna"], "Konsolidierung: Terra")) + .toBe("6-luna, 5.6-luna (+ Konsolidierung: Terra)"); + }); + + test("uses the runtime fallback for the shadow sources", () => { + expect(shadowBackgroundModelBadge(undefined, "Consolidation: Terra")) + .toBe("6-luna, 5.6-luna (+ Consolidation: Terra)"); + }); + + test("keeps a configured source override while adding the memory model", () => { + expect(shadowBackgroundModelBadge(["custom-helper"], "Terra")) + .toBe("custom-helper (+ Terra)"); + }); + + test("does not list the memory model twice when it is an explicit source", () => { + expect(shadowBackgroundModelBadge(["gpt-6-luna", "gpt-5.6-luna", "gpt-5.6-terra"], "Memory: Terra")) + .toBe("6-luna, 5.6-luna, 5.6-terra"); + }); + + test("does not list the newer Terra twice when it is an explicit source", () => { + expect(shadowBackgroundModelBadge(["gpt-6-luna", "gpt-5.6-luna", "gpt-6.1-terra"], "Memory: Terra")) + .toBe("6-luna, 5.6-luna, 6.1-terra"); + }); + + test("treats a broader source prefix as covering the memory model", () => { + expect(shadowBackgroundModelBadge(["gpt-5.6"], "Memory: Terra")).toBe("5.6"); + }); +}); + +const terra: ShadowCallSourceOption = { id: "gpt-5.6-terra", role: "memory", defaultIntercepted: false }; +const terra61: ShadowCallSourceOption = { id: "gpt-6.1-terra", role: "memory", defaultIntercepted: false }; +const luna: ShadowCallSourceOption = { id: "gpt-5.6-luna", role: "helper", defaultIntercepted: true }; + +describe("shadow source toggles", () => { + test("an option is active when any configured prefix covers it", () => { + expect(shadowSourceOptionActive("gpt-5.6-terra", ["gpt-5.6"])).toBe(true); + expect(shadowSourceOptionActive("gpt-5.6-terra", ["gpt-6-luna"])).toBe(false); + }); + + test("switching an inactive source on appends its exact prefix", () => { + expect(toggleShadowCallSource(terra, ["gpt-6-luna", "gpt-5.6-luna"])).toEqual({ + sourceModels: ["gpt-6-luna", "gpt-5.6-luna", "gpt-5.6-terra"], + }); + }); + + test("switching the newer Terra on appends its exact prefix", () => { + expect(toggleShadowCallSource(terra61, ["gpt-6-luna", "gpt-5.6-luna"])).toEqual({ + sourceModels: ["gpt-6-luna", "gpt-5.6-luna", "gpt-6.1-terra"], + }); + }); + + test("switching an active source off removes only its exact prefix", () => { + expect(toggleShadowCallSource(luna, ["gpt-6-luna", "gpt-5.6-luna"])) + .toEqual({ sourceModels: ["gpt-6-luna"] }); + }); + + test("the last active source cannot be switched off", () => { + expect(toggleShadowCallSource(terra, ["gpt-5.6-terra"])).toEqual({ blocked: "last" }); + }); + + test("a broader custom prefix blocks a covered option instead of changing siblings", () => { + expect(toggleShadowCallSource(terra, ["gpt-5.6", "custom-helper"])) + .toEqual({ blocked: "covered", prefix: "gpt-5.6" }); + }); + + test("a covered option stays blocked when the exact prefix comes first", () => { + expect(toggleShadowCallSource(terra, ["gpt-5.6-terra", "gpt-5.6"])) + .toEqual({ blocked: "covered", prefix: "gpt-5.6" }); + }); + + test("a custom entry can be switched off by its exact prefix", () => { + const custom: ShadowCallSourceOption = { id: "custom-helper", role: "custom", defaultIntercepted: false }; + expect(toggleShadowCallSource(custom, ["gpt-6-luna", "custom-helper"])) + .toEqual({ sourceModels: ["gpt-6-luna"] }); + }); +}); diff --git a/src/lib/shadow-call.ts b/src/lib/shadow-call.ts index 4c4467d514b..ad037d4c98f 100644 --- a/src/lib/shadow-call.ts +++ b/src/lib/shadow-call.ts @@ -12,6 +12,33 @@ */ export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-6-luna", "gpt-5.6-luna"] as const; +/** How the dashboard explains a selectable shadow source. */ +export type ShadowCallSourceRole = "helper" | "memory" | "legacy" | "custom"; + +export interface ShadowCallSourceOption { + id: string; + role: ShadowCallSourceRole; + defaultIntercepted: boolean; +} + +/** + * Every source the dashboard offers as an individual toggle. The runtime owns + * this list so the GUI cannot drift from the prefixes the request path matches. + * Terra is the memory-consolidation model and stays opt-in: Codex also selects + * it for ordinary foreground work, and routing it by default would move a + * user-chosen model. Codex currently asks for `gpt-5.6-terra`; newer clients + * can move to `gpt-6.1-terra`, so both generations stay selectable and off by + * default. The retired gpt-5.4-mini helper stays available for clients through + * 0.144.x. + */ +export const SHADOW_SOURCE_MODEL_OPTIONS: readonly ShadowCallSourceOption[] = [ + { id: "gpt-6-luna", role: "helper", defaultIntercepted: true }, + { id: "gpt-5.6-luna", role: "helper", defaultIntercepted: true }, + { id: "gpt-6.1-terra", role: "memory", defaultIntercepted: false }, + { id: "gpt-5.6-terra", role: "memory", defaultIntercepted: false }, + { id: "gpt-5.4-mini", role: "legacy", defaultIntercepted: false }, +]; + /** * Optional blocked model redirects at the shared routing layer. * When `blockedModelRedirects` is configured (e.g. `{ "gpt-5.6-terra": "gpt-5.6-luna" }`), @@ -39,7 +66,34 @@ export function shadowSourceModels(configured?: unknown): string[] { .filter((v): v is string => typeof v === "string" && v.trim() !== "") .map(v => v.trim()) : []; - return configuredStrings.length > 0 ? configuredStrings : [...DEFAULT_SHADOW_SOURCE_MODELS]; + return configuredStrings.length > 0 + ? [...new Set(configuredStrings)] + : [...DEFAULT_SHADOW_SOURCE_MODELS]; +} + +/** True when an override names exactly the built-in prefixes, in any order. */ +export function isDefaultShadowSourceModels(models: readonly string[]): boolean { + const selected = new Set(models); + return selected.size === DEFAULT_SHADOW_SOURCE_MODELS.length + && DEFAULT_SHADOW_SOURCE_MODELS.every(id => selected.has(id)); +} + +/** + * The dashboard's source inventory: the known runtime prefixes plus any custom + * prefix the operator persisted. Keeping the known list here avoids a second, + * drifting model list in the GUI; custom prefixes stay visible so an existing + * override is never silently dropped by a save. + */ +export function shadowSourceModelOptions(configured?: unknown): ShadowCallSourceOption[] { + const selected = shadowSourceModels(configured); + const known = new Set(SHADOW_SOURCE_MODEL_OPTIONS.map(option => option.id)); + const custom = selected + .filter(id => !known.has(id) && !id.includes("/")) + .map(id => ({ id, role: "custom" as const, defaultIntercepted: false })); + return [ + ...SHADOW_SOURCE_MODEL_OPTIONS.map(option => ({ ...option })), + ...custom, + ]; } /** diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index ff3d31515eb..144ff32fb38 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -48,7 +48,7 @@ import { import { removeCredential } from "../../oauth/store"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { isStreamMode } from "../../lib/bun-stream-caps"; -import { shadowSourceModels } from "../../lib/shadow-call"; +import { isDefaultShadowSourceModels, shadowSourceModelOptions, shadowSourceModels } from "../../lib/shadow-call"; import { configureAppOwnedMemoryBudget, enforceAppOwnedMemoryBudget, @@ -124,7 +124,11 @@ import { type DebugFlag, } from "../../lib/debug-settings"; import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types"; -import { shadowCallTargetError } from "./shadow-call-validation"; +import { + normalizeShadowCallSourceModels, + shadowCallSourceModelsError, + shadowCallTargetError, +} from "./shadow-call-validation"; import { autoReviewOverrideConfigError } from "../../config/provider-validation"; import { ConfigWritePublishedError } from "../../config/persist-unlocked"; import { drainAndShutdown } from "../lifecycle"; @@ -151,6 +155,17 @@ function quotaAutoRefreshSettings(config: OcxConfig) { ])); } +/** The complete shadow-call settings body shared by GET and a successful PUT. */ +function shadowCallSettingsResponse(config: OcxConfig) { + const sci = config.shadowCallIntercept ?? {}; + return { + enabled: sci.enabled === true, + model: sci.model ?? "", + sourceModels: shadowSourceModels(sci.sourceModels), + sourceModelOptions: shadowSourceModelOptions(sci.sourceModels), + }; +} + async function sidecarVisionResponseSettings(config: OcxConfig): Promise<{ model: string; reasoning: string; @@ -1299,19 +1314,14 @@ async function handleConfigRoutesImpl(ctx: ManagementContext): Promise { + const sourceModelsError = body.sourceModels === undefined ? null : shadowCallSourceModelsError(body.sourceModels); + if (sourceModelsError) { + return jsonResponse({ error: sourceModelsError, code: "shadow_call_source_invalid" }, 400); + } + const nextSourceModels = body.sourceModels === undefined + ? undefined + : normalizeShadowCallSourceModels(body.sourceModels as string[]); const candidateModel = typeof body.model === "string" ? body.model - : body.enabled === true + : body.enabled === true || body.sourceModels !== undefined ? config.shadowCallIntercept?.model : undefined; - const targetError = shadowCallTargetError(config, candidateModel); - if (targetError) return jsonResponse({ error: targetError }, 400); - config.shadowCallIntercept = { ...config.shadowCallIntercept }; - if (typeof body.enabled === "boolean") config.shadowCallIntercept.enabled = body.enabled; - if (typeof body.model === "string") { - if (body.model === "") delete config.shadowCallIntercept.model; - else config.shadowCallIntercept.model = body.model; + const targetError = shadowCallTargetError(config, candidateModel, nextSourceModels ?? config.shadowCallIntercept?.sourceModels); + if (targetError) { + return jsonResponse({ error: targetError, code: "shadow_call_target_invalid" }, 400); + } + const rollback = captureConfigTopLevelRollback(config, ["shadowCallIntercept"]); + try { + config.shadowCallIntercept = { ...config.shadowCallIntercept }; + if (typeof body.enabled === "boolean") config.shadowCallIntercept.enabled = body.enabled; + if (typeof body.model === "string") { + if (body.model === "") delete config.shadowCallIntercept.model; + else config.shadowCallIntercept.model = body.model; + } + if (nextSourceModels) { + // Drop a list that merely restates the built-in set, so future client + // versions can move the defaults without pinning them here. + if (isDefaultShadowSourceModels(nextSourceModels)) delete config.shadowCallIntercept.sourceModels; + else config.shadowCallIntercept.sourceModels = nextSourceModels; + } + (deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(config); + } catch (error) { + if (!(error instanceof ConfigWritePublishedError)) rollback(); + throw error; } - saveConfigPreservingClaudeCode(config); return null; }); if (refusal) return refusal; - const sci = config.shadowCallIntercept ?? {}; - return jsonResponse({ - ok: true, - enabled: sci.enabled === true, - model: sci.model ?? "", - sourceModels: shadowSourceModels(sci.sourceModels), - }); + return jsonResponse({ ok: true, ...shadowCallSettingsResponse(config) }); } if (url.pathname === "/api/auto-review-settings" && req.method === "GET") { diff --git a/src/server/management/shadow-call-validation.ts b/src/server/management/shadow-call-validation.ts index a7587cc951c..33ef8fea017 100644 --- a/src/server/management/shadow-call-validation.ts +++ b/src/server/management/shadow-call-validation.ts @@ -3,8 +3,16 @@ import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { routeConcreteModel, routeModel } from "../../router"; import type { OcxConfig } from "../../types"; +/** Keep a persisted override small enough for the per-request prefix scan. */ +export const SHADOW_SOURCE_MODEL_LIMIT = 32; +export const SHADOW_SOURCE_PREFIX_MAX_LENGTH = 128; + /** Validate a prospective persisted shadow-call target against its resolved source identities. */ -export function shadowCallTargetError(config: OcxConfig, targetModel: string | undefined): string | null { +export function shadowCallTargetError( + config: OcxConfig, + targetModel: string | undefined, + sourceModels: unknown = config.shadowCallIntercept?.sourceModels, +): string | null { if (!targetModel) return null; let target; @@ -19,7 +27,7 @@ export function shadowCallTargetError(config: OcxConfig, targetModel: string | u return "model must resolve to a configured provider"; } - const intersectsSource = shadowSourceModels(config.shadowCallIntercept?.sourceModels).some(sourceModel => { + const intersectsSource = shadowSourceModels(sourceModels).some(sourceModel => { let source = { providerName: OPENAI_CODEX_PROVIDER_ID, modelId: sourceModel }; try { const resolved = routeConcreteModel(config, sourceModel); @@ -33,6 +41,39 @@ export function shadowCallTargetError(config: OcxConfig, targetModel: string | u : null; } +/** + * Validate a new `sourceModels` list before it reaches persisted config. + * Matching is by bare-model prefix, so provider-qualified entries can never + * match and a slash is rejected rather than stored as a dead rule. + */ +export function shadowCallSourceModelsError(value: unknown): string | null { + if (!Array.isArray(value)) return "sourceModels must be an array"; + if (value.length === 0) return "sourceModels must contain at least one model prefix"; + if (value.length > SHADOW_SOURCE_MODEL_LIMIT) { + return `sourceModels must contain at most ${SHADOW_SOURCE_MODEL_LIMIT} model prefixes`; + } + const seen = new Set(); + for (const [index, entry] of value.entries()) { + if (typeof entry !== "string" || entry.trim() === "") { + return `sourceModels.${index} must be a nonblank model prefix`; + } + if (/\s/.test(entry)) return `sourceModels.${index} must not contain whitespace`; + if (entry.length > SHADOW_SOURCE_PREFIX_MAX_LENGTH) { + return `sourceModels.${index} must be at most ${SHADOW_SOURCE_PREFIX_MAX_LENGTH} characters`; + } + const prefix = entry; + if (prefix.includes("/")) return `sourceModels.${index} must be a bare model prefix, not a provider-qualified model`; + if (seen.has(prefix)) return "sourceModels must not contain duplicate prefixes"; + seen.add(prefix); + } + return null; +} + +/** Trim and deduplicate only after `shadowCallSourceModelsError` accepted the list. */ +export function normalizeShadowCallSourceModels(value: readonly string[]): string[] { + return [...new Set(value.map(entry => entry.trim()))]; +} + /** The shadow-call reference a provider change leaves behind, reported by disable and delete. */ export interface ShadowInterceptDependency { model: string; diff --git a/src/server/responses/memory-models.ts b/src/server/responses/memory-models.ts index 53fc0ebce48..9885b8c285b 100644 --- a/src/server/responses/memory-models.ts +++ b/src/server/responses/memory-models.ts @@ -4,8 +4,9 @@ * Codex writes memories in two background phases, and both ask the provider for a bare native * model: Phase 1 ("extract") summarizes one finished thread per call and asks for * `gpt-5.6-luna` at effort `low`; Phase 2 ("consolidation") is one agent run that merges those - * summaries into the files under `$CODEX_HOME/memories` and asks for `gpt-5.6-terra` at effort - * `medium`. Without a configured target each keeps its existing route; Phase 1 additionally looks like the app's + * summaries into the files under `$CODEX_HOME/memories` and asks for a Terra model at effort + * `medium` (`gpt-5.6-terra` on current clients, with `gpt-6.1-terra` available as clients move). + * Without a configured target each keeps its existing route; Phase 1 additionally looks like the app's * title/commit helper traffic, because the app uses the same model id for those. * * A phase is therefore recognized from Codex's own turn metadata, never inferred from the model diff --git a/tests/responses/responses-memory-models.test.ts b/tests/responses/responses-memory-models.test.ts index 90ed7968e22..f77e78ac4d9 100644 --- a/tests/responses/responses-memory-models.test.ts +++ b/tests/responses/responses-memory-models.test.ts @@ -91,6 +91,8 @@ describe("memory phase detection", () => { test("recognizes each phase from Codex's own turn metadata", () => { expect(detectMemoryModelPhase(body(), new Headers({ "x-codex-turn-metadata": extractMetadata() }))).toBe("extract"); expect(detectMemoryModelPhase(body("gpt-5.6-terra"), new Headers({ "x-codex-turn-metadata": consolidationMetadata() }))).toBe("consolidation"); + expect(detectMemoryModelPhase(body("gpt-6.1-terra"), new Headers({ "x-codex-turn-metadata": consolidationMetadata() }))).toBe("consolidation"); + expect(detectMemoryModelPhase(body("gpt-6.1-terra"), new Headers({ "x-codex-turn-metadata": extractMetadata() }))).toBe("extract"); }); test("explicit non-memory metadata outranks the sub-agent header", () => { @@ -380,6 +382,39 @@ describe("memory model routing", () => { expect(logCtx.shadowCallRewrittenFrom).toBeUndefined(); }); + test("a Terra shadow opt-in still yields to a configured consolidation route", async () => { + const settings = config(); + settings.shadowCallIntercept = { + enabled: true, + model: "gateway/helper", + sourceModels: ["gpt-6-luna", "gpt-5.6-luna", "gpt-5.6-terra"], + }; + const calls: Array> = []; + globalThis.fetch = (async (_input: unknown, init?: RequestInit) => { + calls.push(JSON.parse(String(init?.body))); + return Response.json(completion()); + }) as typeof fetch; + + const configuredRoute = await handleResponses( + request(body("gpt-5.6-terra"), consolidationMetadata()), settings, { model: "", provider: "" }, + ); + expect(configuredRoute.status).toBe(200); + await configuredRoute.text(); + expect(calls[0]!.model).toBe("strong"); + + const withoutMemoryRouting = config(); + delete withoutMemoryRouting.memoryModels; + withoutMemoryRouting.shadowCallIntercept = settings.shadowCallIntercept; + const shadowLog: { model: string; provider: string; shadowCallRewrittenFrom?: string } = { model: "", provider: "" }; + const shadowed = await handleResponses( + request(body("gpt-5.6-terra"), consolidationMetadata()), withoutMemoryRouting, shadowLog, + ); + expect(shadowed.status).toBe(200); + await shadowed.text(); + expect(calls[1]!.model).toBe("helper"); + expect(shadowLog.shadowCallRewrittenFrom).toBe("gpt-5.6-terra"); + }); + test("a target that no longer resolves fails the memory call instead of falling back", async () => { const settings = config(); settings.memoryModels = { extract: { model: "ghost/secret-token" } }; diff --git a/tests/responses/responses-shadow-intercept.test.ts b/tests/responses/responses-shadow-intercept.test.ts index 45936df8bc4..da095101f3e 100644 --- a/tests/responses/responses-shadow-intercept.test.ts +++ b/tests/responses/responses-shadow-intercept.test.ts @@ -9,8 +9,13 @@ import { mkdtempSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleResponses, isShadowSourceModel } from "../../src/server/responses"; -import { shadowCallPreservesSelectedModel, shouldInterceptShadowCall } from "../../src/lib/shadow-call"; +import { + shadowCallPreservesSelectedModel, + shadowSourceModelOptions, + shouldInterceptShadowCall, +} from "../../src/lib/shadow-call"; import { handleManagementAPI } from "../../src/server/management-api"; +import { ConfigWritePublishedError } from "../../src/config/persist-unlocked"; import type { RequestLogContext } from "../../src/server/request-log"; import type { OcxConfig } from "../../src/types"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; @@ -46,6 +51,7 @@ describe("isShadowSourceModel", () => { }); test("does not match non-helper models", () => { + expect(isShadowSourceModel("gpt-6.1-terra")).toBe(false); expect(isShadowSourceModel("gpt-5.6-terra")).toBe(false); expect(isShadowSourceModel("gpt-5.5")).toBe(false); expect(isShadowSourceModel("gpt-5.6-sol")).toBe(false); @@ -74,6 +80,35 @@ describe("isShadowSourceModel", () => { }); }); +describe("shadowSourceModelOptions", () => { + test("exposes both Terra generations and the legacy helper as opt-in sources", () => { + const options = shadowSourceModelOptions(undefined); + expect(options.map(option => [option.id, option.role, option.defaultIntercepted])).toEqual([ + ["gpt-6-luna", "helper", true], + ["gpt-5.6-luna", "helper", true], + ["gpt-6.1-terra", "memory", false], + ["gpt-5.6-terra", "memory", false], + ["gpt-5.4-mini", "legacy", false], + ]); + }); + + test("keeps a custom prefix visible next to the known sources", () => { + const options = shadowSourceModelOptions(["gpt-6-luna", "custom-helper"]); + expect(options.at(-1)).toEqual({ id: "custom-helper", role: "custom", defaultIntercepted: false }); + }); + + test("does not offer a provider-qualified prefix that can never match", () => { + const options = shadowSourceModelOptions(["openai/gpt-5.6-luna"]); + expect(options.some(option => option.id === "openai/gpt-5.6-luna")).toBe(false); + }); + + test("does not list a known source twice", () => { + const options = shadowSourceModelOptions(["gpt-6.1-terra", "gpt-6.1-terra", "gpt-5.6-terra", "gpt-5.6-terra"]); + expect(options.filter(option => option.id === "gpt-6.1-terra")).toHaveLength(1); + expect(options.filter(option => option.id === "gpt-5.6-terra")).toHaveLength(1); + }); +}); + describe("shouldInterceptShadowCall", () => { test("intercepts every shadow source model unconditionally (#1684)", () => { const source = { providerName: "openai", modelId: "gpt-5.6-luna" }; @@ -85,6 +120,7 @@ describe("shouldInterceptShadowCall", () => { test("does not intercept non-source models", () => { const source = { providerName: "openai", modelId: "gpt-5.6-luna" }; const target = { providerName: "xai", modelId: "grok-4.5" }; + expect(shouldInterceptShadowCall("gpt-6.1-terra", undefined, source, target)).toBe(false); expect(shouldInterceptShadowCall("gpt-5.6-terra", undefined, source, target)).toBe(false); expect(shouldInterceptShadowCall("gpt-5.5", undefined, source, target)).toBe(false); }); @@ -489,6 +525,54 @@ describe("shadow call intercept request path (issue #311)", () => { expect(sawFetch).toBe(false); expect(response.status).toBe(404); }); + + test.each(["gpt-5.6-terra", "gpt-6.1-terra"])( + "intercepts Terra only after the operator opts %s in", + async terra => { + takeSpendHome(); + const bodies: Array> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return Response.json({ + choices: [{ message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + }) as typeof fetch; + const config = interceptConfig(); + config.shadowCallIntercept = { + enabled: true, + model: "xai/grok-4.5", + sourceModels: ["gpt-6-luna", "gpt-5.6-luna", "gpt-6.1-terra", "gpt-5.6-terra"], + }; + + const response = await post(config, terra, "memory"); + + expect(response.status).toBe(200); + expect(bodies).toHaveLength(1); + expect(String(bodies[0]?.model ?? "")).toContain("grok-4.5"); + }, + ); + + test("an explicit user turn keeps Terra even when the source is opted in", async () => { + let sawFetch = false; + globalThis.fetch = (async () => { + sawFetch = true; + return Response.json({ ok: true }); + }) as typeof fetch; + const config = interceptConfig(); + config.shadowCallIntercept = { + enabled: true, + model: "xai/grok-4.5", + sourceModels: ["gpt-6-luna", "gpt-5.6-luna", "gpt-5.6-terra"], + }; + + const response = await post(config, "gpt-5.6-terra", undefined, { model: "", provider: "" }, { + "x-codex-turn-metadata": JSON.stringify({ request_kind: "turn", thread_source: "user" }), + }); + + expect(sawFetch).toBe(false); + expect(response.status).toBe(404); + }); }); /** @@ -708,7 +792,12 @@ async function withTempHome(run: () => Promise): Promise { } } -async function shadowApi(config: OcxConfig, method: string, body?: unknown): Promise> { +async function shadowApi( + config: OcxConfig, + method: string, + body?: unknown, + save?: (config: OcxConfig) => void, +): Promise> { // Management API enforces a same-origin gate; a browserless caller must look local. const headers: Record = { origin: "http://127.0.0.1:10100", host: "127.0.0.1:10100" }; if (body !== undefined) headers["content-type"] = "application/json"; @@ -719,13 +808,18 @@ async function shadowApi(config: OcxConfig, method: string, body?: unknown): Pro }); const res = await handleManagementAPI(req, new URL(req.url), config, { createManagementConvergeCodex: catalogConvergenceFactory(), + ...(save ? { saveConfigPreservingClaudeCode: save } : {}), }); expect(res).not.toBeNull(); expect(res!.status).toBe(200); return await res!.json() as Record; } -async function shadowApiResponse(config: OcxConfig, body: unknown): Promise { +async function shadowApiResponse( + config: OcxConfig, + body: unknown, + save?: (config: OcxConfig) => void, +): Promise { const req = new Request("http://localhost/api/shadow-call-settings", { method: "PUT", headers: { @@ -737,6 +831,7 @@ async function shadowApiResponse(config: OcxConfig, body: unknown): Promise }); }); + test("GET reports Terra as an opt-in source option", async () => { + await withTempHome(async () => { + const body = await shadowApi({ port: 0, defaultProvider: "xai", providers: {} } as OcxConfig, "GET"); + expect(body.sourceModelOptions).toContainEqual({ + id: "gpt-6.1-terra", role: "memory", defaultIntercepted: false, + }); + expect(body.sourceModelOptions).toContainEqual({ + id: "gpt-5.6-terra", role: "memory", defaultIntercepted: false, + }); + }); + }); + + test("PUT persists an opted-in source and drops the list when it restates the defaults", async () => { + await withTempHome(async () => { + const config = { + port: 0, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "test-xai-key", + }, + }, + shadowCallIntercept: { enabled: true, model: "xai/grok-4.5" }, + } as OcxConfig; + + const optedIn = await shadowApi(config, "PUT", { + sourceModels: ["gpt-6-luna", "gpt-5.6-luna", "gpt-5.6-terra"], + }); + expect(optedIn.sourceModels).toEqual(["gpt-6-luna", "gpt-5.6-luna", "gpt-5.6-terra"]); + expect(config.shadowCallIntercept?.sourceModels).toEqual(["gpt-6-luna", "gpt-5.6-luna", "gpt-5.6-terra"]); + + const restored = await shadowApi(config, "PUT", { sourceModels: ["gpt-5.6-luna", "gpt-6-luna"] }); + expect(restored.sourceModels).toEqual(["gpt-6-luna", "gpt-5.6-luna"]); + expect(config.shadowCallIntercept?.sourceModels).toBeUndefined(); + }); + }); + + test.each([ + [[], "at least one"], + [["gpt-5.6-terra", "gpt-5.6-terra"], "duplicate"], + [["xai/gpt-5.6-terra"], "bare model prefix"], + [["gpt-5.6 terra"], "whitespace"], + [[" gpt-6-luna"], "whitespace"], + [["gpt-6-luna "], "whitespace"], + [Array.from({ length: 33 }, (_, index) => `custom-${index}`), "at most 32"], + [["x".repeat(129)], "at most 128"], + ])("PUT rejects an invalid source list %#", async (sourceModels, message) => { + await withTempHome(async () => { + const config = { port: 0, defaultProvider: "xai", providers: {} } as OcxConfig; + const response = await shadowApiResponse(config, { sourceModels }); + expect(response.status).toBe(400); + const body = await response.json() as { error: string; code?: string }; + expect(body.error).toContain(message); + // The dashboard cannot infer this from the shared 400 status; the stable code keeps the + // operator-facing message on the source list instead of the replacement target. + expect(body.code).toBe("shadow_call_source_invalid"); + expect(config.shadowCallIntercept).toBeUndefined(); + }); + }); + + test("PUT validates the existing target against a newly selected source", async () => { + await withTempHome(async () => { + const config = { + port: 0, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "test-xai-key", + }, + }, + shadowCallIntercept: { enabled: false, model: "xai/grok-4.5" }, + } as OcxConfig; + const response = await shadowApiResponse(config, { sourceModels: ["grok-4.5"] }); + expect(response.status).toBe(400); + const body = await response.json() as { error: string; code?: string }; + expect(body.error).toContain("must not intersect"); + expect(body.code).toBe("shadow_call_target_invalid"); + expect(config.shadowCallIntercept?.sourceModels).toBeUndefined(); + }); + }); + test("GET and PUT report a configured override instead of the defaults", async () => { await withTempHome(async () => { const config = { @@ -793,4 +975,81 @@ describe("shadow-call settings API reports the intercepted source models", () => expect(config.shadowCallIntercept).toEqual({ sourceModels: ["custom-helper"] }); }); }); + + test("PUT restores every live shadow-call field when persistence throws", async () => { + await withTempHome(async () => { + const config = { + port: 0, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "test-xai-key", + }, + }, + shadowCallIntercept: { + enabled: true, + model: "xai/grok-4.5", + sourceModels: ["gpt-5.6-luna"], + }, + } as OcxConfig; + + await expect(shadowApi(config, "PUT", { + enabled: false, + model: "xai/grok-4.6", + sourceModels: ["gpt-6-luna"], + }, () => { throw new Error("disk full"); })).rejects.toThrow("disk full"); + + // The request path reads the same live block; a failed save must not leave the new + // enabled/model/sourceModels tuple visible after the caller was told it did not persist. + expect(config.shadowCallIntercept).toEqual({ + enabled: true, + model: "xai/grok-4.5", + sourceModels: ["gpt-5.6-luna"], + }); + + const fresh = { port: 0, defaultProvider: "xai", providers: {} } as OcxConfig; + await expect(shadowApi(fresh, "PUT", { enabled: true }, () => { + throw new Error("disk full"); + })).rejects.toThrow("disk full"); + expect(fresh.shadowCallIntercept).toBeUndefined(); + }); + }); + + test("PUT keeps the new live settings when persistence reports publication", async () => { + await withTempHome(async () => { + const config = { + port: 0, + defaultProvider: "xai", + providers: { + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "test-xai-key", + }, + }, + shadowCallIntercept: { + enabled: true, + model: "xai/grok-4.5", + sourceModels: ["gpt-5.6-luna"], + }, + } as OcxConfig; + const error = new ConfigWritePublishedError(new Error("bookkeeping")); + + await expect(shadowApi(config, "PUT", { + enabled: false, + model: "xai/grok-4.6", + sourceModels: ["gpt-6-luna"], + }, () => { throw error; })).rejects.toBe(error); + + expect(config.shadowCallIntercept).toEqual({ + enabled: false, + model: "xai/grok-4.6", + sourceModels: ["gpt-6-luna"], + }); + }); + }); });