diff --git a/AGENTS.md b/AGENTS.md index eaf0853..bc9e991 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,7 @@ ## Unreleased model provider gateway - 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. - 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 6b99e74..b9d3159 100644 --- a/docs/MODEL_PROVIDERS.md +++ b/docs/MODEL_PROVIDERS.md @@ -7,12 +7,14 @@ Keep eligible OpenAI models and custom Responses models in the existing Codex mo ## 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. Select Add a provider, enter a display name, API base URL and API key, then choose Discover and save. -3. If the provider has a large catalog, enter the model IDs to include, one per line. Leave this field empty to discover all models within the supported bounds. -4. Choose Connect Codex, then use Restart Codex in Settings when ready (on the main window in older1.43.3). Connecting prepares settings; the separate restart action normally closes and reopens Codex and resumes eligible unfinished chats with the same login. -5. Keep Switch running, including in its tray. Choose OpenAI or a custom model inside each Codex chat. Model-list refresh can take a few minutes after registering models. +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. -Select an existing provider to edit it. An empty key field retains its saved key; changing the destination URL requires a replacement key. Keys remain masked in the editor and encrypted in the separate local model-providers.vault file. The account vault and original OpenAI login are preserved. +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. + +Draft fields and the last selected provider are encrypted in the existing model-providers.vault when changing providers, attempting a save or closing the editor. A failed save retains the input. More options offers Load saved values, Use these values for a new provider, provider removal and connection restoration. Loading saved values is the explicit way to discard an edit. The account vault and original OpenAI login are preserved. Restart Codex keeps the current account and original chat names. It uses the existing durable recovery and uncertain-Continue suppression. Running work and active unfinished goals are captured; completed or already-running work is checked before another Continue is sent. Existing credit-protection settings still apply. The action stays disabled while another login/restart/handoff is in progress. @@ -38,10 +40,12 @@ Switch supports at most32 providers and128 selected models per provider. Discove JSON validation reads tokens without a payload-sized document metadata pool. It refuses duplicate properties, nesting above64levels, more than one million tokens, property names over1KiB or total property-name bytes over8MiB. Routing controls (`reasoning` and `text`) are each limited to64KiB. These bounds apply before upstream inference and do not remove conversation items. Unchanged OpenAI requests retain exact body bytes; custom-provider rewrites serialize only small controls and stream original history/image value slices. The buffer budget covers request arrays, not all process/runtime/HTTP allocations. -Changing providers sends the replayed conversation to the selected provider. Provider-encrypted reasoning is omitted after a conversation crosses routes. An existing provider-encrypted compaction cannot be reconstructed by another provider: that request fails explicitly and requires a portable text summary. Stateful response references are refused rather than silently dropping history. New gateway sessions are configured for local compaction. +Changing providers sends the replayed conversation to the selected provider. Provider-encrypted reasoning is omitted after a conversation crosses routes. An existing provider-encrypted compaction cannot be reconstructed by another provider: that request fails explicitly and requires a portable text summary. Stateful response references are refused rather than silently dropping history. Compaction remains tied to its original provider. The connection uses existing file-backed OpenAI authentication. The local listener accepts authenticated requests on127.0.0.1 only, refuses browser-origin requests, and never forwards an OpenAI credential or account header to a custom provider. A custom provider receives only its saved key. Redirects are not followed. The only automatic inference retry is the explicit HTTP507 recovery described below. +For Codex requests carrying the native turn identity, the gateway retains a hashed receipt for each distinct input and route for30minutes. Automatic client retries of that same turn request are refused before another upstream send; a new user turn or changed full-input tool follow-up remains eligible. The receipt budget is4096 and a full budget reports503. Receipts contain no request body or credentials and last only for the current gateway process. This prevents the built-in client's retry policy from multiplying the gateway's own recovery budget. + ### Automatic HTTP507 recovery For streaming `POST /responses`, an upstream HTTP507 refusal before its response begins is retried at most three times, after2,5and10seconds. The original attempt plus retries makes at most four upstream requests. All attempts retain the original selected model, provider URL/key, signed-in account, complete input bytes/history and tool-call identities. The current sign-in is revalidated before another attempt. There is no new chat, `Continue` message, model/account change, history trimming or context compression. @@ -68,7 +72,9 @@ 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. -The initial configuration selects the codex_switch_router provider with an explicit catalog URL, and also sets the built-in OpenAI base URL to the same listener for pre-existing OpenAI-provider chats. WebSockets and request compression are disabled for portable full-input HTTP replay. Unrelated custom provider definitions are preserved; already-pinned chats using those independent definitions are outside the verified migration scope. +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. + +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. Workspace-enforced routing, keyring-only authentication, API-key-only dashboard startup, external production provider compatibility, and the actual desktop picker still require their own acceptance evidence. Do not bypass managed policy or replace the owner's running client to verify them. diff --git a/docs/development/PLAN.md b/docs/development/PLAN.md index 351f9d0..6861dbd 100644 --- a/docs/development/PLAN.md +++ b/docs/development/PLAN.md @@ -1,5 +1,9 @@ # Current plan +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. + Extend recovery beyond the pre-response507case to confirmed failed turns of every error type. Preserve original tools/context by continuing the saved chat once through its desktop owner, with three persisted attempts across a failure chain. Verify all-error eligibility, message-less failures, actual interruption exclusion, latest-turn/queue/identity/model/provider checks, no duplicate after IPC ambiguity, restart/exhaustion, bounded decision diagnostics, real rollout/SQLite path handling and actual Codex failed-turn followed by same-chat completion. Existing observed active/completed work is left alone. Bounded HTTP507recovery to streaming Responses passes231local provider/official-CLI checks: original plus at most three requests after2/5/10seconds, retained native/custom body/route identity, early success, terminal exhaustion, cancellation/disconnect, changed sign-in and shutdown. Images/compaction and ambiguous transport/partial-stream failures remain single-send. Preserve the existing input buffer budget, and verify local installation separately from source integration or a new public release. [Recovery contract](../MODEL_PROVIDERS.md#automatic-http507-recovery). diff --git a/docs/development/STATUS.md b/docs/development/STATUS.md index 9cfaa5e..151b7f9 100644 --- a/docs/development/STATUS.md +++ b/docs/development/STATUS.md @@ -1,5 +1,7 @@ # Current status +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. The all-error recovery follow-up passes346account/recovery contracts,234provider/official-CLI checks and99Windows package self-tests on runtime10.0.13. Failed terminal status now controls eligibility across HTTP, stream, authentication, quota, context and compaction errors, including absent error prose. First-request failures no longer need an earlier successful-route receipt; current owner/settings/authentication and gateway history checks remain enforced. Bounded hashed decision diagnostics distinguish continuation, exhaustion and guard exclusions. Source checks and Windows/shared builds are verified. A local installed package also confirmed an identified automatic continuation followed by an active error-free turn and completed tool work; this does not establish completion of the continued project or public release availability. diff --git a/docs/evidence/provider-connection/README.md b/docs/evidence/provider-connection/README.md new file mode 100644 index 0000000..144eee1 --- /dev/null +++ b/docs/evidence/provider-connection/README.md @@ -0,0 +1,13 @@ +# Provider connection state + +These images are production WPF components rendered offscreen with fictional provider metadata and disposable encrypted storage. The two-step flow loads saved fields, keeps keys masked, retains failed edits, and advances to Connect only after saving. Eye, copy, explicit reload/reuse and close/reopen are checked through the editor's normal controls and persistence paths. These are not native Codex picker or real-provider acceptance images. + +![English provider details](providers-en-dark.png) + +![Korean provider details](providers-ko-dark.png) + +![English saved provider connection state](saved-connection-en-dark.png) + +![Korean saved provider connection state](saved-connection-ko-dark.png) + +The current native Codex app-server separately verifies visible custom models, unchanged OpenAI provider identity, per-chat routing, catalog refresh, restoration and bounded upstream retries against fictional endpoints. See [the contract](../../MODEL_PROVIDERS.md). diff --git a/docs/evidence/provider-connection/providers-en-dark.png b/docs/evidence/provider-connection/providers-en-dark.png new file mode 100644 index 0000000..60a7cc8 Binary files /dev/null and b/docs/evidence/provider-connection/providers-en-dark.png differ diff --git a/docs/evidence/provider-connection/providers-ko-dark.png b/docs/evidence/provider-connection/providers-ko-dark.png new file mode 100644 index 0000000..df87595 Binary files /dev/null and b/docs/evidence/provider-connection/providers-ko-dark.png differ diff --git a/docs/evidence/provider-connection/saved-connection-en-dark.png b/docs/evidence/provider-connection/saved-connection-en-dark.png new file mode 100644 index 0000000..038bccb Binary files /dev/null and b/docs/evidence/provider-connection/saved-connection-en-dark.png differ diff --git a/docs/evidence/provider-connection/saved-connection-ko-dark.png b/docs/evidence/provider-connection/saved-connection-ko-dark.png new file mode 100644 index 0000000..dead0a9 Binary files /dev/null and b/docs/evidence/provider-connection/saved-connection-ko-dark.png differ diff --git a/localization/packs/de.json b/localization/packs/de.json index b34c784..79abef1 100644 --- a/localization/packs/de.json +++ b/localization/packs/de.json @@ -458,7 +458,7 @@ "msg.linux_account_file_policy": "Dieser Codex-Kontext erlaubt keine ChatGPT-Anmeldedaten in Dateien. Die Konfiguration wurde nicht geändert.", "msg.linux_account_workspace_policy": "Das ausgewählte Konto entspricht nicht dem für diesen Codex-Kontext erforderlichen Arbeitsbereich.", "msg.linux_account_async_policy": "Kontovorgänge unter Linux erfordern eine erneute asynchrone Prüfung der Client-Richtlinie.", - "providers.model_count": "{0} models discovered", + "providers.model_count": "Saved models: {0}", "providers.incomplete": "The connection update was not confirmed. Restore the previous connection before trying again.", "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", @@ -471,7 +471,7 @@ "providers.name": "Anzeigename", "providers.url": "API-Basis-URL", "providers.key": "API-Schlüssel", - "providers.key_hint": "Leave blank to retain the saved key. Enter a new key when changing the URL. Keys are encrypted locally.", + "providers.key_hint": "Stored encrypted. Reveal or copy only when needed.", "providers.save": "Modelle abrufen und speichern", "providers.remove": "Anbieter entfernen", "providers.models": "Abgerufene Modelle", @@ -487,7 +487,21 @@ "providers.history_hint": "Changing providers sends the replayed conversation to the selected provider. Opaque encrypted reasoning is excluded. Stateful response references are refused. Existing OpenAI-provider chats retain their IDs.", "providers.prepared": "Connection settings prepared. Restart Codex once when ready.", "providers.not_connected": "Codex is not connected to the local gateway.", - "providers.saved": "Provider settings saved. New requests use the saved route; model-list refresh may take a few minutes.", + "providers.saved": "Provider settings saved.", + "providers.step_details": "1. Connection details", + "providers.step_connect": "2. Connect to Codex", + "providers.save_next": "Save and continue", + "providers.edit": "Edit details", + "providers.close": "Close", + "providers.reuse": "Use these values for a new provider", + "providers.reset": "Load saved values", + "providers.key_show": "Show API key", + "providers.key_hide": "Hide API key", + "providers.key_copy": "Copy API key", + "providers.key_copied": "API key copied.", + "providers.copy_failed": "Could not copy.", + "providers.more": "More options", + "providers.advanced": "Advanced", "providers.working": "Working…", "providers.cancelled": "Cancelled or timed out.", "providers.failed": "Provider operation failed.", diff --git a/localization/packs/en.json b/localization/packs/en.json index b4df741..2ef91e9 100644 --- a/localization/packs/en.json +++ b/localization/packs/en.json @@ -458,7 +458,7 @@ "msg.linux_account_file_policy": "This Codex context does not allow ChatGPT file credentials. Its configuration has not been changed.", "msg.linux_account_workspace_policy": "The selected account does not match this Codex context's required workspace.", "msg.linux_account_async_policy": "Linux account operations require a fresh asynchronous client policy check.", - "providers.model_count": "{0} models discovered", + "providers.model_count": "Saved models: {0}", "providers.incomplete": "The connection update was not confirmed. Restore the previous connection before trying again.", "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", @@ -471,7 +471,7 @@ "providers.name": "Display name", "providers.url": "API base URL", "providers.key": "API key", - "providers.key_hint": "Leave blank to retain the saved key. Enter a new key when changing the URL. Keys are encrypted locally.", + "providers.key_hint": "Stored encrypted. Reveal or copy only when needed.", "providers.save": "Discover and save", "providers.remove": "Remove provider", "providers.models": "Discovered models", @@ -487,7 +487,21 @@ "providers.history_hint": "Changing providers sends the replayed conversation to the selected provider. Opaque encrypted reasoning is excluded. Stateful response references are refused. Existing OpenAI-provider chats retain their IDs.", "providers.prepared": "Connection settings prepared. Restart Codex once when ready.", "providers.not_connected": "Codex is not connected to the local gateway.", - "providers.saved": "Provider settings saved. New requests use the saved route; model-list refresh may take a few minutes.", + "providers.saved": "Provider settings saved.", + "providers.step_details": "1. Connection details", + "providers.step_connect": "2. Connect to Codex", + "providers.save_next": "Save and continue", + "providers.edit": "Edit details", + "providers.close": "Close", + "providers.reuse": "Use these values for a new provider", + "providers.reset": "Load saved values", + "providers.key_show": "Show API key", + "providers.key_hide": "Hide API key", + "providers.key_copy": "Copy API key", + "providers.key_copied": "API key copied.", + "providers.copy_failed": "Could not copy.", + "providers.more": "More options", + "providers.advanced": "Advanced", "providers.working": "Working…", "providers.cancelled": "Cancelled or timed out.", "providers.failed": "Provider operation failed.", diff --git a/localization/packs/es.json b/localization/packs/es.json index 02bf6c9..1765b8b 100644 --- a/localization/packs/es.json +++ b/localization/packs/es.json @@ -458,7 +458,7 @@ "msg.linux_account_file_policy": "Este contexto de Codex no permite credenciales de ChatGPT en archivos. No se ha cambiado su configuración.", "msg.linux_account_workspace_policy": "La cuenta seleccionada no coincide con el espacio de trabajo requerido por este contexto de Codex.", "msg.linux_account_async_policy": "Las operaciones de cuenta en Linux requieren una nueva comprobación asíncrona de la política del cliente.", - "providers.model_count": "{0} models discovered", + "providers.model_count": "Saved models: {0}", "providers.incomplete": "The connection update was not confirmed. Restore the previous connection before trying again.", "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", @@ -471,7 +471,7 @@ "providers.name": "Nombre visible", "providers.url": "URL base de API", "providers.key": "Clave de API", - "providers.key_hint": "Leave blank to retain the saved key. Enter a new key when changing the URL. Keys are encrypted locally.", + "providers.key_hint": "Stored encrypted. Reveal or copy only when needed.", "providers.save": "Obtener modelos y guardar", "providers.remove": "Eliminar proveedor", "providers.models": "Modelos obtenidos", @@ -487,7 +487,21 @@ "providers.history_hint": "Changing providers sends the replayed conversation to the selected provider. Opaque encrypted reasoning is excluded. Stateful response references are refused. Existing OpenAI-provider chats retain their IDs.", "providers.prepared": "Connection settings prepared. Restart Codex once when ready.", "providers.not_connected": "Codex is not connected to the local gateway.", - "providers.saved": "Provider settings saved. New requests use the saved route; model-list refresh may take a few minutes.", + "providers.saved": "Provider settings saved.", + "providers.step_details": "1. Connection details", + "providers.step_connect": "2. Connect to Codex", + "providers.save_next": "Save and continue", + "providers.edit": "Edit details", + "providers.close": "Close", + "providers.reuse": "Use these values for a new provider", + "providers.reset": "Load saved values", + "providers.key_show": "Show API key", + "providers.key_hide": "Hide API key", + "providers.key_copy": "Copy API key", + "providers.key_copied": "API key copied.", + "providers.copy_failed": "Could not copy.", + "providers.more": "More options", + "providers.advanced": "Advanced", "providers.working": "Working…", "providers.cancelled": "Cancelled or timed out.", "providers.failed": "Provider operation failed.", diff --git a/localization/packs/fr.json b/localization/packs/fr.json index a400c76..b48b7b3 100644 --- a/localization/packs/fr.json +++ b/localization/packs/fr.json @@ -458,7 +458,7 @@ "msg.linux_account_file_policy": "Ce contexte Codex ne permet pas les identifiants ChatGPT stockés dans un fichier. Sa configuration reste inchangée.", "msg.linux_account_workspace_policy": "Le compte sélectionné ne correspond pas à l’espace de travail requis par ce contexte Codex.", "msg.linux_account_async_policy": "Les opérations de compte sous Linux nécessitent une nouvelle vérification asynchrone de la politique du client.", - "providers.model_count": "{0} models discovered", + "providers.model_count": "Saved models: {0}", "providers.incomplete": "The connection update was not confirmed. Restore the previous connection before trying again.", "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", @@ -471,7 +471,7 @@ "providers.name": "Nom affiché", "providers.url": "URL de base de l’API", "providers.key": "Clé API", - "providers.key_hint": "Leave blank to retain the saved key. Enter a new key when changing the URL. Keys are encrypted locally.", + "providers.key_hint": "Stored encrypted. Reveal or copy only when needed.", "providers.save": "Récupérer et enregistrer", "providers.remove": "Supprimer le fournisseur", "providers.models": "Modèles récupérés", @@ -487,7 +487,21 @@ "providers.history_hint": "Changing providers sends the replayed conversation to the selected provider. Opaque encrypted reasoning is excluded. Stateful response references are refused. Existing OpenAI-provider chats retain their IDs.", "providers.prepared": "Connection settings prepared. Restart Codex once when ready.", "providers.not_connected": "Codex is not connected to the local gateway.", - "providers.saved": "Provider settings saved. New requests use the saved route; model-list refresh may take a few minutes.", + "providers.saved": "Provider settings saved.", + "providers.step_details": "1. Connection details", + "providers.step_connect": "2. Connect to Codex", + "providers.save_next": "Save and continue", + "providers.edit": "Edit details", + "providers.close": "Close", + "providers.reuse": "Use these values for a new provider", + "providers.reset": "Load saved values", + "providers.key_show": "Show API key", + "providers.key_hide": "Hide API key", + "providers.key_copy": "Copy API key", + "providers.key_copied": "API key copied.", + "providers.copy_failed": "Could not copy.", + "providers.more": "More options", + "providers.advanced": "Advanced", "providers.working": "Working…", "providers.cancelled": "Cancelled or timed out.", "providers.failed": "Provider operation failed.", diff --git a/localization/packs/ja.json b/localization/packs/ja.json index ed8b898..036ae80 100644 --- a/localization/packs/ja.json +++ b/localization/packs/ja.json @@ -458,7 +458,7 @@ "msg.linux_account_file_policy": "この Codex コンテキストでは ChatGPT のファイル認証情報を使用できません。設定は変更されていません。", "msg.linux_account_workspace_policy": "選択したアカウントは、この Codex コンテキストで必要なワークスペースと一致しません。", "msg.linux_account_async_policy": "Linux のアカウント操作には、最新のクライアントポリシーの非同期確認が必要です。", - "providers.model_count": "{0} models discovered", + "providers.model_count": "Saved models: {0}", "providers.incomplete": "The connection update was not confirmed. Restore the previous connection before trying again.", "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", @@ -471,7 +471,7 @@ "providers.name": "表示名", "providers.url": "API ベース URL", "providers.key": "API キー", - "providers.key_hint": "Leave blank to retain the saved key. Enter a new key when changing the URL. Keys are encrypted locally.", + "providers.key_hint": "Stored encrypted. Reveal or copy only when needed.", "providers.save": "モデルを取得して保存", "providers.remove": "プロバイダーを削除", "providers.models": "取得したモデル", @@ -487,7 +487,21 @@ "providers.history_hint": "Changing providers sends the replayed conversation to the selected provider. Opaque encrypted reasoning is excluded. Stateful response references are refused. Existing OpenAI-provider chats retain their IDs.", "providers.prepared": "Connection settings prepared. Restart Codex once when ready.", "providers.not_connected": "Codex is not connected to the local gateway.", - "providers.saved": "Provider settings saved. New requests use the saved route; model-list refresh may take a few minutes.", + "providers.saved": "Provider settings saved.", + "providers.step_details": "1. Connection details", + "providers.step_connect": "2. Connect to Codex", + "providers.save_next": "Save and continue", + "providers.edit": "Edit details", + "providers.close": "Close", + "providers.reuse": "Use these values for a new provider", + "providers.reset": "Load saved values", + "providers.key_show": "Show API key", + "providers.key_hide": "Hide API key", + "providers.key_copy": "Copy API key", + "providers.key_copied": "API key copied.", + "providers.copy_failed": "Could not copy.", + "providers.more": "More options", + "providers.advanced": "Advanced", "providers.working": "Working…", "providers.cancelled": "Cancelled or timed out.", "providers.failed": "Provider operation failed.", diff --git a/localization/packs/ko.json b/localization/packs/ko.json index 53aa568..fc1a23d 100644 --- a/localization/packs/ko.json +++ b/localization/packs/ko.json @@ -458,7 +458,7 @@ "msg.linux_account_file_policy": "현재 Codex 문맥에서는 ChatGPT 파일 인증 정보를 사용할 수 없습니다. 설정은 변경하지 않았습니다.", "msg.linux_account_workspace_policy": "선택한 계정이 현재 Codex 문맥에서 요구하는 워크스페이스와 일치하지 않습니다.", "msg.linux_account_async_policy": "Linux 계정 작업에는 최신 클라이언트 정책의 비동기 확인이 필요합니다.", - "providers.model_count": "모델 {0}개를 조회했습니다", + "providers.model_count": "저장된 모델 {0}개", "providers.incomplete": "연결 설정 변경 결과를 확인하지 못했습니다. 기존 연결 복원으로 안전하게 되돌린 뒤 다시 시도하세요.", "providers.details": "호환성과 대화 전달 안내", "providers.restored": "기존 연결 설정을 복원했습니다. 편할 때 Codex를 한 번 재시작하세요.", @@ -471,7 +471,7 @@ "providers.name": "표시 이름", "providers.url": "API 기본 주소", "providers.key": "API 키", - "providers.key_hint": "비워 두면 저장된 키를 유지합니다. 주소를 바꾸면 새 키를 입력하세요. 키는 로컬에 암호화해 저장합니다.", + "providers.key_hint": "암호화해 저장합니다. 필요할 때만 표시하거나 복사하세요.", "providers.save": "모델 조회 후 저장", "providers.remove": "제공자 제거", "providers.models": "조회한 모델", @@ -487,7 +487,21 @@ "providers.history_hint": "제공자를 바꾸면 기존 대화 내용이 선택한 제공자에게 전달됩니다. 제공자 전용으로 암호화된 추론은 제외합니다. 응답 ID에 의존하는 요청은 거부합니다. 기존 OpenAI 채팅의 식별자는 유지합니다.", "providers.prepared": "연결 설정을 준비했습니다. 편할 때 Codex를 한 번 재시작하세요.", "providers.not_connected": "Codex가 로컬 중계에 연결되어 있지 않습니다.", - "providers.saved": "제공자 설정을 저장했습니다. 다음 요청부터 적용되며 모델 목록 갱신은 몇 분 걸릴 수 있습니다.", + "providers.saved": "제공자 설정을 저장했습니다.", + "providers.step_details": "1. 연결 정보", + "providers.step_connect": "2. Codex에 연결", + "providers.save_next": "저장하고 계속", + "providers.edit": "정보 수정", + "providers.close": "닫기", + "providers.reuse": "이 정보로 새 제공자 추가", + "providers.reset": "저장된 값 불러오기", + "providers.key_show": "API 키 표시", + "providers.key_hide": "API 키 숨기기", + "providers.key_copy": "API 키 복사", + "providers.key_copied": "API 키를 복사했습니다.", + "providers.copy_failed": "복사하지 못했습니다.", + "providers.more": "더 보기", + "providers.advanced": "고급 설정", "providers.working": "처리 중…", "providers.cancelled": "취소되었거나 시간이 초과되었습니다.", "providers.failed": "제공자 작업에 실패했습니다.", diff --git a/localization/packs/pt-BR.json b/localization/packs/pt-BR.json index 9f2634c..22bcb3f 100644 --- a/localization/packs/pt-BR.json +++ b/localization/packs/pt-BR.json @@ -458,7 +458,7 @@ "msg.linux_account_file_policy": "Este contexto do Codex não permite credenciais do ChatGPT em arquivos. A configuração não foi alterada.", "msg.linux_account_workspace_policy": "A conta selecionada não corresponde ao espaço de trabalho exigido por este contexto do Codex.", "msg.linux_account_async_policy": "As operações de conta no Linux exigem uma nova verificação assíncrona da política do cliente.", - "providers.model_count": "{0} models discovered", + "providers.model_count": "Saved models: {0}", "providers.incomplete": "The connection update was not confirmed. Restore the previous connection before trying again.", "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", @@ -471,7 +471,7 @@ "providers.name": "Nome de exibição", "providers.url": "URL base da API", "providers.key": "Chave da API", - "providers.key_hint": "Leave blank to retain the saved key. Enter a new key when changing the URL. Keys are encrypted locally.", + "providers.key_hint": "Stored encrypted. Reveal or copy only when needed.", "providers.save": "Consultar modelos e salvar", "providers.remove": "Remover provedor", "providers.models": "Modelos consultados", @@ -487,7 +487,21 @@ "providers.history_hint": "Changing providers sends the replayed conversation to the selected provider. Opaque encrypted reasoning is excluded. Stateful response references are refused. Existing OpenAI-provider chats retain their IDs.", "providers.prepared": "Connection settings prepared. Restart Codex once when ready.", "providers.not_connected": "Codex is not connected to the local gateway.", - "providers.saved": "Provider settings saved. New requests use the saved route; model-list refresh may take a few minutes.", + "providers.saved": "Provider settings saved.", + "providers.step_details": "1. Connection details", + "providers.step_connect": "2. Connect to Codex", + "providers.save_next": "Save and continue", + "providers.edit": "Edit details", + "providers.close": "Close", + "providers.reuse": "Use these values for a new provider", + "providers.reset": "Load saved values", + "providers.key_show": "Show API key", + "providers.key_hide": "Hide API key", + "providers.key_copy": "Copy API key", + "providers.key_copied": "API key copied.", + "providers.copy_failed": "Could not copy.", + "providers.more": "More options", + "providers.advanced": "Advanced", "providers.working": "Working…", "providers.cancelled": "Cancelled or timed out.", "providers.failed": "Provider operation failed.", diff --git a/localization/packs/zh-CN.json b/localization/packs/zh-CN.json index 50345d6..36a4420 100644 --- a/localization/packs/zh-CN.json +++ b/localization/packs/zh-CN.json @@ -458,7 +458,7 @@ "msg.linux_account_file_policy": "当前 Codex 上下文不允许使用 ChatGPT 文件凭据。未更改其配置。", "msg.linux_account_workspace_policy": "所选账户与当前 Codex 上下文要求的工作区不匹配。", "msg.linux_account_async_policy": "Linux 账户操作需要异步重新检查客户端策略。", - "providers.model_count": "{0} models discovered", + "providers.model_count": "Saved models: {0}", "providers.incomplete": "The connection update was not confirmed. Restore the previous connection before trying again.", "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", @@ -471,7 +471,7 @@ "providers.name": "显示名称", "providers.url": "API 基础地址", "providers.key": "API 密钥", - "providers.key_hint": "Leave blank to retain the saved key. Enter a new key when changing the URL. Keys are encrypted locally.", + "providers.key_hint": "Stored encrypted. Reveal or copy only when needed.", "providers.save": "获取模型并保存", "providers.remove": "移除提供商", "providers.models": "已获取的模型", @@ -487,7 +487,21 @@ "providers.history_hint": "Changing providers sends the replayed conversation to the selected provider. Opaque encrypted reasoning is excluded. Stateful response references are refused. Existing OpenAI-provider chats retain their IDs.", "providers.prepared": "Connection settings prepared. Restart Codex once when ready.", "providers.not_connected": "Codex is not connected to the local gateway.", - "providers.saved": "Provider settings saved. New requests use the saved route; model-list refresh may take a few minutes.", + "providers.saved": "Provider settings saved.", + "providers.step_details": "1. Connection details", + "providers.step_connect": "2. Connect to Codex", + "providers.save_next": "Save and continue", + "providers.edit": "Edit details", + "providers.close": "Close", + "providers.reuse": "Use these values for a new provider", + "providers.reset": "Load saved values", + "providers.key_show": "Show API key", + "providers.key_hide": "Hide API key", + "providers.key_copy": "Copy API key", + "providers.key_copied": "API key copied.", + "providers.copy_failed": "Could not copy.", + "providers.more": "More options", + "providers.advanced": "Advanced", "providers.working": "Working…", "providers.cancelled": "Cancelled or timed out.", "providers.failed": "Provider operation failed.", diff --git a/localization/packs/zh-TW.json b/localization/packs/zh-TW.json index 9bbd5bd..fbbe131 100644 --- a/localization/packs/zh-TW.json +++ b/localization/packs/zh-TW.json @@ -458,7 +458,7 @@ "msg.linux_account_file_policy": "目前的 Codex 情境不允許使用 ChatGPT 檔案憑證。其設定未變更。", "msg.linux_account_workspace_policy": "所選帳戶與目前 Codex 情境要求的工作區不符。", "msg.linux_account_async_policy": "Linux 帳戶操作需要非同步重新檢查用戶端原則。", - "providers.model_count": "{0} models discovered", + "providers.model_count": "Saved models: {0}", "providers.incomplete": "The connection update was not confirmed. Restore the previous connection before trying again.", "providers.details": "Compatibility and conversation sharing", "providers.restored": "Previous connection settings restored. Restart Codex once when ready.", @@ -471,7 +471,7 @@ "providers.name": "顯示名稱", "providers.url": "API 基礎位址", "providers.key": "API 金鑰", - "providers.key_hint": "Leave blank to retain the saved key. Enter a new key when changing the URL. Keys are encrypted locally.", + "providers.key_hint": "Stored encrypted. Reveal or copy only when needed.", "providers.save": "取得模型並儲存", "providers.remove": "移除供應商", "providers.models": "已取得的模型", @@ -487,7 +487,21 @@ "providers.history_hint": "Changing providers sends the replayed conversation to the selected provider. Opaque encrypted reasoning is excluded. Stateful response references are refused. Existing OpenAI-provider chats retain their IDs.", "providers.prepared": "Connection settings prepared. Restart Codex once when ready.", "providers.not_connected": "Codex is not connected to the local gateway.", - "providers.saved": "Provider settings saved. New requests use the saved route; model-list refresh may take a few minutes.", + "providers.saved": "Provider settings saved.", + "providers.step_details": "1. Connection details", + "providers.step_connect": "2. Connect to Codex", + "providers.save_next": "Save and continue", + "providers.edit": "Edit details", + "providers.close": "Close", + "providers.reuse": "Use these values for a new provider", + "providers.reset": "Load saved values", + "providers.key_show": "Show API key", + "providers.key_hide": "Hide API key", + "providers.key_copy": "Copy API key", + "providers.key_copied": "API key copied.", + "providers.copy_failed": "Could not copy.", + "providers.more": "More options", + "providers.advanced": "Advanced", "providers.working": "Working…", "providers.cancelled": "Cancelled or timed out.", "providers.failed": "Provider operation failed.", diff --git a/resources/Burn0Icons.json b/resources/Burn0Icons.json index 302a8ef..67505a1 100644 --- a/resources/Burn0Icons.json +++ b/resources/Burn0Icons.json @@ -1 +1 @@ -{"window-minimize":[["path",{"d":"M2 13h20"}]],"window-maximize":[["rect",{"x":"3","y":"3","width":"18","height":"18"}]],"window-restore":[["path",{"d":"M7 7V3h14v14h-4"}],["rect",{"x":"3","y":"7","width":"14","height":"14"}]],"window-close":[["path",{"d":"m4 4 16 16M20 4 4 20"}]],"plus":[["path",{"d":"M5 12h14"}],["path",{"d":"M12 5v14"}]],"more":[["circle",{"cx":"12","cy":"12","r":"1"}],["circle",{"cx":"19","cy":"12","r":"1"}],["circle",{"cx":"5","cy":"12","r":"1"}]],"clock":[["circle",{"cx":"12","cy":"12","r":"10"}],["path",{"d":"M12 6v6h4"}]],"user":[["path",{"d":"M17.925 20.056a6 6 0 0 0-11.851.001"}],["circle",{"cx":"12","cy":"11","r":"4"}],["circle",{"cx":"12","cy":"12","r":"10"}]],"check":[["path",{"d":"M20 6 9 17l-5-5"}]],"close":[["path",{"d":"M18 6 6 18"}],["path",{"d":"m6 6 12 12"}]],"settings":[["path",{"d":"M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"}],["circle",{"cx":"12","cy":"12","r":"3"}]],"lock":[["circle",{"cx":"12","cy":"16","r":"1"}],["rect",{"x":"3","y":"10","width":"18","height":"12","rx":"2"}],["path",{"d":"M7 10V7a5 5 0 0 1 10 0v3"}]],"download":[["path",{"d":"M12 15V3"}],["path",{"d":"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["path",{"d":"m7 10 5 5 5-5"}]],"help":[["circle",{"cx":"12","cy":"12","r":"10"}],["path",{"d":"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"}],["path",{"d":"M12 17h.01"}]],"refresh":[["path",{"d":"M20 11A8.1 8.1 0 0 0 4.5 9M4 4V9H9M4 13A8.1 8.1 0 0 0 19.5 15M20 20V15H15"}]],"sun":[["circle",{"cx":"12","cy":"12","r":"4"}],["path",{"d":"M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5L19 19M5 19l1.5-1.5M17.5 6.5L19 5"}]],"moon":[["path",{"d":"M20.5 13A8.5 8.5 0 1 1 11 3.5A6.5 6.5 0 0 0 20.5 13Z"}]]} +{"window-minimize":[["path",{"d":"M2 13h20"}]],"window-maximize":[["rect",{"x":"3","y":"3","width":"18","height":"18"}]],"window-restore":[["path",{"d":"M7 7V3h14v14h-4"}],["rect",{"x":"3","y":"7","width":"14","height":"14"}]],"window-close":[["path",{"d":"m4 4 16 16M20 4 4 20"}]],"plus":[["path",{"d":"M5 12h14"}],["path",{"d":"M12 5v14"}]],"more":[["circle",{"cx":"12","cy":"12","r":"1"}],["circle",{"cx":"19","cy":"12","r":"1"}],["circle",{"cx":"5","cy":"12","r":"1"}]],"clock":[["circle",{"cx":"12","cy":"12","r":"10"}],["path",{"d":"M12 6v6h4"}]],"user":[["path",{"d":"M17.925 20.056a6 6 0 0 0-11.851.001"}],["circle",{"cx":"12","cy":"11","r":"4"}],["circle",{"cx":"12","cy":"12","r":"10"}]],"check":[["path",{"d":"M20 6 9 17l-5-5"}]],"close":[["path",{"d":"M18 6 6 18"}],["path",{"d":"m6 6 12 12"}]],"settings":[["path",{"d":"M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"}],["circle",{"cx":"12","cy":"12","r":"3"}]],"lock":[["circle",{"cx":"12","cy":"16","r":"1"}],["rect",{"x":"3","y":"10","width":"18","height":"12","rx":"2"}],["path",{"d":"M7 10V7a5 5 0 0 1 10 0v3"}]],"download":[["path",{"d":"M12 15V3"}],["path",{"d":"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"}],["path",{"d":"m7 10 5 5 5-5"}]],"help":[["circle",{"cx":"12","cy":"12","r":"10"}],["path",{"d":"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"}],["path",{"d":"M12 17h.01"}]],"refresh":[["path",{"d":"M20 11A8.1 8.1 0 0 0 4.5 9M4 4V9H9M4 13A8.1 8.1 0 0 0 19.5 15M20 20V15H15"}]],"sun":[["circle",{"cx":"12","cy":"12","r":"4"}],["path",{"d":"M12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5L19 19M5 19l1.5-1.5M17.5 6.5L19 5"}]],"moon":[["path",{"d":"M20.5 13A8.5 8.5 0 1 1 11 3.5A6.5 6.5 0 0 0 20.5 13Z"}]],"eye":[["path",{"d":"M2 12C4 8 7 5 12 5S20 8 22 12C20 16 17 19 12 19S4 16 2 12Z"}],["circle",{"cx":"12","cy":"12","r":"3"}]],"eye-off":[["path",{"d":"M2 12C4 8 7 5 12 5S20 8 22 12C20 16 17 19 12 19S4 16 2 12Z"}],["circle",{"cx":"12","cy":"12","r":"3"}],["path",{"d":"M3 3L21 21"}]],"copy":[["path",{"d":"M9 9H21V21H9Z M15 9V3H3V15H9"}]]} diff --git a/src/application/AccountService.cs b/src/application/AccountService.cs index 66bc4ff..e8b7ac1 100644 --- a/src/application/AccountService.cs +++ b/src/application/AccountService.cs @@ -49,7 +49,7 @@ internal ModelProviderManager ModelProviders if (!File.Exists(AuthPath)) return null; if ((File.GetAttributes(AuthPath) & FileAttributes.ReparsePoint) != 0) throw new IOException("The Codex sign-in file is a linked path."); return Encoding.UTF8.GetString(SecureFiles.ReadPrivateBytes(AuthPath)); - })); + }), codexHome: CodexHome); internal LinuxDesktopRuntime? LinuxDesktop { get; } internal bool HasLinuxDesktop => LinuxDesktop != null; private HashSet _switchTargets = new(); @@ -127,6 +127,7 @@ await Task.WhenAll(accounts.Select(async account => var result = Accounts.FirstOrDefault(a => a.Key == account.Key); if (result != null) updated?.Invoke(result); })).ConfigureAwait(false); + if (_modelProviders != null) await _modelProviders.RefreshCatalogAsync(cancellation).ConfigureAwait(false); } public async Task RefreshAccountAsync(string key, CancellationToken cancellation, bool supersede = false) { @@ -337,6 +338,7 @@ bool Running() => OperatingSystem.IsLinux() ? LinuxDesktopRuntime.Running(cancel SecureFiles.AtomicWriteText(AuthPath, selectedAuth); selected.RequiresApply = false; SynchronizeCurrentCore(policy); Vault.Save(); } + if (_modelProviders != null) await _modelProviders.RefreshCatalogAsync(cancellation, true).ConfigureAwait(false); if (launch) { if (OperatingSystem.IsLinux()) { if (LinuxDesktop != null) await LinuxDesktop.StartAsync(cancellation).ConfigureAwait(false); } diff --git a/src/application/ModelGateway.cs b/src/application/ModelGateway.cs index 5f9afbf..c2a2fd1 100644 --- a/src/application/ModelGateway.cs +++ b/src/application/ModelGateway.cs @@ -33,6 +33,7 @@ internal sealed class ModelGateway : IAsyncDisposable private readonly SemaphoreSlim _streamSlots = new(48, 48); private readonly SemaphoreSlim _rejections = new(4, 4); private readonly GatewayBodyBudget _bodyBudget = new(); + private readonly Dictionary _requestReceipts = new(); private Task? _accept; private int _nextRequest; private sealed record NativeCatalog(Dictionary Models, DateTimeOffset Expires); @@ -201,6 +202,18 @@ void ReleaseInput() 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); + if (requestKey != null) + { + var admission = ReserveRequest(requestKey); + if (admission != 0) + { + await ErrorAsync(response, admission == 1 ? 400 : 503, admission == 1 + ? "This Codex turn request has already been sent upstream. Its input was not replayed; start a new turn to retry." + : "The local model gateway's request receipt capacity is busy.", cancellation).ConfigureAwait(false); + return; + } + } var retries = 0; HttpRequestMessage CreateUpstream() { @@ -279,13 +292,20 @@ 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(); } } - private async Task CatalogAsync(GatewayIdentity identity, HttpListenerRequest incoming, CancellationToken cancellation, string? clientVersion = null) + 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) { - var version = clientVersion ?? incoming.QueryString["client_version"] ?? ""; + var version = clientVersion ?? incoming?.QueryString["client_version"] ?? ""; if (version.Length is < 1 or > 80 || version.Any(c => !(char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '+'))) throw new InvalidDataException("The model catalog request has no valid client version."); using var request = new HttpRequestMessage(HttpMethod.Get, "https://chatgpt.com/backend-api/codex/models?client_version=" + Uri.EscapeDataString(version)); - CopyOpenAiHeaders(incoming, request, identity); + if (incoming != null) CopyOpenAiHeaders(incoming, request, identity); + else + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", identity.Token); + if (identity.AccountId != null) request.Headers.TryAddWithoutValidation("ChatGPT-Account-ID", identity.AccountId); + } using var response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellation).ConfigureAwait(false); if (!response.IsSuccessStatusCode) throw new IOException("The current account's OpenAI model catalog is unavailable."); var result = JsonNode.Parse(await ProviderCatalog.ReadLimitedAsync(response.Content, ProviderCatalog.MaximumCatalogBytes, cancellation).ConfigureAwait(false)) as JsonObject; @@ -322,12 +342,43 @@ private async Task KnownOpenAiModelAsync(string model, GatewayIdentity ide return _nativeCatalogs.TryGetValue(key, out cached) ? cached.Models.GetValueOrDefault(model) : null; } private static string IdentityKey(GatewayIdentity identity) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity.Token + "\n" + identity.AccountId))); + private int ReserveRequest(string key) + { + lock (_requestReceipts) + { + var now = DateTimeOffset.UtcNow; + if (_requestReceipts.Count >= 4096) + foreach (var expired in _requestReceipts.Where(pair => pair.Value <= now).Select(pair => pair.Key).ToArray()) _requestReceipts.Remove(expired); + if (_requestReceipts.TryGetValue(key, out var until) && until > now) return 1; + if (_requestReceipts.Count >= 4096) return 2; + _requestReceipts[key] = now.AddMinutes(30); return 0; + } + } + private static string? RequestKey(ReadOnlyMemory body, GatewayIdentity identity, string route, string path) + { + var fields = GatewayRoutedContent.Fields(body); + var metadata = fields.FirstOrDefault(field => field.Name == "client_metadata").Value; + if (metadata.IsEmpty || metadata.Length > 65536) return null; + using var client = JsonDocument.Parse(metadata); + if (client.RootElement.ValueKind != JsonValueKind.Object || + !client.RootElement.TryGetProperty("x-codex-turn-metadata", out var encoded) || encoded.ValueKind != JsonValueKind.String) return null; + using var turnMetadata = JsonDocument.Parse(encoded.GetString()!); + if (!Guid.TryParse(JsonValue.String(turnMetadata.RootElement, "turn_id"), out var turn)) return null; + using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); + hash.AppendData(Encoding.UTF8.GetBytes((identity.AccountId ?? identity.Token) + "\n" + turn.ToString("D") + "\n" + route + "\n" + path)); + foreach (var field in fields.Where(field => field.Name != "client_metadata")) + { + hash.AppendData(Encoding.UTF8.GetBytes("\n" + field.Name + "\n")); + hash.AppendData(field.Value.Span); + } + return Convert.ToHexString(hash.GetHashAndReset()); + } internal static string? ClientVersion(string? value) { if (value == null) return null; var direct = Regex.Match(value, @"^\d+\.\d+\.\d+(?:[-+][A-Za-z0-9.-]+)?$"); if (direct.Success) return direct.Value.Split(new[] { '-', '+' })[0]; - var agent = Regex.Match(value, @"(?:Codex Desktop|codex_cli_rs|Codex CLI|codex)/(\d+\.\d+\.\d+)"); + var agent = Regex.Match(value, @"(?:Codex Desktop|codex_cli_rs|Codex CLI|codex_account_desk|codex)/(\d+\.\d+\.\d+)"); return agent.Success ? agent.Groups[1].Value : null; } private static void CopyOpenAiHeaders(HttpListenerRequest incoming, HttpRequestMessage outgoing, GatewayIdentity identity) diff --git a/src/application/ModelProviderManager.cs b/src/application/ModelProviderManager.cs index 18d64a9..79c1d63 100644 --- a/src/application/ModelProviderManager.cs +++ b/src/application/ModelProviderManager.cs @@ -3,6 +3,8 @@ using System.IO; using System.Linq; using System.Net.Http; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; @@ -11,7 +13,7 @@ namespace CodexAccountDesk; -internal sealed class ModelProviderManager : IAsyncDisposable +internal sealed partial class ModelProviderManager : IAsyncDisposable { private readonly ModelProviderStore _store; private readonly HttpClient _http; @@ -21,13 +23,20 @@ internal sealed class ModelProviderManager : IAsyncDisposable private readonly CancellationTokenSource _lifetime = new(); private Task? _disposal; private ModelGateway? _gateway; + private readonly string _catalogPath; + private string? _home; + private string? _catalogIdentity; + private DateTimeOffset _catalogUpdated; public ModelProviderSettings Settings => _store.Read(); public bool Running => _gateway?.Running == true; public string? LastError => _gateway?.LastError; public DateTimeOffset? LastRequestAt => _gateway?.LastRequestAt; public string? BaseUrl => _gateway?.BaseUrl; - internal ModelProviderManager(string directory, Func authenticate, HttpClient? http = null) - { _store = new ModelProviderStore(directory); _authenticate = authenticate; _http = http ?? ProviderCatalog.CreateHttpClient(); } + internal ModelProviderManager(string directory, Func authenticate, HttpClient? http = null, string? codexHome = null) + { + _store = new ModelProviderStore(directory); _authenticate = authenticate; _http = http ?? ProviderCatalog.CreateHttpClient(); + _catalogPath = Path.GetFullPath(Path.Combine(directory, "model-providers-catalog.json")); _home = codexHome; + } public async Task SaveAsync(string? id, string name, string url, string key, CancellationToken cancellation, IReadOnlyCollection? includedIds = null) { using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellation, _lifetime.Token); cancellation = lifetime.Token; @@ -50,7 +59,8 @@ public async Task SaveAsync(string? id, string name, string url, foreach (var model in provider.Models) 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; }); + _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); return provider; } finally { _changes.Release(); } @@ -59,7 +69,11 @@ public async Task RemoveAsync(string id, CancellationToken cancellation) { using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellation, _lifetime.Token); cancellation = lifetime.Token; await _changes.WaitAsync(cancellation).ConfigureAwait(false); - try { _store.Update(next => next.Providers.RemoveAll(p => p.Id == id)); } + 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); + } finally { _changes.Release(); } } internal async Task SaveReasoningAsync(string providerId, string modelId, ProviderReasoningOptions? options, CancellationToken cancellation) @@ -73,6 +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); } finally { _changes.Release(); } } @@ -111,21 +126,17 @@ public async Task ConnectAsync(string executable, string home, CancellationToken var version = rpc.Initialization is JsonElement hello ? ModelGateway.ClientVersion(JsonValue.String(hello, "userAgent")) : null; if (version != null) _store.Update(next => next.LastClientVersion = version); var snapshot = await ReadUserConfigAsync(rpc, home, cancellation).ConfigureAwait(false); - if (ValueAt(snapshot.Config, "model_providers.codex_switch_router") != null) - throw new InvalidOperationException("The provider ID codex_switch_router is already configured. Review it before connecting."); + 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 (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); var edits = new Dictionary { - ["openai_base_url"] = NodeValue.Create(_gateway!.BaseUrl), ["model_provider"] = NodeValue.Create("codex_switch_router"), - ["model_catalog_json"] = null, ["features.responses_websockets_v2"] = NodeValue.Create(false), - ["features.enable_request_compression"] = NodeValue.Create(false), - ["model_providers.codex_switch_router"] = new JsonObject - { - ["name"] = "Codex Switch", ["base_url"] = _gateway.BaseUrl, ["wire_api"] = "responses", - ["requires_openai_auth"] = true, ["supports_websockets"] = false, - ["model_catalog_url"] = _gateway.BaseUrl + "/models", - ["request_max_retries"] = 0, ["stream_max_retries"] = 0, - ["capabilities"] = new JsonObject { ["remote_compaction"] = "unsupported" } - } + ["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()); // Persist recovery before the atomic config write. A crash can only leave an extra listener, @@ -138,6 +149,51 @@ public async Task ConnectAsync(string executable, string home, CancellationToken } finally { _changes.Release(); } } + internal async Task RefreshCatalogAsync(CancellationToken cancellation, bool force = false) + { + using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellation, _lifetime.Token); + await _changes.WaitAsync(lifetime.Token).ConfigureAwait(false); + try { await RefreshCatalogCoreAsync(lifetime.Token, force).ConfigureAwait(false); } + finally { _changes.Release(); } + } + private Task RefreshCatalogCoreAsync(CancellationToken cancellation, bool force) + { + var settings = _store.Read(); + if (!settings.Connected || settings.AppliedConfig == null || + !settings.AppliedConfig.TryGetValue("model_catalog_json", out var path) || path?.GetValue() != _catalogPath) + return Task.CompletedTask; + return WriteCatalogAsync(settings.LastClientVersion ?? throw new InvalidDataException("The connected Codex version is unavailable."), cancellation, force); + } + private GatewayIdentity ReadIdentity() + { + if (_home == null) throw new InvalidOperationException("The connected Codex home is unavailable."); + var bytes = SecureFiles.ReadPrivateBytes(Path.Combine(_home, "auth.json")); + try + { + using var document = JsonDocument.Parse(bytes); var root = document.RootElement; + var token = JsonValue.String(root, "OPENAI_API_KEY"); string? account = null; + if (string.IsNullOrWhiteSpace(token) && root.TryGetProperty("tokens", out var tokens)) + { token = JsonValue.String(tokens, "access_token"); account = JsonValue.String(tokens, "account_id"); } + return _authenticate("Bearer " + token, account) ?? throw new InvalidOperationException("The current OpenAI sign-in is required to refresh the model catalog."); + } + finally { CryptographicOperations.ZeroMemory(bytes); } + } + private async Task WriteCatalogAsync(string version, CancellationToken cancellation, bool force = true) + { + 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 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."); + var bytes = JsonSerializer.SerializeToUtf8Bytes(catalog); + if (bytes.Length > ProviderCatalog.MaximumCatalogBytes) throw new InvalidDataException("The combined model catalog exceeds the client limit."); + SecureFiles.AtomicWrite(_catalogPath, bytes); + _catalogIdentity = identityKey; _catalogUpdated = DateTimeOffset.UtcNow; + } public async Task DisconnectAsync(string executable, string home, CancellationToken cancellation) { using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellation, _lifetime.Token); cancellation = lifetime.Token; diff --git a/src/application/ModelProviders.cs b/src/application/ModelProviders.cs index 9fc8891..676ec08 100644 --- a/src/application/ModelProviders.cs +++ b/src/application/ModelProviders.cs @@ -43,6 +43,8 @@ internal sealed class ModelProviderSettings public bool Connected { get; set; } public bool ConnectionWriteConfirmed { get; set; } public List Providers { get; set; } = new(); + public string? EditorSelection { get; set; } + public Dictionary EditorDrafts { get; set; } = new(); public Dictionary? OriginalConfig { get; set; } public Dictionary? AppliedConfig { get; set; } public Dictionary ConversationRoutes { get; set; } = new(); @@ -72,6 +74,15 @@ 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.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."); + foreach (var (id, draft) in _settings.EditorDrafts) + { + if (id != ModelProviderManager.NewEditorProvider && !Guid.TryParseExact(id, "N", out _) || draft == null) + throw new InvalidDataException("The provider editor state is invalid."); + draft.Validate(); + } } public ModelProviderSettings Read() { lock (_gate) return Clone(_settings); } internal bool RequiresPortableHistory(string? session, GatewayIdentity identity, string route) diff --git a/src/application/ProviderEditorState.cs b/src/application/ProviderEditorState.cs new file mode 100644 index 0000000..1eb2fa8 --- /dev/null +++ b/src/application/ProviderEditorState.cs @@ -0,0 +1,98 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace CodexAccountDesk; + +internal sealed class ProviderEditorDraft +{ + public string Name { get; set; } = ""; + public string BaseUrl { get; set; } = ""; + public string? ApiKey { get; set; } + public string ModelIds { get; set; } = ""; + public int Step { get; set; } + internal ProviderEditorDraft Copy() => new() { Name = Name, BaseUrl = BaseUrl, ApiKey = ApiKey, ModelIds = ModelIds, Step = Step }; + internal static ProviderEditorDraft From(ModelProvider provider, int step = 0) => new() + { Name = provider.Name, BaseUrl = provider.BaseUrl, ModelIds = string.Join("\n", provider.IncludedModelIds ?? new()), Step = step }; + internal void Validate() + { + if (Name == null || Name.Length > 80 || BaseUrl == null || BaseUrl.Length > 32768 || + ApiKey?.Length > 16384 || ModelIds == null || ModelIds.Length > 65536 || Step is < 0 or > 1) + throw new InvalidDataException("The provider editor draft is invalid or too large."); + } +} + +internal sealed partial class ModelProviderManager +{ + internal const string NewEditorProvider = "new"; + internal ProviderEditorDraft ReadEditorDraft(string? id) + { + var settings = Settings; + if (settings.EditorDrafts.TryGetValue(id ?? NewEditorProvider, out var draft)) return draft; + var provider = settings.Providers.FirstOrDefault(item => item.Id == id); + return provider == null ? new() : ProviderEditorDraft.From(provider); + } + internal void RememberEditorDraft(string? id, ProviderEditorDraft draft) + { + if (id != null && !Guid.TryParseExact(id, "N", out _)) throw new ArgumentException("Invalid provider selection."); + draft.Validate(); var copy = draft.Copy(); + _store.Update(settings => + { + var key = id ?? NewEditorProvider; + if (!settings.EditorDrafts.ContainsKey(key) && settings.EditorDrafts.Count >= 33) + throw new InvalidOperationException("The provider editor draft limit was reached."); + settings.EditorDrafts[key] = copy; settings.EditorSelection = key; + }); + } + internal void ForgetEditorDraft(string? id) => _store.Update(settings => settings.EditorDrafts.Remove(id ?? NewEditorProvider)); + private static void RememberSavedEditor(ModelProviderSettings settings, ModelProvider provider, string? previousId) + { + var draft = ProviderEditorDraft.From(provider, 1); draft.Validate(); + settings.EditorDrafts.Remove(previousId ?? NewEditorProvider); + settings.EditorDrafts[provider.Id] = draft; + settings.EditorSelection = provider.Id; + } +} + +internal sealed class ProviderEditorSession +{ + private readonly ModelProviderManager _manager; + private string _savedKey = ""; + public string? SelectedId { get; private set; } + public ProviderEditorDraft Draft { get; private set; } = new(); + public string ApiKey => Draft.ApiKey ?? _savedKey; + public bool IsSaved => SelectedId != null && _manager.Settings.Providers.Any(item => item.Id == SelectedId); + internal ProviderEditorSession(ModelProviderManager manager) + { + _manager = manager; + var settings = manager.Settings; + var selected = settings.EditorSelection; + Load(selected == ModelProviderManager.NewEditorProvider ? null : selected ?? settings.Providers.FirstOrDefault()?.Id); + } + private void Load(string? id) + { + SelectedId = id; + _savedKey = _manager.Settings.Providers.FirstOrDefault(item => item.Id == id)?.ApiKey ?? ""; + Draft = _manager.ReadEditorDraft(id); + } + public void Persist() => _manager.RememberEditorDraft(SelectedId, Draft); + public void Select(string? id) { Persist(); Load(id); Persist(); } + public void SetApiKey(string key) => Draft.ApiKey = key; + public void Edit() { Draft.Step = 0; Persist(); } + public void LoadSaved() { _manager.ForgetEditorDraft(SelectedId); Load(SelectedId); Draft.Step = 0; Persist(); } + public void UseForNewProvider() + { + Persist(); var draft = new ProviderEditorDraft { BaseUrl = Draft.BaseUrl, ApiKey = ApiKey, ModelIds = Draft.ModelIds }; + _manager.RememberEditorDraft(null, draft); Load(null); + } + public async Task SaveAsync(CancellationToken cancellation) + { + Persist(); var draft = Draft.Copy(); + if (draft.ApiKey != null && string.IsNullOrWhiteSpace(draft.ApiKey)) throw new ArgumentException("Enter an API key."); + var provider = await _manager.SaveAsync(SelectedId, draft.Name, draft.BaseUrl, draft.ApiKey ?? "", cancellation, + draft.ModelIds.Split('\n', StringSplitOptions.RemoveEmptyEntries)).ConfigureAwait(false); + Load(provider.Id); + } +} diff --git a/src/desktop/DeskButtonIcon.cs b/src/desktop/DeskButtonIcon.cs index 966a705..d17560f 100644 --- a/src/desktop/DeskButtonIcon.cs +++ b/src/desktop/DeskButtonIcon.cs @@ -4,7 +4,7 @@ namespace CodexAccountDesk; -internal enum DeskButtonIconKind { More, Refresh, Plus, Up, Down, Sun, Moon, Launch, Settings } +internal enum DeskButtonIconKind { More, Refresh, Plus, Up, Down, Sun, Moon, Launch, Settings, Eye, EyeOff, Copy } internal sealed class DeskButtonIcon : Control { @@ -16,6 +16,9 @@ internal sealed class DeskButtonIcon : Control private static readonly Geometry Moon = Geometry.Parse("M20.5 13A8.5 8.5 0 1 1 11 3.5A6.5 6.5 0 0 0 20.5 13Z"); private static readonly Geometry Launch = Geometry.Parse("M14 4H20V10M20 4L10 14M10 5H5V20H20V15"); private static readonly Geometry Settings = Geometry.Parse("M9 3H15L16 6L19 7L22 10V14L19 17L16 18L15 21H9L8 18L5 17L2 14V10L5 7L8 6Z M15 12A3 3 0 1 1 9 12A3 3 0 1 1 15 12"); + private static readonly Geometry Eye = Geometry.Parse("M2 12C4 8 7 5 12 5S20 8 22 12C20 16 17 19 12 19S4 16 2 12Z M15 12A3 3 0 1 1 9 12A3 3 0 1 1 15 12"); + private static readonly Geometry EyeOff = Geometry.Parse("M2 12C4 8 7 5 12 5S20 8 22 12C20 16 17 19 12 19S4 16 2 12Z M15 12A3 3 0 1 1 9 12A3 3 0 1 1 15 12 M3 3L21 21"); + private static readonly Geometry Copy = Geometry.Parse("M9 9H21V21H9Z M15 9V3H3V15H9"); private readonly DeskButtonIconKind _kind; private readonly IBrush _brush; public DeskButtonIcon(DeskButtonIconKind kind, IBrush brush) { _kind = kind; _brush = brush; IsHitTestVisible = false; Width = Height = 14; } @@ -29,7 +32,8 @@ public override void Render(DrawingContext context) } else context.DrawGeometry(null, new Pen(_brush, 1.8, lineCap: PenLineCap.Round, lineJoin: PenLineJoin.Round), _kind switch { DeskButtonIconKind.Refresh => Refresh, DeskButtonIconKind.Plus => Plus, DeskButtonIconKind.Up => Up, - DeskButtonIconKind.Down => Down, DeskButtonIconKind.Sun => Sun, DeskButtonIconKind.Launch => Launch, DeskButtonIconKind.Settings => Settings, _ => Moon }); + DeskButtonIconKind.Down => Down, DeskButtonIconKind.Sun => Sun, DeskButtonIconKind.Launch => Launch, DeskButtonIconKind.Settings => Settings, + DeskButtonIconKind.Eye => Eye, DeskButtonIconKind.EyeOff => EyeOff, DeskButtonIconKind.Copy => Copy, _ => Moon }); } } } diff --git a/src/desktop/DeskProvidersWindow.cs b/src/desktop/DeskProvidersWindow.cs index a706aee..7ba0929 100644 --- a/src/desktop/DeskProvidersWindow.cs +++ b/src/desktop/DeskProvidersWindow.cs @@ -4,7 +4,9 @@ using System.Threading; using System.Threading.Tasks; using Avalonia; +using Avalonia.Automation; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Input.Platform; using Avalonia.Layout; using Avalonia.Media; @@ -15,133 +17,195 @@ internal sealed class DeskProvidersWindow : Window { private readonly AccountService _service; private readonly ModelProviderManager _manager; - private readonly ComboBox _providers = new() { HorizontalAlignment = HorizontalAlignment.Stretch }; - private readonly TextBox _name = new(), _url = new(), _key = new() { PasswordChar = '●' }; - private readonly TextBox _included = new() { AcceptsReturn = true, Height = 54 }; - private readonly ComboBox _reasoningModel = new() { HorizontalAlignment = HorizontalAlignment.Stretch }, _reasoningDefault = new() { ItemsSource = ProviderReasoning.Efforts }; + private readonly DeskTheme _theme; + private readonly Func? _copyText; + private ProviderEditorSession _editor; + private readonly TextBox _name = new() { MaxLength = 80 }, _url = new() { MaxLength = 32768 }, _key = new() { PasswordChar = '●', MaxLength = 16384 }; + private readonly TextBox _included = new() { AcceptsReturn = true, MaxLength = 65536, Height = 76 }; + private readonly StackPanel _entry = new(), _ready = new(), _reasoningPanel = new(); + private readonly TextBlock _stepTitle = new(), _models = new() { TextWrapping = TextWrapping.Wrap }, _status = new() { TextWrapping = TextWrapping.Wrap }; + private readonly ComboBox _reasoningModel = new(), _reasoningDefault = new() { ItemsSource = ProviderReasoning.Efforts }; private readonly CheckBox _reasoningOverride = new(); private readonly Dictionary _reasoningLevels = new(); - private readonly StackPanel _reasoningPanel = new() { Spacing = 8 }; - private readonly TextBlock _status = new() { TextWrapping = TextWrapping.Wrap }, _models = new() { TextWrapping = TextWrapping.Wrap }; - private readonly List