diff --git a/AGENTS.md b/AGENTS.md index bc9e991..c03b05f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,7 @@ - Model provider keys and connection recovery are stored separately in model-providers.vault using the existing protection identity. Preserve this file, the account vault and the original Codex sign-in. - ProviderEditorSession retains drafts and selection in that same encrypted store. A null draft API key retains the saved credential; an explicitly empty key is invalid. Revealing/copying a loaded key must never authorize sending it to a changed destination. Preserve drafts on provider changes, failed saves and close/reopen. - Connection/restoration edits use the official version-checked configuration RPC. Model choices route per request; never restart the owner's Codex for verification. +- New connections publish one stable codex-switch model. The saved actual-model selection applies globally to new virtual-model requests; snapshot its destination/key/options once and retain them through streams/retries. Never retarget a duplicate turn request after selection changes, fall back from a missing route, or rewrite the catalog merely to select a model. Preserve legacy aliases and explicit history/image compatibility refusals. - The gateway runs inside Switch. Treat an active gateway as part of ongoing Codex work when planning utility updates; use an owner-approved idle handoff. - Provider checks use disposable storage, fictional upstreams and an explicit reviewed CLI. Keep component renders, native app-server contracts, native desktop picker confirmation, real provider acceptance and public release as separate states. [Contract](docs/MODEL_PROVIDERS.md). diff --git a/docs/MODEL_PROVIDERS.md b/docs/MODEL_PROVIDERS.md index b9d3159..ac07120 100644 --- a/docs/MODEL_PROVIDERS.md +++ b/docs/MODEL_PROVIDERS.md @@ -1,16 +1,18 @@ # Configurable model providers -Available in the 1.43.3 release. [Issue138](https://github.com/Gaebabi/codex-switch/issues/138) tracks remaining actual desktop picker and external-provider compatibility acceptance. +Provider support is available in the1.43.3release. Fixed-model routing below is a source change after1.44.0; the existing release downloads retain the earlier picker. [Issue138](https://github.com/Gaebabi/codex-switch/issues/138) tracks remaining actual desktop picker and external-provider compatibility acceptance. -Keep eligible OpenAI models and custom Responses models in the existing Codex model picker. OpenAI models retain their original display names; custom models include their provider name in parentheses. Selecting a model changes that chat's next request; an in-flight response keeps the route and key it started with. +New connections expose one stable **Codex Switch** model (`codex-switch`) in Codex. Choose the actual OpenAI or custom Responses model in Switch's **Model** dropdown on Home or in provider setup. The selection is global to all new requests using the virtual model. Each admitted request retains its selected model, destination, key and reasoning default through its response and bounded retries; changing the dropdown never retargets an in-flight stream. A duplicate native turn request is refused rather than replayed against a newly selected backend. ## Set up a provider 1. In1.44.0, open Model providers in the titlebar Settings gear. Older1.43.3 Windows packages use Models in the toolbar. 2. In the first step, select Add a provider or a saved provider, then enter its display name, API base URL and API key. Choose Save and continue to discover and save its models. 3. For a large catalog, expand the optional model-ID field and enter one ID per line. Leave it empty to discover all models within the supported bounds. Reasoning and conversation-handoff controls stay under Advanced in the second step. -4. In the second step, choose Connect Codex, then Restart Codex when ready. Saving alone does not connect or restart Codex. The restart button uses the existing normal restart and chat-recovery flow with the same login. -5. Keep Switch running, including in its tray. Choose OpenAI or a custom model inside each Codex chat. New connections use an explicit local model catalog; after changing its models or reasoning settings, restart Codex when ready to load the updated catalog. +4. In the second step, choose the actual model, then Connect Codex. Restart Codex once when no work is running to load the fixed model and gateway. Saving or selecting alone does not connect or restart Codex. The restart button uses the existing normal restart and chat-recovery flow with the same login. +5. Keep Switch running, including in its tray. Select **Codex Switch** in Codex for an existing chat; new chats default to it. Change the actual model in Switch whenever needed. Later selections, provider additions and saved reasoning defaults apply to new requests without changing the fixed catalog or restarting Codex. + +The dropdown preserves its exact selection in the existing encrypted provider store. An unavailable/deleted selection is shown explicitly and requests fail before inference; Switch never silently selects another destination. OpenAI choices come from the current account catalog and are revalidated for its sign-in before inference. The first connection/upgrade still needs a Codex restart: changing an already running client's catalog/endpoint is not supported. Existing OpenAI models and custom aliases remain accepted by the gateway for older connected chats; a legacy installation upgrades only through an explicit Connect action. The built-in OpenAI provider identity is retained throughout. Saved providers reopen with their fields and a masked key. The eye button shows or hides the current key, and Copy copies it only after an explicit click. Neither action changes the key. Changing the destination URL still requires an explicitly entered replacement key; displaying a saved key does not authorize sending it elsewhere. @@ -30,9 +32,9 @@ A provider's native Codex catalog retains its model instructions and capabilitie ### GPT reasoning controls -Custom GPT models can use the ordinary Codex reasoning control. Switch passes the selected `reasoning.effort` to the custom Responses endpoint. A native Codex catalog supplies the supported levels; a plain list uses the exact-ID OpenAI match described above. The destination must support the same effort values; matching IDs do not certify external provider behavior. +For the fixed model, configure reasoning in Switch. Each request uses the actual selected model's saved default, or its provider/exact-ID OpenAI metadata default. The virtual Codex entry keeps no per-backend reasoning menu, so changing models does not require a catalog refresh. Older custom aliases retain their ordinary Codex reasoning control and pass its explicit effort. The destination must support the declared values; matching IDs do not certify external provider behavior. -For a model absent from that catalog or a route with different support, expand **GPT reasoning in Codex**, select the saved model, enable **Set supported reasoning levels**, select its documented levels and a default, then save. Disable the override and save to return to automatic metadata. These settings apply to one model and remain after rediscovery at the same destination. A changed destination resets them. Refresh the Codex model list after saving; its cache may take a few minutes to update. +For a model absent from that catalog or a route with different support, expand **Model reasoning**, select the saved model, enable **Set supported reasoning levels**, select its documented levels and a default, then save. Disable the override and save to return to automatic metadata. These settings apply to one model and remain after rediscovery at the same destination. A changed destination resets them. Fixed-model requests use the saved default from the next request; older per-model catalogs require their original refresh/restart behavior. Supported control values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh` and `max`, subject to the installed Codex version and the model's API. Unsupported selections fail before inference rather than silently using another strength. A plain list with no exact match or explicit settings keeps provider-default behavior. No additional reasoning API or token-budget conversion is used. @@ -56,7 +58,7 @@ While waiting, Switch sends only SSE comment keep-alives to the original caller. A later hotfix observes confirmed desktop turn failures of every error type, including HTTP507exhaustion, invalid event streams, authentication, quota and context errors. Eligibility uses the persisted failed status, without matching error words or requiring an error message. After the original turn has stopped, Switch can send one identified `Continue` through that chat's existing desktop owner, inheriting its settings. It does not replay the original Responses input after tools or partial output may already have run. Consecutive failed automatic continuation turns share at most three attempts, with3/10/30second backoff; successful completion closes that recovery chain. This is separate from the pre-response HTTP507request budget, so each new continuation has its own bounded HTTP507retries. A persistent error can still require user action after all three continuations fail. -The observer requires a recent persisted `task_complete` failure, an observed stopped main chat with no approval/input/queued follow-up, matching current model/provider, a connected original OpenAI/Switch provider and unchanged current sign-in/route during backoff. Custom model aliases must still be registered. An earlier successful request is not required: the first request can fail before successful-route history exists. This never fabricates route history or bypasses encrypted-compaction and portable-history checks in the gateway. It skips active, completed, interrupted, paused/limited-goal and unsupported-provider chats. The bounded read-only rollout tail comes from the existing SQLite thread record; a newer `task_started` invalidates a historical failure. No original transcript is rewritten, no account or model is changed and no window is opened or activated. +The observer requires a recent persisted `task_complete` failure, an observed stopped main chat with no approval/input/queued follow-up, matching current model/provider, a connected original OpenAI/Switch provider and unchanged current sign-in/route during backoff. Custom model aliases and the virtual model's selected actual route must still be registered. An earlier successful request is not required: the first request can fail before successful-route history exists. This never fabricates route history or bypasses encrypted-compaction and portable-history checks in the gateway. It skips active, completed, interrupted, paused/limited-goal and unsupported-provider chats. The bounded read-only rollout tail comes from the existing SQLite thread record; a newer `task_started` invalidates a historical failure. No original transcript is rewritten, no account or model is changed and no window is opened or activated. Encrypted recovery receipts persist attempt count and message/turn identities before submission. A lost/ambiguous IPC response or persistence error stops that recovery rather than resending. Exhaustion survives a Switch restart. Reauthentication, route edits, new user turns, queues and actual user interruptions prevent resubmission. Generic idle/EOF alone does not establish a failed turn. The observer applies only to chats observed in this local desktop with a connected OpenAI/Switch provider; it does not revive archived or unloaded historical chats. The local `stream-recovery-state.json` also retains up to128deduplicated decisions with fixed reason codes, hashed chat/turn references and attempt counts. It omits chat text, raw errors, account identities and provider secrets. @@ -72,7 +74,7 @@ The large-upload admission slot and request buffers are released when final upst Unregistered model names fail explicitly. OpenAI routes are checked against the current sign-in's catalog, while custom routes use their registered aliases. Managed provider/catalog requirements are refused before preparing a local connection. -New connections preserve the existing built-in `openai` provider identity and set `openai_base_url` to the loopback listener. An explicit private `model_catalog_json` file supplies the combined native and custom model list to Codex and its desktop picker. The catalog contains model metadata, not API keys. It is refreshed on provider edits, account changes and the existing refresh cycle; Codex loads the file at startup. WebSockets and request compression are disabled for portable full-input HTTP replay. Existing provider definitions are preserved. +New connections preserve the existing built-in `openai` provider identity and set `openai_base_url` to the loopback listener. An explicit private `model_catalog_json` supplies the one stable virtual model, and `model` defaults to `codex-switch`. It contains no API keys. Selections and provider/reasoning edits do not rewrite this fixed catalog. Codex loads it at startup; WebSockets and request compression are disabled for portable full-input HTTP replay. Existing provider definitions are preserved. The wrapper uses generic client instructions, no asserted context-window size, no hosted-tool extensions and image-envelope support; the gateway refuses image input for a selected backend that does not declare it. Actual upstream context/tool/streaming limits still apply; history is never silently truncated to fit. Older connections that use `codex_switch_router` remain untouched. Recent Codex versions reject a chat when its saved provider identity differs from the current provider, even if both use the same gateway. Restoring/reconnecting an older installation can therefore affect which provider-pinned chats it can reopen. Switch does not rewrite chat history or bypass that check. Keep the older connection for its existing router-pinned chats; new built-in OpenAI connections retain compatibility with original OpenAI chats. diff --git a/docs/development/OPERATIONS.md b/docs/development/OPERATIONS.md index 23fbe0c..3052dce 100644 --- a/docs/development/OPERATIONS.md +++ b/docs/development/OPERATIONS.md @@ -1,5 +1,7 @@ # Operations +Fixed-model routing after1.44.0 keeps one `codex-switch` model/default and moves actual model selection to a direct Home/setup dropdown. Selection is global for new virtual-model requests and persists in the existing protected provider vault. Snapshot at authenticated HTTP admission, before body upload, and retain destination/model/key/options through streaming and507retries. Native duplicate receipts use the fixed logical request identity so a selection change cannot replay it to a different backend. Do not rewrite the catalog for route/provider/reasoning edits; keep legacy aliases and history/image refusals. Recovery also pins the saved selection during backoff. Initial connection/legacy upgrade needs one owner-idle Codex restart; no owner restart is part of verification. [Contract](../MODEL_PROVIDERS.md) / [fictional native/component evidence](../evidence/fixed-model-router/README.md). + Failed-turn recovery uses `DesktopStreamRecoveryMonitor` and a separately encrypted `stream-recovery.vault`, preserving the original vault/key identities. It reads a bounded original rollout tail through exact read-only SQLite metadata and waits for a confirmed failed stopped main turn of any error type. HTTP507, quota, authentication, context, compaction and malformed-stream errors all qualify; missing error prose does not imply success. It sends one inherited-settings identified Continue via the existing desktop owner, without deep-link activation, model changes, account application or original HTTP replay. Three consecutive automatic attempts share persisted3/10/30second backoff and do not reset on app restart; success closes the chain. Ambiguous send/persistence failures stop that chat's recovery. Existing cancellation, queue, approval, route/authentication and completed/active guards stay enforced. An earlier successful-route receipt is not required, and gateway history checks remain enforced. Inspect bounded `decisions` in local `stream-recovery-state.json` for hashed references and reason codes such as `continued`, `attempt-limit`, `selected-model-unregistered` or `state-changed`; no raw error/chat/provider data is logged. [Behavior and limitations](../MODEL_PROVIDERS.md#continue-after-a-failed-response-stream). The Home Errors counter reports currently stopped main chats from the same authenticated desktop observation as Run. Count a main chat once when its runtime status is systemError, or idle and its latest turn failed. Active, interrupted, recovered and child chats are excluded; unavailable or incomplete discovery displays an em dash. Counts update on state patches and clear on recovery/removal. It is not a cumulative gateway request/error log. Windows and shared desktop use the same protocol count and localized warning-colored presentation. diff --git a/docs/development/PLAN.md b/docs/development/PLAN.md index 6861dbd..8e3d577 100644 --- a/docs/development/PLAN.md +++ b/docs/development/PLAN.md @@ -1,5 +1,7 @@ # Current plan +One stable `codex-switch` model and direct Windows/shared-desktop actual-model dropdown are implemented. Global selection stays in the existing encrypted store; each admitted route/key/options snapshot survives upload/streaming/retries, and native duplicates cannot retarget after a selection change. Current-native-CLI/provider292, WPF124, account/recovery350and language API42checks pass with both desktop builds. Complete final-head CI/review/normal merge and independently verify installation while preserving personal Codex work. First connection/legacy upgrade still needs one owner-idle Codex restart; later provider additions and route/reasoning selections do not rewrite its fixed catalog or restart Codex. + Custom-provider setup now keeps connection state visible after every editor action and preserves the original OpenAI provider identity. New connections supply an explicit private combined model catalog. Native visible-model discovery, same-chat provider selection, catalog refresh/restoration and bounded retry receipts are verified with disposable inputs. Require final-head repository/native CI before promotion; owner restarts, actual desktop picker confirmation and binary releases remain separate actions. The two-step onboarding follow-up preserves encrypted drafts, selected provider and saved keys through errors and reopening. Explicit eye/copy/reuse/load and user-requested restart actions are implemented. Provider, account/recovery, language and narrow Windows-component checks pass alongside both desktop builds. Complete final-head CI and review before promotion; owner installation and binary release remain separate. diff --git a/docs/development/STATUS.md b/docs/development/STATUS.md index 151b7f9..8b33be1 100644 --- a/docs/development/STATUS.md +++ b/docs/development/STATUS.md @@ -1,5 +1,7 @@ # Current status +Fixed-model routing passes292provider/current-native-CLI contracts,124offscreen WPF checks,350account/recovery checks and42language API checks, with both desktop builds and9catalogs of535messages. One `codex-switch` catalog entry/default and the persisted actual-model dropdown keep the native model stable while routes change. Same-process/same-chat A/B/OpenAI, a provider added after startup, saved reasoning defaults, upload/stream/retry snapshots, duplicate-client refusal, deletion/image/history guards and malformed-cache preservation are verified with fictional upstreams. [Component images and scoped receipts](../evidence/fixed-model-router/README.md). Initial connection/upgrade still requires one Codex restart; final-head CI, owner restart and personal picker/external-provider acceptance remain separate. See [model routing](../MODEL_PROVIDERS.md). + Provider setup and onboarding pass255gateway/current-native-CLI checks,350account/recovery checks,116offscreen WPF checks and42language API checks. The two-step editor retains encrypted drafts and selection through failed saves, provider changes and close/reopen. Saved keys remain masked, with explicit eye/copy/reuse/load actions; a displayed key never authorizes a changed destination. New connections retain OpenAI identity and use a private combined catalog, with bounded native-request receipts. Windows/shared desktop builds and all9language catalogs pass. Existing accounts, router-pinned chats and owner installations remain unchanged; actual Codex picker interaction and a binary release are separate. Unreleased: the chat date button independently updates active root chat titles with the existing KST prefix and verified metadata path. It does not switch accounts or restart Codex. Errors stop the operation and report the number already verified. Native UI screenshots and real-user acceptance remain pending. diff --git a/docs/evidence/fixed-model-router/README.md b/docs/evidence/fixed-model-router/README.md new file mode 100644 index 0000000..65160ff --- /dev/null +++ b/docs/evidence/fixed-model-router/README.md @@ -0,0 +1,21 @@ +# Fixed model and actual-model dropdown + +The source change keeps one `codex-switch` model in Codex and moves actual OpenAI/custom Responses model selection to Switch. The saved selection applies to new virtual-model requests across chats; upload, stream and retry snapshots preserve requests already admitted. First connection/legacy upgrade still needs one owner-idle Codex restart. Later selections and newly saved providers do not require another restart. + +## Application component evidence + +The pictures render the production Windows WPF controls offscreen with fictional provider data. The before and after setup pictures use the same500×510viewport and fictional provider. Home is500×360. No personal account, Codex window or native desktop picker was operated for these images. They verify the actual dropdown/rendered setup, not real external-provider inference. + +- [Before setup](../provider-connection/saved-connection-ko-dark.png), from the reviewed provider onboarding source. +- After setup: [English](setup-en.png) / [Korean](setup-ko.png). +- Home dropdown: [English](home-en.png) / [Korean](home-ko.png). + +![Actual-model selection during setup](setup-en.png) + +![Actual-model selection on Home](home-en.png) + +## Runtime contract + +`tests/model-providers` uses an explicitly reviewed official Codex executable, disposable homes and fictional upstreams. After initial fixed-model configuration, the same app-server process and chat complete requests through A, B and original OpenAI while its model list stays fixed. The fixture also adds a provider after startup and changes a saved reasoning default. Separate actual loopback HTTP cases cover selection during body upload, an A stream continuing alongside a new B request, retained507retry credentials/routes, duplicate-client refusal, persisted/deleted choices and image compatibility refusal. + +The source/input hashes, contract counts and narrow evidence scope are in [verification.json](verification.json). No real desktop picker, owner conversation, authenticated external provider or public binary release acceptance is claimed. The earlier per-model catalog path remains covered for existing connections. diff --git a/docs/evidence/fixed-model-router/home-en.png b/docs/evidence/fixed-model-router/home-en.png new file mode 100644 index 0000000..b5878f8 Binary files /dev/null and b/docs/evidence/fixed-model-router/home-en.png differ diff --git a/docs/evidence/fixed-model-router/home-ko.png b/docs/evidence/fixed-model-router/home-ko.png new file mode 100644 index 0000000..f96a54c Binary files /dev/null and b/docs/evidence/fixed-model-router/home-ko.png differ diff --git a/docs/evidence/fixed-model-router/setup-en.png b/docs/evidence/fixed-model-router/setup-en.png new file mode 100644 index 0000000..467a55a Binary files /dev/null and b/docs/evidence/fixed-model-router/setup-en.png differ diff --git a/docs/evidence/fixed-model-router/setup-ko.png b/docs/evidence/fixed-model-router/setup-ko.png new file mode 100644 index 0000000..71a3625 Binary files /dev/null and b/docs/evidence/fixed-model-router/setup-ko.png differ diff --git a/docs/evidence/fixed-model-router/verification.json b/docs/evidence/fixed-model-router/verification.json new file mode 100644 index 0000000..224caab --- /dev/null +++ b/docs/evidence/fixed-model-router/verification.json @@ -0,0 +1,52 @@ +{ + "schema": 1, + "nativeCli": "codex-cli 0.167.0-alpha.3.2", + "providerContracts": 292, + "accountRecoveryContracts": 350, + "languageApiContracts": 42, + "wpfComponentContracts": 124, + "catalogs": 9, + "messagesPerCatalog": 535, + "productionSourceSha256NormalizedLf": { + "src/windows/ModelRoutePicker.cs": "5eb84163027b233581129dd968513a8b027812e362ff2c0c048693de4a581e56", + "src/windows/MainWindow.cs": "e74370fd898f90f4649a9e9a4249ccfde52939143ef8478c855e30061d2cfab0", + "src/windows/ModelProvidersWindow.cs": "831a7e8234164c7cccb18ce36fe12f424fe0032a8266d6e83fa3c9e609191a5f", + "src/application/ModelRouting.cs": "5879e1179991b91330b10c20bb1848de6e9b1abb3bf5f5135a4121f1872217fe", + "tests/model-providers-ui/Program.cs": "c3ea142478f5a447536aac55ee6232a0a145ca94fa880cb2003e5747a724891e" + }, + "pictures": [ + { + "file": "setup-en.png", + "sha256": "1b4a555bca6aba28ab660ed4ba94edb3eca49c5acfb826d6af16e7cb39bdc752", + "dimensions": [ + 500, + 510 + ] + }, + { + "file": "home-en.png", + "sha256": "bd7a7292f55ef8e355a06a92f57cbcfdd960cf5783805e5a312e7dc6416c078e", + "dimensions": [ + 500, + 360 + ] + }, + { + "file": "setup-ko.png", + "sha256": "10d5be1f90c4728b84006f1184e30e54c22e6619ed0af4fb9d3416edfc1f305c", + "dimensions": [ + 500, + 510 + ] + }, + { + "file": "home-ko.png", + "sha256": "136a396415bcfc71cf8749ce74b060e2b467ef25673578503fe7c89eafde49b5", + "dimensions": [ + 500, + 360 + ] + } + ], + "scope": "Fictional upstreams/disposable current native CLI and offscreen production WPF controls only. No owner process/account, real desktop picker, external-provider inference or public release acceptance." +} diff --git a/localization/packs/de.json b/localization/packs/de.json index 79abef1..c81e641 100644 --- a/localization/packs/de.json +++ b/localization/packs/de.json @@ -8,12 +8,12 @@ "strings": { "sessions.errors": "Fehler", "sessions.errors_help": "Zählt lokale Hauptchats, die durch einen Systemfehler oder einen fehlgeschlagenen letzten Schritt angehalten wurden. Unteragenten, aktive Chats, Abbrüche und behobene Fehler sind ausgeschlossen.", - "providers.reasoning_title": "GPT-Denkintensität in Codex", + "providers.reasoning_title": "Modell-Reasoning", "providers.reasoning_model": "Modell", "providers.reasoning_override": "Unterstützte Denkstufen festlegen", "providers.reasoning_default": "Standardstufe", "providers.reasoning_save": "Denkeinstellungen speichern", - "providers.reasoning_hint": "Wählen Sie nur Stufen, die die Responses API dieses Modells unterstützt. Ohne Anpassung gelten Anbieterangaben oder ein Modell mit exakt gleicher ID im OpenAI-Katalog. Codex sendet die gewählte Stufe mit der nächsten Anfrage.", + "providers.reasoning_hint": "Verwenden Sie unterstützte Stufen. Bei Codex Switch gilt die gespeicherte Standardstufe für die nächste Anfrage. Ohne Überschreibung gelten die Anbieterdaten.", "restart.title": "Restart Codex", "restart.help": "Restart Codex with the current login and reopen eligible unfinished chats. Running/completed work is checked before sending Continue.", "restart.done": "Codex restarted and eligible chats resumed.", @@ -463,6 +463,12 @@ "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", "providers.include": "Model IDs to include (optional, one per line)", + "router.model": "Modell", + "router.setup_model": "Modell (Einrichtung nötig)", + "router.choose": "Tatsächliches Modell auswählen…", + "router.unavailable": "Gewähltes Modell nicht verfügbar", + "router.next_request": "Gilt für jede neue Codex-Switch-Modellanfrage. Laufende Antworten behalten ihre Route.", + "router.first_connection": "Codex verbinden und einmal im Leerlauf neu starten. Spätere Modellwechsel benötigen keinen Neustart.", "providers.title": "Modellanbieter", "providers.short": "Modelle", "providers.intro": "Keep OpenAI and custom Responses models in the same Codex model picker.", @@ -478,7 +484,7 @@ "providers.metadata_hint": "A Codex catalog retains provider metadata. A plain model list uses generic text-only client settings without a claimed context limit or reasoning support. Responses streaming, tools and follow-ups must be compatible.", "providers.connect": "Codex verbinden", "providers.disconnect": "Verbindung wiederherstellen", - "providers.connection_hint": "Initial connection and restoration require one Codex restart when you are ready. Switch never restarts Codex here. Keep Switch running while connected. Saved provider edits apply to new requests; the picker may take a few minutes to refresh.", + "providers.connection_hint": "Codex verbinden und einmal neu starten, wenn Sie bereit sind. Switch geöffnet lassen. Danach gilt die Modellauswahl in Switch ohne Neustart für neue Anfragen.", "providers.handoff_title": "Bestehenden Chat fortsetzen", "providers.handoff_steps": "1. Zum ursprünglichen Anbieter, Modell und angemeldeten Konto zurückkehren.\n2. Die Vorlage kopieren und im ursprünglichen Chat eine Klartextzusammenfassung anfordern. Dafür wird das ursprüngliche Konto verwendet.\n3. Beim gewünschten Anbieter einen neuen Chat öffnen und die Zusammenfassung einfügen. Den alten Chat behalten.\nWird auch die ursprüngliche Verbindung abgelehnt, die Switch-Verbindung wiederherstellen und Codex im Leerlauf neu starten. Kopieren sendet keinen Chat und ändert keine Einstellungen.", "providers.handoff_copy": "Übergabevorlage kopieren", @@ -489,7 +495,7 @@ "providers.not_connected": "Codex is not connected to the local gateway.", "providers.saved": "Provider settings saved.", "providers.step_details": "1. Connection details", - "providers.step_connect": "2. Connect to Codex", + "providers.step_connect": "2. Auswählen und verbinden", "providers.save_next": "Save and continue", "providers.edit": "Edit details", "providers.close": "Close", diff --git a/localization/packs/en.json b/localization/packs/en.json index 2ef91e9..6bbb6a6 100644 --- a/localization/packs/en.json +++ b/localization/packs/en.json @@ -8,12 +8,12 @@ "strings": { "sessions.errors": "Errors", "sessions.errors_help": "Counts local main chats currently stopped by a system error or failed latest turn. Excludes subagents, active chats, cancellations and past errors after recovery.", - "providers.reasoning_title": "GPT reasoning in Codex", + "providers.reasoning_title": "Model reasoning", "providers.reasoning_model": "Model", "providers.reasoning_override": "Set supported reasoning levels", "providers.reasoning_default": "Default level", "providers.reasoning_save": "Save reasoning settings", - "providers.reasoning_hint": "Use the levels supported by this model's Responses API. Leave the override off to use provider metadata or an exact match in the OpenAI catalog. Codex sends the selected effort with the next request.", + "providers.reasoning_hint": "Use the model’s supported levels. For Codex Switch, its saved default applies to the next request. Leave the override off to use provider metadata.", "restart.title": "Restart Codex", "restart.help": "Restart Codex with the current login and reopen eligible unfinished chats. Running/completed work is checked before sending Continue.", "restart.done": "Codex restarted and eligible chats resumed.", @@ -463,6 +463,12 @@ "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", "providers.include": "Model IDs to include (optional, one per line)", + "router.model": "Model", + "router.setup_model": "Model (setup required)", + "router.choose": "Select the actual model…", + "router.unavailable": "Selected model unavailable", + "router.next_request": "Applies to every new Codex Switch model request. Existing responses keep their route.", + "router.first_connection": "Connect Codex and restart once when idle. Later model selections apply without restarting.", "providers.title": "Model providers", "providers.short": "Models", "providers.intro": "Keep OpenAI and custom Responses models in the same Codex model picker.", @@ -478,7 +484,7 @@ "providers.metadata_hint": "A Codex catalog retains provider metadata. A plain model list uses generic text-only client settings without a claimed context limit or reasoning support. Responses streaming, tools and follow-ups must be compatible.", "providers.connect": "Connect Codex", "providers.disconnect": "Restore connection", - "providers.connection_hint": "Initial connection and restoration require one Codex restart when you are ready. Switch never restarts Codex here. Keep Switch running while connected. Saved provider edits apply to new requests; the picker may take a few minutes to refresh.", + "providers.connection_hint": "Connect Codex and restart once when ready. Keep Switch running. After that, select the actual model in Switch; new requests use it without restarting.", "providers.handoff_title": "Continue an existing chat", "providers.handoff_steps": "1. Return to the original provider, model and signed-in account.\n2. Copy the prompt below and ask that original chat for a plain-text handoff summary. Generating it uses the original account.\n3. Start a new chat on the provider you want and paste the summary there. Keep the original chat.\nIf the original route also refuses, restore the Switch connection and use Restart Codex when idle first. Copying this prompt sends no conversation and changes no settings.", "providers.handoff_copy": "Copy handoff prompt", @@ -489,7 +495,7 @@ "providers.not_connected": "Codex is not connected to the local gateway.", "providers.saved": "Provider settings saved.", "providers.step_details": "1. Connection details", - "providers.step_connect": "2. Connect to Codex", + "providers.step_connect": "2. Select and connect", "providers.save_next": "Save and continue", "providers.edit": "Edit details", "providers.close": "Close", diff --git a/localization/packs/es.json b/localization/packs/es.json index 1765b8b..25dd698 100644 --- a/localization/packs/es.json +++ b/localization/packs/es.json @@ -8,12 +8,12 @@ "strings": { "sessions.errors": "Errores", "sessions.errors_help": "Cuenta los chats principales locales detenidos por un error del sistema o el fallo del último turno. Excluye subagentes, chats activos, cancelaciones y errores ya recuperados.", - "providers.reasoning_title": "Razonamiento de GPT en Codex", + "providers.reasoning_title": "Razonamiento del modelo", "providers.reasoning_model": "Modelo", "providers.reasoning_override": "Definir niveles de razonamiento compatibles", "providers.reasoning_default": "Nivel predeterminado", "providers.reasoning_save": "Guardar ajustes de razonamiento", - "providers.reasoning_hint": "Seleccione solo niveles compatibles con la API Responses de este modelo. Sin ajustes manuales, se usan los datos del proveedor o del modelo con el mismo ID exacto en el catálogo de OpenAI. Codex envía el nivel elegido en la siguiente solicitud.", + "providers.reasoning_hint": "Usa los niveles compatibles. En Codex Switch, el nivel predeterminado guardado se aplica a la próxima solicitud. Sin anulación se usan los metadatos del proveedor.", "restart.title": "Restart Codex", "restart.help": "Restart Codex with the current login and reopen eligible unfinished chats. Running/completed work is checked before sending Continue.", "restart.done": "Codex restarted and eligible chats resumed.", @@ -463,6 +463,12 @@ "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", "providers.include": "Model IDs to include (optional, one per line)", + "router.model": "Modelo", + "router.setup_model": "Modelo (requiere conexión)", + "router.choose": "Seleccionar el modelo real…", + "router.unavailable": "Modelo seleccionado no disponible", + "router.next_request": "Se aplica a cada nueva solicitud del modelo Codex Switch. Las respuestas en curso conservan su ruta.", + "router.first_connection": "Conecta Codex y reinícialo una vez cuando esté inactivo. Después puedes cambiar de modelo sin reiniciar.", "providers.title": "Proveedores de modelos", "providers.short": "Modelos", "providers.intro": "Keep OpenAI and custom Responses models in the same Codex model picker.", @@ -478,7 +484,7 @@ "providers.metadata_hint": "A Codex catalog retains provider metadata. A plain model list uses generic text-only client settings without a claimed context limit or reasoning support. Responses streaming, tools and follow-ups must be compatible.", "providers.connect": "Conectar Codex", "providers.disconnect": "Restaurar conexión", - "providers.connection_hint": "Initial connection and restoration require one Codex restart when you are ready. Switch never restarts Codex here. Keep Switch running while connected. Saved provider edits apply to new requests; the picker may take a few minutes to refresh.", + "providers.connection_hint": "Conecta Codex y reinícialo una vez cuando estés listo. Mantén Switch abierto. Después, el modelo elegido en Switch se aplica a las nuevas solicitudes sin reiniciar.", "providers.handoff_title": "Continuar un chat existente", "providers.handoff_steps": "1. Vuelve al proveedor, modelo y cuenta originales.\n2. Copia la solicitud y pide al chat original un resumen de traspaso en texto. Se utilizará la cuenta original.\n3. Abre un chat nuevo con el proveedor deseado y pega el resumen. Conserva el chat original.\nSi la conexión original también se rechaza, restaura la conexión de Switch y reinicia Codex cuando esté inactivo. Copiar no envía conversaciones ni cambia ajustes.", "providers.handoff_copy": "Copiar solicitud de resumen", @@ -489,7 +495,7 @@ "providers.not_connected": "Codex is not connected to the local gateway.", "providers.saved": "Provider settings saved.", "providers.step_details": "1. Connection details", - "providers.step_connect": "2. Connect to Codex", + "providers.step_connect": "2. Seleccionar y conectar", "providers.save_next": "Save and continue", "providers.edit": "Edit details", "providers.close": "Close", diff --git a/localization/packs/fr.json b/localization/packs/fr.json index b48b7b3..498de7c 100644 --- a/localization/packs/fr.json +++ b/localization/packs/fr.json @@ -8,12 +8,12 @@ "strings": { "sessions.errors": "Erreurs", "sessions.errors_help": "Nombre de chats principaux locaux arrêtés par une erreur système ou un échec du dernier tour. Exclut les sous-agents, les chats actifs, les annulations et les erreurs résolues.", - "providers.reasoning_title": "Raisonnement GPT dans Codex", + "providers.reasoning_title": "Raisonnement du modèle", "providers.reasoning_model": "Modèle", "providers.reasoning_override": "Définir les niveaux de raisonnement pris en charge", "providers.reasoning_default": "Niveau par défaut", "providers.reasoning_save": "Enregistrer les réglages", - "providers.reasoning_hint": "Choisissez uniquement les niveaux pris en charge par l’API Responses de ce modèle. Sans réglage manuel, les métadonnées du fournisseur ou du modèle au même identifiant exact dans le catalogue OpenAI sont utilisées. Codex envoie le niveau choisi avec la prochaine requête.", + "providers.reasoning_hint": "Utilisez les niveaux pris en charge. Avec Codex Switch, le niveau par défaut enregistré s’applique à la prochaine requête. Sans remplacement, les métadonnées du fournisseur sont utilisées.", "restart.title": "Restart Codex", "restart.help": "Restart Codex with the current login and reopen eligible unfinished chats. Running/completed work is checked before sending Continue.", "restart.done": "Codex restarted and eligible chats resumed.", @@ -463,6 +463,12 @@ "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", "providers.include": "Model IDs to include (optional, one per line)", + "router.model": "Modèle", + "router.setup_model": "Modèle (connexion requise)", + "router.choose": "Choisir le modèle réel…", + "router.unavailable": "Modèle sélectionné indisponible", + "router.next_request": "S’applique à chaque nouvelle requête du modèle Codex Switch. Les réponses en cours conservent leur route.", + "router.first_connection": "Connectez Codex et redémarrez-le une fois au repos. Les changements suivants ne nécessitent aucun redémarrage.", "providers.title": "Fournisseurs de modèles", "providers.short": "Modèles", "providers.intro": "Keep OpenAI and custom Responses models in the same Codex model picker.", @@ -478,7 +484,7 @@ "providers.metadata_hint": "A Codex catalog retains provider metadata. A plain model list uses generic text-only client settings without a claimed context limit or reasoning support. Responses streaming, tools and follow-ups must be compatible.", "providers.connect": "Connecter Codex", "providers.disconnect": "Rétablir la connexion", - "providers.connection_hint": "Initial connection and restoration require one Codex restart when you are ready. Switch never restarts Codex here. Keep Switch running while connected. Saved provider edits apply to new requests; the picker may take a few minutes to refresh.", + "providers.connection_hint": "Connectez Codex et redémarrez-le une fois lorsque vous êtes prêt. Gardez Switch ouvert. Ensuite, le modèle choisi dans Switch s’applique aux nouvelles requêtes sans redémarrage.", "providers.handoff_title": "Continuer une conversation existante", "providers.handoff_steps": "1. Revenez au fournisseur, au modèle et au compte d’origine.\n2. Copiez la demande et demandez un résumé de transmission en texte dans la conversation d’origine. Le compte d’origine sera utilisé.\n3. Ouvrez une nouvelle conversation chez le fournisseur souhaité et collez le résumé. Conservez l’original.\nSi la connexion d’origine est aussi refusée, restaurez la connexion Switch puis redémarrez Codex au repos. Copier ne transmet aucune conversation et ne change aucun réglage.", "providers.handoff_copy": "Copier la demande de résumé", @@ -489,7 +495,7 @@ "providers.not_connected": "Codex is not connected to the local gateway.", "providers.saved": "Provider settings saved.", "providers.step_details": "1. Connection details", - "providers.step_connect": "2. Connect to Codex", + "providers.step_connect": "2. Choisir et connecter", "providers.save_next": "Save and continue", "providers.edit": "Edit details", "providers.close": "Close", diff --git a/localization/packs/ja.json b/localization/packs/ja.json index 036ae80..1dfd12a 100644 --- a/localization/packs/ja.json +++ b/localization/packs/ja.json @@ -8,12 +8,12 @@ "strings": { "sessions.errors": "エラー", "sessions.errors_help": "システムエラーまたは最新ターンの失敗で停止したローカルのメインチャット数です。サブエージェント、実行中のチャット、キャンセルと復旧済みの過去エラーは除きます。", - "providers.reasoning_title": "Codex で GPT の推論強度を使う", + "providers.reasoning_title": "モデルの推論設定", "providers.reasoning_model": "モデル", "providers.reasoning_override": "対応する推論レベルを指定", "providers.reasoning_default": "既定のレベル", "providers.reasoning_save": "推論設定を保存", - "providers.reasoning_hint": "このモデルの Responses API が対応するレベルを選択してください。指定しない場合は提供元の情報または OpenAI カタログで ID が完全一致するモデルの設定を使います。選択した強度は次のリクエストで送信されます。", + "providers.reasoning_hint": "モデルが対応する強度を指定してください。Codex Switchでは保存した既定値が次のリクエストに適用されます。上書きしない場合は提供者の設定を使います。", "restart.title": "Restart Codex", "restart.help": "Restart Codex with the current login and reopen eligible unfinished chats. Running/completed work is checked before sending Continue.", "restart.done": "Codex restarted and eligible chats resumed.", @@ -463,6 +463,12 @@ "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", "providers.include": "Model IDs to include (optional, one per line)", + "router.model": "モデル", + "router.setup_model": "モデル(接続が必要)", + "router.choose": "実際に使うモデルを選択…", + "router.unavailable": "選択したモデルは利用できません", + "router.next_request": "Codex Switchモデルを使うすべての会話の次のリクエストから適用されます。処理中の応答は元の経路を維持します。", + "router.first_connection": "Codexを接続し、作業していないときに一度再起動してください。その後のモデル変更に再起動は不要です。", "providers.title": "モデル提供者", "providers.short": "モデル", "providers.intro": "Keep OpenAI and custom Responses models in the same Codex model picker.", @@ -478,7 +484,7 @@ "providers.metadata_hint": "A Codex catalog retains provider metadata. A plain model list uses generic text-only client settings without a claimed context limit or reasoning support. Responses streaming, tools and follow-ups must be compatible.", "providers.connect": "Codex を接続", "providers.disconnect": "接続を復元", - "providers.connection_hint": "Initial connection and restoration require one Codex restart when you are ready. Switch never restarts Codex here. Keep Switch running while connected. Saved provider edits apply to new requests; the picker may take a few minutes to refresh.", + "providers.connection_hint": "Codexを接続し、準備ができたら一度再起動してください。接続中はSwitchを起動しておいてください。その後はSwitchで実際のモデルを選ぶと、再起動せず次のリクエストから適用されます。", "providers.handoff_title": "既存の会話を引き継ぐ", "providers.handoff_steps": "1. 元のプロバイダー・モデルとログインアカウントに戻します。\n2. 下の依頼文をコピーし、元の会話でプレーンテキストの引き継ぎ要約を依頼します。要約には元のアカウントが使われます。\n3. 希望するプロバイダーで新しい会話を開き、要約を貼り付けます。元の会話は残してください。\n元の接続も拒否される場合は、接続を復元し、作業中でないときにCodexを再起動してください。依頼文のコピーでは会話を送信せず、設定も変更しません。", "providers.handoff_copy": "要約の依頼文をコピー", @@ -489,7 +495,7 @@ "providers.not_connected": "Codex is not connected to the local gateway.", "providers.saved": "Provider settings saved.", "providers.step_details": "1. Connection details", - "providers.step_connect": "2. Connect to Codex", + "providers.step_connect": "2. モデルを選択して接続", "providers.save_next": "Save and continue", "providers.edit": "Edit details", "providers.close": "Close", diff --git a/localization/packs/ko.json b/localization/packs/ko.json index fc1a23d..f19dec7 100644 --- a/localization/packs/ko.json +++ b/localization/packs/ko.json @@ -8,12 +8,12 @@ "strings": { "sessions.errors": "오류", "sessions.errors_help": "시스템 오류 또는 최근 턴 실패로 멈춘 로컬 주 대화 수입니다. 하위 에이전트, 실행 중인 대화, 사용자 중단과 복구된 과거 오류는 제외합니다.", - "providers.reasoning_title": "Codex에서 GPT 추론 강도 사용", + "providers.reasoning_title": "모델 추론 설정", "providers.reasoning_model": "모델", "providers.reasoning_override": "지원하는 추론 단계 직접 설정", "providers.reasoning_default": "기본 단계", "providers.reasoning_save": "추론 설정 저장", - "providers.reasoning_hint": "이 모델의 Responses API에서 지원하는 단계만 선택하세요. 직접 설정을 끄면 제공자 정보나 OpenAI 목록에서 ID가 같은 모델의 설정을 사용합니다. Codex에서 선택한 강도는 다음 요청에 전달됩니다.", + "providers.reasoning_hint": "모델이 지원하는 강도를 지정하세요. Codex Switch에서는 저장한 기본 강도가 다음 요청에 적용됩니다. 재정의를 끄면 제공자 메타데이터를 사용합니다.", "restart.title": "Restart Codex", "restart.help": "현재 로그인을 유지해 Codex를 재시작하고 미완료 채팅을 다시 엽니다. 실행 중이거나 완료된 작업을 확인한 뒤 필요한 채팅에만 이어가기를 보냅니다.", "restart.done": "Codex를 재시작하고 재개 대상 채팅을 이어갔습니다.", @@ -463,6 +463,12 @@ "providers.details": "호환성과 대화 전달 안내", "providers.restored": "기존 연결 설정을 복원했습니다. 편할 때 Codex를 한 번 재시작하세요.", "providers.include": "포함할 모델 ID (선택 사항, 한 줄에 하나)", + "router.model": "모델", + "router.setup_model": "모델 (연결 필요)", + "router.choose": "실제 사용할 모델 선택…", + "router.unavailable": "선택한 모델을 사용할 수 없음", + "router.next_request": "Codex Switch 모델을 쓰는 모든 대화의 새 요청부터 적용됩니다. 진행 중인 응답은 기존 경로를 유지합니다.", + "router.first_connection": "Codex 연결 후 작업이 없을 때 한 번 재시작하세요. 이후 모델 선택은 재시작 없이 적용됩니다.", "providers.title": "모델 제공자", "providers.short": "모델", "providers.intro": "OpenAI 모델과 커스텀 Responses 모델을 Codex의 같은 선택창에서 고릅니다.", @@ -478,7 +484,7 @@ "providers.metadata_hint": "Codex 카탈로그가 있으면 해당 모델 정보를 유지합니다. 일반 모델 목록에는 추론과 컨텍스트 크기를 보장하지 않는 기본 텍스트 설정을 사용합니다. Responses 스트리밍과 도구 호출, 후속 대화를 지원해야 합니다.", "providers.connect": "Codex 연결", "providers.disconnect": "기존 연결 복원", - "providers.connection_hint": "처음 연결하거나 복원한 뒤에는 편할 때 Codex를 한 번 재시작하세요. 여기서 Codex를 재시작하지 않습니다. 연결 중에는 Switch를 켜 두세요. 제공자 수정은 다음 요청부터 적용되며, 모델 목록은 새로 고침까지 몇 분 걸릴 수 있습니다.", + "providers.connection_hint": "Codex 연결 후 준비되면 한 번 재시작하세요. 연결 중에는 Switch를 켜 두세요. 이후에는 Switch에서 실제 모델을 선택하면 재시작 없이 새 요청부터 적용됩니다.", "providers.handoff_title": "기존 대화 이어가기", "providers.handoff_steps": "1. 원래 공급자·모델과 당시 로그인 계정으로 돌아가세요.\n2. 아래 요청문을 복사해 원래 대화에서 텍스트 인계 요약을 요청하세요. 요약 생성에는 원래 계정이 사용됩니다.\n3. 원하는 공급자의 새 대화를 열어 요약을 붙여 넣으세요. 원래 대화는 보존하세요.\n원래 연결도 거부되면 먼저 연결 복원 후 작업이 없을 때 Codex 재시작을 사용하세요. 요청문 복사는 대화를 전송하거나 설정을 바꾸지 않습니다.", "providers.handoff_copy": "요약 요청문 복사", @@ -489,7 +495,7 @@ "providers.not_connected": "Codex가 로컬 중계에 연결되어 있지 않습니다.", "providers.saved": "제공자 설정을 저장했습니다.", "providers.step_details": "1. 연결 정보", - "providers.step_connect": "2. Codex에 연결", + "providers.step_connect": "2. 모델 선택과 연결", "providers.save_next": "저장하고 계속", "providers.edit": "정보 수정", "providers.close": "닫기", diff --git a/localization/packs/pt-BR.json b/localization/packs/pt-BR.json index 22bcb3f..24555cc 100644 --- a/localization/packs/pt-BR.json +++ b/localization/packs/pt-BR.json @@ -8,12 +8,12 @@ "strings": { "sessions.errors": "Erros", "sessions.errors_help": "Conta conversas principais locais paradas por erro de sistema ou falha no último turno. Exclui subagentes, conversas ativas, cancelamentos e erros já resolvidos.", - "providers.reasoning_title": "Raciocínio GPT no Codex", + "providers.reasoning_title": "Raciocínio do modelo", "providers.reasoning_model": "Modelo", "providers.reasoning_override": "Definir níveis de raciocínio compatíveis", "providers.reasoning_default": "Nível padrão", "providers.reasoning_save": "Salvar ajustes de raciocínio", - "providers.reasoning_hint": "Selecione apenas níveis compatíveis com a API Responses deste modelo. Sem ajustes manuais, são usados os dados do provedor ou do modelo com o mesmo ID exato no catálogo da OpenAI. O Codex envia o nível escolhido na próxima solicitação.", + "providers.reasoning_hint": "Use os níveis compatíveis. No Codex Switch, o padrão salvo vale para a próxima solicitação. Sem substituição, são usados os metadados do provedor.", "restart.title": "Restart Codex", "restart.help": "Restart Codex with the current login and reopen eligible unfinished chats. Running/completed work is checked before sending Continue.", "restart.done": "Codex restarted and eligible chats resumed.", @@ -463,6 +463,12 @@ "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", "providers.include": "Model IDs to include (optional, one per line)", + "router.model": "Modelo", + "router.setup_model": "Modelo (conexão necessária)", + "router.choose": "Selecionar o modelo real…", + "router.unavailable": "Modelo selecionado indisponível", + "router.next_request": "Aplica-se a cada nova solicitação do modelo Codex Switch. Respostas em andamento mantêm sua rota.", + "router.first_connection": "Conecte o Codex e reinicie uma vez quando estiver ocioso. As próximas trocas de modelo não exigem reinicialização.", "providers.title": "Provedores de modelos", "providers.short": "Modelos", "providers.intro": "Keep OpenAI and custom Responses models in the same Codex model picker.", @@ -478,7 +484,7 @@ "providers.metadata_hint": "A Codex catalog retains provider metadata. A plain model list uses generic text-only client settings without a claimed context limit or reasoning support. Responses streaming, tools and follow-ups must be compatible.", "providers.connect": "Conectar Codex", "providers.disconnect": "Restaurar conexão", - "providers.connection_hint": "Initial connection and restoration require one Codex restart when you are ready. Switch never restarts Codex here. Keep Switch running while connected. Saved provider edits apply to new requests; the picker may take a few minutes to refresh.", + "providers.connection_hint": "Conecte o Codex e reinicie uma vez quando estiver pronto. Mantenha o Switch aberto. Depois, o modelo escolhido no Switch vale para novas solicitações sem reiniciar.", "providers.handoff_title": "Continuar uma conversa existente", "providers.handoff_steps": "1. Volte ao provedor, modelo e conta originais.\n2. Copie o pedido e solicite um resumo de transferência em texto na conversa original. A conta original será usada.\n3. Abra uma nova conversa no provedor desejado e cole o resumo. Preserve a conversa original.\nSe a conexão original também for recusada, restaure a conexão do Switch e reinicie o Codex quando estiver ocioso. Copiar não envia conversas nem altera configurações.", "providers.handoff_copy": "Copiar pedido de resumo", @@ -489,7 +495,7 @@ "providers.not_connected": "Codex is not connected to the local gateway.", "providers.saved": "Provider settings saved.", "providers.step_details": "1. Connection details", - "providers.step_connect": "2. Connect to Codex", + "providers.step_connect": "2. Selecionar e conectar", "providers.save_next": "Save and continue", "providers.edit": "Edit details", "providers.close": "Close", diff --git a/localization/packs/zh-CN.json b/localization/packs/zh-CN.json index 36a4420..5e79666 100644 --- a/localization/packs/zh-CN.json +++ b/localization/packs/zh-CN.json @@ -8,12 +8,12 @@ "strings": { "sessions.errors": "错误", "sessions.errors_help": "因系统错误或最新轮次失败而停止的本地主对话数。不包含子代理、正在运行的对话、用户取消或已恢复的历史错误。", - "providers.reasoning_title": "在 Codex 中使用 GPT 推理强度", + "providers.reasoning_title": "模型推理设置", "providers.reasoning_model": "模型", "providers.reasoning_override": "指定支持的推理级别", "providers.reasoning_default": "默认级别", "providers.reasoning_save": "保存推理设置", - "providers.reasoning_hint": "仅选择此模型的 Responses API 支持的级别。关闭手动设置时,使用提供商信息或 OpenAI 目录中 ID 完全相同的模型设置。所选强度会随下一次请求发送。", + "providers.reasoning_hint": "请选择模型支持的强度。Codex Switch 会将保存的默认强度用于下一次请求。关闭覆盖后使用提供商元数据。", "restart.title": "Restart Codex", "restart.help": "Restart Codex with the current login and reopen eligible unfinished chats. Running/completed work is checked before sending Continue.", "restart.done": "Codex restarted and eligible chats resumed.", @@ -463,6 +463,12 @@ "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", "providers.include": "Model IDs to include (optional, one per line)", + "router.model": "模型", + "router.setup_model": "模型(需要连接)", + "router.choose": "选择实际使用的模型…", + "router.unavailable": "所选模型不可用", + "router.next_request": "适用于所有使用 Codex Switch 模型的对话的新请求。正在生成的回复保持原有路由。", + "router.first_connection": "连接 Codex 后,在空闲时重启一次。之后切换模型无需重启。", "providers.title": "模型提供商", "providers.short": "模型", "providers.intro": "Keep OpenAI and custom Responses models in the same Codex model picker.", @@ -478,7 +484,7 @@ "providers.metadata_hint": "A Codex catalog retains provider metadata. A plain model list uses generic text-only client settings without a claimed context limit or reasoning support. Responses streaming, tools and follow-ups must be compatible.", "providers.connect": "连接 Codex", "providers.disconnect": "恢复连接", - "providers.connection_hint": "Initial connection and restoration require one Codex restart when you are ready. Switch never restarts Codex here. Keep Switch running while connected. Saved provider edits apply to new requests; the picker may take a few minutes to refresh.", + "providers.connection_hint": "连接 Codex,准备好后重启一次。连接期间请保持 Switch 运行。之后在 Switch 中选择实际模型,即可应用于新请求,无需重启。", "providers.handoff_title": "继续现有对话", "providers.handoff_steps": "1. 切回原来的提供商、模型及登录账户。\n2. 复制下面的请求,在原对话中要求生成纯文本交接摘要。生成摘要会使用原账户。\n3. 在目标提供商中创建新对话并粘贴摘要。保留原对话。\n如果原连接也被拒绝,请先恢复Switch连接,待无任务运行时重启Codex。复制请求不会发送对话或更改设置。", "providers.handoff_copy": "复制摘要请求", @@ -489,7 +495,7 @@ "providers.not_connected": "Codex is not connected to the local gateway.", "providers.saved": "Provider settings saved.", "providers.step_details": "1. Connection details", - "providers.step_connect": "2. Connect to Codex", + "providers.step_connect": "2. 选择模型并连接", "providers.save_next": "Save and continue", "providers.edit": "Edit details", "providers.close": "Close", diff --git a/localization/packs/zh-TW.json b/localization/packs/zh-TW.json index fbbe131..bcc8601 100644 --- a/localization/packs/zh-TW.json +++ b/localization/packs/zh-TW.json @@ -8,12 +8,12 @@ "strings": { "sessions.errors": "錯誤", "sessions.errors_help": "因系統錯誤或最新回合失敗而停止的本機主對話數。不包含子代理、執行中的對話、使用者取消或已復原的歷史錯誤。", - "providers.reasoning_title": "在 Codex 中使用 GPT 推理強度", + "providers.reasoning_title": "模型推理設定", "providers.reasoning_model": "模型", "providers.reasoning_override": "指定支援的推理級別", "providers.reasoning_default": "預設級別", "providers.reasoning_save": "儲存推理設定", - "providers.reasoning_hint": "僅選擇此模型的 Responses API 支援的級別。關閉手動設定時,使用供應商資訊或 OpenAI 目錄中 ID 完全相同的模型設定。所選強度會隨下一次請求傳送。", + "providers.reasoning_hint": "請選擇模型支援的強度。Codex Switch 會將儲存的預設強度用於下一次請求。關閉覆寫後使用供應商中繼資料。", "restart.title": "Restart Codex", "restart.help": "Restart Codex with the current login and reopen eligible unfinished chats. Running/completed work is checked before sending Continue.", "restart.done": "Codex restarted and eligible chats resumed.", @@ -463,6 +463,12 @@ "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", "providers.include": "Model IDs to include (optional, one per line)", + "router.model": "模型", + "router.setup_model": "模型(需要連線)", + "router.choose": "選擇實際使用的模型…", + "router.unavailable": "所選模型無法使用", + "router.next_request": "套用至所有使用 Codex Switch 模型的對話的新請求。正在產生的回覆會保留原本路由。", + "router.first_connection": "連線 Codex 後,請在閒置時重新啟動一次。之後切換模型無須重新啟動。", "providers.title": "模型供應商", "providers.short": "模型", "providers.intro": "Keep OpenAI and custom Responses models in the same Codex model picker.", @@ -478,7 +484,7 @@ "providers.metadata_hint": "A Codex catalog retains provider metadata. A plain model list uses generic text-only client settings without a claimed context limit or reasoning support. Responses streaming, tools and follow-ups must be compatible.", "providers.connect": "連線 Codex", "providers.disconnect": "還原連線", - "providers.connection_hint": "Initial connection and restoration require one Codex restart when you are ready. Switch never restarts Codex here. Keep Switch running while connected. Saved provider edits apply to new requests; the picker may take a few minutes to refresh.", + "providers.connection_hint": "連線 Codex,準備好後重新啟動一次。連線期間請保持 Switch 執行。之後在 Switch 中選擇實際模型,即可套用至新請求,無須重新啟動。", "providers.handoff_title": "繼續現有對話", "providers.handoff_steps": "1. 切回原來的提供者、模型及登入帳戶。\n2. 複製下方請求,在原對話中要求產生純文字交接摘要。產生摘要會使用原帳戶。\n3. 在目標提供者中建立新對話並貼上摘要。保留原對話。\n如果原連線也被拒絕,請先還原Switch連線,待沒有工作執行時重新啟動Codex。複製請求不會傳送對話或變更設定。", "providers.handoff_copy": "複製摘要請求", @@ -489,7 +495,7 @@ "providers.not_connected": "Codex is not connected to the local gateway.", "providers.saved": "Provider settings saved.", "providers.step_details": "1. Connection details", - "providers.step_connect": "2. Connect to Codex", + "providers.step_connect": "2. 選擇模型並連線", "providers.save_next": "Save and continue", "providers.edit": "Edit details", "providers.close": "Close", diff --git a/src/application/DesktopStreamRecovery.cs b/src/application/DesktopStreamRecovery.cs index 3eae032..3a27b74 100644 --- a/src/application/DesktopStreamRecovery.cs +++ b/src/application/DesktopStreamRecovery.cs @@ -33,6 +33,7 @@ internal static class StreamRecoveryPolicy // a failed request. Actual user interruptions remain a separate turn state. internal static bool Failed(string status) => status == "failed"; internal static bool RegisteredModel(string model, ModelProviderSettings providers) => + model == ModelRouting.Model ? ModelRouting.Registered(providers, providers.SelectedRoute) : !model.StartsWith("cs_", StringComparison.Ordinal) || providers.Providers.Any(provider => provider.Models.Any(item => item.Alias(provider.Id) == model)); internal static bool Same(StreamRecoveryObservation a, StreamRecoveryObservation b) => b.Ready && diff --git a/src/application/DesktopStreamRecoveryMonitor.cs b/src/application/DesktopStreamRecoveryMonitor.cs index a4e41e5..dc83a5c 100644 --- a/src/application/DesktopStreamRecoveryMonitor.cs +++ b/src/application/DesktopStreamRecoveryMonitor.cs @@ -130,7 +130,7 @@ private static bool Stopped(DesktopThreadState thread) => !thread.Waiting && thr if (!StreamRecoveryPolicy.RegisteredModel(outcome.Model, providers)) return Skip("selected-model-unregistered", outcome.Id); var rechecked = await Task.Run(() => DesktopTurnOutcomes.Read(_service.CodexHome, id), cancellation).ConfigureAwait(false); if (rechecked == null || rechecked.Id != outcome.Id || rechecked.Status != outcome.Status) return Skip("turn-changed", outcome.Id); - var route = SecureFiles.Hash(JsonSerializer.Serialize(providers.Providers)); + var route = SecureFiles.Hash(JsonSerializer.Serialize(new { providers.Providers, providers.SelectedRoute })); var current = _sessions.Threads.SingleOrDefault(item => item.Id == id); if (current == null || current.Owner != thread.Owner || !Stopped(current) || _sessions.QueuedFor(id) != 0) return Skip("state-changed", outcome.Id); return new(id, thread.Owner, outcome.Id, outcome.Status, outcome.Error, outcome.Model, outcome.Provider, identity, route, outcome.At, true); diff --git a/src/application/GatewayJsonRequest.cs b/src/application/GatewayJsonRequest.cs index b34042f..f9bf29f 100644 --- a/src/application/GatewayJsonRequest.cs +++ b/src/application/GatewayJsonRequest.cs @@ -41,6 +41,16 @@ internal bool Present(string name) { var value = Get(name); return !value.IsEmpty && !value.Span.SequenceEqual("null"u8); } internal string? Model => String(Get("model")); internal bool Streaming => Get("stream").Span.SequenceEqual("true"u8); + internal bool HasInputType(string type) + { + var input = Get("input"); + if (input.IsEmpty) return false; + var reader = new Utf8JsonReader(input.Span); + while (reader.Read()) + if (reader.TokenType == JsonTokenType.PropertyName && reader.ValueTextEquals("type") && reader.Read() && + reader.TokenType == JsonTokenType.String && reader.ValueTextEquals(type)) return true; + return false; + } internal static string? String(ReadOnlyMemory value) { if (value.IsEmpty || value.Length > 1024) return null; diff --git a/src/application/GatewayRoutedContent.cs b/src/application/GatewayRoutedContent.cs index 27721b8..9486f5d 100644 --- a/src/application/GatewayRoutedContent.cs +++ b/src/application/GatewayRoutedContent.cs @@ -37,7 +37,7 @@ internal GatewayRoutedContent(ReadOnlyMemory original, JsonObject options, foreach (var field in Fields(original)) { if (custom && (field.Name == "prompt_cache_key" || generic && field.Name == "service_tier")) continue; - if (custom && field.Name == "reasoning" && !options.ContainsKey("reasoning")) continue; + if (custom && field.Name == "reasoning" && options["reasoning"] == null) continue; if (comma) Add(","); comma = true; _parts.Add(JsonSerializer.SerializeToUtf8Bytes(field.Name)); Add(":"); if (custom && (field.Name is "model" or "reasoning" || generic && field.Name == "text")) @@ -45,6 +45,11 @@ internal GatewayRoutedContent(ReadOnlyMemory original, JsonObject options, else if (removeReasoning && field.Name == "input") AddInput(field.Value); else _parts.Add(field.Value); } + if (custom && !Fields(original).Any(field => field.Name == "reasoning") && options["reasoning"] != null) + { + if (comma) Add(","); + Add("\"reasoning\":"); _parts.Add(JsonSerializer.SerializeToUtf8Bytes(options["reasoning"])); + } Add("}"); _length = _parts.Sum(part => (long)part.Length); Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); } diff --git a/src/application/ModelGateway.cs b/src/application/ModelGateway.cs index c2a2fd1..aa8a082 100644 --- a/src/application/ModelGateway.cs +++ b/src/application/ModelGateway.cs @@ -93,6 +93,7 @@ private async Task ServeAsync(HttpListenerContext context) { await ErrorAsync(response, 403, "Only authenticated local Codex requests are accepted.", cancellation).ConfigureAwait(false); return; } var identity = _authenticate(request.Headers["Authorization"], request.Headers["ChatGPT-Account-ID"]); if (identity == null) { await ErrorAsync(response, 401, "The current OpenAI sign-in is required.", cancellation).ConfigureAwait(false); return; } + var routingSettings = _store.Read(); LastRequestAt = DateTimeOffset.UtcNow; var path = request.Url?.AbsolutePath; if (path == "/backend-api/codex/models" && request.HttpMethod == "GET") @@ -161,11 +162,19 @@ void ReleaseInput() var model = original.Model; if (string.IsNullOrEmpty(model)) throw new InvalidDataException("A model must be selected."); // Snapshot a route and its key before the first upstream byte. Settings edits never retarget an in-flight request. - var providers = _store.Read().Providers; + var settings = routingSettings; var routed = model == ModelRouting.Model; + if (routed) + { + if (!ModelRouting.Registered(settings, settings.SelectedRoute)) + { await ErrorAsync(response, 400, "Select an available model in Codex Switch before sending another request.", cancellation).ConfigureAwait(false); return; } + model = settings.SelectedRoute!; + } + var providers = settings.Providers; var route = providers.SelectMany(p => p.Models.Select(m => (Provider: p, Model: m))).FirstOrDefault(pair => pair.Model.Alias(pair.Provider.Id) == model); if (model.StartsWith("cs_", StringComparison.Ordinal) && route.Provider == null) { await ErrorAsync(response, 404, "The selected custom model is no longer registered. Select another model.", cancellation).ConfigureAwait(false); return; } - if (route.Provider == null && !await KnownOpenAiModelAsync(model, identity, request, cancellation).ConfigureAwait(false)) + JsonObject? openAiModel = null; + if (route.Provider == null && (openAiModel = await NativeModelAsync(model, identity, request, cancellation).ConfigureAwait(false)) == null) { await ErrorAsync(response, 404, "The selected model is not in this account's OpenAI catalog or registered custom routes. Select a model from the combined list.", cancellation).ConfigureAwait(false); return; } // Incremental provider state and opaque reasoning cannot be transferred between upstreams. // HTTP full-input replay is required for every routed request, including after gateway restart. @@ -181,28 +190,48 @@ void ReleaseInput() removeReasoning = original.HasEncrypted("reasoning"); } Uri destination; - if (route.Provider != null) + var rewrite = routed || route.Provider != null; + if (rewrite) { - body = new JsonObject { ["model"] = route.Model.UpstreamId }; + body = new JsonObject { ["model"] = route.Provider == null ? model : route.Model.UpstreamId }; foreach (var option in GatewayRoutedContent.Fields(bytesIn).Where(field => field.Name is "reasoning" or "text")) - { - // Routing controls are small; unbounded history/images never enter a mutable tree. - if (option.Value.Length > 65536) throw new InvalidDataException("The model routing options are too large."); - body[option.Name] = JsonNode.Parse(option.Value.Span); - } + { + if (option.Value.Length > 65536) throw new InvalidDataException("The model routing options are too large."); + body[option.Name] = JsonNode.Parse(option.Value.Span); + } + } + if (route.Provider != null) + { JsonObject? native = null; - if (route.Model.GenericMetadata && route.Model.ReasoningOverride == null && body["reasoning"] != null) + if (route.Model.GenericMetadata && route.Model.ReasoningOverride == null && + (routed ? settings.OpenAiModels.Any(choice => choice.Id == route.Model.UpstreamId) : body!["reasoning"] != null)) native = await NativeModelAsync(route.Model.UpstreamId, identity, request, cancellation).ConfigureAwait(false); - var reasoningError = ProviderReasoning.PrepareRequest(route.Model, ProviderReasoning.Metadata(route.Model, native), body, native != null); + var metadata = ProviderReasoning.Metadata(route.Model, native); + if (routed) + { + if (original.HasInputType("input_image") && (metadata["input_modalities"] as JsonArray)?.Any(item => item?.GetValue() == "image") != true) + { await ErrorAsync(response, 400, "The model selected in Codex Switch does not declare image input support. Select an image-capable model; no input was removed or sent.", cancellation).ConfigureAwait(false); return; } + ModelRouting.ApplyReasoning(body!, metadata); + } + var reasoningError = ProviderReasoning.PrepareRequest(route.Model, metadata, body!, native != null); if (reasoningError != null) { await ErrorAsync(response, 400, reasoningError, cancellation).ConfigureAwait(false); return; } if (route.Model.GenericMetadata) { - if (body["text"] is JsonObject text) text.Remove("verbosity"); + if (body!["text"] is JsonObject text) text.Remove("verbosity"); } destination = new Uri(ProviderCatalog.Endpoint(route.Provider.BaseUrl), path.EndsWith("/compact", StringComparison.Ordinal) ? "responses/compact" : "responses"); } - else destination = new Uri((identity.ApiKey ? "https://api.openai.com/v1/" : "https://chatgpt.com/backend-api/codex/") + (path.EndsWith("/compact", StringComparison.Ordinal) ? "responses/compact" : "responses")); - var requestKey = RequestKey(bytesIn, identity, routeIdentity, path); + else + { + if (routed) + { + if (original.HasInputType("input_image") && (openAiModel!["input_modalities"] as JsonArray)?.Any(item => item?.GetValue() == "image") != true) + { await ErrorAsync(response, 400, "The model selected in Codex Switch does not declare image input support. Select an image-capable model; no input was removed or sent.", cancellation).ConfigureAwait(false); return; } + ModelRouting.ApplyReasoning(body!, openAiModel!); + } + destination = new Uri((identity.ApiKey ? "https://api.openai.com/v1/" : "https://chatgpt.com/backend-api/codex/") + (path.EndsWith("/compact", StringComparison.Ordinal) ? "responses/compact" : "responses")); + } + var requestKey = RequestKey(bytesIn, identity, routed ? ModelRouting.Model : routeIdentity, path); if (requestKey != null) { var admission = ReserveRequest(requestKey); @@ -220,8 +249,8 @@ HttpRequestMessage CreateUpstream() var upstream = new HttpRequestMessage(HttpMethod.Post, destination); if (route.Provider == null) CopyOpenAiHeaders(request, upstream, identity); else upstream.Headers.Authorization = new AuthenticationHeaderValue("Bearer", route.Provider.ApiKey); - upstream.Content = route.Provider != null || removeReasoning ? - new GatewayRoutedContent(bytesIn, body ?? new JsonObject(), route.Provider != null, route.Model?.GenericMetadata == true, removeReasoning) : + upstream.Content = rewrite || removeReasoning ? + new GatewayRoutedContent(bytesIn, body ?? new JsonObject(), rewrite, route.Model?.GenericMetadata == true, removeReasoning) : new ReadOnlyMemoryContent(bytesIn); upstream.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); return upstream; @@ -292,9 +321,9 @@ async Task BeforeRetry(int retry, TimeSpan delay, CancellationToken token) } finally { if (largeBody) _largeBodies.Release(); if (workSlot) _slots.Release(); if (streamSlot) _streamSlots.Release(); context.Response.Close(); } } - internal Task ReadCatalogAsync(GatewayIdentity identity, string clientVersion, CancellationToken cancellation) => - CatalogAsync(identity, null, cancellation, clientVersion); - private async Task CatalogAsync(GatewayIdentity identity, HttpListenerRequest? incoming, CancellationToken cancellation, string? clientVersion = null) + internal Task ReadCatalogAsync(GatewayIdentity identity, string clientVersion, CancellationToken cancellation, bool? router = null) => + CatalogAsync(identity, null, cancellation, clientVersion, router); + private async Task CatalogAsync(GatewayIdentity identity, HttpListenerRequest? incoming, CancellationToken cancellation, string? clientVersion = null, bool? router = null) { var version = clientVersion ?? incoming?.QueryString["client_version"] ?? ""; if (version.Length is < 1 or > 80 || version.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '+'))) @@ -315,10 +344,25 @@ private async Task CatalogAsync(GatewayIdentity identity, HttpListen { if (model is not JsonObject metadata || metadata["slug"]?.GetValue() is not string slug || !slugs.Add(slug)) throw new InvalidDataException("The OpenAI catalog has invalid model IDs."); } + if (models.Count > 256 || slugs.Contains(ModelRouting.Model) || slugs.Any(slug => slug.StartsWith("cs_", StringComparison.Ordinal))) + throw new InvalidDataException("The OpenAI model catalog has too many models or reserved route IDs."); + var choices = models.Cast().Where(model => model["visibility"]?.GetValue() == "list" && + (!identity.ApiKey || model["supported_in_api"]?.GetValue() == true)).Select(model => new ModelRouteChoice(model["slug"]!.GetValue(), + (model["display_name"]?.GetValue() ?? model["slug"]!.GetValue()) + " (OpenAI)")).ToList(); + ModelRouting.ValidateChoices(choices); if (_nativeCatalogs.Count >= 64) _nativeCatalogs.Clear(); var nativeModels = models.Cast().ToDictionary(m => m["slug"]!.GetValue(), m => (JsonObject)m.DeepClone(), StringComparer.Ordinal); _nativeCatalogs[IdentityKey(identity)] = new NativeCatalog(nativeModels, DateTimeOffset.UtcNow.AddMinutes(5)); - if (_store.Read().LastClientVersion != version) _store.Update(settings => settings.LastClientVersion = version); + _store.Update(settings => + { + settings.LastClientVersion = version; + settings.OpenAiModels = choices; + }); + if (router ?? _store.Read().RouterEnabled) + { + result!["models"] = new JsonArray(ModelRouting.Metadata()); + return result; + } foreach (var provider in _store.Read().Providers) foreach (var model in provider.Models) { @@ -330,8 +374,6 @@ private async Task CatalogAsync(GatewayIdentity identity, HttpListen } return result!; } - private async Task KnownOpenAiModelAsync(string model, GatewayIdentity identity, HttpListenerRequest incoming, CancellationToken cancellation) - => await NativeModelAsync(model, identity, incoming, cancellation).ConfigureAwait(false) != null; private async Task NativeModelAsync(string model, GatewayIdentity identity, HttpListenerRequest incoming, CancellationToken cancellation) { var key = IdentityKey(identity); diff --git a/src/application/ModelProviderManager.cs b/src/application/ModelProviderManager.cs index 79c1d63..76313bc 100644 --- a/src/application/ModelProviderManager.cs +++ b/src/application/ModelProviderManager.cs @@ -60,7 +60,7 @@ public async Task SaveAsync(string? id, string name, string url, model.ReasoningOverride = previous.Models.FirstOrDefault(old => old.UpstreamId == model.UpstreamId)?.ReasoningOverride; cancellation.ThrowIfCancellationRequested(); _store.Update(next => { var index = next.Providers.FindIndex(p => p.Id == provider.Id); if (index < 0) next.Providers.Add(provider); else next.Providers[index] = provider; RememberSavedEditor(next, provider, id); }); - await RefreshCatalogCoreAsync(cancellation, true).ConfigureAwait(false); + if (!Settings.RouterEnabled) await RefreshCatalogCoreAsync(cancellation, true).ConfigureAwait(false); return provider; } finally { _changes.Release(); } @@ -72,7 +72,7 @@ public async Task RemoveAsync(string id, CancellationToken cancellation) try { _store.Update(next => { next.Providers.RemoveAll(p => p.Id == id); next.EditorDrafts.Remove(id); if (next.EditorSelection == id) next.EditorSelection = null; }); - await RefreshCatalogCoreAsync(cancellation, true).ConfigureAwait(false); + if (!Settings.RouterEnabled) await RefreshCatalogCoreAsync(cancellation, true).ConfigureAwait(false); } finally { _changes.Release(); } } @@ -87,7 +87,7 @@ internal async Task SaveReasoningAsync(string providerId, string modelId, Provid { lifetime.Token.ThrowIfCancellationRequested(); _store.Update(next => next.Providers.Single(p => p.Id == providerId).Models.Single(m => m.UpstreamId == modelId).ReasoningOverride = options); - await RefreshCatalogCoreAsync(lifetime.Token, true).ConfigureAwait(false); + if (!Settings.RouterEnabled) await RefreshCatalogCoreAsync(lifetime.Token, true).ConfigureAwait(false); } finally { _changes.Release(); } } @@ -106,13 +106,15 @@ internal void Start() catch { gateway.DisposeAsync().AsTask().GetAwaiter().GetResult(); throw; } } } - public async Task ConnectAsync(string executable, string home, CancellationToken cancellation) + public async Task ConnectAsync(string executable, string home, CancellationToken cancellation, bool router = true) { using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellation, _lifetime.Token); cancellation = lifetime.Token; await _changes.WaitAsync(cancellation).ConfigureAwait(false); try { - if (_store.Read().Connected) throw new InvalidOperationException("Codex connection settings are already prepared. Restart Codex once when ready."); + var settings = _store.Read(); + if (settings.Connected && (settings.RouterEnabled || !router)) throw new InvalidOperationException("Codex connection settings are already prepared. Restart Codex once when ready."); + if (router && !ModelRouting.Registered(settings, settings.SelectedRoute)) throw new InvalidOperationException("Select an available model in Codex Switch before connecting."); Start(); await using var rpc = new CodexRpc(executable, home, preserveProvider: true); await rpc.InitializeAsync(cancellation).ConfigureAwait(false); @@ -129,19 +131,24 @@ public async Task ConnectAsync(string executable, string home, CancellationToken var originalProvider = ValueAt(snapshot.Config, "model_provider")?.GetValue(); if (originalProvider != null && originalProvider != "openai") throw new InvalidOperationException("Restore the original OpenAI provider before connecting the combined model catalog."); + if (settings.AppliedConfig != null) + foreach (var (key, applied) in settings.AppliedConfig) + if (!JsonNode.DeepEquals(ValueAt(snapshot.Config, key), applied) && !JsonNode.DeepEquals(ValueAt(snapshot.Config, key), settings.OriginalConfig![key])) + throw new InvalidOperationException("Codex connection settings changed outside Switch. Review them before preparing the fixed model."); if (version == null) throw new InvalidOperationException("The Codex version could not be verified before preparing the model catalog."); _home = Path.GetFullPath(home); - await WriteCatalogAsync(version, cancellation).ConfigureAwait(false); + await WriteCatalogAsync(version, cancellation, router: router).ConfigureAwait(false); var edits = new Dictionary { ["openai_base_url"] = NodeValue.Create(_gateway!.BaseUrl), ["model_catalog_json"] = NodeValue.Create(_catalogPath), ["features.responses_websockets_v2"] = NodeValue.Create(false), ["features.enable_request_compression"] = NodeValue.Create(false) }; - var original = edits.Keys.Append("model").ToDictionary(key => key, key => ValueAt(snapshot.Config, key)?.DeepClone()); + if (router) edits["model"] = NodeValue.Create(ModelRouting.Model); + var original = settings.OriginalConfig ?? edits.Keys.Append("model").Distinct().ToDictionary(key => key, key => ValueAt(snapshot.Config, key)?.DeepClone()); // Persist recovery before the atomic config write. A crash can only leave an extra listener, // never a client configured for a listener that will not be started on the next Switch launch. - _store.Update(next => { next.OriginalConfig = original; next.AppliedConfig = edits; next.Connected = true; next.ConnectionWriteConfirmed = false; }); + _store.Update(next => { next.OriginalConfig = original; next.AppliedConfig = edits; next.RouterEnabled = router; next.Connected = true; next.ConnectionWriteConfirmed = false; }); // Keep recovery information if the RPC commits before its reply is lost. // Disconnect re-reads the file and refuses to overwrite divergent values. await WriteConfigAsync(rpc, home, snapshot.Version, edits, cancellation).ConfigureAwait(false); @@ -178,14 +185,14 @@ private GatewayIdentity ReadIdentity() } finally { CryptographicOperations.ZeroMemory(bytes); } } - private async Task WriteCatalogAsync(string version, CancellationToken cancellation, bool force = true) + private async Task WriteCatalogAsync(string version, CancellationToken cancellation, bool force = true, bool? router = null) { var identity = ReadIdentity(); var identityKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity.Token + "\n" + identity.AccountId))); if (!force && _catalogIdentity == identityKey && DateTimeOffset.UtcNow - _catalogUpdated < TimeSpan.FromMinutes(5) && File.Exists(_catalogPath)) return; Start(); using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation); deadline.CancelAfter(TimeSpan.FromSeconds(30)); - var catalog = await _gateway!.ReadCatalogAsync(identity, version, deadline.Token).ConfigureAwait(false); + var catalog = await _gateway!.ReadCatalogAsync(identity, version, deadline.Token, router).ConfigureAwait(false); var current = ReadIdentity(); if (current.Token != identity.Token || current.AccountId != identity.AccountId || current.ApiKey != identity.ApiKey) throw new InvalidOperationException("The OpenAI sign-in changed while refreshing the model catalog."); @@ -209,11 +216,13 @@ public async Task DisconnectAsync(string executable, string home, CancellationTo foreach (var (key, applied) in settings.AppliedConfig) { var current = ValueAt(snapshot.Config, key); + if (key == "model" && current is NodeValue changed && changed.TryGetValue(out var currentModel) && + currentModel != ModelRouting.Model && !currentModel.StartsWith("cs_", StringComparison.Ordinal)) continue; if (!JsonNode.DeepEquals(current, applied) && !JsonNode.DeepEquals(current, settings.OriginalConfig[key])) throw new InvalidOperationException("Codex connection settings changed outside Switch. Review them before disconnecting."); restore[key] = settings.OriginalConfig[key]?.DeepClone(); } - if (ValueAt(snapshot.Config, "model") is NodeValue model && model.TryGetValue(out var selected) && selected.StartsWith("cs_", StringComparison.Ordinal)) + if (ValueAt(snapshot.Config, "model") is NodeValue model && model.TryGetValue(out var selected) && (selected == ModelRouting.Model || selected.StartsWith("cs_", StringComparison.Ordinal))) restore["model"] = settings.OriginalConfig["model"]?.DeepClone(); await WriteConfigAsync(rpc, home, snapshot.Version, restore, cancellation).ConfigureAwait(false); _store.Update(next => { next.Connected = false; next.ConnectionWriteConfirmed = false; next.OriginalConfig = null; next.AppliedConfig = null; }); diff --git a/src/application/ModelProviders.cs b/src/application/ModelProviders.cs index 676ec08..0ae1d70 100644 --- a/src/application/ModelProviders.cs +++ b/src/application/ModelProviders.cs @@ -42,6 +42,9 @@ internal sealed class ModelProviderSettings public string? LastClientVersion { get; set; } public bool Connected { get; set; } public bool ConnectionWriteConfirmed { get; set; } + public bool RouterEnabled { get; set; } + public string? SelectedRoute { get; set; } + public List OpenAiModels { get; set; } = new(); public List Providers { get; set; } = new(); public string? EditorSelection { get; set; } public Dictionary EditorDrafts { get; set; } = new(); @@ -74,6 +77,9 @@ public ModelProviderStore(string directory) _settings.Providers.Select(p => p.Id).Distinct(StringComparer.Ordinal).Count() != _settings.Providers.Count) throw new InvalidDataException("The model provider store is invalid."); foreach (var provider in _settings.Providers) ProviderCatalog.Validate(provider); + if (_settings.SelectedRoute?.Length > 256 || _settings.SelectedRoute != null && (string.IsNullOrWhiteSpace(_settings.SelectedRoute) || _settings.SelectedRoute.Any(char.IsControl))) + throw new InvalidDataException("The saved model routing choices are invalid."); + ModelRouting.ValidateChoices(_settings.OpenAiModels); if (_settings.EditorDrafts == null || _settings.EditorDrafts.Count > 33 || _settings.EditorSelection != null && _settings.EditorSelection != ModelProviderManager.NewEditorProvider && !Guid.TryParseExact(_settings.EditorSelection, "N", out _)) throw new InvalidDataException("The provider editor state is invalid."); diff --git a/src/application/ModelRouting.cs b/src/application/ModelRouting.cs new file mode 100644 index 0000000..602cb0e --- /dev/null +++ b/src/application/ModelRouting.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json.Nodes; + +namespace CodexAccountDesk; + +internal sealed record ModelRouteChoice(string Id, string Label) +{ + public override string ToString() => Label; +} + +internal static class ModelRouting +{ + internal const string Model = "codex-switch"; + internal static List Choices(ModelProviderSettings settings) => settings.OpenAiModels + .Concat(settings.Providers.SelectMany(provider => provider.Models.Select(model => + new ModelRouteChoice(model.Alias(provider.Id), model.UpstreamId + " (" + provider.Name + ")")))).ToList(); + internal static bool Registered(ModelProviderSettings settings, string? route) => + route != null && Choices(settings).Any(choice => choice.Id == route); + internal static void ValidateChoices(List? choices) + { + if (choices == null || choices.Count > 256 || choices.Any(choice => choice == null || string.IsNullOrWhiteSpace(choice.Id) || choice.Id.Length > 256 || + choice.Id.Any(char.IsControl) || string.IsNullOrWhiteSpace(choice.Label) || choice.Label.Length > 512) || + choices.Select(choice => choice.Id).Distinct(StringComparer.Ordinal).Count() != choices.Count) + throw new InvalidDataException("The OpenAI model routing choices are invalid."); + } + internal static JsonObject Metadata() + { + var model = ProviderCatalog.Generic(Model); + model["display_name"] = "Codex Switch"; + model["description"] = "Select the actual model in Codex Switch. New requests use the saved selection."; + model["supported_reasoning_levels"] = new JsonArray(); + model["input_modalities"] = new JsonArray("text", "image"); + model["upgrade"] = null; + model["auto_review_model_override"] = null; + return model; + } + internal static void ApplyReasoning(JsonObject body, JsonObject metadata) + { + var effort = metadata["default_reasoning_level"]?.GetValue(); + body["reasoning"] = effort != null && metadata["supported_reasoning_levels"] is JsonArray levels && + levels.Any(level => level?["effort"]?.GetValue() == effort) + ? new JsonObject { ["effort"] = effort } : null; + } +} + +internal sealed partial class ModelProviderManager +{ + internal IReadOnlyList RoutingChoices => ModelRouting.Choices(Settings); + internal void SelectRoute(string id) + { + _store.Update(settings => + { + if (!ModelRouting.Registered(settings, id)) throw new InvalidOperationException("Select a currently registered model in Codex Switch."); + settings.SelectedRoute = id; + }); + } +} diff --git a/src/desktop/DeskModelRoutePicker.cs b/src/desktop/DeskModelRoutePicker.cs new file mode 100644 index 0000000..c4c0bca --- /dev/null +++ b/src/desktop/DeskModelRoutePicker.cs @@ -0,0 +1,60 @@ +using System; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; + +namespace CodexAccountDesk; + +internal sealed class DeskModelRoutePicker : Grid +{ + private readonly ModelProviderManager _manager; + private readonly DeskTheme _theme; + private readonly Button _models = new() { MinHeight = 30, MinWidth = 0, HorizontalContentAlignment = HorizontalAlignment.Stretch, Padding = new Thickness(10, 5, 10, 5), CornerRadius = new CornerRadius(7) }; + private readonly TextBlock _caption = new() { TextTrimming = TextTrimming.CharacterEllipsis, VerticalAlignment = VerticalAlignment.Center }; + private readonly TextBlock _label; + private readonly Action _failed; + private readonly Action? _selected; + internal DeskModelRoutePicker(DeskTheme theme, ModelProviderManager manager, Action failed, Action? selected = null) + { + _theme = theme; _manager = manager; _failed = failed; _selected = selected; ColumnDefinitions = new ColumnDefinitions("Auto,*"); Margin = new Thickness(0, 0, 0, 6); + _label = theme.Text("", 12, "muted"); _label.Margin = new Thickness(0, 0, 10, 0); _label.VerticalAlignment = VerticalAlignment.Center; + Children.Add(_label); _models.Name = "ModelRoutePicker"; _models.FontSize = 12; + _models.Foreground = _caption.Foreground = theme.Brush("text"); _models.Background = theme.Brush("surface"); + var content = new Grid { ColumnDefinitions = new ColumnDefinitions("*,Auto") }; content.Children.Add(_caption); + var arrow = theme.Text("▾", 12); arrow.Margin = new Thickness(10, 0, 0, 0); arrow.VerticalAlignment = VerticalAlignment.Center; + Grid.SetColumn(arrow, 1); content.Children.Add(arrow); _models.Content = content; + Grid.SetColumn(_models, 1); Children.Add(_models); + void Open() + { + var menu = _theme.Menu(_models); var selectedId = _manager.Settings.SelectedRoute; + foreach (var choice in _manager.RoutingChoices) + { + var item = new MenuItem { Header = choice.Label, ToggleType = MenuItemToggleType.Radio, IsChecked = choice.Id == selectedId }; + item.Click += (_, _) => + { + try { _manager.SelectRoute(choice.Id); menu.Close(); Refresh(); _selected?.Invoke(); } + catch (Exception error) { menu.Close(); Refresh(); _failed(error); } + }; + menu.Items.Add(item); + } + _models.ContextMenu = menu; menu.Open(_models); + } + _models.Click += (_, _) => Open(); _models.KeyDown += (_, args) => { if (args.Key == Key.Down) { Open(); args.Handled = true; } }; + Refresh(); + } + internal void Refresh() + { + if (_models.ContextMenu?.IsOpen == true) return; + var settings = _manager.Settings; var choices = _manager.RoutingChoices; + IsVisible = choices.Count != 0 || settings.SelectedRoute != null; + _label.Text = Loc.T(settings.Connected && settings.RouterEnabled ? "router.model" : "router.setup_model"); + _caption.Text = choices.FirstOrDefault(choice => choice.Id == settings.SelectedRoute)?.Label ?? Loc.T(settings.SelectedRoute == null ? "router.choose" : "router.unavailable"); + AutomationProperties.SetName(_models, Loc.T("router.model") + ": " + _caption.Text); + ToolTip.SetTip(_models, _caption.Text + "\n" + Loc.T(settings.Connected && settings.RouterEnabled ? "router.next_request" : "router.first_connection")); + } +} diff --git a/src/desktop/DeskProvidersWindow.cs b/src/desktop/DeskProvidersWindow.cs index 7ba0929..2c97da6 100644 --- a/src/desktop/DeskProvidersWindow.cs +++ b/src/desktop/DeskProvidersWindow.cs @@ -29,6 +29,7 @@ internal sealed class DeskProvidersWindow : Window private readonly Dictionary _reasoningLevels = new(); private readonly CancellationTokenSource _stop = new(); private readonly Button _providerPicker, _more, _reveal, _copyKey, _primary, _back; + private readonly DeskModelRoutePicker _routePicker; private bool _busy, _loading, _keyVisible, _closed; private string? _message; internal bool RestartRequested { get; private set; } @@ -55,6 +56,8 @@ internal DeskProvidersWindow(DeskTheme theme, AccountService service, Func { _message = error.Message; Render(); }, Render); + _routePicker.Margin = new Thickness(0, 0, 0, 16); _ready.Children.Add(_routePicker); _models.FontSize = 14; _models.LineHeight = 23; _models.Margin = new Thickness(0, 0, 0, 18); _ready.Children.Add(_models); BuildAdvanced(_ready); form.Children.Add(_ready); var scroll = new ScrollViewer { Content = form, VerticalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled }; Grid.SetRow(scroll, 1); root.Children.Add(scroll); @@ -151,7 +154,7 @@ private async Task PrimaryAsync() { if (_busy) return; if (_editor.Draft.Step == 0) await RunAsync(async () => { await _editor.SaveAsync(_stop.Token); _message = null; LoadFields(); }); - else if (!_manager.Settings.Connected) await RunAsync(async () => { await _service.ConnectModelProvidersAsync(_stop.Token); _message = null; }); + else if (!_manager.Settings.Connected || !_manager.Settings.RouterEnabled) await RunAsync(async () => { await _service.ConnectModelProvidersAsync(_stop.Token); _message = null; }); else if (!_manager.Settings.ConnectionWriteConfirmed) await RunAsync(async () => { await _service.DisconnectModelProvidersAsync(_stop.Token); _message = null; }); else { _editor.Persist(); RestartRequested = true; Close(); } } @@ -165,6 +168,7 @@ private async Task RunAsync(Func action) } private void Render() { + _routePicker.Refresh(); var settings = _manager.Settings; var ready = _editor.Draft.Step == 1; var provider = settings.Providers.FirstOrDefault(item => item.Id == _editor.SelectedId); _providerPicker.Content = (provider?.Name ?? Loc.T("providers.new")) + " ▾"; _providerPicker.IsEnabled = _more.IsEnabled = !_busy; @@ -174,9 +178,9 @@ private void Render() _reveal.IsEnabled = _copyKey.IsEnabled = _editor.ApiKey.Length != 0; _models.Text = provider == null ? "" : Loc.F("providers.model_count", provider.Models.Count) + "\n" + string.Join("\n", provider.Models.Take(3).Select(item => item.UpstreamId)); _back.Content = Loc.T(ready ? "providers.edit" : "providers.close"); _back.IsEnabled = !_busy; - _primary.Content = Loc.T(!ready ? "providers.save_next" : !settings.Connected ? "providers.connect" : !settings.ConnectionWriteConfirmed ? "providers.disconnect" : "restart.title"); - _primary.IsEnabled = !_busy && (ready || !string.IsNullOrWhiteSpace(_editor.Draft.Name) && !string.IsNullOrWhiteSpace(_editor.Draft.BaseUrl) && _editor.ApiKey.Length != 0); - _status.Text = _message ?? (ready ? Loc.T(settings.Connected ? settings.ConnectionWriteConfirmed ? "providers.prepared" : "providers.incomplete" : "providers.not_connected") : ""); _status.IsVisible = _status.Text.Length != 0; + _primary.Content = Loc.T(!ready ? "providers.save_next" : !settings.Connected || !settings.RouterEnabled ? "providers.connect" : !settings.ConnectionWriteConfirmed ? "providers.disconnect" : "restart.title"); + _primary.IsEnabled = !_busy && (ready ? settings.Connected && settings.RouterEnabled || ModelRouting.Registered(settings, settings.SelectedRoute) : !string.IsNullOrWhiteSpace(_editor.Draft.Name) && !string.IsNullOrWhiteSpace(_editor.Draft.BaseUrl) && _editor.ApiKey.Length != 0); + _status.Text = _message ?? (ready ? Loc.T(settings.Connected && !settings.RouterEnabled ? "router.first_connection" : settings.Connected ? settings.ConnectionWriteConfirmed ? "providers.prepared" : "providers.incomplete" : "providers.not_connected") : ""); _status.IsVisible = _status.Text.Length != 0; } private void BuildAdvanced(Panel parent) { diff --git a/src/desktop/DeskWindow.cs b/src/desktop/DeskWindow.cs index 101377f..6ba0b41 100644 --- a/src/desktop/DeskWindow.cs +++ b/src/desktop/DeskWindow.cs @@ -128,7 +128,7 @@ private void Build() _theme.Apply(this); FlowDirection = Loc.RightToLeft ? FlowDirection.RightToLeft : FlowDirection.LeftToRight; var nativeTitle = OperatingSystem.IsLinux(); - var shell = new Grid { RowDefinitions = new RowDefinitions(nativeTitle ? "34,0,*,50" : "34,Auto,*,50") }; + var shell = new Grid { RowDefinitions = new RowDefinitions(nativeTitle ? "34,0,Auto,*,50" : "34,Auto,Auto,*,50") }; var titlebar = new Grid { Background = _theme.Brush("chrome"), ColumnDefinitions = new ColumnDefinitions("*,Auto,Auto,Auto"), Margin = new Thickness(0) }; var caption = _theme.Text("Codex Switch", 12); caption.VerticalAlignment = VerticalAlignment.Center; var mark = new BrandMark { Width = 18, Height = 18, Ink = _theme.Brush("text") }; @@ -180,16 +180,22 @@ private void Build() Grid.SetColumn(toolbar, 0); titlebar.Children.Add(toolbar); } else { Grid.SetRow(toolbar, 1); shell.Children.Add(toolbar); } + if (_service != null) + { + var routePicker = new DeskModelRoutePicker(_theme, _service.ModelProviders, error => { _statusMessage = error.Message; Build(); }) + { Margin = new Thickness(8, 2, 8, 4), IsEnabled = _ready && !Exiting && _switch == null && _login == null }; + Grid.SetRow(routePicker, 2); shell.Children.Add(routePicker); + } _accounts = new Grid { Margin = new Thickness(4, 1) }; _accountScroll = new ScrollViewer { Content = _accounts, HorizontalScrollBarVisibility = Avalonia.Controls.Primitives.ScrollBarVisibility.Disabled }; - _accountScroll.SizeChanged += (_, _) => ResizeAccounts(); Grid.SetRow(_accountScroll, 2); shell.Children.Add(_accountScroll); + _accountScroll.SizeChanged += (_, _) => ResizeAccounts(); Grid.SetRow(_accountScroll, 3); shell.Children.Add(_accountScroll); var footer = new Grid { RowDefinitions = new RowDefinitions("22,24"), ColumnDefinitions = new ColumnDefinitions("*,Auto"), Margin = new Thickness(8, 2) }; _hardware = new Grid { Height = 18, Margin = new Thickness(0, 0, 0, 4), FlowDirection = FlowDirection.LeftToRight }; Grid.SetColumnSpan(_hardware, 2); footer.Children.Add(_hardware); _volumes = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6, FlowDirection = FlowDirection.LeftToRight }; var footerActions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8 }; footerActions.Children.Add(_volumes); Grid.SetRow(footerActions, 1); footer.Children.Add(footerActions); _status = _theme.Text(_statusMessage, 10, "muted"); ToolTip.SetTip(_status, _statusMessage); _status.MaxWidth = 150; _status.VerticalAlignment = VerticalAlignment.Center; - Grid.SetColumn(_status, 1); Grid.SetRow(_status, 1); footer.Children.Add(_status); Grid.SetRow(footer, 3); shell.Children.Add(footer); Content = shell; + Grid.SetColumn(_status, 1); Grid.SetRow(_status, 1); footer.Children.Add(_status); Grid.SetRow(footer, 4); shell.Children.Add(footer); Content = shell; RenderAccounts(); RenderSessions(); RenderHardware(); RenderVolumes(); } private void ChangeLanguage(string code) diff --git a/src/windows/MainWindow.cs b/src/windows/MainWindow.cs index 915b942..74ae6a5 100644 --- a/src/windows/MainWindow.cs +++ b/src/windows/MainWindow.cs @@ -41,6 +41,7 @@ internal sealed class MainWindow : Window private readonly MenuItem _restartCodex = new(); private readonly Button _homeActions = new(); private bool _providerEditing; + private ModelRoutePicker? _routePicker; private readonly Button _stampTitles = new(); private MenuItem? _importCurrent; private readonly Border _loginPanel = new(); @@ -181,7 +182,10 @@ private UIElement BuildRoot() _stampTitles.ToolTip = Loc.T("titles.help"); toolbarRow.Children.Add(_stampTitles); _sessionCount.HorizontalAlignment = HorizontalAlignment.Center; Grid.SetColumn(_sessionCount, 1); - toolbarRow.Children.Add(_sessionCount); body.Children.Add(toolbarRow); + toolbarRow.Children.Add(_sessionCount); + var modelHeader = new StackPanel(); modelHeader.Children.Add(toolbarRow); + _routePicker = new ModelRoutePicker(_theme, _service.ModelProviders, error => SetStatus(error.Message, true)); + modelHeader.Children.Add(_routePicker); body.Children.Add(modelHeader); _providers.Header = Loc.T("providers.title"); _providers.Name = "ModelProvidersMenuItem"; _providers.Click -= OpenModelProviders; _providers.Click += OpenModelProviders; _restartCodex.Header = Loc.T("restart.title"); _restartCodex.Name = "RestartCodexMenuItem"; @@ -291,7 +295,7 @@ private void OpenModelProviders(object? sender, RoutedEventArgs args) var restart = false; try { _providerEditing = true; var window = new ModelProvidersWindow(this, _theme, _service); window.ShowDialog(); restart = window.RestartRequested; } catch (Exception error) { SetStatus(Loc.T("providers.failed") + " " + error.Message, true); } - finally { _providerEditing = false; } + finally { _providerEditing = false; _routePicker?.Refresh(); } if (restart) RestartCodexClicked(sender, args); } private async Task ImportLanguagePackAsync() @@ -991,6 +995,8 @@ private void RenderStatus() } private void UpdateControls() { + _routePicker?.Refresh(); + if (_routePicker != null) _routePicker.IsEnabled = _ready && !_closing && !_switching && !_loggingIn; _providers.IsEnabled = !_closing && !_switching && !_loggingIn; _restartCodex.IsEnabled = _ready && !_closing && !_switching && !_loggingIn && !_providerEditing && _service.Accounts.Any(profile => profile.IsCurrent) && PendingSwitch() is not { RestartOnly: false }; var automaticSwitchLabel = Loc.T(_service.Vault.Data.AutomaticSwitchEnabled ? "auto.on" : "auto.off"); diff --git a/src/windows/ModelProvidersWindow.cs b/src/windows/ModelProvidersWindow.cs index 38aff98..ac16bd3 100644 --- a/src/windows/ModelProvidersWindow.cs +++ b/src/windows/ModelProvidersWindow.cs @@ -29,6 +29,7 @@ internal sealed class ModelProvidersWindow : Window private readonly CancellationTokenSource _stop = new(); private readonly Button _providerPicker, _more, _reveal, _copyKey, _primary, _back; private readonly ScrollViewer _scroll; + private readonly ModelRoutePicker _routePicker; private bool _busy, _loading, _syncingKey, _keyVisible, _closed; private string? _message; internal bool RestartRequested { get; private set; } @@ -62,6 +63,8 @@ internal ModelProvidersWindow(Window owner, Burn0Theme theme, AccountService ser AutomationProperties.SetName(_key, Loc.T("providers.key")); AutomationProperties.SetName(_plainKey, Loc.T("providers.key")); var filters = new StackPanel(); Input(_included); filters.Children.Add(Frame(_included)); _entry.Children.Add(new Expander { Header = Loc.T("providers.include"), Foreground = _theme.Color("muted"), Content = filters, Margin = new Thickness(0, 0, 0, 8) }); form.Children.Add(_entry); + _routePicker = new ModelRoutePicker(theme, _manager, error => { _message = error.Message; Render(); }, Render); + _routePicker.Margin = new Thickness(0, 0, 0, 16); _ready.Children.Add(_routePicker); _models.FontSize = 14; _models.LineHeight = 23; _models.Margin = new Thickness(0, 0, 0, 18); _ready.Children.Add(_models); BuildAdvanced(_ready); form.Children.Add(_ready); _scroll = new ScrollViewer { Content = form, VerticalScrollBarVisibility = ScrollBarVisibility.Auto, HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled }; Grid.SetRow(_scroll, 1); root.Children.Add(_scroll); @@ -178,7 +181,7 @@ private async Task PrimaryAsync() { if (_busy) return; if (_editor.Draft.Step == 0) await RunAsync(async () => { await _editor.SaveAsync(_stop.Token); _message = null; LoadFields(); }); - else if (!_manager.Settings.Connected) await RunAsync(async () => { await _service.ConnectModelProvidersAsync(_stop.Token); _message = null; }); + else if (!_manager.Settings.Connected || !_manager.Settings.RouterEnabled) await RunAsync(async () => { await _service.ConnectModelProvidersAsync(_stop.Token); _message = null; }); else if (!_manager.Settings.ConnectionWriteConfirmed) await RunAsync(async () => { await _service.DisconnectModelProvidersAsync(_stop.Token); _message = null; }); else { _editor.Persist(); RestartRequested = true; Close(); } } @@ -192,6 +195,7 @@ private async Task RunAsync(Func action) } private void Render() { + _routePicker.Refresh(); var settings = _manager.Settings; var ready = _editor.Draft.Step == 1; var provider = settings.Providers.FirstOrDefault(item => item.Id == _editor.SelectedId); _providerPicker.Content = (provider?.Name ?? Loc.T("providers.new")) + " ▾"; _providerPicker.IsEnabled = _more.IsEnabled = !_busy; @@ -202,9 +206,9 @@ private void Render() _reveal.IsEnabled = _copyKey.IsEnabled = _editor.ApiKey.Length != 0; _models.Text = provider == null ? "" : Loc.F("providers.model_count", provider.Models.Count) + "\n" + string.Join("\n", provider.Models.Take(3).Select(item => item.UpstreamId)); _back.Content = Loc.T(ready ? "providers.edit" : "providers.close"); _back.IsEnabled = !_busy; - _primary.Content = Loc.T(!ready ? "providers.save_next" : !settings.Connected ? "providers.connect" : !settings.ConnectionWriteConfirmed ? "providers.disconnect" : "restart.title"); - _primary.IsEnabled = !_busy && (ready || !string.IsNullOrWhiteSpace(_editor.Draft.Name) && !string.IsNullOrWhiteSpace(_editor.Draft.BaseUrl) && _editor.ApiKey.Length != 0); - _status.Text = _message ?? (ready ? Loc.T(settings.Connected ? settings.ConnectionWriteConfirmed ? "providers.prepared" : "providers.incomplete" : "providers.not_connected") : ""); + _primary.Content = Loc.T(!ready ? "providers.save_next" : !settings.Connected || !settings.RouterEnabled ? "providers.connect" : !settings.ConnectionWriteConfirmed ? "providers.disconnect" : "restart.title"); + _primary.IsEnabled = !_busy && (ready ? settings.Connected && settings.RouterEnabled || ModelRouting.Registered(settings, settings.SelectedRoute) : !string.IsNullOrWhiteSpace(_editor.Draft.Name) && !string.IsNullOrWhiteSpace(_editor.Draft.BaseUrl) && _editor.ApiKey.Length != 0); + _status.Text = _message ?? (ready ? Loc.T(settings.Connected && !settings.RouterEnabled ? "router.first_connection" : settings.Connected ? settings.ConnectionWriteConfirmed ? "providers.prepared" : "providers.incomplete" : "providers.not_connected") : ""); _status.Visibility = _status.Text.Length == 0 ? Visibility.Collapsed : Visibility.Visible; } private void BuildAdvanced(Panel parent) diff --git a/src/windows/ModelRoutePicker.cs b/src/windows/ModelRoutePicker.cs new file mode 100644 index 0000000..d4dee65 --- /dev/null +++ b/src/windows/ModelRoutePicker.cs @@ -0,0 +1,67 @@ +using System; +using System.Linq; +using System.Windows; +using System.Windows.Automation; +using System.Windows.Controls; +using System.Windows.Input; + +namespace CodexAccountDesk; + +internal sealed class ModelRoutePicker : Grid +{ + private readonly ModelProviderManager _manager; + private readonly Burn0Theme _theme; + private readonly Button _models = new() { MinHeight = 30, MinWidth = 0, HorizontalContentAlignment = HorizontalAlignment.Stretch, Padding = new Thickness(10, 5, 10, 5) }; + private readonly TextBlock _caption = new() { TextTrimming = TextTrimming.CharacterEllipsis, VerticalAlignment = VerticalAlignment.Center }; + private readonly TextBlock _label; + private readonly Action _failed; + private readonly Action? _selected; + internal Button Models => _models; + internal ModelRoutePicker(Burn0Theme theme, ModelProviderManager manager, Action failed, Action? selected = null) + { + _theme = theme; _manager = manager; _failed = failed; _selected = selected; Margin = new Thickness(0, 0, 0, 6); + ColumnDefinitions.Add(new() { Width = GridLength.Auto }); ColumnDefinitions.Add(new()); + _label = new TextBlock { Foreground = theme.Color("muted"), FontSize = 12, Margin = new Thickness(0, 0, 10, 0), VerticalAlignment = VerticalAlignment.Center }; + Children.Add(_label); _models.Name = "ModelRoutePicker"; _models.FontSize = 12; + _models.Foreground = _caption.Foreground = theme.Color("text"); _models.Background = theme.Color("surface"); _models.BorderBrush = theme.Color("divider"); _models.BorderThickness = new Thickness(1); + var border = new FrameworkElementFactory(typeof(Border)); border.SetValue(Border.CornerRadiusProperty, new CornerRadius(7)); + border.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Control.BackgroundProperty)); border.SetValue(Border.BorderBrushProperty, new TemplateBindingExtension(Control.BorderBrushProperty)); + border.SetValue(Border.BorderThicknessProperty, new TemplateBindingExtension(Control.BorderThicknessProperty)); border.SetValue(Border.PaddingProperty, new TemplateBindingExtension(Control.PaddingProperty)); + var presenter = new FrameworkElementFactory(typeof(ContentPresenter)); presenter.SetValue(ContentPresenter.ContentProperty, new TemplateBindingExtension(ContentControl.ContentProperty)); + border.AppendChild(presenter); _models.Template = new ControlTemplate(typeof(Button)) { VisualTree = border }; + var style = new Style(typeof(Button)); var disabled = new Trigger { Property = IsEnabledProperty, Value = false }; disabled.Setters.Add(new Setter(OpacityProperty, .55)); style.Triggers.Add(disabled); + var focus = new Trigger { Property = IsKeyboardFocusedProperty, Value = true }; focus.Setters.Add(new Setter(Control.BorderBrushProperty, theme.Color("accent"))); style.Triggers.Add(focus); _models.Style = style; + var content = new Grid(); content.ColumnDefinitions.Add(new()); content.ColumnDefinitions.Add(new() { Width = GridLength.Auto }); content.Children.Add(_caption); + var arrow = new TextBlock { Text = "▾", Foreground = theme.Color("muted"), Margin = new Thickness(10, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center }; Grid.SetColumn(arrow, 1); content.Children.Add(arrow); _models.Content = content; + Grid.SetColumn(_models, 1); Children.Add(_models); + void Open() { var menu = CreateMenu(); _models.ContextMenu = menu; menu.IsOpen = true; } + _models.Click += (_, _) => Open(); _models.PreviewKeyDown += (_, args) => { if (args.Key == Key.Down) { Open(); args.Handled = true; } }; + Refresh(); + } + internal ContextMenu CreateMenu() + { + var menu = DropdownTheme.Create(_models, _theme); var selected = _manager.Settings.SelectedRoute; + foreach (var choice in _manager.RoutingChoices) + { + var item = new MenuItem { Header = choice.Label, Tag = choice.Id, IsCheckable = true, IsChecked = choice.Id == selected }; + item.Click += (_, _) => + { + try { _manager.SelectRoute(choice.Id); menu.IsOpen = false; Refresh(); _selected?.Invoke(); } + catch (Exception error) { menu.IsOpen = false; Refresh(); _failed(error); } + }; + menu.Items.Add(item); + } + return menu; + } + internal void Refresh() + { + if (_models.ContextMenu?.IsOpen == true) return; + var settings = _manager.Settings; var choices = _manager.RoutingChoices; + Visibility = choices.Count == 0 && settings.SelectedRoute == null ? Visibility.Collapsed : Visibility.Visible; + _label.Text = Loc.T(settings.Connected && settings.RouterEnabled ? "router.model" : "router.setup_model"); + _caption.Text = choices.FirstOrDefault(choice => choice.Id == settings.SelectedRoute)?.Label ?? Loc.T(settings.SelectedRoute == null ? "router.choose" : "router.unavailable"); + _models.Tag = settings.SelectedRoute; + AutomationProperties.SetName(_models, Loc.T("router.model") + ": " + _caption.Text); + _models.ToolTip = _caption.Text + "\n" + Loc.T(settings.Connected && settings.RouterEnabled ? "router.next_request" : "router.first_connection"); + } +} diff --git a/tests/model-providers-ui/Program.cs b/tests/model-providers-ui/Program.cs index 000de68..078ba72 100644 --- a/tests/model-providers-ui/Program.cs +++ b/tests/model-providers-ui/Program.cs @@ -18,16 +18,16 @@ private static FrameworkElement Surface(Window window) TextElement.SetForeground(viewport, window.Foreground); TextElement.SetFontFamily(viewport, window.FontFamily); TextElement.SetFontSize(viewport, window.FontSize); viewport.Children.Add(content); return viewport; } - private static void Layout(FrameworkElement root) - { root.Width = 500; root.Height = 510; root.Measure(new Size(500, 510)); root.Arrange(new Rect(0, 0, 500, 510)); root.UpdateLayout(); } - private static void Capture(FrameworkElement root, string path, Brush background) + private static void Layout(FrameworkElement root, double width = 500, double height = 510) + { root.Width = width; root.Height = height; root.Measure(new Size(width, height)); root.Arrange(new Rect(0, 0, width, height)); root.UpdateLayout(); } + private static void Capture(FrameworkElement root, string path, Brush background, int width = 500, int height = 510) { - Layout(root); + Layout(root, width, height); var frame = new System.Windows.Threading.DispatcherFrame(); root.Dispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.ApplicationIdle, new Action(() => frame.Continue = false)); System.Windows.Threading.Dispatcher.PushFrame(frame); - var image = new RenderTargetBitmap(500, 510, 96, 96, PixelFormats.Pbgra32); - var canvas = new DrawingVisual(); using (var context = canvas.RenderOpen()) context.DrawRectangle(background, null, new Rect(0, 0, 500, 510)); + var image = new RenderTargetBitmap(width, height, 96, 96, PixelFormats.Pbgra32); + var canvas = new DrawingVisual(); using (var context = canvas.RenderOpen()) context.DrawRectangle(background, null, new Rect(0, 0, width, height)); image.Render(canvas); image.Render(root); var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(image)); using var file = File.Create(path); encoder.Save(file); } @@ -83,6 +83,11 @@ private static int Main(string[] args) url.Text = saved.BaseUrl; primary.RaiseEvent(new RoutedEventArgs(Button.ClickEvent)); Layout(root); if (editor.Draft.Step != 1 || ready.Visibility != Visibility.Visible || entry.Visibility != Visibility.Collapsed || (string)primary.Content != Loc.T("providers.connect") || key.Password != copied) throw new Exception("The real Save action must retain fields and advance to Connect."); + var routePicker = Field(window, "_routePicker"); + routePicker.CreateMenu().Items.OfType().Single(item => item.Tag as string == manager.Settings.Providers.Single().Models.Single().Alias(saved.Id)).RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + if (manager.Settings.SelectedRoute != routePicker.Models.Tag as string || !primary.IsEnabled || manager.Settings.Connected) + throw new Exception("The actual routing dropdown must save the selected model without connecting Codex."); + checks.Add(language + "/" + dark + ": routing dropdown persists the actual choice and enables initial connection without changing Codex"); Capture(root, Path.Combine(args[0], "saved-connection-" + language + "-" + (dark ? "dark" : "light") + ".png"), theme.Color("canvas")); checks.Add(language + "/" + dark + ": failed and successful saves preserve input and advance only after persistence"); Field