From 2babfd5549c779328b4b2c3b1017f4dcf01a7086 Mon Sep 17 00:00:00 2001 From: Gaebabi <45709904+Gaebabi@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:35:32 +0900 Subject: [PATCH 01/15] Codex created this commit automatically to snapshot recent local work. Changed areas (staged name-status): M docs/development/OPERATIONS.md A scripts/retain-desktop-images.py M scripts/run-local-linux-desktop.py Diff summary: docs/development/OPERATIONS.md | 2 ++ scripts/retain-desktop-images.py | 48 ++++++++++++++++++++++++++++++++++++++ scripts/run-local-linux-desktop.py | 2 ++ 3 files changed, 52 insertions(+) Recommended next work: review changed files and confirm any generated or broad asset changes; run focused checks for affected areas before relying on this snapshot. --- docs/development/OPERATIONS.md | 2 ++ scripts/retain-desktop-images.py | 48 ++++++++++++++++++++++++++++++ scripts/run-local-linux-desktop.py | 2 ++ 3 files changed, 52 insertions(+) create mode 100644 scripts/retain-desktop-images.py diff --git a/docs/development/OPERATIONS.md b/docs/development/OPERATIONS.md index c75da91..21b6a84 100644 --- a/docs/development/OPERATIONS.md +++ b/docs/development/OPERATIONS.md @@ -4,6 +4,8 @@ Public `Gaebabi/codex-switch`, baseline `main`, is the canonical source. Clone i ## Build and verify +The offline Linux desktop runner retains three recent `codex-switch-desktop-session` test images. Images referenced by any container, including stopped owner-authenticated sessions, and images younger than six hours are protected. `python scripts/retain-desktop-images.py --keep 3` applies the same policy manually. It does not remove containers, volumes, credentials, or interactive-session images. Source-bound image IDs in older receipts may describe history after an unused image has been retired. + The Linux source1.40.0 preview enables ordinary account/usage and [managed desktop integration](LINUX.md#source1400-ordinary-integration--issue74--pr75). Start Codex from this utility before working; an external/unmanaged client cannot be adopted. Existing credential/context policy is respected. Explicit checksum CLI-only mode has no desktop resumption. `scripts/run-local-linux-desktop.py` exercises the production backend plus fictional auth/offline native client in an owned container; authenticated two-account continuation and full desktop/Release acceptance remain separate. Run from the repository root with .NET10.0.4xx, currently SDK10.0.402/runtime10.0.13. `global.json` allows current patch updates. Use clean output folders for package candidates. .NET10's reviewed current Mac support baseline is macOS15+; the bundle minimum and app version match source1.40.1. [KDE6 provider contracts and native gates](KDE_SECRET_SERVICE.md) describe explicit package/checksum support; runtime providers are not installed or altered by the app. [Linux IPC](LINUX_IPC.md) records controlled launch, native peer trust, kernel/display/container requirements and the remaining lifecycle/account gates. [Local desktop-session verification](LINUX_DESKTOP_SESSION.md) exercises ordinary installed UI and actual provider/client lifecycles without host homes or credentials. diff --git a/scripts/retain-desktop-images.py b/scripts/retain-desktop-images.py new file mode 100644 index 0000000..3e11f24 --- /dev/null +++ b/scripts/retain-desktop-images.py @@ -0,0 +1,48 @@ +"""Retain three offline test images; never remove images referenced by containers.""" +import argparse +import json +import re +import subprocess +from datetime import datetime, timezone, timedelta + +def docker(*arguments): + return subprocess.run(['docker', *arguments], check=True, capture_output=True, + text=True, encoding='utf-8', timeout=120).stdout + +def retain(keep=3, minimum_hours=6): + rows = [json.loads(line) for line in docker('image', 'ls', '--no-trunc', '--format', '{{json .}}').splitlines() if line] + rows = [row for row in rows if row['Repository'] == 'codex-switch-desktop-session' + and re.fullmatch(r'[0-9a-f]{12}', row['Tag'])] + images = {} + for row in rows: + images.setdefault(row['ID'], row) + ordered = [] + for identifier, row in images.items(): + created = json.loads(docker('image', 'inspect', '--format', '{{json .Created}}', identifier)) + # Docker emits nanosecond fractions; datetime accepts microsecond precision. + moment = datetime.fromisoformat(created.replace('Z', '+00:00')) + ordered.append((moment, identifier)) + ordered.sort(reverse=True) + containers = docker('ps', '-aq').splitlines() + referenced = set() + for identifier in containers: + referenced.add(docker('inspect', '--format', '{{.Image}}', identifier).strip()) + cutoff = datetime.now(timezone.utc) - timedelta(hours=minimum_hours) + removed = [] + for created, identifier in ordered[keep:]: + if created >= cutoff or identifier in referenced: + continue + # Without --force Docker also refuses a container-reference race. + docker('image', 'rm', identifier) + removed.append(identifier) + return {'family': 'codex-switch-desktop-session', 'keep': keep, + 'minimumHours': minimum_hours, 'removedImages': removed, + 'containersAndVolumesRemoved': False} + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--keep', type=int, default=3) + args = parser.parse_args() + if args.keep < 1: + parser.error('--keep must be positive') + print(json.dumps(retain(args.keep))) diff --git a/scripts/run-local-linux-desktop.py b/scripts/run-local-linux-desktop.py index f86390b..200d27d 100644 --- a/scripts/run-local-linux-desktop.py +++ b/scripts/run-local-linux-desktop.py @@ -112,3 +112,5 @@ def git(*arguments): if item['Name'] != '/' + name or item['Config']['Labels'].get('codex-switch-desktop-session') != head or item['Mounts']: raise RuntimeError('Refuse cleanup of an unexpected container') run(['docker', 'rm', '--force', identifier], capture_output=True) + run([sys.executable, str(repository / 'scripts/retain-desktop-images.py'), '--keep', '3'], + cwd=repository) From 8511b164b99cf39c84f4ef47d9854e55acb5790d Mon Sep 17 00:00:00 2001 From: Gaebabi <45709904+Gaebabi@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:31:08 +0900 Subject: [PATCH 02/15] chore: automated work snapshot Codex created this commit automatically. Changed areas: AGENTS.md, Directory.Build.props. Next work: Review changed files for urgent follow-ups; run relevant repository tests; clean up temporary artifacts. --- AGENTS.md | 20 ++++++++++++++++++++ Directory.Build.props | 4 ++++ 2 files changed, 24 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 6803d95..469126d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,3 +10,23 @@ - Keep development plan/status/operations and public guides consistent when paths/build/CI change. Local/private operating context belongs outside published documents. - Every issue/PR needs actual application verification screenshots: before/after for changed behavior and a confirmation screen for a new feature. Repository screenshots do not substitute. Planned work shows the existing app baseline and explicitly pending confirmation. Remove private source pixels before applying blur; captions state the actual evidence scope. - Create automatic backup commits only after satisfaction is confirmed. A direct PR request authorizes scoped branch commits/push/PR. Merge/release/external messages need explicit scope; the current owner request includes review and merge, not a new binary release. + + + +## VM build and cache storage + +CODEXWORK VM builds use `D:/Workstation/Set-ProjectEnvironment.ps1 -Project codex-switch` and the registered `PROJECT_BUILD_ROOT` / `PROJECT_CACHE_ROOT`. Read `D:/Workstation/BUILD_CACHE_POLICY.md`. Reproducible outputs belong under `W:/Builds/CODEXWORK/codex-switch`; use each tool's real output option. Do not silently create a second output on D: when W: is unavailable. Source, real Git worktrees, credentials, databases, emulator userdata, original assets and final acceptance evidence stay on D:. Keep active Unity Library/Temp and executable virtual environments local for filesystem/locking compatibility; do not move or delete active caches. W: is regenerable storage on the acknowledged HDD; only separately backed-up asset copies belong there. Large build admission requires 40 GiB free on D:; archive verified inactive checkpoints to the reliable HostSourceHistory area first, never discard unique state. + + + +## Codex shared-storage build execution + +Windows elevated Codex sandbox accounts have a separate drive/authentication context: they cannot directly use the interactive user's W: SMB mapping. Do not disable the sandbox, add network drives as native writable roots, or replace missing W: with local D: outputs. Submit registered operations to the ordinary-user build worker: + +`D:/Tools/Python311/python.exe D:/Workstation/Invoke-ProjectBuild.py --project --operation ` + +Operations: `storage-probe` (harmless write/read/delete); `npm-ci`, `npm-build`, `npm-test` (optional `--relative-path` to the package folder); `dotnet-restore`, `dotnet-build`, `dotnet-test` (`--relative-path` to the repository's csproj/sln); `reborn-windows`, `reborn-android`, `reborn-editmode`, `reborn-audit` for DragonMate-Reborn; `infinity-android` for InfinityCrystal-Unity. Only registered projects/operations are accepted; arbitrary shell commands and paths outside the project are rejected. The worker runs as the normal CodexWork user and receives build/cache routing without unrelated credentials. Build admission requires 40 GiB free on local D:. + +The client waits up to 30 seconds, then returns a receipt path while the same build continues. Read that receipt and its existing operation lease; do not start a duplicate. Detailed logs stay in D:/Workstation/private/shared-build-logs. Do not kill unrelated build processes. A routing probe is infrastructure evidence, not product compilation or Player acceptance. + +The permanent ordinary-user task is CodexWork-RegisteredSharedBuildWorker, starts at CodexWork logon, and accepts structured requests only under D:/Caches/Temp/CodexSharedBuildQueue. Preserve this active queue during temp cleanup. The only extra native sandbox writable root is this local queue, not W: or historical/private data. Normal interactive terminals may use Set-ProjectEnvironment and the project tools directly. In CodexSandbox accounts the environment loader skips user DPAPI Git credentials and uses this registered build path for shared I/O. diff --git a/Directory.Build.props b/Directory.Build.props index 43b2380..9a829f7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,4 +2,8 @@ 1.40.1 + + W:\Builds\CODEXWORK\codex-switch\$(MSBuildProjectName)\bin\ + W:\Caches\CODEXWORK\projects\codex-switch\msbuild\$(MSBuildProjectName)\obj\ + From 87ab4c2b112f7744a16cd32fb18941e6c1561230 Mon Sep 17 00:00:00 2001 From: Gaebabi <45709904+Gaebabi@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:31:38 +0900 Subject: [PATCH 03/15] chore: automated work snapshot Codex created this commit automatically. Changed areas (AGENTS.md | 8 ++++++++ scripts/build.ps1 | 15 +++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-)): M AGENTS.md; M scripts/build.ps1. Next work: review changed files for generated content or secrets; run focused tests/build checks for the affected project before release; handle TODOs and cleanup found in the diff. --- AGENTS.md | 8 ++++++++ scripts/build.ps1 | 15 +++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 469126d..52a411d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,3 +30,11 @@ Operations: `storage-probe` (harmless write/read/delete); `npm-ci`, `npm-build`, The client waits up to 30 seconds, then returns a receipt path while the same build continues. Read that receipt and its existing operation lease; do not start a duplicate. Detailed logs stay in D:/Workstation/private/shared-build-logs. Do not kill unrelated build processes. A routing probe is infrastructure evidence, not product compilation or Player acceptance. The permanent ordinary-user task is CodexWork-RegisteredSharedBuildWorker, starts at CodexWork logon, and accepts structured requests only under D:/Caches/Temp/CodexSharedBuildQueue. Preserve this active queue during temp cleanup. The only extra native sandbox writable root is this local queue, not W: or historical/private data. Normal interactive terminals may use Set-ProjectEnvironment and the project tools directly. In CodexSandbox accounts the environment loader skips user DPAPI Git credentials and uses this registered build path for shared I/O. + + +## 사용자 지시: 빌드 산출물은 W:만 사용 (2026-10-22) + +- APK/AAB/Player/배포 ZIP 및 재생성 dist/bin/obj/publish는 `W:/Builds/CODEXWORK/`에만 생성한다. D:/Deliverables 또는 D:/artifacts로 바이너리를 다시 복사하지 않는다. “최종 증거”/durableRoot를 빌드 복사 예외로 해석하지 않는다. +- `D:/Workstation/BuildOutputPolicy.py --project --output `로 실제 경로와 공유 저장소 쓰기/읽기를 검사한다. W: 실패 시 중단하며 D: 대체는 금지한다. 등록 worker의 npm/.NET 출력 검사도 우회하지 않는다. +- D: 검수 증거는 로그·manifest·해시·원본 화면/영상·장부다. 소스·DB·인증·AVD·설치된 도구·원본 에셋/복구 입력을 빌드 정리로 지우지 않는다. Unity Library/Temp와 OS TEMP는 도구 호환성상 로컬 D:를 유지한다. D: 40GiB 여유 조건은 이 로컬 중간 작업 공간 조건이며 APK 출력 위치가 아니다. +- 자세한 정본은 `D:/Workstation/BUILD_CACHE_POLICY.md`와 `build-cache-routing.json`이다. 과거 D: 산출물은 W: 사본 SHA 검증 후 로컬 실데이터만 제거하고 필요한 옛 경로는 링크로 유지한다. 실행 중 쓰기·추적된 원본·검수 증거 혼합 폴더를 일괄 삭제하지 않는다. diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 84fc0d8..356baea 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -1,8 +1,19 @@ param([switch]$Verify) $ErrorActionPreference = 'Stop' $taskRoot = Split-Path -Parent $PSScriptRoot -$taskOutput = Join-Path $taskRoot 'artifacts\win-x64' -& dotnet publish (Join-Path $taskRoot 'src/windows/CodexSwitch.Windows.csproj') -c Release -r win-x64 --self-contained true --nologo -p:UseSharedCompilation=false -o $taskOutput +$storageArguments = @() +if ($env:COMPUTERNAME -eq 'CODEXWORK') { + . D:/Workstation/Set-ProjectEnvironment.ps1 -Project codex-switch + $taskRun = Join-Path $env:PROJECT_BUILD_ROOT ('local-windows/'+[DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss-fff')+'-'+[guid]::NewGuid().ToString('N').Substring(0,8)) + $taskOutput = Join-Path $taskRun 'win-x64' + & D:/Tools/Python311/python.exe D:/Workstation/BuildOutputPolicy.py --project codex-switch --output $taskOutput + if ($LASTEXITCODE -ne 0) { throw 'W-only output admission failed' } + $storageArguments = @('-p:UseArtifactsOutput=true', ('-p:ArtifactsPath='+(Join-Path $taskRun 'managed'))) +} else { + # Public Windows CI retains its existing package interface outside this VM. + $taskOutput = Join-Path $taskRoot 'artifacts\win-x64' +} +& dotnet publish (Join-Path $taskRoot 'src/windows/CodexSwitch.Windows.csproj') -c Release -r win-x64 --self-contained true --nologo -p:UseSharedCompilation=false @storageArguments -o $taskOutput if ($LASTEXITCODE -ne 0) { throw 'Windows publish failed' } if ($Verify) { $taskReport = Join-Path $taskRoot 'artifacts\self-tests.json' From 07128055607e474f9bb6c156d318b5e7e54a21df Mon Sep 17 00:00:00 2001 From: Gaebabi <45709904+Gaebabi@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:51:27 +0900 Subject: [PATCH 04/15] [1/2 Image gateway] Restore built-in images and allow larger conversations --- docs/MODEL_PROVIDERS.md | 6 ++- docs/development/OPERATIONS.md | 4 ++ docs/development/PLAN.md | 2 + docs/development/STATUS.md | 2 + docs/evidence/image-gateway/README.md | 16 +++++++ src/application/ModelGateway.cs | 32 ++++++++++++-- tests/model-providers/Program.cs | 64 ++++++++++++++++++++++++++- 7 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 docs/evidence/image-gateway/README.md diff --git a/docs/MODEL_PROVIDERS.md b/docs/MODEL_PROVIDERS.md index f915dde..dc54eee 100644 --- a/docs/MODEL_PROVIDERS.md +++ b/docs/MODEL_PROVIDERS.md @@ -34,12 +34,16 @@ For a model absent from that catalog or a route with different support, expand * 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. -Switch supports at most 32 providers and 128 selected models per provider. Discovery reads are bounded at 4MiB; the complete Codex-facing catalog must fit the client's 1MiB limit. Requests are bounded at 16MiB. Select fewer model IDs when necessary. A discovery result alone does not certify the provider's tool or conversation compatibility. +Switch supports at most 32 providers and 128 selected models per provider. Discovery reads are bounded at 4MiB; the complete Codex-facing catalog must fit the client's 1MiB limit. Full-input conversation and image requests are bounded at 64MiB. Larger requests report an explicit HTTP413 error; unsupported media types report HTTP415. Select fewer model IDs when necessary. A discovery result alone does not certify the provider's tool or conversation compatibility. 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. 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 and generation requests are not retried by the gateway. +Built-in image generation and editing use the original OpenAI sign-in through `images/generations` and `images/edits`. These requests preserve their JSON body, image references and request IDs and do not change the chat's model route. A custom text model selection does not redirect built-in images to that provider or its API key. Image availability and usage limits belong to the original OpenAI account. This is separate from a text model's ability to receive image attachments. + +The image-route and 64MiB corrections are source hotfixes after release1.43.3. Older packages can report an unsupported-endpoint404 for the built-in image tool, or `A bounded JSON Responses request is required` when a full-input conversation exceeds16MiB. The hotfix does not remove conversation history to make it fit. [Verification and delivery scope](evidence/image-gateway/README.md). + 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. diff --git a/docs/development/OPERATIONS.md b/docs/development/OPERATIONS.md index 5986257..c16dd7a 100644 --- a/docs/development/OPERATIONS.md +++ b/docs/development/OPERATIONS.md @@ -1,5 +1,9 @@ # Operations +Built-in image generation requires `images/generations` and `images/edits` in addition to Responses/model discovery. The gateway forwards these JSON requests only with the original OpenAI identity and preserves the body and safe request IDs; custom text aliases and saved custom-provider keys do not select the image route. Browser/loopback authentication, redirect refusal and no-retry behavior still apply. + +Conversation and image request bodies are bounded at64MiB, including chunked bodies. An oversized request reports HTTP413 with the limit; unsupported media types report415. Older builds combined the16MiB and JSON checks into `A bounded JSON Responses request is required`, so a growing full-input chat could fail after a successful tool call. Preserve that chat and update the gateway before retrying. [Hotfix evidence](../evidence/image-gateway/README.md). + Model-provider instruction attribution is included in published package `licenses/`. Mac app bundles place those text files in `Contents/Resources/licenses` before signing; Windows and Linux retain the publish directory layout. Native package checks compare the license and notice with the source and verify the complete signed bundle. Issue139/source1.43.2 waits asynchronously for transient Windows runtime-file sharing locks for at most two seconds, preserving login cancellation after release. Every attempt revalidates the private GUID path/link boundary; nonsharing and persistent failures still surface. No process termination, permission widening, account setting or background worker is added. [Release gates](RELEASE_1_43_2.md). diff --git a/docs/development/PLAN.md b/docs/development/PLAN.md index d12c7f7..1773449 100644 --- a/docs/development/PLAN.md +++ b/docs/development/PLAN.md @@ -1,5 +1,7 @@ # Current plan +Integrate the post1.43.3 image-route and large-request hotfix with focused gateway regressions and native application continuity evidence. Preserve original OpenAI image authentication, custom-provider key separation, complete chat inputs and bounded memory use. Require the six protected final-head checks and recorded review before merge. This source update does not publish a new binary release. [Verification](../evidence/image-gateway/README.md). + The current source work covers [safe handoff recovery and shutdown](https://github.com/Gaebabi/codex-switch/issues/141) and [configurable model providers](https://github.com/Gaebabi/codex-switch/issues/138). Complete protected CI and native application evidence before merge. Actual desktop picker behavior, external provider compatibility and the originally reported controller-exit cause remain separate acceptance items. ## Historical delivery checkpoints diff --git a/docs/development/STATUS.md b/docs/development/STATUS.md index 67300af..d94e50c 100644 --- a/docs/development/STATUS.md +++ b/docs/development/STATUS.md @@ -1,5 +1,7 @@ # Current status +The post1.43.3 image-gateway hotfix forwards built-in image generation/edit JSON endpoints through the original OpenAI identity and raises the bounded conversation/image body limit from16MiB to64MiB. Size and media failures report413 and415 separately. Local official-client/fictional-upstream verification passes109 checks; an authorized local installation passes99 self-tests, actual built-in generation and continuation of a previously failing chat. External image-edit acceptance and a new public binary release remain separate. [Evidence and limitations](../evidence/image-gateway/README.md). + Release1.43.3 packages the merged provider gateway, history presentation and handoff protection. [Issue141](https://github.com/Gaebabi/codex-switch/issues/141) remains open because the reported controller exit has not been reproduced. [Issue138](https://github.com/Gaebabi/codex-switch/issues/138) retains actual native picker and external-provider compatibility acceptance. Release preparation requires exact-head CI, review and public asset verification; the release link becomes current only after publication. [Native application continuity and Windows file-lock regression](../evidence/runtime-cleanup/README.md) preserve original pixels and exact source/receipt hashes. Windows220 account/history/recovery checks pass; shared/native Mac/Linux acceptance remains distinct. Final review/public1.43.2 delivery is tracked in [PR140](https://github.com/Gaebabi/codex-switch/pull/140). diff --git a/docs/evidence/image-gateway/README.md b/docs/evidence/image-gateway/README.md new file mode 100644 index 0000000..e880af7 --- /dev/null +++ b/docs/evidence/image-gateway/README.md @@ -0,0 +1,16 @@ +# Built-in image routing and large conversation requests + +The gateway previously returned404 for built-in `images/generations` and `images/edits` calls. A long full-input chat could also fail with `A bounded JSON Responses request is required` after exceeding16MiB. The hotfix forwards image JSON through the original OpenAI account, preserves image references and request IDs, and accepts bounded64MiB bodies. Size/media errors are now distinct413/415 responses. + +## Application evidence + +[Actual Windows application baseline](../recovery-shutdown/after.png) uses fictional accounts on a private desktop. It shows the existing Switch application, not the image-route failure. Fresh exact-source hosted application continuity frames are pending capture. Screenshots do not establish image generation, body-size behavior or authenticated handoff; the independent checks below establish their stated scopes. + +## Behavior verification + +- Official CLI0.163.0 and fictional upstreams pass109 local checks, including both image endpoints, original ChatGPT/API-key identities, exact request-body preservation, request IDs, no custom-provider credential use, unchanged conversation-route records, anonymous/browser refusal, failure sanitization and no retry. +- A17MiB conversation and a17MiB image-edit body retain their full bytes. A declared body above64MiB receives413 before contacting upstream. Incorrect media receives415. The normal reader also bounds chunked inputs. +- The authorized local Windows package passes99 self-tests and contains the reviewed self-contained runtime10.0.13. One diagnostic transparent-image generation succeeds. The originally failing chat resumes and completes two more built-in image generations and subsequent commands without another gateway/turn error during the observed interval. +- Real image-edit acceptance was not exercised. Actual desktop-picker, broader external-provider compatibility and the originally reported controller-exit cause remain in their existing issues. No new binary release is part of this PR. + +Private account data, chat text, generated product assets, local paths and raw diagnostic captures are excluded from this public evidence. Native screenshot provenance and final-head CI will be added after capture. diff --git a/src/application/ModelGateway.cs b/src/application/ModelGateway.cs index 0de431d..b09fcdc 100644 --- a/src/application/ModelGateway.cs +++ b/src/application/ModelGateway.cs @@ -19,6 +19,7 @@ namespace CodexAccountDesk; internal sealed class ModelGateway : IAsyncDisposable { + internal const int MaximumRequestBytes = 64 * 1024 * 1024; private readonly ModelProviderStore _store; private readonly Func _authenticate; private readonly HttpClient _http; @@ -93,15 +94,38 @@ private async Task ServeAsync(HttpListenerContext context) response.ContentType = "application/json"; response.Headers["Cache-Control"] = "no-store"; response.ContentLength64 = bytes.Length; await response.OutputStream.WriteAsync(bytes, cancellation).ConfigureAwait(false); return; } - if (request.HttpMethod != "POST" || path is not ("/backend-api/codex/responses" or "/backend-api/codex/responses/compact")) + var imageRequest = path is "/backend-api/codex/images/generations" or "/backend-api/codex/images/edits"; + if (request.HttpMethod != "POST" || path == null || !imageRequest && path is not ("/backend-api/codex/responses" or "/backend-api/codex/responses/compact")) { await ErrorAsync(response, 404, "This model gateway endpoint is not supported.", cancellation).ConfigureAwait(false); return; } - if (request.ContentType?.StartsWith("application/json", StringComparison.OrdinalIgnoreCase) != true || request.ContentLength64 > 16 * 1024 * 1024) - { await ErrorAsync(response, 400, "A bounded JSON Responses request is required.", cancellation).ConfigureAwait(false); return; } + if (!MediaTypeHeaderValue.TryParse(request.ContentType, out var contentType) || !string.Equals(contentType.MediaType, "application/json", StringComparison.OrdinalIgnoreCase)) + { await ErrorAsync(response, 415, "Use application/json for model and image requests.", cancellation).ConfigureAwait(false); return; } + if (request.ContentLength64 > MaximumRequestBytes) + { await ErrorAsync(response, 413, "This request exceeds the Switch gateway's 64 MiB limit. Reduce attached images or prepare a text handoff in the original chat.", cancellation).ConfigureAwait(false); return; } if (!string.IsNullOrEmpty(request.Headers["Content-Encoding"]) && request.Headers["Content-Encoding"] != "identity") { await ErrorAsync(response, 415, "Disable Codex request compression in the connection settings before using the local gateway.", cancellation).ConfigureAwait(false); return; } using var input = new StreamContent(request.InputStream); - var bytesIn = await ProviderCatalog.ReadLimitedAsync(input, 16 * 1024 * 1024, cancellation).ConfigureAwait(false); + byte[] bytesIn; + try { bytesIn = await ProviderCatalog.ReadLimitedAsync(input, MaximumRequestBytes, cancellation).ConfigureAwait(false); } + catch (InvalidDataException) + { await ErrorAsync(response, 413, "This request exceeds the Switch gateway's 64 MiB limit. Reduce attached images or prepare a text handoff in the original chat.", cancellation).ConfigureAwait(false); return; } var body = JsonNode.Parse(bytesIn) as JsonObject ?? throw new InvalidDataException("The Responses request is invalid."); + if (imageRequest) + { + using var image = new HttpRequestMessage(HttpMethod.Post, new Uri((identity.ApiKey ? "https://api.openai.com/v1/" : "https://chatgpt.com/backend-api/codex/") + path["/backend-api/codex/".Length..])); + CopyOpenAiHeaders(request, image, identity); + image.Content = new ByteArrayContent(bytesIn); image.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + using var imageResult = await _http.SendAsync(image, HttpCompletionOption.ResponseHeadersRead, cancellation).ConfigureAwait(false); + if (!imageResult.IsSuccessStatusCode) + { await ErrorAsync(response, (int)imageResult.StatusCode is >= 300 and < 400 ? 502 : (int)imageResult.StatusCode, "The OpenAI image endpoint returned HTTP " + (int)imageResult.StatusCode + ". No request was retried.", cancellation).ConfigureAwait(false); return; } + response.StatusCode = (int)imageResult.StatusCode; response.ContentType = imageResult.Content.Headers.ContentType?.ToString() ?? "application/json"; + response.Headers["Cache-Control"] = "no-store"; + foreach (var name in new[] { "x-codex-imagegen-request-id", "x-request-id" }) + if (imageResult.Headers.TryGetValues(name, out var values)) response.Headers[name] = values.FirstOrDefault(); + response.SendChunked = true; started = true; + await using var imageStream = await imageResult.Content.ReadAsStreamAsync(cancellation).ConfigureAwait(false); + await imageStream.CopyToAsync(response.OutputStream, cancellation).ConfigureAwait(false); + LastError = null; return; + } var model = body["model"]?.GetValue(); 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. diff --git a/tests/model-providers/Program.cs b/tests/model-providers/Program.cs index db3fb34..be4106e 100644 --- a/tests/model-providers/Program.cs +++ b/tests/model-providers/Program.cs @@ -92,6 +92,59 @@ async Task Send(string model, string session, JsonArray? in using (var refreshed = await client.GetAsync(manager.BaseUrl + "/models?client_version=0.163.0")) { } using (var anonymous = new HttpClient()) using (var result = await anonymous.GetAsync(manager.BaseUrl + "/models?client_version=0.163.0")) Check("Anonymous loopback requests refuse", result.StatusCode == HttpStatusCode.Unauthorized); + var routesBeforeImages = JsonSerializer.Serialize(manager.Settings.ConversationRoutes); + var fictionalImage = "{\"model\":\"gpt-image-fixture\",\"prompt\":\"�㱸 �̹���\",\"background\":\"transparent\",\"images\":[{\"image_url\":\"data:image/png;base64,Zml4dHVyZQ==\"}]}"; + foreach (var endpoint in new[] { "images/generations", "images/edits" }) + { + using var result = await client.PostAsync(manager.BaseUrl + "/" + endpoint, new StringContent(fictionalImage, Encoding.UTF8, "application/json")); + Check("Built-in image route completes: " + endpoint, result.IsSuccessStatusCode && (await result.Content.ReadAsStringAsync()).Contains("fixture-image")); + var forwarded = handler.Requests.Last(); + Check("Built-in images retain original login, exact body and endpoint: " + endpoint, forwarded.Host == "chatgpt.com" && forwarded.Path == "/backend-api/codex/" + endpoint && forwarded.Token == "fictional-openai-token" && forwarded.Account == "fictional-account" && forwarded.Body == fictionalImage); + Check("Built-in image request IDs survive forwarding: " + endpoint, result.Headers.GetValues("x-codex-imagegen-request-id").Single() == "fixture-image-request"); + } + Check("Images leave conversation provider provenance unchanged", JsonSerializer.Serialize(manager.Settings.ConversationRoutes) == routesBeforeImages); + using (var anonymous = new HttpClient()) + using (var result = await anonymous.PostAsync(manager.BaseUrl + "/images/generations", new StringContent(fictionalImage, Encoding.UTF8, "application/json"))) + Check("Anonymous image requests are refused", result.StatusCode == HttpStatusCode.Unauthorized); + using (var browser = new HttpRequestMessage(HttpMethod.Post, manager.BaseUrl + "/images/edits")) + { + browser.Headers.Add("Origin", "https://browser.example.invalid"); browser.Content = new StringContent(fictionalImage, Encoding.UTF8, "application/json"); + using var result = await client.SendAsync(browser); Check("Browser-origin image edits are refused", result.StatusCode == HttpStatusCode.Forbidden); + } + handler.ImageFailure = true; + var beforeFailedImage = handler.Requests.Count; + using (var result = await client.PostAsync(manager.BaseUrl + "/images/generations", new StringContent(fictionalImage, Encoding.UTF8, "application/json"))) + Check("Image failures are not retried or echoed with credentials", result.StatusCode == HttpStatusCode.TooManyRequests && handler.Requests.Count == beforeFailedImage + 1 && !(await result.Content.ReadAsStringAsync()).Contains("fictional-key")); + handler.ImageFailure = false; + var largeBody = "{\"model\":\"fixture-openai\",\"input\":\"" + new string('x', 17 * 1024 * 1024) + "\"}"; + using (var result = await client.PostAsync(manager.BaseUrl + "/responses", new StringContent(largeBody, Encoding.UTF8, "application/json"))) + { + await result.Content.ReadAsStringAsync(); + Check("A full-input conversation above the old 16 MiB limit is forwarded unchanged", result.IsSuccessStatusCode && handler.Requests.Last().Body == largeBody); + } + using (var result = await client.PostAsync(manager.BaseUrl + "/images/edits", new StringContent(largeBody, Encoding.UTF8, "application/json"))) + { + await result.Content.ReadAsStringAsync(); + Check("Image edit data above 16 MiB is retained by the same bounded gateway", result.IsSuccessStatusCode && handler.Requests.Last().Body == largeBody); + } + var beforeInvalidImage = handler.Requests.Count; + using (var invalid = new StringContent("{}", Encoding.UTF8, "application/jsonp")) + using (var result = await client.PostAsync(manager.BaseUrl + "/images/generations", invalid)) + Check("Invalid image media type reports 415 without contacting upstream", result.StatusCode == HttpStatusCode.UnsupportedMediaType && handler.Requests.Count == beforeInvalidImage); + using (var oversized = new TcpClient()) + { + var endpoint = new Uri(manager.BaseUrl!); await oversized.ConnectAsync(endpoint.Host, endpoint.Port); + await oversized.GetStream().WriteAsync(Encoding.ASCII.GetBytes("POST " + endpoint.AbsolutePath + "/responses HTTP/1.1\r\nHost: " + endpoint.Authority + "\r\nAuthorization: Bearer fictional-openai-token\r\nChatGPT-Account-ID: fictional-account\r\nContent-Type: application/json\r\nContent-Length: " + (ModelGateway.MaximumRequestBytes + 1L) + "\r\nConnection: close\r\n\r\n")); + using var reader = new StreamReader(oversized.GetStream()); + var refusal = await reader.ReadToEndAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Check("Oversized declared requests report the explicit 64 MiB bound before upstream", refusal.Contains("413") && refusal.Contains("64 MiB") && handler.Requests.Count == beforeInvalidImage); + } + await using (var api = new ModelProviderManager(Path.Combine(root, "api-images"), (authorization, account) => GatewayIdentity.Authenticate(authorization, account, () => "{\"OPENAI_API_KEY\":\"fictional-image-api-key\"}"), new HttpClient(handler))) + { + api.Start(); using var apiClient = new HttpClient(); apiClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "fictional-image-api-key"); + using var result = await apiClient.PostAsync(api.BaseUrl + "/images/edits", new StringContent(fictionalImage, Encoding.UTF8, "application/json")); + Check("Explicit API-key login uses the OpenAI Images API with its own identity", result.IsSuccessStatusCode && handler.Requests.Last().Host == "api.openai.com" && handler.Requests.Last().Path == "/v1/images/edits" && handler.Requests.Last().Token == "fictional-image-api-key" && handler.Requests.Last().Account == null); + } using (var request = new HttpRequestMessage(HttpMethod.Get, manager.BaseUrl + "/models?client_version=0.163.0")) { request.Headers.Add("Origin", "https://browser.example.invalid"); using var result = await client.SendAsync(request); Check("Browser-origin requests refuse", result.StatusCode == HttpStatusCode.Forbidden); } using (var result = await Send("cs_missing", "missing")) Check("Removed or unknown aliases never fall back to OpenAI", result.StatusCode == HttpStatusCode.NotFound); @@ -201,7 +254,7 @@ async Task Send(string model, string session, JsonArray? in if (Path.GetFileName(root).Length == 32 && Guid.TryParseExact(Path.GetFileName(root), "N", out _) && Directory.Exists(root)) Directory.Delete(root, true); } -internal sealed record Captured(string Method, string Host, string? Token, string? Account, string? Body); +internal sealed record Captured(string Method, string Host, string? Token, string? Account, string? Body, string Path); internal sealed class Upstreams : HttpMessageHandler { internal ConcurrentQueue Requests { get; } = new(); @@ -210,6 +263,7 @@ internal sealed class Upstreams : HttpMessageHandler internal bool FailNext; internal bool BlockDiscovery; internal bool MatchingReasoning; + internal bool ImageFailure; internal TaskCompletionSource DiscoveryEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); internal GatedStream? Slow; internal string? BlockedKey; @@ -219,7 +273,13 @@ protected override async Task SendAsync(HttpRequestMessage { var body = request.Content == null ? null : await request.Content.ReadAsStringAsync(cancellation); Requests.Enqueue(new Captured(request.Method.Method, request.RequestUri!.Host, request.Headers.Authorization?.Parameter, - request.Headers.TryGetValues("ChatGPT-Account-ID", out var ids) ? ids.Single() : null, body)); + request.Headers.TryGetValues("ChatGPT-Account-ID", out var ids) ? ids.Single() : null, body, request.RequestUri.AbsolutePath)); + if (request.RequestUri.AbsolutePath.Contains("/images/", StringComparison.Ordinal)) + { + if (ImageFailure) return new(HttpStatusCode.TooManyRequests) { Content = new StringContent("fictional-key-must-not-be-echoed") }; + var image = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent("{\"data\":[{\"b64_json\":\"fixture-image\"}]}", Encoding.UTF8, "application/json") }; + image.Headers.Add("x-codex-imagegen-request-id", "fixture-image-request"); return image; + } if (request.RequestUri.Host == "redirect.example.invalid") return new(HttpStatusCode.Found) { Headers = { Location = new Uri("https://other.example.invalid/models") } }; if (request.Method == HttpMethod.Get) { From a7c6dfcb1bb5c753f7226a29c063e52f2901690a Mon Sep 17 00:00:00 2001 From: Gaebabi <45709904+Gaebabi@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:59:17 +0900 Subject: [PATCH 05/15] [2/2 Image gateway] Record native continuity and preserve UTF-8 fixture --- docs/evidence/image-gateway/README.md | 8 +++- docs/evidence/image-gateway/after-windows.png | Bin 0 -> 55551 bytes .../evidence/image-gateway/before-windows.png | Bin 0 -> 55551 bytes docs/evidence/image-gateway/evidence.json | 45 ++++++++++++++++++ tests/model-providers/Program.cs | 2 +- 5 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 docs/evidence/image-gateway/after-windows.png create mode 100644 docs/evidence/image-gateway/before-windows.png create mode 100644 docs/evidence/image-gateway/evidence.json diff --git a/docs/evidence/image-gateway/README.md b/docs/evidence/image-gateway/README.md index e880af7..286b0be 100644 --- a/docs/evidence/image-gateway/README.md +++ b/docs/evidence/image-gateway/README.md @@ -4,7 +4,11 @@ The gateway previously returned404 for built-in `images/generations` and `images ## Application evidence -[Actual Windows application baseline](../recovery-shutdown/after.png) uses fictional accounts on a private desktop. It shows the existing Switch application, not the image-route failure. Fresh exact-source hosted application continuity frames are pending capture. Screenshots do not establish image generation, body-size behavior or authenticated handoff; the independent checks below establish their stated scopes. +| Main1.43.3 baseline | Hotfix application | +| --- | --- | +| ![Native Windows account app before hotfix](before-windows.png) | ![Native Windows account app after hotfix](after-windows.png) | + +These hosted native Windows own-window captures use fictional accounts and have identical bytes. The baseline is main `62d87ac196b71dbd2aa97faf8ebf08fbaf870998`; the after capture is implementation `07128055607e474f9bb6c156d318b5e7e54a21df`. They confirm application continuity, not the image-route failure or an authenticated handoff. The independent checks below establish image and body-size behavior. [Exact workflow/artifact/image hashes](evidence.json). ## Behavior verification @@ -13,4 +17,4 @@ The gateway previously returned404 for built-in `images/generations` and `images - The authorized local Windows package passes99 self-tests and contains the reviewed self-contained runtime10.0.13. One diagnostic transparent-image generation succeeds. The originally failing chat resumes and completes two more built-in image generations and subsequent commands without another gateway/turn error during the observed interval. - Real image-edit acceptance was not exercised. Actual desktop-picker, broader external-provider compatibility and the originally reported controller-exit cause remain in their existing issues. No new binary release is part of this PR. -Private account data, chat text, generated product assets, local paths and raw diagnostic captures are excluded from this public evidence. Native screenshot provenance and final-head CI will be added after capture. +Private account data, chat text, generated product assets, local paths and raw diagnostic captures are excluded from this public evidence. Capture [Verify41282507176](https://github.com/Gaebabi/codex-switch/actions/runs/41282507176) supplies the after frame; [Verify41227072128](https://github.com/Gaebabi/codex-switch/actions/runs/41227072128) supplies the main baseline. Final-head protected CI remains required after this evidence-only update and the restored UTF-8 test-fixture text; production gateway bytes are unchanged. diff --git a/docs/evidence/image-gateway/after-windows.png b/docs/evidence/image-gateway/after-windows.png new file mode 100644 index 0000000000000000000000000000000000000000..8442e9a06223db47a5700d02e53526817fc388cc GIT binary patch literal 55551 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 Send(string model, string session, JsonArray? in using (var anonymous = new HttpClient()) using (var result = await anonymous.GetAsync(manager.BaseUrl + "/models?client_version=0.163.0")) Check("Anonymous loopback requests refuse", result.StatusCode == HttpStatusCode.Unauthorized); var routesBeforeImages = JsonSerializer.Serialize(manager.Settings.ConversationRoutes); - var fictionalImage = "{\"model\":\"gpt-image-fixture\",\"prompt\":\"�㱸 �̹���\",\"background\":\"transparent\",\"images\":[{\"image_url\":\"data:image/png;base64,Zml4dHVyZQ==\"}]}"; + var fictionalImage = "{\"model\":\"gpt-image-fixture\",\"prompt\":\"허구 이미지\",\"background\":\"transparent\",\"images\":[{\"image_url\":\"data:image/png;base64,Zml4dHVyZQ==\"}]}"; foreach (var endpoint in new[] { "images/generations", "images/edits" }) { using var result = await client.PostAsync(manager.BaseUrl + "/" + endpoint, new StringContent(fictionalImage, Encoding.UTF8, "application/json")); From 504b2ddf18195cdf7294bacc181b71d1de84f1b5 Mon Sep 17 00:00:00 2001 From: Gaebabi <45709904+Gaebabi@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:01:51 +0900 Subject: [PATCH 06/15] [1/2 Gateway payload] Preserve image-heavy chats above 64 MiB (#147) --- docs/MODEL_PROVIDERS.md | 4 +- docs/development/OPERATIONS.md | 2 +- docs/development/PLAN.md | 2 + docs/development/STATUS.md | 2 + .../evidence/gateway-payload-budget/README.md | 20 +++++ .../gateway-payload-budget/before-windows.png | Bin 0 -> 55551 bytes src/application/ModelGateway.cs | 77 ++++++++++++++---- tests/model-providers/Program.cs | 65 ++++++++++++++- 8 files changed, 148 insertions(+), 24 deletions(-) create mode 100644 docs/evidence/gateway-payload-budget/README.md create mode 100644 docs/evidence/gateway-payload-budget/before-windows.png diff --git a/docs/MODEL_PROVIDERS.md b/docs/MODEL_PROVIDERS.md index dc54eee..1ff4468 100644 --- a/docs/MODEL_PROVIDERS.md +++ b/docs/MODEL_PROVIDERS.md @@ -34,7 +34,7 @@ For a model absent from that catalog or a route with different support, expand * 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. -Switch supports at most 32 providers and 128 selected models per provider. Discovery reads are bounded at 4MiB; the complete Codex-facing catalog must fit the client's 1MiB limit. Full-input conversation and image requests are bounded at 64MiB. Larger requests report an explicit HTTP413 error; unsupported media types report HTTP415. Select fewer model IDs when necessary. A discovery result alone does not certify the provider's tool or conversation compatibility. +Switch supports at most 32 providers and 128 selected models per provider. Discovery reads are bounded at 4MiB; the complete Codex-facing catalog must fit the client's 1MiB limit. Full-input conversation and image requests are bounded at 512MiB, including chunked bodies. At most two bodies larger than16MiB or of unknown length are buffered concurrently; additional large requests report503 before allocation. Unchanged OpenAI requests reuse the original buffer without a mutable JSON tree or a second body copy. Larger requests report an explicit HTTP413 error; unsupported media types report HTTP415. Upstream limits still apply. Select fewer model IDs when necessary. A discovery result alone does not certify the provider's tool or conversation compatibility. 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. @@ -44,6 +44,8 @@ Built-in image generation and editing use the original OpenAI sign-in through `i The image-route and 64MiB corrections are source hotfixes after release1.43.3. Older packages can report an unsupported-endpoint404 for the built-in image tool, or `A bounded JSON Responses request is required` when a full-input conversation exceeds16MiB. The hotfix does not remove conversation history to make it fit. [Verification and delivery scope](evidence/image-gateway/README.md). +A subsequent source correction raises the64MiB intermediary limit to512MiB because an image-heavy full-input chat can exceed it. The [OpenAI image input guide](https://developers.openai.com/api/docs/guides/images-vision?api-mode=responses) documents up to512MB total payload for the public API; authenticated Codex and custom upstream acceptance must be verified separately. This change preserves image data and encrypted history and does not silently compact or discard them. + 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. diff --git a/docs/development/OPERATIONS.md b/docs/development/OPERATIONS.md index c16dd7a..5168837 100644 --- a/docs/development/OPERATIONS.md +++ b/docs/development/OPERATIONS.md @@ -2,7 +2,7 @@ Built-in image generation requires `images/generations` and `images/edits` in addition to Responses/model discovery. The gateway forwards these JSON requests only with the original OpenAI identity and preserves the body and safe request IDs; custom text aliases and saved custom-provider keys do not select the image route. Browser/loopback authentication, redirect refusal and no-retry behavior still apply. -Conversation and image request bodies are bounded at64MiB, including chunked bodies. An oversized request reports HTTP413 with the limit; unsupported media types report415. Older builds combined the16MiB and JSON checks into `A bounded JSON Responses request is required`, so a growing full-input chat could fail after a successful tool call. Preserve that chat and update the gateway before retrying. [Hotfix evidence](../evidence/image-gateway/README.md). +Conversation and image request bodies are bounded at512MiB, including chunked bodies. The prior64MiB hotfix can still reject image-heavy full-input chats before reaching OpenAI. Buffer large or unknown-length requests at most two at a time, reject additional large requests with503 before allocation, and reuse the original buffer for unchanged OpenAI forwarding. An oversized request reports HTTP413 with the limit; unsupported media types report415. Older builds combined the16MiB and JSON checks into `A bounded JSON Responses request is required`. Preserve the chat and update the gateway before retrying; never remove its history to bypass a size refusal. The upstream can impose its own limits. [Earlier hotfix evidence](../evidence/image-gateway/README.md). Model-provider instruction attribution is included in published package `licenses/`. Mac app bundles place those text files in `Contents/Resources/licenses` before signing; Windows and Linux retain the publish directory layout. Native package checks compare the license and notice with the source and verify the complete signed bundle. diff --git a/docs/development/PLAN.md b/docs/development/PLAN.md index 1773449..caf7826 100644 --- a/docs/development/PLAN.md +++ b/docs/development/PLAN.md @@ -1,5 +1,7 @@ # Current plan +Correct the remaining64MiB intermediary refusal for image-heavy conversations with a512MiB bounded body reader, limited concurrent large buffers and original-buffer forwarding. Verify declared and chunked requests, exact bounds, cancellation and unchanged history/authentication before local installation and authorized chat resumption. Public delivery remains a separate evidence state. + Integrate the post1.43.3 image-route and large-request hotfix with focused gateway regressions and native application continuity evidence. Preserve original OpenAI image authentication, custom-provider key separation, complete chat inputs and bounded memory use. Require the six protected final-head checks and recorded review before merge. This source update does not publish a new binary release. [Verification](../evidence/image-gateway/README.md). The current source work covers [safe handoff recovery and shutdown](https://github.com/Gaebabi/codex-switch/issues/141) and [configurable model providers](https://github.com/Gaebabi/codex-switch/issues/138). Complete protected CI and native application evidence before merge. Actual desktop picker behavior, external provider compatibility and the originally reported controller-exit cause remain separate acceptance items. diff --git a/docs/development/STATUS.md b/docs/development/STATUS.md index d94e50c..490e4df 100644 --- a/docs/development/STATUS.md +++ b/docs/development/STATUS.md @@ -1,5 +1,7 @@ # Current status +The gateway payload-budget follow-up is in local verification: the64MiB limit still interrupted an image-heavy chat, so source now accepts bounded512MiB bodies and limits concurrent large buffers to two. Unchanged OpenAI inputs reuse the owned request buffer without a mutable JSON tree or ToArray copy. Regression, installed package and actual chat resumption evidence are recorded separately; the earlier109/99 results below do not validate this follow-up. + The post1.43.3 image-gateway hotfix forwards built-in image generation/edit JSON endpoints through the original OpenAI identity and raises the bounded conversation/image body limit from16MiB to64MiB. Size and media failures report413 and415 separately. Local official-client/fictional-upstream verification passes109 checks; an authorized local installation passes99 self-tests, actual built-in generation and continuation of a previously failing chat. External image-edit acceptance and a new public binary release remain separate. [Evidence and limitations](../evidence/image-gateway/README.md). Release1.43.3 packages the merged provider gateway, history presentation and handoff protection. [Issue141](https://github.com/Gaebabi/codex-switch/issues/141) remains open because the reported controller exit has not been reproduced. [Issue138](https://github.com/Gaebabi/codex-switch/issues/138) retains actual native picker and external-provider compatibility acceptance. Release preparation requires exact-head CI, review and public asset verification; the release link becomes current only after publication. diff --git a/docs/evidence/gateway-payload-budget/README.md b/docs/evidence/gateway-payload-budget/README.md new file mode 100644 index 0000000..1358a15 --- /dev/null +++ b/docs/evidence/gateway-payload-budget/README.md @@ -0,0 +1,20 @@ +# Image-heavy chat payloads + +After the image-route hotfix, an image-heavy full-input conversation still received a Switch-generated413 before reaching its original OpenAI upstream. The64MiB intermediary limit was smaller than the accumulated input. The follow-up accepts bounded512MiB bodies, preserves the complete input and reuses the original buffer for unchanged OpenAI requests. + +## Application evidence + +| Current-main baseline | Follow-up application | +| --- | --- | +| ![Native Windows account app before the payload correction](before-windows.png) | Pending hosted capture of this implementation. | + +The baseline is an actual hosted Windows own-window capture with fictional accounts at main `d4725801fd21f9b57a9bcce882f78a1602efebe9`, from [Verify41284301154](https://github.com/Gaebabi/codex-switch/actions/runs/41284301154), artifact13074960359. Its original SHA256 is `320049d86e415b3991ed0525e353b855d15949d0f76f1f45bf6c66044e4b9ee8`; dimensions800×560. It confirms the unchanged account application baseline, not the payload error. The after capture remains pending until this implementation's hosted native run completes. Gateway regressions and private authorized resumption provide separate behavior evidence. + +## Verification + +- Local Windows official CLI0.163.0 and fictional-upstream verification passes120 checks. A65MiB full-input Responses request, chunked compaction and image edit retain their entire body. Declared sizes above512MiB refuse before upstream. Exact-bound/overflow/cancellation cases use the same reader with a small test bound. +- Two incomplete chunked uploads occupy the large-body admission slots; a third receives503 before upstream while a small request remains usable. Duplicate routing/history fields, an invalid model type and a non-object request refuse before inference. Original provider/account history guards remain in the suite. +- The initial authorized local payload package passes99 self-tests with self-contained runtime10.0.13. An existing chat previously blocked at64MiB resumes, returns assistant text and completes subsequent commands. Its product work is still in progress. The final strict-JSON package is verified separately before installation. +- No owner Codex restart, account switch, deleted image/history, implicit compaction or new public binary release is part of this correction. Source, local installation and protected-CI delivery remain separate evidence states. + +The [OpenAI image input guide](https://developers.openai.com/api/docs/guides/images-vision?api-mode=responses) documents up to512MB total payload for the public API. This does not guarantee the same limit on every authenticated Codex/custom upstream. Payloads above Switch's512MiB bound still fail explicitly. Private transcripts, account data, generated product assets and raw local diagnostics are excluded from public evidence. diff --git a/docs/evidence/gateway-payload-budget/before-windows.png b/docs/evidence/gateway-payload-budget/before-windows.png new file mode 100644 index 0000000000000000000000000000000000000000..8442e9a06223db47a5700d02e53526817fc388cc GIT binary patch literal 55551 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 _authenticate; private readonly HttpClient _http; @@ -27,6 +29,7 @@ internal sealed class ModelGateway : IAsyncDisposable private readonly CancellationTokenSource _stop = new(); private readonly ConcurrentDictionary _requests = new(); private readonly SemaphoreSlim _slots = new(16, 16); + private readonly SemaphoreSlim _largeBodies = new(2, 2); private Task? _accept; private int _nextRequest; private sealed record NativeCatalog(Dictionary Models, DateTimeOffset Expires); @@ -73,7 +76,7 @@ private async Task AcceptAsync() } private async Task ServeAsync(HttpListenerContext context) { - var started = false; + var started = false; var largeBody = false; using var deadline = CancellationTokenSource.CreateLinkedTokenSource(_stop.Token); deadline.CancelAfter(TimeSpan.FromMinutes(30)); var cancellation = deadline.Token; try @@ -100,20 +103,31 @@ private async Task ServeAsync(HttpListenerContext context) if (!MediaTypeHeaderValue.TryParse(request.ContentType, out var contentType) || !string.Equals(contentType.MediaType, "application/json", StringComparison.OrdinalIgnoreCase)) { await ErrorAsync(response, 415, "Use application/json for model and image requests.", cancellation).ConfigureAwait(false); return; } if (request.ContentLength64 > MaximumRequestBytes) - { await ErrorAsync(response, 413, "This request exceeds the Switch gateway's 64 MiB limit. Reduce attached images or prepare a text handoff in the original chat.", cancellation).ConfigureAwait(false); return; } + { await ErrorAsync(response, 413, RequestTooLarge, cancellation).ConfigureAwait(false); return; } if (!string.IsNullOrEmpty(request.Headers["Content-Encoding"]) && request.Headers["Content-Encoding"] != "identity") { await ErrorAsync(response, 415, "Disable Codex request compression in the connection settings before using the local gateway.", cancellation).ConfigureAwait(false); return; } - using var input = new StreamContent(request.InputStream); - byte[] bytesIn; - try { bytesIn = await ProviderCatalog.ReadLimitedAsync(input, MaximumRequestBytes, cancellation).ConfigureAwait(false); } + // Unknown-length bodies can also be large. Admit at most two before allocating buffers. + if (request.ContentLength64 < 0 || request.ContentLength64 > SmallRequestBytes) + { + largeBody = _largeBodies.Wait(0); + if (!largeBody) { await ErrorAsync(response, 503, "The local model gateway is processing other large requests. Try again when they finish.", cancellation).ConfigureAwait(false); return; } + } + MemoryStream input; + try { input = await ReadBodyAsync(request.InputStream, request.ContentLength64, MaximumRequestBytes, cancellation).ConfigureAwait(false); } catch (InvalidDataException) - { await ErrorAsync(response, 413, "This request exceeds the Switch gateway's 64 MiB limit. Reduce attached images or prepare a text handoff in the original chat.", cancellation).ConfigureAwait(false); return; } - var body = JsonNode.Parse(bytesIn) as JsonObject ?? throw new InvalidDataException("The Responses request is invalid."); + { await ErrorAsync(response, 413, RequestTooLarge, cancellation).ConfigureAwait(false); return; } + using var bufferedInput = input; + // Parse the owned buffer without ToArray or a mutable JSON tree for unchanged requests. + var bytesIn = input.GetBuffer().AsMemory(0, checked((int)input.Length)); + using var document = JsonDocument.Parse(bytesIn, new JsonDocumentOptions { AllowDuplicateProperties = false }); + var original = document.RootElement; + if (original.ValueKind != JsonValueKind.Object) throw new InvalidDataException("The Responses request is invalid."); + JsonObject? body = null; if (imageRequest) { using var image = new HttpRequestMessage(HttpMethod.Post, new Uri((identity.ApiKey ? "https://api.openai.com/v1/" : "https://chatgpt.com/backend-api/codex/") + path["/backend-api/codex/".Length..])); CopyOpenAiHeaders(request, image, identity); - image.Content = new ByteArrayContent(bytesIn); image.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + image.Content = new ByteArrayContent(input.GetBuffer(), 0, bytesIn.Length); image.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); using var imageResult = await _http.SendAsync(image, HttpCompletionOption.ResponseHeadersRead, cancellation).ConfigureAwait(false); if (!imageResult.IsSuccessStatusCode) { await ErrorAsync(response, (int)imageResult.StatusCode is >= 300 and < 400 ? 502 : (int)imageResult.StatusCode, "The OpenAI image endpoint returned HTTP " + (int)imageResult.StatusCode + ". No request was retried.", cancellation).ConfigureAwait(false); return; } @@ -126,7 +140,7 @@ private async Task ServeAsync(HttpListenerContext context) await imageStream.CopyToAsync(response.OutputStream, cancellation).ConfigureAwait(false); LastError = null; return; } - var model = body["model"]?.GetValue(); + var model = original.TryGetProperty("model", out var selected) && selected.ValueKind == JsonValueKind.String ? selected.GetString() : null; 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; @@ -137,22 +151,32 @@ private async Task ServeAsync(HttpListenerContext context) { 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. - if (body["previous_response_id"] != null || body["conversation"] != null) + if (original.TryGetProperty("previous_response_id", out var previous) && previous.ValueKind != JsonValueKind.Null || + original.TryGetProperty("conversation", out var conversation) && conversation.ValueKind != JsonValueKind.Null) { await ErrorAsync(response, 400, "Use full-input HTTP Responses replay; provider-specific response references cannot be routed safely.", cancellation).ConfigureAwait(false); return; } var changedBody = false; var routeIdentity = route.Provider == null ? "openai" : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(route.Provider.Id + "\n" + route.Provider.BaseUrl + "\n" + route.Provider.ApiKey))); var portableHistory = _store.RequiresPortableHistory(request.Headers["session_id"] ?? request.Headers["conversation_id"], identity, routeIdentity); - if (portableHistory && body["input"] is JsonArray items) + if (portableHistory && original.TryGetProperty("input", out var originalItems) && originalItems.ValueKind == JsonValueKind.Array) { - if (items.Any(item => item is JsonObject obj && obj["type"]?.GetValue() == "compaction" && obj["encrypted_content"] != null)) + bool Encrypted(JsonElement item, string type) => item.ValueKind == JsonValueKind.Object && + item.TryGetProperty("type", out var kind) && kind.ValueKind == JsonValueKind.String && kind.GetString() == type && + item.TryGetProperty("encrypted_content", out var encrypted) && encrypted.ValueKind != JsonValueKind.Null; + if (originalItems.EnumerateArray().Any(item => Encrypted(item, "compaction"))) { await ErrorAsync(response, 400, ModelHistoryHandoff.Refusal, cancellation).ConfigureAwait(false); return; } - for (var i = items.Count - 1; i >= 0; i--) - if (items[i] is JsonObject obj && obj["type"]?.GetValue() == "reasoning" && obj["encrypted_content"] != null) { items.RemoveAt(i); changedBody = true; } + if (originalItems.EnumerateArray().Any(item => Encrypted(item, "reasoning"))) + { + body = JsonNode.Parse(bytesIn.Span)!.AsObject(); + var items = body["input"]!.AsArray(); + for (var i = items.Count - 1; i >= 0; i--) + if (items[i] is JsonObject obj && obj["type"]?.GetValue() == "reasoning" && obj["encrypted_content"] != null) { items.RemoveAt(i); changedBody = true; } + } } Uri destination; if (route.Provider != null) { changedBody = true; + body ??= JsonNode.Parse(bytesIn.Span)!.AsObject(); body["model"] = route.Model.UpstreamId; body.Remove("prompt_cache_key"); JsonObject? native = null; @@ -171,7 +195,8 @@ private async Task ServeAsync(HttpListenerContext context) using 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 = new ByteArrayContent(changedBody ? JsonSerializer.SerializeToUtf8Bytes(body) : bytesIn); upstream.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + upstream.Content = changedBody ? new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(body)) : new ByteArrayContent(input.GetBuffer(), 0, bytesIn.Length); + upstream.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); using var result = await _http.SendAsync(upstream, HttpCompletionOption.ResponseHeadersRead, cancellation).ConfigureAwait(false); if (!result.IsSuccessStatusCode) { await ErrorAsync(response, (int)result.StatusCode is >= 300 and < 400 ? 502 : (int)result.StatusCode, "The selected upstream returned HTTP " + (int)result.StatusCode + ". No request was retried.", cancellation).ConfigureAwait(false); return; } @@ -195,7 +220,23 @@ private async Task ServeAsync(HttpListenerContext context) catch (Exception transport) when (transport is IOException or HttpListenerException or OperationCanceledException or ObjectDisposedException) { context.Response.Abort(); } } } - finally { context.Response.Close(); } + finally { if (largeBody) _largeBodies.Release(); context.Response.Close(); } + } + internal static async Task ReadBodyAsync(Stream source, long contentLength, int maximum, CancellationToken cancellation) + { + if (contentLength > maximum) throw new InvalidDataException("The model request is too large."); + var target = new MemoryStream(contentLength >= 0 ? checked((int)contentLength) : Math.Min(maximum, 64 * 1024)); + try + { + var buffer = new byte[64 * 1024]; int count; + while ((count = await source.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, maximum - target.Length + 1)), cancellation).ConfigureAwait(false)) > 0) + { + if (target.Length + count > maximum) throw new InvalidDataException("The model request is too large."); + target.Write(buffer, 0, count); + } + target.Position = 0; return target; + } + catch { target.Dispose(); throw; } } private async Task CatalogAsync(GatewayIdentity identity, HttpListenerRequest incoming, CancellationToken cancellation, string? clientVersion = null) { @@ -273,6 +314,6 @@ public async ValueTask DisposeAsync() _stop.Cancel(); _listener.Close(); if (_accept != null) await _accept.ConfigureAwait(false); await Task.WhenAll(_requests.Values).ConfigureAwait(false); - _stop.Dispose(); _slots.Dispose(); + _stop.Dispose(); _slots.Dispose(); _largeBodies.Dispose(); } } diff --git a/tests/model-providers/Program.cs b/tests/model-providers/Program.cs index 36b7925..81c4787 100644 --- a/tests/model-providers/Program.cs +++ b/tests/model-providers/Program.cs @@ -11,6 +11,31 @@ void Check(string name, bool value) { if (!value) throw new Exception(name); checks.Add(name); } Check("Full chat endpoint normalizes to the API base URL", ProviderCatalog.Endpoint("https://openrouter.ai/api/v1/chat/completions").AbsoluteUri == "https://openrouter.ai/api/v1/"); Check("Responses and model-list endpoints normalize without changing their authority", ProviderCatalog.Endpoint("https://example.invalid/proxy/v1/responses/").AbsoluteUri == "https://example.invalid/proxy/v1/" && ProviderCatalog.Endpoint("https://example.invalid/v1/models").AbsoluteUri == "https://example.invalid/v1/"); +using (var source = new MemoryStream(Encoding.UTF8.GetBytes("12345678"))) +using (var exact = await ModelGateway.ReadBodyAsync(source, -1, 8, CancellationToken.None)) + Check("An unknown-length body at the exact bound is retained without closing its source", exact.Length == 8 && source.CanRead && Encoding.UTF8.GetString(exact.GetBuffer(), 0, (int)exact.Length) == "12345678"); +using (var source = new MemoryStream(new byte[9])) +{ + var oversized = false; + try { using var unexpected = await ModelGateway.ReadBodyAsync(source, -1, 8, CancellationToken.None); } + catch (InvalidDataException) { oversized = true; } + Check("An unknown-length body is rejected at the first byte above its bound", oversized && source.Position == 9); +} +using (var source = new MemoryStream(new byte[9])) +{ + var oversized = false; + try { using var unexpected = await ModelGateway.ReadBodyAsync(source, 9, 8, CancellationToken.None); } + catch (InvalidDataException) { oversized = true; } + Check("An oversized declared body is rejected without reading its source", oversized && source.Position == 0); +} +using (var cancelled = new CancellationTokenSource()) +using (var source = new MemoryStream(new byte[8])) +{ + cancelled.Cancel(); var stopped = false; + try { using var unexpected = await ModelGateway.ReadBodyAsync(source, 8, 8, cancelled.Token); } + catch (OperationCanceledException) { stopped = true; } + Check("Request buffering honors cancellation", stopped); +} async Task Refuses(string name, Func action) { try { await action(); } catch (Exception e) when (e is ArgumentException or InvalidOperationException or IOException or InvalidDataException) { Check(name, true); return; } throw new Exception(name); } var root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Temp", "CodexSwitchModelProviders", Guid.NewGuid().ToString("N")); @@ -116,18 +141,30 @@ async Task Send(string model, string session, JsonArray? in using (var result = await client.PostAsync(manager.BaseUrl + "/images/generations", new StringContent(fictionalImage, Encoding.UTF8, "application/json"))) Check("Image failures are not retried or echoed with credentials", result.StatusCode == HttpStatusCode.TooManyRequests && handler.Requests.Count == beforeFailedImage + 1 && !(await result.Content.ReadAsStringAsync()).Contains("fictional-key")); handler.ImageFailure = false; - var largeBody = "{\"model\":\"fixture-openai\",\"input\":\"" + new string('x', 17 * 1024 * 1024) + "\"}"; + var largeBody = "{\"model\":\"fixture-openai\",\"input\":\"" + new string('x', 65 * 1024 * 1024) + "\"}"; using (var result = await client.PostAsync(manager.BaseUrl + "/responses", new StringContent(largeBody, Encoding.UTF8, "application/json"))) { await result.Content.ReadAsStringAsync(); - Check("A full-input conversation above the old 16 MiB limit is forwarded unchanged", result.IsSuccessStatusCode && handler.Requests.Last().Body == largeBody); + Check("A full-input conversation above 64 MiB is forwarded unchanged", result.IsSuccessStatusCode && handler.Requests.Last().Body == largeBody); + } + using (var chunked = new HttpRequestMessage(HttpMethod.Post, manager.BaseUrl + "/responses/compact")) + { + chunked.Headers.TransferEncodingChunked = true; + chunked.Content = new StringContent(largeBody, Encoding.UTF8, "application/json"); + using var result = await client.SendAsync(chunked); await result.Content.ReadAsStringAsync(); + Check("Chunked compaction above 64 MiB preserves the entire original input", result.IsSuccessStatusCode && handler.Requests.Last().Body == largeBody && handler.Requests.Last().Path.EndsWith("/compact")); } using (var result = await client.PostAsync(manager.BaseUrl + "/images/edits", new StringContent(largeBody, Encoding.UTF8, "application/json"))) { await result.Content.ReadAsStringAsync(); - Check("Image edit data above 16 MiB is retained by the same bounded gateway", result.IsSuccessStatusCode && handler.Requests.Last().Body == largeBody); + Check("Image edit data above 64 MiB is retained by the same bounded gateway", result.IsSuccessStatusCode && handler.Requests.Last().Body == largeBody); } var beforeInvalidImage = handler.Requests.Count; + foreach (var invalidJson in new[] { "{\"model\":\"fixture-openai\",\"model\":\"cs_missing\"}", "{\"model\":false}", "[1,2]", "{\"model\":\"fixture-openai\",\"input\":[],\"input\":[{\"type\":\"compaction\",\"encrypted_content\":\"opaque\"}]}" }) + { + using var result = await client.PostAsync(manager.BaseUrl + "/responses", new StringContent(invalidJson, Encoding.UTF8, "application/json")); + Check("Ambiguous or malformed request JSON refuses before upstream: " + invalidJson, result.StatusCode == HttpStatusCode.BadRequest && handler.Requests.Count == beforeInvalidImage); + } using (var invalid = new StringContent("{}", Encoding.UTF8, "application/jsonp")) using (var result = await client.PostAsync(manager.BaseUrl + "/images/generations", invalid)) Check("Invalid image media type reports 415 without contacting upstream", result.StatusCode == HttpStatusCode.UnsupportedMediaType && handler.Requests.Count == beforeInvalidImage); @@ -137,7 +174,27 @@ async Task Send(string model, string session, JsonArray? in await oversized.GetStream().WriteAsync(Encoding.ASCII.GetBytes("POST " + endpoint.AbsolutePath + "/responses HTTP/1.1\r\nHost: " + endpoint.Authority + "\r\nAuthorization: Bearer fictional-openai-token\r\nChatGPT-Account-ID: fictional-account\r\nContent-Type: application/json\r\nContent-Length: " + (ModelGateway.MaximumRequestBytes + 1L) + "\r\nConnection: close\r\n\r\n")); using var reader = new StreamReader(oversized.GetStream()); var refusal = await reader.ReadToEndAsync().WaitAsync(TimeSpan.FromSeconds(5)); - Check("Oversized declared requests report the explicit 64 MiB bound before upstream", refusal.Contains("413") && refusal.Contains("64 MiB") && handler.Requests.Count == beforeInvalidImage); + Check("Oversized declared requests report the explicit 512 MiB bound before upstream", refusal.Contains("413") && refusal.Contains("512 MiB") && handler.Requests.Count == beforeInvalidImage); + } + // Hold two incomplete chunked uploads without allocating large test payloads. + // A successful small request orders acceptance before the third large upload. + using (var first = new TcpClient()) + using (var second = new TcpClient()) + { + var endpoint = new Uri(manager.BaseUrl!); + foreach (var pending in new[] { first, second }) + { + await pending.ConnectAsync(endpoint.Host, endpoint.Port); + await pending.GetStream().WriteAsync(Encoding.ASCII.GetBytes("POST " + endpoint.AbsolutePath + "/responses HTTP/1.1\r\nHost: " + endpoint.Authority + "\r\nAuthorization: Bearer fictional-openai-token\r\nChatGPT-Account-ID: fictional-account\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n1\r\n{\r\n")); + } + using (var small = await Send("fixture-openai", "large-admission-small")) + Check("Small requests remain usable while two unknown-length uploads are pending", small.IsSuccessStatusCode); + using var third = new TcpClient(); await third.ConnectAsync(endpoint.Host, endpoint.Port); + var beforeBusy = handler.Requests.Count; + await third.GetStream().WriteAsync(Encoding.ASCII.GetBytes("POST " + endpoint.AbsolutePath + "/responses HTTP/1.1\r\nHost: " + endpoint.Authority + "\r\nAuthorization: Bearer fictional-openai-token\r\nChatGPT-Account-ID: fictional-account\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n")); + using var reader = new StreamReader(third.GetStream()); + var busy = await reader.ReadToEndAsync().WaitAsync(TimeSpan.FromSeconds(5)); + Check("A third large upload refuses before buffering or contacting upstream", busy.Contains("503") && handler.Requests.Count == beforeBusy); } await using (var api = new ModelProviderManager(Path.Combine(root, "api-images"), (authorization, account) => GatewayIdentity.Authenticate(authorization, account, () => "{\"OPENAI_API_KEY\":\"fictional-image-api-key\"}"), new HttpClient(handler))) { From 1397adbcc15f772f09c5b198d92da0c425328534 Mon Sep 17 00:00:00 2001 From: Gaebabi <45709904+Gaebabi@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:13:45 +0900 Subject: [PATCH 07/15] [2/2 Gateway payload] Release uploaded bodies during response streaming --- docs/MODEL_PROVIDERS.md | 2 + .../evidence/gateway-payload-budget/README.md | 9 ++-- .../gateway-payload-budget/after-windows.png | Bin 0 -> 55551 bytes .../gateway-payload-budget/evidence.json | 39 ++++++++++++++++++ src/application/ModelGateway.cs | 11 +++++ tests/model-providers/Program.cs | 17 ++++++++ 6 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 docs/evidence/gateway-payload-budget/after-windows.png create mode 100644 docs/evidence/gateway-payload-budget/evidence.json diff --git a/docs/MODEL_PROVIDERS.md b/docs/MODEL_PROVIDERS.md index 1ff4468..65386bc 100644 --- a/docs/MODEL_PROVIDERS.md +++ b/docs/MODEL_PROVIDERS.md @@ -46,6 +46,8 @@ The image-route and 64MiB corrections are source hotfixes after release1.43.3. O A subsequent source correction raises the64MiB intermediary limit to512MiB because an image-heavy full-input chat can exceed it. The [OpenAI image input guide](https://developers.openai.com/api/docs/guides/images-vision?api-mode=responses) documents up to512MB total payload for the public API; authenticated Codex and custom upstream acceptance must be verified separately. This change preserves image data and encrypted history and does not silently compact or discard them. +The large-upload admission slot and request buffers are released after upstream response headers arrive, so ongoing response streams do not occupy large-upload capacity. + 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. diff --git a/docs/evidence/gateway-payload-budget/README.md b/docs/evidence/gateway-payload-budget/README.md index 1358a15..94d880a 100644 --- a/docs/evidence/gateway-payload-budget/README.md +++ b/docs/evidence/gateway-payload-budget/README.md @@ -6,15 +6,16 @@ After the image-route hotfix, an image-heavy full-input conversation still recei | Current-main baseline | Follow-up application | | --- | --- | -| ![Native Windows account app before the payload correction](before-windows.png) | Pending hosted capture of this implementation. | +| ![Native Windows account app before the payload correction](before-windows.png) | ![Native Windows account app after the payload correction](after-windows.png) | -The baseline is an actual hosted Windows own-window capture with fictional accounts at main `d4725801fd21f9b57a9bcce882f78a1602efebe9`, from [Verify41284301154](https://github.com/Gaebabi/codex-switch/actions/runs/41284301154), artifact13074960359. Its original SHA256 is `320049d86e415b3991ed0525e353b855d15949d0f76f1f45bf6c66044e4b9ee8`; dimensions800×560. It confirms the unchanged account application baseline, not the payload error. The after capture remains pending until this implementation's hosted native run completes. Gateway regressions and private authorized resumption provide separate behavior evidence. +These are actual hosted Windows own-window captures with fictional accounts. The baseline is main `d4725801fd21f9b57a9bcce882f78a1602efebe9`, [Verify41284301154](https://github.com/Gaebabi/codex-switch/actions/runs/41284301154), artifact13074960359. The implementation is `504b2ddf18195cdf7294bacc181b71d1de84f1b5`, [Verify41300103733](https://github.com/Gaebabi/codex-switch/actions/runs/41300103733), artifact13081495441. Both original800×560 images have SHA256 `320049d86e415b3991ed0525e353b855d15949d0f76f1f45bf6c66044e4b9ee8`. They confirm unchanged account application continuity, not the payload error. The final follow-up also releases upload buffers after response headers; final-head native/CI checks remain required. [Source/artifact-bound evidence](evidence.json). Gateway regressions and private authorized resumption provide separate behavior evidence. ## Verification -- Local Windows official CLI0.163.0 and fictional-upstream verification passes120 checks. A65MiB full-input Responses request, chunked compaction and image edit retain their entire body. Declared sizes above512MiB refuse before upstream. Exact-bound/overflow/cancellation cases use the same reader with a small test bound. +- Local Windows official CLI0.163.0 and fictional-upstream verification passes121 checks. A65MiB full-input Responses request, chunked compaction and image edit retain their entire body. Declared sizes above512MiB refuse before upstream. Exact-bound/overflow/cancellation cases use the same reader with a small test bound. - Two incomplete chunked uploads occupy the large-body admission slots; a third receives503 before upstream while a small request remains usable. Duplicate routing/history fields, an invalid model type and a non-object request refuse before inference. Original provider/account history guards remain in the suite. -- The initial authorized local payload package passes99 self-tests with self-contained runtime10.0.13. An existing chat previously blocked at64MiB resumes, returns assistant text and completes subsequent commands. Its product work is still in progress. The final strict-JSON package is verified separately before installation. +- Completed large uploads release their buffers and admission slots at response headers, allowing another large upload while two earlier response streams remain open. +- Both the initial and final local payload packages pass99 self-tests with self-contained runtime10.0.13. An existing chat previously blocked at64MiB resumes, returns assistant text and completes subsequent commands. Its product work is still in progress. Final installed-process/state preservation is verified separately after an idle gateway handoff. - No owner Codex restart, account switch, deleted image/history, implicit compaction or new public binary release is part of this correction. Source, local installation and protected-CI delivery remain separate evidence states. The [OpenAI image input guide](https://developers.openai.com/api/docs/guides/images-vision?api-mode=responses) documents up to512MB total payload for the public API. This does not guarantee the same limit on every authenticated Codex/custom upstream. Payloads above Switch's512MiB bound still fail explicitly. Private transcripts, account data, generated product assets and raw local diagnostics are excluded from public evidence. diff --git a/docs/evidence/gateway-payload-budget/after-windows.png b/docs/evidence/gateway-payload-budget/after-windows.png new file mode 100644 index 0000000000000000000000000000000000000000..8442e9a06223db47a5700d02e53526817fc388cc GIT binary patch literal 55551 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 300 and < 400 ? 502 : (int)imageResult.StatusCode, "The OpenAI image endpoint returned HTTP " + (int)imageResult.StatusCode + ". No request was retried.", cancellation).ConfigureAwait(false); return; } response.StatusCode = (int)imageResult.StatusCode; response.ContentType = imageResult.Content.Headers.ContentType?.ToString() ?? "application/json"; @@ -198,6 +208,7 @@ bool Encrypted(JsonElement item, string type) => item.ValueKind == JsonValueKind upstream.Content = changedBody ? new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(body)) : new ByteArrayContent(input.GetBuffer(), 0, bytesIn.Length); upstream.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); using var result = await _http.SendAsync(upstream, HttpCompletionOption.ResponseHeadersRead, cancellation).ConfigureAwait(false); + ReleaseInput(upstream); if (!result.IsSuccessStatusCode) { await ErrorAsync(response, (int)result.StatusCode is >= 300 and < 400 ? 502 : (int)result.StatusCode, "The selected upstream returned HTTP " + (int)result.StatusCode + ". No request was retried.", cancellation).ConfigureAwait(false); return; } _store.RecordSuccessfulRoute(request.Headers["session_id"] ?? request.Headers["conversation_id"], identity, routeIdentity); diff --git a/tests/model-providers/Program.cs b/tests/model-providers/Program.cs index 81c4787..b62764d 100644 --- a/tests/model-providers/Program.cs +++ b/tests/model-providers/Program.cs @@ -159,6 +159,23 @@ async Task Send(string model, string session, JsonArray? in await result.Content.ReadAsStringAsync(); Check("Image edit data above 64 MiB is retained by the same bounded gateway", result.IsSuccessStatusCode && handler.Requests.Last().Body == largeBody); } + var pendingStreams = new List<(HttpResponseMessage Response, GatedStream Upstream)>(); + try + { + for (var i = 0; i < 2; i++) + { + handler.StreamNext = true; var slow = handler.Slow = new GatedStream(); + using var request = new HttpRequestMessage(HttpMethod.Post, manager.BaseUrl + "/responses") { Content = new StringContent(largeBody, Encoding.UTF8, "application/json") }; + var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + pendingStreams.Add((response, slow)); + } + using var following = await client.PostAsync(manager.BaseUrl + "/responses", new StringContent(largeBody, Encoding.UTF8, "application/json")); + Check("Finished large uploads release admission while their response streams are still open", following.IsSuccessStatusCode && pendingStreams.All(p => p.Response.IsSuccessStatusCode && !p.Upstream.Finish.Task.IsCompleted)); + } + finally + { + foreach (var pending in pendingStreams) { pending.Upstream.Finish.TrySetResult(); await pending.Response.Content.ReadAsStringAsync(); pending.Response.Dispose(); } + } var beforeInvalidImage = handler.Requests.Count; foreach (var invalidJson in new[] { "{\"model\":\"fixture-openai\",\"model\":\"cs_missing\"}", "{\"model\":false}", "[1,2]", "{\"model\":\"fixture-openai\",\"input\":[],\"input\":[{\"type\":\"compaction\",\"encrypted_content\":\"opaque\"}]}" }) { From 19cc3666c2d2f2000122b5ffc263ad43771614bc Mon Sep 17 00:00:00 2001 From: Gaebabi <45709904+Gaebabi@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:23:04 +0900 Subject: [PATCH 08/15] [1/2 Gateway load] Bound memory and stream concurrent model requests (#149) --- .github/workflows/verify.yml | 3 + docs/ARCHITECTURE.md | 2 + docs/MODEL_PROVIDERS.md | 4 +- docs/development/OPERATIONS.md | 4 +- docs/development/PLAN.md | 2 + docs/development/STATUS.md | 2 + docs/evidence/gateway-load/README.md | 31 +++ docs/evidence/gateway-load/before-windows.png | Bin 0 -> 55551 bytes src/application/GatewayJsonRequest.cs | 63 ++++++ src/application/GatewayRequestBody.cs | 75 +++++++ src/application/GatewayRoutedContent.cs | 86 +++++++ src/application/ModelGateway.cs | 111 ++++----- tests/model-providers/GatewayLoadContracts.cs | 211 ++++++++++++++++++ tests/model-providers/Program.cs | 37 +-- 14 files changed, 561 insertions(+), 70 deletions(-) create mode 100644 docs/evidence/gateway-load/README.md create mode 100644 docs/evidence/gateway-load/before-windows.png create mode 100644 src/application/GatewayJsonRequest.cs create mode 100644 src/application/GatewayRequestBody.cs create mode 100644 src/application/GatewayRoutedContent.cs create mode 100644 tests/model-providers/GatewayLoadContracts.cs diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index bd0015b..4c381d9 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -132,6 +132,9 @@ jobs: - name: Verify encrypted model providers and account-free loopback routing shell: pwsh run: dotnet run --project tests/model-providers/CodexSwitch.ModelProviders.Tests.csproj -c Release --no-launch-profile -p:UseSharedCompilation=false -- --codex "$env:CODEX_ACCOUNT_DESK_CLI" + - name: Verify bounded concurrent gateway load with generated near-limit inputs + shell: pwsh + run: dotnet run --project tests/model-providers/CodexSwitch.ModelProviders.Tests.csproj -c Release --no-build --no-launch-profile -- --stress - name: Verify provider editor components shell: pwsh run: dotnet run --project tests/model-providers-ui/ModelProviders.Ui.Tests.csproj -c Release --no-launch-profile -p:UseSharedCompilation=false -- "${{ github.workspace }}/artifacts/model-provider-ui" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 12eefa9..eea586a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,5 +1,7 @@ # Development map +The optional model gateway uses bounded in-memory request arrays:1GiB aggregate capacity with small-request headroom,16 upload/routing slots, eight large uploads and48independent response streams. Token validation and original-byte slices avoid full JSON history copies for native and custom routes. Array growth reserves overlap; buffers and upload slots release at response headers. Overload never replays inference. [Limits](MODEL_PROVIDERS.md) / [verification](evidence/gateway-load/README.md). + Release1.43.3 adds ModelProviderManager, ModelProviderStore, ProviderCatalog and ModelGateway in the existing application assembly, with WPF/shared desktop editors. Provider keys and connection recovery use the existing native protection identity in a separate encrypted file. The loopback gateway routes each selected alias using an immutable request snapshot; OpenAI authentication is confined to its OpenAI route. Official config RPCs atomically prepare or restore only owned settings. [Compatibility, history and acceptance](MODEL_PROVIDERS.md). Source1.41.0 adds `LinuxTerminalCommands` in the UI-neutral Application assembly. The Linux host dispatches `cli` before Avalonia/native display initialization. It composes the existing vault/account/policy/native executable services, serializes with GUI and terminal operations, refuses conflicting CLI clients, and delegates exact CLI/exec session restoration to the descriptor-bound official executable. It uses no shell, permission override, desktop adoption or plaintext credential path. [Terminal contract and current evidence](development/LINUX_TERMINAL.md). diff --git a/docs/MODEL_PROVIDERS.md b/docs/MODEL_PROVIDERS.md index 65386bc..9975263 100644 --- a/docs/MODEL_PROVIDERS.md +++ b/docs/MODEL_PROVIDERS.md @@ -34,7 +34,9 @@ For a model absent from that catalog or a route with different support, expand * 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. -Switch supports at most 32 providers and 128 selected models per provider. Discovery reads are bounded at 4MiB; the complete Codex-facing catalog must fit the client's 1MiB limit. Full-input conversation and image requests are bounded at 512MiB, including chunked bodies. At most two bodies larger than16MiB or of unknown length are buffered concurrently; additional large requests report503 before allocation. Unchanged OpenAI requests reuse the original buffer without a mutable JSON tree or a second body copy. Larger requests report an explicit HTTP413 error; unsupported media types report HTTP415. Upstream limits still apply. Select fewer model IDs when necessary. A discovery result alone does not certify the provider's tool or conversation compatibility. +Switch supports at most32 providers and128 selected models per provider. Discovery reads are bounded at4MiB; the complete Codex-facing catalog must fit the client's1MiB limit. Full-input conversation and image requests are bounded at512MiB, including chunked bodies. Request buffers share a1GiB capacity budget, with64MiB reserved for small requests and resize overlap included. At most eight bodies larger than16MiB or of unknown length upload concurrently, within16 upload/routing slots. Up to48 upstream responses can stream concurrently; those streams do not occupy upload slots. Overload reports503 with `Retry-After: 1` and never retries inference internally. Above-limit bodies report413; unsupported media types report415. Upstream limits still apply. A discovery result alone does not certify tool or conversation compatibility. + +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. diff --git a/docs/development/OPERATIONS.md b/docs/development/OPERATIONS.md index 5168837..4ba30e5 100644 --- a/docs/development/OPERATIONS.md +++ b/docs/development/OPERATIONS.md @@ -2,7 +2,9 @@ Built-in image generation requires `images/generations` and `images/edits` in addition to Responses/model discovery. The gateway forwards these JSON requests only with the original OpenAI identity and preserves the body and safe request IDs; custom text aliases and saved custom-provider keys do not select the image route. Browser/loopback authentication, redirect refusal and no-retry behavior still apply. -Conversation and image request bodies are bounded at512MiB, including chunked bodies. The prior64MiB hotfix can still reject image-heavy full-input chats before reaching OpenAI. Buffer large or unknown-length requests at most two at a time, reject additional large requests with503 before allocation, and reuse the original buffer for unchanged OpenAI forwarding. An oversized request reports HTTP413 with the limit; unsupported media types report415. Older builds combined the16MiB and JSON checks into `A bounded JSON Responses request is required`. Preserve the chat and update the gateway before retrying; never remove its history to bypass a size refusal. The upstream can impose its own limits. [Earlier hotfix evidence](../evidence/image-gateway/README.md). +Conversation and image request bodies remain bounded at512MiB, including chunked bodies. The gateway admits eight large/unknown-length uploads within16 upload/routing slots and a1GiB aggregate request-buffer budget.64MiB stays available for small requests; unknown-length growth counts both old/new arrays before resizing. Uploads time out after two minutes, and completed uploads release their buffers/slots at upstream response headers. Up to48 responses stream independently. Bounded overload responses include503/Retry-After; no internal generation retry is added. Disconnected clients cannot fault the listener's accept loop. [Load verification](../evidence/gateway-load/README.md). + +Token validation avoids the payload-sized metadata pool used by JsonDocument. Full history/images remain original byte slices even when custom routing changes small controls. Strict duplicate/nesting/token/property/control bounds are documented in [model-provider limits](../MODEL_PROVIDERS.md). A body above512MiB reports413; unsupported media reports415. Preserve the chat and update the gateway before retrying a local size refusal; never remove its history to bypass it. Upstream limits can differ. [Earlier hotfix evidence](../evidence/image-gateway/README.md). Model-provider instruction attribution is included in published package `licenses/`. Mac app bundles place those text files in `Contents/Resources/licenses` before signing; Windows and Linux retain the publish directory layout. Native package checks compare the license and notice with the source and verify the complete signed bundle. diff --git a/docs/development/PLAN.md b/docs/development/PLAN.md index caf7826..ad83248 100644 --- a/docs/development/PLAN.md +++ b/docs/development/PLAN.md @@ -1,5 +1,7 @@ # Current plan +Issue149 strengthens concurrent gateway load with an aggregate request-buffer budget, separate upload/stream limits, sliced custom rewrites and token-based validation. Verify exact512MiB bodies, eight65MiB custom uploads,48open response streams, overload/disconnect recovery and retained-memory measurements. Require final-head protected/native CI and privacy review before repository integration. Local installation and actual provider/account acceptance remain separate. + Correct the remaining64MiB intermediary refusal for image-heavy conversations with a512MiB bounded body reader, limited concurrent large buffers and original-buffer forwarding. Verify declared and chunked requests, exact bounds, cancellation and unchanged history/authentication before local installation and authorized chat resumption. Public delivery remains a separate evidence state. Integrate the post1.43.3 image-route and large-request hotfix with focused gateway regressions and native application continuity evidence. Preserve original OpenAI image authentication, custom-provider key separation, complete chat inputs and bounded memory use. Require the six protected final-head checks and recorded review before merge. This source update does not publish a new binary release. [Verification](../evidence/image-gateway/README.md). diff --git a/docs/development/STATUS.md b/docs/development/STATUS.md index 490e4df..0d65625 100644 --- a/docs/development/STATUS.md +++ b/docs/development/STATUS.md @@ -1,5 +1,7 @@ # Current status +Issue149 is in local/CI verification. Source bounds request arrays at1GiB with small-request headroom, allows eight large uploads and48response streams, and forwards custom history/image slices without a full mutable JSON copy. A measured local generated-byte run accepts512MiB Responses/compaction/image-edit bodies and eight concurrent65MiB custom requests; token-based validation removes the prior payload-sized pooled metadata retention. Exact final check counts, measurements, native app continuity, installation and protected repository delivery are recorded separately in [load evidence](../evidence/gateway-load/README.md). + The gateway payload-budget follow-up is in local verification: the64MiB limit still interrupted an image-heavy chat, so source now accepts bounded512MiB bodies and limits concurrent large buffers to two. Unchanged OpenAI inputs reuse the owned request buffer without a mutable JSON tree or ToArray copy. Regression, installed package and actual chat resumption evidence are recorded separately; the earlier109/99 results below do not validate this follow-up. The post1.43.3 image-gateway hotfix forwards built-in image generation/edit JSON endpoints through the original OpenAI identity and raises the bounded conversation/image body limit from16MiB to64MiB. Size and media failures report413 and415 separately. Local official-client/fictional-upstream verification passes109 checks; an authorized local installation passes99 self-tests, actual built-in generation and continuation of a previously failing chat. External image-edit acceptance and a new public binary release remain separate. [Evidence and limitations](../evidence/image-gateway/README.md). diff --git a/docs/evidence/gateway-load/README.md b/docs/evidence/gateway-load/README.md new file mode 100644 index 0000000..558d8dd --- /dev/null +++ b/docs/evidence/gateway-load/README.md @@ -0,0 +1,31 @@ +# Concurrent gateway load + +Issue149 improves image-heavy concurrent chats without increasing the512MiB per-request limit. The gateway previously allowed two large uploads and used payload-sized pooled JSON metadata; custom routing also rebuilt the complete mutable history. Request arrays now share a1GiB capacity budget, eight large uploads can proceed within it, and48response streams leave upload capacity available. Token validation and sliced forwarding preserve history/images without a second body tree. + +## Actual application evidence + +| Current main | Load-resilience implementation | +| --- | --- | +| ![Native Windows application baseline](before-windows.png) | Fresh hosted implementation capture pending. | + +The baseline is the actual hosted native Windows own-window app with fictional accounts, source `9741e6ee202f4938ccdb8caf49ae5bc998e89941`, [Verify41302434623](https://github.com/Gaebabi/codex-switch/actions/runs/41302434623), artifact13081834051. Its original800×560 pixels have SHA256 `320049d86e415b3991ed0525e353b855d15949d0f76f1f45bf6c66044e4b9ee8`. It confirms application continuity, not network throughput. Fresh after/final source capture remains pending. Raw private chats and account evidence are excluded. + +## Isolated behavior and load measurements + +Local Windows official CLI0.163.0/fictional-upstream verification passes184checks. Generated-byte stress runs separately in a fresh process and passes74checks. The stress input uses fixed fictional bytes; its upstream consumes streams without buffering another copy. No external inference or owner home is used. + +| Scenario | Observed result | +| --- | --- | +| Exact512MiB Responses request | HTTP200, complete536,870,912bytes forwarded | +| Exact512MiB chunked compaction | HTTP200, complete536,870,912bytes forwarded | +| Exact512MiB image edit | HTTP200, complete536,870,912bytes forwarded | +| Eight simultaneous65MiB custom uploads |8/8HTTP200,545,259,184forwarded bytes;527ms in this local fake-upstream run | +|48open response streams | All admitted; model discovery remains available;49th generation refuses503 before inference | +|16incomplete uploads/disconnected overload clients | Bounded overload; listener recovers and releases slots | +| Aggregate byte-budget saturation | Refuses before reading the next body; small-request reserve remains usable | + +The measured run took9,170ms. Peak managed heap was1,013,850,160bytes; after forced collection between near-limit inputs it retained at most3,821,384bytes from a2,848,064byte baseline. A diagnostic predecessor using JsonDocument retained payload-sized metadata, reaching4,580,629,248peak bytes in a comparable run. Measurements depend on runtime, host load and the sampling interval; the1GiB budget bounds request-array capacity, not all process allocations or total RSS. Timing is not an external API throughput promise. + +`GatewayLoadContracts` also verifies escaped duplicate property names, nested duplicates, malformed JSON, cancellation/budget release, exact unchanged history slices, opaque-reasoning filtering, Retry-After and overload recovery. The normal Windows protected workflow runs the generated-byte stress mode after ordinary provider/official-client checks. No test weakens authentication, provider provenance or encrypted-compaction refusal. + +Source verification, hosted native app continuity, protected final-head CI, an installed package and authenticated upstream acceptance remain separate gates. Current package self-test/installation evidence is recorded when performed; no new public binary release is part of this change. diff --git a/docs/evidence/gateway-load/before-windows.png b/docs/evidence/gateway-load/before-windows.png new file mode 100644 index 0000000000000000000000000000000000000000..8442e9a06223db47a5700d02e53526817fc388cc GIT binary patch literal 55551 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 _fields; + internal GatewayJsonRequest(ReadOnlyMemory bytes) + { + var reader = new Utf8JsonReader(bytes.Span); + var stack = new Stack?>(); + var tokens = 0; var propertyBytes = 0; + if (!reader.Read() || reader.TokenType != JsonTokenType.StartObject) throw new InvalidDataException("The Responses request is invalid."); + do + { + if (++tokens > 1000000) throw new InvalidDataException("The model request has too many JSON tokens."); + switch (reader.TokenType) + { + case JsonTokenType.StartObject: stack.Push(new HashSet(StringComparer.Ordinal)); break; + case JsonTokenType.StartArray: stack.Push(null); break; + case JsonTokenType.EndObject: + case JsonTokenType.EndArray: stack.Pop(); break; + case JsonTokenType.PropertyName: + if (reader.ValueSpan.Length > 1024 || (propertyBytes += reader.ValueSpan.Length) > 8 * 1024 * 1024) + throw new InvalidDataException("The model request's JSON property names are too large."); + if (!stack.Peek()!.Add(reader.GetString()!)) throw new JsonException("Duplicate JSON properties are not accepted."); + break; + } + } while (reader.Read()); + if (stack.Count != 0) throw new JsonException("The model request is incomplete."); + _fields = GatewayRoutedContent.Fields(bytes); + } + internal ReadOnlyMemory Get(string name) => _fields.FirstOrDefault(field => field.Name == name).Value; + internal bool Present(string name) + { var value = Get(name); return !value.IsEmpty && !value.Span.SequenceEqual("null"u8); } + internal string? Model => String(Get("model")); + internal static string? String(ReadOnlyMemory value) + { + if (value.IsEmpty || value.Length > 1024) return null; + var reader = new Utf8JsonReader(value.Span); + return reader.Read() && reader.TokenType == JsonTokenType.String ? reader.GetString() : null; + } + internal bool HasEncrypted(string type) + { + var input = Get("input"); + if (input.IsEmpty || input.Span[0] != (byte)'[') return false; + foreach (var item in GatewayRoutedContent.Items(input)) + { + if (item.Span[0] != (byte)'{') continue; + var fields = GatewayRoutedContent.Fields(item); + var encrypted = fields.FirstOrDefault(field => field.Name == "encrypted_content").Value; + if (String(fields.FirstOrDefault(field => field.Name == "type").Value) == type && !encrypted.IsEmpty && !encrypted.Span.SequenceEqual("null"u8)) return true; + } + return false; + } + internal void Release() => _fields.Clear(); +} diff --git a/src/application/GatewayRequestBody.cs b/src/application/GatewayRequestBody.cs new file mode 100644 index 0000000..bfc71c4 --- /dev/null +++ b/src/application/GatewayRequestBody.cs @@ -0,0 +1,75 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace CodexAccountDesk; + +// Counts live request-buffer capacity, including both arrays during growth. +// Large uploads leave capacity available for ordinary small requests. +internal sealed class GatewayBodyBudget +{ + internal const long MaximumBytes = 1024L * 1024 * 1024; + private const long SmallReserve = 64L * 1024 * 1024; + private readonly object _gate = new(); + private long _bytes; + internal long Bytes { get { lock (_gate) return _bytes; } } + internal bool TryReserve(int count, bool large) + { + lock (_gate) + { + if (_bytes + count > MaximumBytes - (large ? SmallReserve : 0)) return false; + _bytes += count; return true; + } + } + internal void Release(int count) { lock (_gate) _bytes -= count; } +} + +internal sealed class GatewayBusyException : IOException { } + +internal sealed class GatewayRequestBody : IDisposable +{ + private readonly GatewayBodyBudget _budget; + private readonly bool _large; + private MemoryStream? _stream; + private int _reserved; + internal Memory Bytes => (_stream ?? throw new ObjectDisposedException(nameof(GatewayRequestBody))).GetBuffer().AsMemory(0, checked((int)_stream.Length)); + private GatewayRequestBody(GatewayBodyBudget budget, bool large) { _budget = budget; _large = large; } + internal static async Task ReadAsync(Stream source, long length, int maximum, GatewayBodyBudget budget, bool large, CancellationToken cancellation) + { + if (length > maximum) throw new InvalidDataException("The model request is too large."); + var body = new GatewayRequestBody(budget, large); + try + { + var capacity = length >= 0 ? checked((int)length) : Math.Min(maximum, 64 * 1024); + if (!budget.TryReserve(capacity, large)) throw new GatewayBusyException(); + body._reserved = capacity; + body._stream = new MemoryStream(capacity); + var buffer = new byte[64 * 1024]; + while (true) + { + var count = await source.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, maximum - body._stream.Length + 1)), cancellation).ConfigureAwait(false); + if (count == 0) break; + var required = checked((int)body._stream.Length + count); + if (required > maximum) throw new InvalidDataException("The model request is too large."); + if (required > body._reserved) + { + var next = (int)Math.Min(maximum, Math.Max(required, Math.Max(64 * 1024L, 2L * body._reserved))); + // Reserve the entire new array before releasing the old one. + if (!budget.TryReserve(next, large)) throw new GatewayBusyException(); + try { body._stream.Capacity = next; } + catch { budget.Release(next); throw; } + budget.Release(body._reserved); body._reserved = next; + } + body._stream.Write(buffer, 0, count); + } + body._stream.Position = 0; return body; + } + catch { body.Dispose(); throw; } + } + public void Dispose() + { + if (_stream != null) { _stream.SetLength(0); _stream.Capacity = 0; _stream.Dispose(); _stream = null; } + _budget.Release(_reserved); _reserved = 0; + } +} diff --git a/src/application/GatewayRoutedContent.cs b/src/application/GatewayRoutedContent.cs new file mode 100644 index 0000000..27721b8 --- /dev/null +++ b/src/application/GatewayRoutedContent.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +namespace CodexAccountDesk; + +// Only small routing options are mutable. Conversation/image values are slices +// of the validated original body, never a second JSON tree or serialized copy. +internal sealed class GatewayRoutedContent : HttpContent +{ + private readonly List> _parts = new(); + private readonly long _length; + internal readonly record struct Field(string Name, ReadOnlyMemory Value); + internal static List Fields(ReadOnlyMemory json) + { + var reader = new Utf8JsonReader(json.Span); reader.Read(); + var fields = new List(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + var name = reader.GetString()!; reader.Read(); + var start = checked((int)reader.TokenStartIndex); reader.Skip(); + fields.Add(new Field(name, json.Slice(start, checked((int)reader.BytesConsumed) - start))); + } + return fields; + } + internal GatewayRoutedContent(ReadOnlyMemory original, JsonObject options, bool custom, bool generic, bool removeReasoning) + { + Add("{"); var comma = false; + 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 (comma) Add(","); comma = true; + _parts.Add(JsonSerializer.SerializeToUtf8Bytes(field.Name)); Add(":"); + if (custom && (field.Name is "model" or "reasoning" || generic && field.Name == "text")) + _parts.Add(JsonSerializer.SerializeToUtf8Bytes(options[field.Name])); + else if (removeReasoning && field.Name == "input") AddInput(field.Value); + else _parts.Add(field.Value); + } + Add("}"); _length = _parts.Sum(part => (long)part.Length); + Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json"); + } + private void Add(string value) => _parts.Add(Encoding.UTF8.GetBytes(value)); + internal static List> Items(ReadOnlyMemory json) + { + var items = new List>(); + var reader = new Utf8JsonReader(json.Span); reader.Read(); + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + var start = checked((int)reader.TokenStartIndex); reader.Skip(); + items.Add(json.Slice(start, checked((int)reader.BytesConsumed) - start)); + } + return items; + } + private void AddInput(ReadOnlyMemory json) + { + Add("["); var comma = false; + foreach (var value in Items(json)) + { + if (value.Span[0] == (byte)'{') + { + var fields = Fields(value); + var type = fields.FirstOrDefault(f => f.Name == "type").Value; + var encrypted = fields.FirstOrDefault(f => f.Name == "encrypted_content").Value; + if (GatewayJsonRequest.String(type) == "reasoning" && !encrypted.IsEmpty && !encrypted.Span.SequenceEqual("null"u8)) continue; + } + if (comma) Add(","); comma = true; _parts.Add(value); + } + Add("]"); + } + protected override bool TryComputeLength(out long length) { length = _length; return true; } + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => SerializeToStreamAsync(stream, context, CancellationToken.None); + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellationToken) + { + foreach (var part in _parts) await stream.WriteAsync(part, cancellationToken).ConfigureAwait(false); + } + protected override void Dispose(bool disposing) { if (disposing) _parts.Clear(); base.Dispose(disposing); } +} diff --git a/src/application/ModelGateway.cs b/src/application/ModelGateway.cs index 94b9f58..401168f 100644 --- a/src/application/ModelGateway.cs +++ b/src/application/ModelGateway.cs @@ -29,7 +29,10 @@ internal sealed class ModelGateway : IAsyncDisposable private readonly CancellationTokenSource _stop = new(); private readonly ConcurrentDictionary _requests = new(); private readonly SemaphoreSlim _slots = new(16, 16); - private readonly SemaphoreSlim _largeBodies = new(2, 2); + private readonly SemaphoreSlim _largeBodies = new(8, 8); + private readonly SemaphoreSlim _streamSlots = new(48, 48); + private readonly SemaphoreSlim _rejections = new(4, 4); + private readonly GatewayBodyBudget _bodyBudget = new(); private Task? _accept; private int _nextRequest; private sealed record NativeCatalog(Dictionary Models, DateTimeOffset Expires); @@ -64,10 +67,12 @@ private async Task AcceptAsync() while (!_stop.IsCancellationRequested) { var context = await _listener.GetContextAsync().WaitAsync(_stop.Token).ConfigureAwait(false); - if (!_slots.Wait(0)) { await ErrorAsync(context.Response, 503, "The local model gateway is busy.", _stop.Token).ConfigureAwait(false); continue; } + var admitted = _slots.Wait(0); + if (!admitted && !_rejections.Wait(0)) { context.Response.Abort(); continue; } var id = Interlocked.Increment(ref _nextRequest); - var task = ServeAsync(context); _requests[id] = task; - _ = task.ContinueWith(_ => { _requests.TryRemove(id, out var removed); _slots.Release(); }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + // Parsing/routing an already-buffered large body must not block acceptance. + var task = Task.Run(() => admitted ? ServeAsync(context) : RejectAsync(context)); _requests[id] = task; + _ = task.ContinueWith(_ => _requests.TryRemove(id, out var removed), CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); } } catch (OperationCanceledException) when (_stop.IsCancellationRequested) { } @@ -76,7 +81,7 @@ private async Task AcceptAsync() } private async Task ServeAsync(HttpListenerContext context) { - var started = false; var largeBody = false; + var started = false; var largeBody = false; var workSlot = true; var streamSlot = false; using var deadline = CancellationTokenSource.CreateLinkedTokenSource(_stop.Token); deadline.CancelAfter(TimeSpan.FromMinutes(30)); var cancellation = deadline.Token; try @@ -106,37 +111,40 @@ private async Task ServeAsync(HttpListenerContext context) { await ErrorAsync(response, 413, RequestTooLarge, cancellation).ConfigureAwait(false); return; } if (!string.IsNullOrEmpty(request.Headers["Content-Encoding"]) && request.Headers["Content-Encoding"] != "identity") { await ErrorAsync(response, 415, "Disable Codex request compression in the connection settings before using the local gateway.", cancellation).ConfigureAwait(false); return; } - // Unknown-length bodies can also be large. Admit at most two before allocating buffers. + streamSlot = _streamSlots.Wait(0); + if (!streamSlot) { await ErrorAsync(response, 503, "The local model gateway has reached its concurrent response limit.", cancellation).ConfigureAwait(false); return; } + // Unknown-length bodies are budgeted incrementally. Keep small-request headroom. if (request.ContentLength64 < 0 || request.ContentLength64 > SmallRequestBytes) { largeBody = _largeBodies.Wait(0); if (!largeBody) { await ErrorAsync(response, 503, "The local model gateway is processing other large requests. Try again when they finish.", cancellation).ConfigureAwait(false); return; } } - MemoryStream input; - try { input = await ReadBodyAsync(request.InputStream, request.ContentLength64, MaximumRequestBytes, cancellation).ConfigureAwait(false); } + GatewayRequestBody input; + using var uploadDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation); + uploadDeadline.CancelAfter(TimeSpan.FromMinutes(2)); + try { input = await GatewayRequestBody.ReadAsync(request.InputStream, request.ContentLength64, MaximumRequestBytes, _bodyBudget, largeBody, uploadDeadline.Token).ConfigureAwait(false); } catch (InvalidDataException) { await ErrorAsync(response, 413, RequestTooLarge, cancellation).ConfigureAwait(false); return; } using var bufferedInput = input; // Parse the owned buffer without ToArray or a mutable JSON tree for unchanged requests. - var bytesIn = input.GetBuffer().AsMemory(0, checked((int)input.Length)); - using var document = JsonDocument.Parse(bytesIn, new JsonDocumentOptions { AllowDuplicateProperties = false }); - var original = document.RootElement; - if (original.ValueKind != JsonValueKind.Object) throw new InvalidDataException("The Responses request is invalid."); + var bytesIn = input.Bytes; + var original = new GatewayJsonRequest(bytesIn); JsonObject? body = null; void ReleaseInput(HttpRequestMessage outgoing) { // Upload is complete once response headers arrive. Do not retain its buffers // or admission slot during a potentially long-running response stream. outgoing.Content?.Dispose(); outgoing.Content = null; - document.Dispose(); body = null; bytesIn = default; - input.SetLength(0); input.Capacity = 0; + original.Release(); body = null; bytesIn = default; + input.Dispose(); if (largeBody) { _largeBodies.Release(); largeBody = false; } + if (workSlot) { _slots.Release(); workSlot = false; } } if (imageRequest) { using var image = new HttpRequestMessage(HttpMethod.Post, new Uri((identity.ApiKey ? "https://api.openai.com/v1/" : "https://chatgpt.com/backend-api/codex/") + path["/backend-api/codex/".Length..])); CopyOpenAiHeaders(request, image, identity); - image.Content = new ByteArrayContent(input.GetBuffer(), 0, bytesIn.Length); image.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + image.Content = new ReadOnlyMemoryContent(bytesIn); image.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); using var imageResult = await _http.SendAsync(image, HttpCompletionOption.ResponseHeadersRead, cancellation).ConfigureAwait(false); ReleaseInput(image); if (!imageResult.IsSuccessStatusCode) @@ -150,7 +158,7 @@ void ReleaseInput(HttpRequestMessage outgoing) await imageStream.CopyToAsync(response.OutputStream, cancellation).ConfigureAwait(false); LastError = null; return; } - var model = original.TryGetProperty("model", out var selected) && selected.ValueKind == JsonValueKind.String ? selected.GetString() : null; + 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; @@ -161,34 +169,27 @@ void ReleaseInput(HttpRequestMessage outgoing) { 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. - if (original.TryGetProperty("previous_response_id", out var previous) && previous.ValueKind != JsonValueKind.Null || - original.TryGetProperty("conversation", out var conversation) && conversation.ValueKind != JsonValueKind.Null) + if (original.Present("previous_response_id") || original.Present("conversation")) { await ErrorAsync(response, 400, "Use full-input HTTP Responses replay; provider-specific response references cannot be routed safely.", cancellation).ConfigureAwait(false); return; } - var changedBody = false; + var removeReasoning = false; var routeIdentity = route.Provider == null ? "openai" : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(route.Provider.Id + "\n" + route.Provider.BaseUrl + "\n" + route.Provider.ApiKey))); var portableHistory = _store.RequiresPortableHistory(request.Headers["session_id"] ?? request.Headers["conversation_id"], identity, routeIdentity); - if (portableHistory && original.TryGetProperty("input", out var originalItems) && originalItems.ValueKind == JsonValueKind.Array) + if (portableHistory) { - bool Encrypted(JsonElement item, string type) => item.ValueKind == JsonValueKind.Object && - item.TryGetProperty("type", out var kind) && kind.ValueKind == JsonValueKind.String && kind.GetString() == type && - item.TryGetProperty("encrypted_content", out var encrypted) && encrypted.ValueKind != JsonValueKind.Null; - if (originalItems.EnumerateArray().Any(item => Encrypted(item, "compaction"))) + if (original.HasEncrypted("compaction")) { await ErrorAsync(response, 400, ModelHistoryHandoff.Refusal, cancellation).ConfigureAwait(false); return; } - if (originalItems.EnumerateArray().Any(item => Encrypted(item, "reasoning"))) - { - body = JsonNode.Parse(bytesIn.Span)!.AsObject(); - var items = body["input"]!.AsArray(); - for (var i = items.Count - 1; i >= 0; i--) - if (items[i] is JsonObject obj && obj["type"]?.GetValue() == "reasoning" && obj["encrypted_content"] != null) { items.RemoveAt(i); changedBody = true; } - } + removeReasoning = original.HasEncrypted("reasoning"); } Uri destination; if (route.Provider != null) { - changedBody = true; - body ??= JsonNode.Parse(bytesIn.Span)!.AsObject(); - body["model"] = route.Model.UpstreamId; - body.Remove("prompt_cache_key"); + body = new JsonObject { ["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); + } JsonObject? native = null; if (route.Model.GenericMetadata && route.Model.ReasoningOverride == null && body["reasoning"] != null) native = await NativeModelAsync(route.Model.UpstreamId, identity, request, cancellation).ConfigureAwait(false); @@ -196,7 +197,6 @@ bool Encrypted(JsonElement item, string type) => item.ValueKind == JsonValueKind if (reasoningError != null) { await ErrorAsync(response, 400, reasoningError, cancellation).ConfigureAwait(false); return; } if (route.Model.GenericMetadata) { - body.Remove("service_tier"); 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"); @@ -205,7 +205,9 @@ bool Encrypted(JsonElement item, string type) => item.ValueKind == JsonValueKind using 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 = changedBody ? new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(body)) : new ByteArrayContent(input.GetBuffer(), 0, bytesIn.Length); + upstream.Content = route.Provider != null || removeReasoning ? + new GatewayRoutedContent(bytesIn, body ?? new JsonObject(), route.Provider != null, route.Model?.GenericMetadata == true, removeReasoning) : + new ReadOnlyMemoryContent(bytesIn); upstream.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); using var result = await _http.SendAsync(upstream, HttpCompletionOption.ResponseHeadersRead, cancellation).ConfigureAwait(false); ReleaseInput(upstream); @@ -220,6 +222,8 @@ bool Encrypted(JsonElement item, string type) => item.ValueKind == JsonValueKind { await response.OutputStream.WriteAsync(buffer.AsMemory(0, count), cancellation).ConfigureAwait(false); await response.OutputStream.FlushAsync(cancellation).ConfigureAwait(false); } LastError = null; } + catch (GatewayBusyException) + { if (!started) await TryErrorAsync(context.Response, 503, "The local model gateway's request buffer budget is busy. Try again when uploads finish.", cancellation).ConfigureAwait(false); } catch (OperationCanceledException) { LastError = "The model request was cancelled or timed out."; context.Response.Abort(); } catch (Exception error) { @@ -231,23 +235,7 @@ bool Encrypted(JsonElement item, string type) => item.ValueKind == JsonValueKind catch (Exception transport) when (transport is IOException or HttpListenerException or OperationCanceledException or ObjectDisposedException) { context.Response.Abort(); } } } - finally { if (largeBody) _largeBodies.Release(); context.Response.Close(); } - } - internal static async Task ReadBodyAsync(Stream source, long contentLength, int maximum, CancellationToken cancellation) - { - if (contentLength > maximum) throw new InvalidDataException("The model request is too large."); - var target = new MemoryStream(contentLength >= 0 ? checked((int)contentLength) : Math.Min(maximum, 64 * 1024)); - try - { - var buffer = new byte[64 * 1024]; int count; - while ((count = await source.ReadAsync(buffer.AsMemory(0, (int)Math.Min(buffer.Length, maximum - target.Length + 1)), cancellation).ConfigureAwait(false)) > 0) - { - if (target.Length + count > maximum) throw new InvalidDataException("The model request is too large."); - target.Write(buffer, 0, count); - } - target.Position = 0; return target; - } - catch { target.Dispose(); throw; } + 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) { @@ -316,15 +304,28 @@ private static void CopyOpenAiHeaders(HttpListenerRequest incoming, HttpRequestM private static async Task ErrorAsync(HttpListenerResponse response, int status, string message, CancellationToken cancellation) { response.StatusCode = status; response.ContentType = "application/json"; response.Headers["Cache-Control"] = "no-store"; + if (status == 503) response.Headers["Retry-After"] = "1"; var bytes = JsonSerializer.SerializeToUtf8Bytes(new { error = new { message, type = "model_gateway_error" } }); response.ContentLength64 = bytes.Length; try { await response.OutputStream.WriteAsync(bytes, cancellation).ConfigureAwait(false); } finally { response.Close(); } } + private static async Task TryErrorAsync(HttpListenerResponse response, int status, string message, CancellationToken cancellation) + { + try { await ErrorAsync(response, status, message, cancellation).ConfigureAwait(false); } + catch (Exception error) when (error is IOException or HttpListenerException or ObjectDisposedException or OperationCanceledException) { response.Abort(); } + } + private async Task RejectAsync(HttpListenerContext context) + { + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(_stop.Token); + deadline.CancelAfter(TimeSpan.FromSeconds(2)); + try { await TryErrorAsync(context.Response, 503, "The local model gateway is busy.", deadline.Token).ConfigureAwait(false); } + finally { _rejections.Release(); } + } public async ValueTask DisposeAsync() { _stop.Cancel(); _listener.Close(); if (_accept != null) await _accept.ConfigureAwait(false); await Task.WhenAll(_requests.Values).ConfigureAwait(false); - _stop.Dispose(); _slots.Dispose(); _largeBodies.Dispose(); + _stop.Dispose(); _slots.Dispose(); _largeBodies.Dispose(); _streamSlots.Dispose(); _rejections.Dispose(); } } diff --git a/tests/model-providers/GatewayLoadContracts.cs b/tests/model-providers/GatewayLoadContracts.cs new file mode 100644 index 0000000..e05f4d4 --- /dev/null +++ b/tests/model-providers/GatewayLoadContracts.cs @@ -0,0 +1,211 @@ +using CodexAccountDesk; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +internal static class GatewayLoadContracts +{ + internal static async Task Run(string root, Action check, bool stress) + { + var budget = new GatewayBodyBudget(); + check("Aggregate buffer reservations preserve small-request headroom", budget.TryReserve(512 * 1024 * 1024, true) && !budget.TryReserve(512 * 1024 * 1024, true) && budget.TryReserve(16 * 1024 * 1024, false)); + budget.Release(528 * 1024 * 1024); + check("Completed reservations return the entire aggregate buffer budget", budget.Bytes == 0); + check("An occupied aggregate buffer budget can reserve small-request capacity", budget.TryReserve(960 * 1024 * 1024, true) && budget.TryReserve(64 * 1024 * 1024, false)); + using (var source = new MemoryStream(new byte[1])) + { + var refused = false; + try { using var body = await GatewayRequestBody.ReadAsync(source, 1, 1, budget, true, CancellationToken.None); } + catch (GatewayBusyException) { refused = true; } + check("Aggregate saturation refuses before reading or allocating the next body", refused && source.Position == 0); + } + budget.Release(1024 * 1024 * 1024); + using (var cancelled = new CancellationTokenSource()) + using (var source = new MemoryStream(new byte[64])) + { + cancelled.Cancel(); + try { using var body = await GatewayRequestBody.ReadAsync(source, 64, 64, budget, false, cancelled.Token); } + catch (OperationCanceledException) { } + check("Cancelled readers release their buffer reservation", budget.Bytes == 0); + } + foreach (var bad in new[] { "{\"model\":\"a\",\"mo\\u0064el\":\"b\"}", "{\"input\":[{\"x\":1,\"x\":2}]}", "{\"x\":[1,]}", "{}{}" }) + { + var refused = false; + try { _ = new GatewayJsonRequest(Encoding.UTF8.GetBytes(bad)); } + catch (JsonException) { refused = true; } + check("Token validation retains strict nested and escaped-name JSON refusal: " + bad, refused); + } + var exact = Encoding.UTF8.GetBytes("{\"model\":\"alias\",\"input\":[{\"type\":\"\\u0072easoning\",\"encrypted_content\":\"opaque\"},{\"role\":\"user\",\"content\":\"한글\\u0020image-data\"}],\"reasoning\":{\"effort\":\"high\"},\"prompt_cache_key\":\"remove\",\"extra\":{ \"kept\" : true }}"); + using (var routed = new GatewayRoutedContent(exact, new JsonObject { ["model"] = "load-model", ["reasoning"] = new JsonObject { ["effort"] = "high" } }, true, true, true)) + { + var text = await routed.ReadAsStringAsync(); + var parsed = JsonNode.Parse(text)!; + check("Sliced custom rewrites retain exact history bytes and remove escaped opaque reasoning", !text.Contains("opaque") && text.Contains("한글\\u0020image-data") && text.Contains("{ \"kept\" : true }") && parsed["model"]!.GetValue() == "load-model" && parsed["reasoning"]!["effort"]!.GetValue() == "high" && parsed["prompt_cache_key"] == null); + } + var handler = new LoadUpstream(); + var store = new ModelProviderStore(Path.Combine(root, "load-providers")); + var custom = new ModelProvider { Name = "Load fixture", BaseUrl = "https://load.example.invalid/v1", ApiKey = "fictional-load-key" }; + custom.Models.Add(new ProviderModel { UpstreamId = "load-model", GenericMetadata = true, Metadata = ProviderCatalog.Generic("load-model") }); + store.Update(next => next.Providers.Add(custom)); + await using var gateway = new ModelGateway(store, (auth, _) => auth == "Bearer fictional-load-token" ? new GatewayIdentity("fictional-load-token", "fictional-load-account", false) : null, new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }); + gateway.Start(); + using var client = new HttpClient { Timeout = TimeSpan.FromMinutes(3) }; + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "fictional-load-token"); + // Establish native-model metadata before concurrency measurements. + using (var listed = await client.GetAsync(gateway.BaseUrl + "/models?client_version=0.163.0")) listed.EnsureSuccessStatusCode(); + + var streams = new List(); + try + { + handler.HoldResponses = true; + for (var i = 0; i < 48; i++) + { + using var request = new HttpRequestMessage(HttpMethod.Post, gateway.BaseUrl + "/responses") { Content = new StringContent("{\"model\":\"load-model\",\"input\":\"fixture\"}", Encoding.UTF8, "application/json") }; + var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + streams.Add(response); check("Concurrent response stream admitted: " + i, response.IsSuccessStatusCode); + } + var before = handler.Requests; + using (var rejected = await client.PostAsync(gateway.BaseUrl + "/responses", new StringContent("{\"model\":\"load-model\"}", Encoding.UTF8, "application/json"))) + check("The 49th response refuses with retry guidance before inference", rejected.StatusCode == HttpStatusCode.ServiceUnavailable && rejected.Headers.RetryAfter?.Delta == TimeSpan.FromSeconds(1) && handler.Requests == before); + using (var listed = await client.GetAsync(gateway.BaseUrl + "/models?client_version=0.163.0")) + check("Model discovery remains available while 48 responses stream", listed.IsSuccessStatusCode); + } + finally + { + handler.HoldResponses = false; + foreach (var slow in handler.Streams) slow.Finish.TrySetResult(); + foreach (var response in streams) { await response.Content.ReadAsStringAsync(); response.Dispose(); } + } + using (var recovered = await client.PostAsync(gateway.BaseUrl + "/responses", new StringContent("{\"model\":\"load-model\"}", Encoding.UTF8, "application/json"))) + check("The listener resumes generation after response overload drains", recovered.IsSuccessStatusCode); + + // Disconnect overload clients while all ordinary upload slots are occupied. + var uploads = new List(); + try + { + var endpoint = new Uri(gateway.BaseUrl); + for (var i = 0; i < 16; i++) + { + var connection = new TcpClient(); uploads.Add(connection); await connection.ConnectAsync(endpoint.Host, endpoint.Port); + var length = i < 8 ? "Transfer-Encoding: chunked\r\n" : "Content-Length: 100\r\n"; + await connection.GetStream().WriteAsync(Encoding.ASCII.GetBytes("POST " + endpoint.AbsolutePath + "/responses HTTP/1.1\r\nHost: " + endpoint.Authority + "\r\nAuthorization: Bearer fictional-load-token\r\nContent-Type: application/json\r\n" + length + "\r\n")); + } + using (var busy = await client.GetAsync(gateway.BaseUrl + "/models?client_version=0.163.0")) + check("Sixteen incomplete uploads refuse extra work with bounded overload handling", busy.StatusCode == HttpStatusCode.ServiceUnavailable); + for (var i = 0; i < 24; i++) + { + using var disconnected = new TcpClient(); await disconnected.ConnectAsync(endpoint.Host, endpoint.Port); + await disconnected.GetStream().WriteAsync(Encoding.ASCII.GetBytes("GET " + endpoint.AbsolutePath + "/models?client_version=0.163.0 HTTP/1.1\r\nHost: " + endpoint.Authority + "\r\n\r\n")); + } + } + finally { foreach (var upload in uploads) upload.Dispose(); } + var resumed = false; + for (var attempt = 0; attempt < 60 && !resumed; attempt++) + { + using var listed = await client.GetAsync(gateway.BaseUrl + "/models?client_version=0.163.0"); + resumed = listed.IsSuccessStatusCode; + if (!resumed) await Task.Delay(50); + } + check("Disconnected overload clients cannot stop the accept loop or leak upload slots", resumed); + + if (!stress) return; + var started = Stopwatch.StartNew(); + var initial = GC.GetTotalMemory(true); + var peak = initial; + using var monitorStop = new CancellationTokenSource(); + var monitor = Task.Run(async () => { while (!monitorStop.IsCancellationRequested) { peak = Math.Max(peak, GC.GetTotalMemory(false)); await Task.Delay(10); } }); + var measurements = new List(); + long retained = 0; + try + { + foreach (var endpoint in new[] { "/responses", "/responses/compact", "/images/edits" }) + { + var before = handler.Bytes; + using var request = new HttpRequestMessage(HttpMethod.Post, gateway.BaseUrl + endpoint) { Content = new GeneratedJsonContent(ModelGateway.MaximumRequestBytes, "load-model", endpoint.EndsWith("compact")) }; + using var response = await client.SendAsync(request); await response.Content.ReadAsStringAsync(); + check("An exact 512 MiB request is fully streamed to the original upstream: " + endpoint, response.IsSuccessStatusCode && handler.Bytes - before == ModelGateway.MaximumRequestBytes); + measurements.Add(new { endpoint, bytes = ModelGateway.MaximumRequestBytes, status = (int)response.StatusCode }); + GC.Collect(); GC.WaitForPendingFinalizers(); + retained = Math.Max(retained, GC.GetTotalMemory(true)); + } + var totalBefore = handler.Bytes; var clock = Stopwatch.StartNew(); + var results = await Task.WhenAll(Enumerable.Range(0, 8).Select(async i => + { + using var request = new HttpRequestMessage(HttpMethod.Post, gateway.BaseUrl + "/responses") { Content = new GeneratedJsonContent(65 * 1024 * 1024, custom.Models[0].Alias(custom.Id), false) }; + using var response = await client.SendAsync(request); await response.Content.ReadAsStringAsync(); return response.StatusCode; + })); + check("Eight concurrent 65 MiB custom uploads preserve throughput within the byte budget", results.All(status => status == HttpStatusCode.OK) && handler.Bytes - totalBefore > 8L * 64 * 1024 * 1024); + measurements.Add(new { scenario = "eight-custom-uploads", bytes = handler.Bytes - totalBefore, milliseconds = clock.ElapsedMilliseconds, successes = results.Count(status => status == HttpStatusCode.OK) }); + check("Near-limit JSON parsing does not retain payload-sized pooled metadata", retained - initial < 64L * 1024 * 1024); + } + finally { monitorStop.Cancel(); await monitor; } + Console.WriteLine(JsonSerializer.Serialize(new { gatewayLoad = new { measurements, elapsedMs = started.ElapsedMilliseconds, baselineManagedBytes = initial, peakManagedBytes = peak, retainedManagedBytesAfterLargeRequests = retained, bufferBudgetBytes = GatewayBodyBudget.MaximumBytes, scope = "Generated fictional bytes and streaming fake upstream; excludes external API and owner account acceptance." } })); + } + + private sealed class LoadUpstream : HttpMessageHandler + { + internal bool HoldResponses; + internal int Requests; + internal long Bytes; + internal ConcurrentQueue Streams { get; } = new(); + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellation) + { + if (request.Method == HttpMethod.Get) + return new(HttpStatusCode.OK) { Content = new StringContent(new JsonObject { ["models"] = new JsonArray(ProviderCatalog.Generic("load-model")) }.ToJsonString(), Encoding.UTF8, "application/json") }; + Interlocked.Increment(ref Requests); + using var sink = new CountingSink(); + await request.Content!.CopyToAsync(sink, cancellation); + Interlocked.Add(ref Bytes, sink.Bytes); + if (HoldResponses) + { + var slow = new GatedStream(); Streams.Enqueue(slow); + var content = new StreamContent(slow); content.Headers.ContentType = new MediaTypeHeaderValue("text/event-stream"); + return new(HttpStatusCode.OK) { Content = content }; + } + return new(HttpStatusCode.OK) { Content = new StringContent("data: {\"type\":\"response.completed\"}\n\n", Encoding.UTF8, "text/event-stream") }; + } + } + + private sealed class CountingSink : Stream + { + internal long Bytes; + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => Bytes; + public override long Position { get => Bytes; set => throw new NotSupportedException(); } + public override void Flush() { } + public override void Write(byte[] buffer, int offset, int count) => Bytes += count; + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { cancellationToken.ThrowIfCancellationRequested(); Bytes += buffer.Length; return ValueTask.CompletedTask; } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } + + private sealed class GeneratedJsonContent : HttpContent + { + private readonly int _length; + private readonly byte[] _prefix; + private readonly byte[] _suffix = "\"}"u8.ToArray(); + private readonly bool _chunked; + internal GeneratedJsonContent(int length, string model, bool chunked) + { _length = length; _chunked = chunked; _prefix = Encoding.UTF8.GetBytes("{\"model\":" + JsonSerializer.Serialize(model) + ",\"input\":\""); Headers.ContentType = new MediaTypeHeaderValue("application/json"); } + protected override bool TryComputeLength(out long length) { length = _length; return !_chunked; } + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => SerializeToStreamAsync(stream, context, CancellationToken.None); + protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context, CancellationToken cancellation) + { + await stream.WriteAsync(_prefix, cancellation); + var buffer = new byte[64 * 1024]; Array.Fill(buffer, (byte)'x'); + for (var remaining = _length - _prefix.Length - _suffix.Length; remaining > 0;) + { var count = Math.Min(remaining, buffer.Length); await stream.WriteAsync(buffer.AsMemory(0, count), cancellation); remaining -= count; } + await stream.WriteAsync(_suffix, cancellation); + } + } +} diff --git a/tests/model-providers/Program.cs b/tests/model-providers/Program.cs index b62764d..696d5f9 100644 --- a/tests/model-providers/Program.cs +++ b/tests/model-providers/Program.cs @@ -12,19 +12,19 @@ Check("Full chat endpoint normalizes to the API base URL", ProviderCatalog.Endpoint("https://openrouter.ai/api/v1/chat/completions").AbsoluteUri == "https://openrouter.ai/api/v1/"); Check("Responses and model-list endpoints normalize without changing their authority", ProviderCatalog.Endpoint("https://example.invalid/proxy/v1/responses/").AbsoluteUri == "https://example.invalid/proxy/v1/" && ProviderCatalog.Endpoint("https://example.invalid/v1/models").AbsoluteUri == "https://example.invalid/v1/"); using (var source = new MemoryStream(Encoding.UTF8.GetBytes("12345678"))) -using (var exact = await ModelGateway.ReadBodyAsync(source, -1, 8, CancellationToken.None)) - Check("An unknown-length body at the exact bound is retained without closing its source", exact.Length == 8 && source.CanRead && Encoding.UTF8.GetString(exact.GetBuffer(), 0, (int)exact.Length) == "12345678"); +using (var exact = await GatewayRequestBody.ReadAsync(source, -1, 8, new GatewayBodyBudget(), true, CancellationToken.None)) + Check("An unknown-length body at the exact bound is retained without closing its source", exact.Bytes.Length == 8 && source.CanRead && Encoding.UTF8.GetString(exact.Bytes.Span) == "12345678"); using (var source = new MemoryStream(new byte[9])) { var oversized = false; - try { using var unexpected = await ModelGateway.ReadBodyAsync(source, -1, 8, CancellationToken.None); } + try { using var unexpected = await GatewayRequestBody.ReadAsync(source, -1, 8, new GatewayBodyBudget(), true, CancellationToken.None); } catch (InvalidDataException) { oversized = true; } Check("An unknown-length body is rejected at the first byte above its bound", oversized && source.Position == 9); } using (var source = new MemoryStream(new byte[9])) { var oversized = false; - try { using var unexpected = await ModelGateway.ReadBodyAsync(source, 9, 8, CancellationToken.None); } + try { using var unexpected = await GatewayRequestBody.ReadAsync(source, 9, 8, new GatewayBodyBudget(), true, CancellationToken.None); } catch (InvalidDataException) { oversized = true; } Check("An oversized declared body is rejected without reading its source", oversized && source.Position == 0); } @@ -32,7 +32,7 @@ using (var source = new MemoryStream(new byte[8])) { cancelled.Cancel(); var stopped = false; - try { using var unexpected = await ModelGateway.ReadBodyAsync(source, 8, 8, cancelled.Token); } + try { using var unexpected = await GatewayRequestBody.ReadAsync(source, 8, 8, new GatewayBodyBudget(), true, cancelled.Token); } catch (OperationCanceledException) { stopped = true; } Check("Request buffering honors cancellation", stopped); } @@ -40,6 +40,16 @@ async Task Refuses(string name, Func action) { try { await action(); } catch (Exception e) when (e is ArgumentException or InvalidOperationException or IOException or InvalidDataException) { Check(name, true); return; } throw new Exception(name); } var root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Temp", "CodexSwitchModelProviders", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); +if (args.Contains("--stress")) +{ + try + { + await GatewayLoadContracts.Run(root, Check, true); + Console.WriteLine(JsonSerializer.Serialize(new { passed = checks.Count, checks, scope = "Isolated generated-byte gateway stress; no external inference or owner home." })); + } + finally { Directory.Delete(root, true); } + return; +} var nativeHome = Path.Combine(root, "native-home"); Directory.CreateDirectory(nativeHome); var handler = new Upstreams(); GatewayIdentity? Authenticate(string? auth, string? account) => GatewayIdentity.Authenticate(auth, account, () => "{\"tokens\":{\"access_token\":\"fictional-openai-token\",\"account_id\":\"fictional-account\"}}"); @@ -193,26 +203,26 @@ async Task Send(string model, string session, JsonArray? in var refusal = await reader.ReadToEndAsync().WaitAsync(TimeSpan.FromSeconds(5)); Check("Oversized declared requests report the explicit 512 MiB bound before upstream", refusal.Contains("413") && refusal.Contains("512 MiB") && handler.Requests.Count == beforeInvalidImage); } - // Hold two incomplete chunked uploads without allocating large test payloads. - // A successful small request orders acceptance before the third large upload. - using (var first = new TcpClient()) - using (var second = new TcpClient()) + // Incomplete uploads verify admission without allocating large test payloads. + var pendingUploads = Enumerable.Range(0, 8).Select(_ => new TcpClient()).ToArray(); + try { var endpoint = new Uri(manager.BaseUrl!); - foreach (var pending in new[] { first, second }) + foreach (var pending in pendingUploads) { await pending.ConnectAsync(endpoint.Host, endpoint.Port); await pending.GetStream().WriteAsync(Encoding.ASCII.GetBytes("POST " + endpoint.AbsolutePath + "/responses HTTP/1.1\r\nHost: " + endpoint.Authority + "\r\nAuthorization: Bearer fictional-openai-token\r\nChatGPT-Account-ID: fictional-account\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n1\r\n{\r\n")); } using (var small = await Send("fixture-openai", "large-admission-small")) - Check("Small requests remain usable while two unknown-length uploads are pending", small.IsSuccessStatusCode); + Check("Small requests remain usable while eight unknown-length uploads are pending", small.IsSuccessStatusCode); using var third = new TcpClient(); await third.ConnectAsync(endpoint.Host, endpoint.Port); var beforeBusy = handler.Requests.Count; await third.GetStream().WriteAsync(Encoding.ASCII.GetBytes("POST " + endpoint.AbsolutePath + "/responses HTTP/1.1\r\nHost: " + endpoint.Authority + "\r\nAuthorization: Bearer fictional-openai-token\r\nChatGPT-Account-ID: fictional-account\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n")); using var reader = new StreamReader(third.GetStream()); var busy = await reader.ReadToEndAsync().WaitAsync(TimeSpan.FromSeconds(5)); - Check("A third large upload refuses before buffering or contacting upstream", busy.Contains("503") && handler.Requests.Count == beforeBusy); + Check("A ninth large upload refuses before buffering or contacting upstream", busy.Contains("503") && busy.Contains("Retry-After: 1") && handler.Requests.Count == beforeBusy); } + finally { foreach (var pending in pendingUploads) pending.Dispose(); } await using (var api = new ModelProviderManager(Path.Combine(root, "api-images"), (authorization, account) => GatewayIdentity.Authenticate(authorization, account, () => "{\"OPENAI_API_KEY\":\"fictional-image-api-key\"}"), new HttpClient(handler))) { api.Start(); using var apiClient = new HttpClient(); apiClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "fictional-image-api-key"); @@ -321,7 +331,8 @@ async Task Send(string model, string session, JsonArray? in var cancelled = false; try { await saving; } catch (OperationCanceledException) { cancelled = true; } Check("Shutdown cancels and drains discovery before disposing state without saving a partial key", cancelled && !File.Exists(Path.Combine(cancelPath, "model-providers.vault"))); - Console.WriteLine(JsonSerializer.Serialize(new { passed = checks.Count, checks, nativeClient = args.Length != 0, framework = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, scope = "Disposable encrypted storage, loopback gateway and fictional upstreams; no owner home, account or desktop." }, new JsonSerializerOptions { WriteIndented = true })); + await GatewayLoadContracts.Run(root, Check, args.Contains("--stress")); + Console.WriteLine(JsonSerializer.Serialize(new { passed = checks.Count, checks, nativeClient = args.Contains("--codex"), framework = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, scope = "Disposable encrypted storage, loopback gateway and fictional upstreams; no owner home, account or desktop." }, new JsonSerializerOptions { WriteIndented = true })); } finally { From 83ac878cab83e0e1f6a7a007f2cf9d96ba94e285 Mon Sep 17 00:00:00 2001 From: Gaebabi <45709904+Gaebabi@users.noreply.github.com> Date: Sun, 1 Nov 2026 21:31:27 +0900 Subject: [PATCH 09/15] [2/2 Gateway load] Record native continuity and measured load verification --- docs/development/STATUS.md | 2 +- docs/evidence/gateway-load/README.md | 6 +- docs/evidence/gateway-load/after-windows.png | Bin 0 -> 55551 bytes docs/evidence/gateway-load/evidence.json | 67 +++++++++++++++++++ 4 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 docs/evidence/gateway-load/after-windows.png create mode 100644 docs/evidence/gateway-load/evidence.json diff --git a/docs/development/STATUS.md b/docs/development/STATUS.md index 0d65625..59245d3 100644 --- a/docs/development/STATUS.md +++ b/docs/development/STATUS.md @@ -1,6 +1,6 @@ # Current status -Issue149 is in local/CI verification. Source bounds request arrays at1GiB with small-request headroom, allows eight large uploads and48response streams, and forwards custom history/image slices without a full mutable JSON copy. A measured local generated-byte run accepts512MiB Responses/compaction/image-edit bodies and eight concurrent65MiB custom requests; token-based validation removes the prior payload-sized pooled metadata retention. Exact final check counts, measurements, native app continuity, installation and protected repository delivery are recorded separately in [load evidence](../evidence/gateway-load/README.md). +Issue149's implementation passes184local official-client/provider checks,74separate generated-byte stress checks and99Windows package self-tests. It bounds request arrays at1GiB with small-request headroom, allows eight large uploads and48response streams, and forwards custom history/image slices without a full mutable JSON copy. Exact512MiB Responses/compaction/image-edit and eight simultaneous65MiB custom uploads succeed; token validation removes payload-sized pooled metadata retention. Actual hosted Windows before/after images confirm app continuity. Protected final-head CI, installed-process state and external-provider acceptance are separate gates. [Measurements, evidence and delivery scope](../evidence/gateway-load/README.md). The gateway payload-budget follow-up is in local verification: the64MiB limit still interrupted an image-heavy chat, so source now accepts bounded512MiB bodies and limits concurrent large buffers to two. Unchanged OpenAI inputs reuse the owned request buffer without a mutable JSON tree or ToArray copy. Regression, installed package and actual chat resumption evidence are recorded separately; the earlier109/99 results below do not validate this follow-up. diff --git a/docs/evidence/gateway-load/README.md b/docs/evidence/gateway-load/README.md index 558d8dd..8076688 100644 --- a/docs/evidence/gateway-load/README.md +++ b/docs/evidence/gateway-load/README.md @@ -6,9 +6,9 @@ Issue149 improves image-heavy concurrent chats without increasing the512MiB per- | Current main | Load-resilience implementation | | --- | --- | -| ![Native Windows application baseline](before-windows.png) | Fresh hosted implementation capture pending. | +| ![Native Windows application baseline](before-windows.png) | ![Native Windows application after load improvements](after-windows.png) | -The baseline is the actual hosted native Windows own-window app with fictional accounts, source `9741e6ee202f4938ccdb8caf49ae5bc998e89941`, [Verify41302434623](https://github.com/Gaebabi/codex-switch/actions/runs/41302434623), artifact13081834051. Its original800×560 pixels have SHA256 `320049d86e415b3991ed0525e353b855d15949d0f76f1f45bf6c66044e4b9ee8`. It confirms application continuity, not network throughput. Fresh after/final source capture remains pending. Raw private chats and account evidence are excluded. +These are actual hosted native Windows own-window apps with fictional accounts. The baseline is source `9741e6ee202f4938ccdb8caf49ae5bc998e89941`, [Verify41302434623](https://github.com/Gaebabi/codex-switch/actions/runs/41302434623), artifact13081834051. The implementation is `19cc3666c2d2f2000122b5ffc263ad43771614bc`, [Verify41306288667](https://github.com/Gaebabi/codex-switch/actions/runs/41306288667), artifact13083975412. Both original800×560 images have SHA256 `320049d86e415b3991ed0525e353b855d15949d0f76f1f45bf6c66044e4b9ee8`. They confirm application continuity, not network throughput. [Exact hashes and measurements](evidence.json). Final-head protected CI remains required after this evidence update; production code is unchanged. Raw private chats and account evidence are excluded. ## Isolated behavior and load measurements @@ -28,4 +28,4 @@ The measured run took9,170ms. Peak managed heap was1,013,850,160bytes; after for `GatewayLoadContracts` also verifies escaped duplicate property names, nested duplicates, malformed JSON, cancellation/budget release, exact unchanged history slices, opaque-reasoning filtering, Retry-After and overload recovery. The normal Windows protected workflow runs the generated-byte stress mode after ordinary provider/official-client checks. No test weakens authentication, provider provenance or encrypted-compaction refusal. -Source verification, hosted native app continuity, protected final-head CI, an installed package and authenticated upstream acceptance remain separate gates. Current package self-test/installation evidence is recorded when performed; no new public binary release is part of this change. +The Windows self-contained package includes runtime10.0.13, contains494files and passes99self-tests. Source verification, hosted native app continuity, protected final-head CI, an installed process and authenticated upstream acceptance remain separate gates. No new public binary release is part of this change. diff --git a/docs/evidence/gateway-load/after-windows.png b/docs/evidence/gateway-load/after-windows.png new file mode 100644 index 0000000000000000000000000000000000000000..8442e9a06223db47a5700d02e53526817fc388cc GIT binary patch literal 55551 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 Date: Mon, 2 Nov 2026 02:21:12 +0900 Subject: [PATCH 10/15] [1/1 Release delivery] Record verified public version 1.44.0 packages --- docs/RELEASES.md | 2 +- docs/development/HOME_SIMPLIFICATION.md | 2 +- docs/development/PLAN.md | 2 +- docs/development/RELEASE_1_44_0.md | 10 ++++++++++ docs/development/STATUS.md | 2 +- docs/evidence/home-simplification/README.md | 2 +- 6 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/RELEASES.md b/docs/RELEASES.md index 72c8abb..0c8409c 100644 --- a/docs/RELEASES.md +++ b/docs/RELEASES.md @@ -1,6 +1,6 @@ # Binary releases -[Release1.44.0](https://github.com/Gaebabi/codex-switch/releases/tag/v1.44.0) simplifies Home around the existing quota tanks: Add/Refresh remain visible while one Settings gear groups global actions. It includes the corrected built-in image endpoints and bounded concurrent handling for image-heavy chats. The package set remains Windows x64, macOS15+ Apple Silicon/Intel and Linux x64/ARM64 integration previews. [Verification and installation gates](development/RELEASE_1_44_0.md). Links are staged until publication and public download verification complete. +[Release1.44.0](https://github.com/Gaebabi/codex-switch/releases/tag/v1.44.0) simplifies Home around the existing quota tanks: Add/Refresh remain visible while one Settings gear groups global actions. It includes the corrected built-in image endpoints and bounded concurrent handling for image-heavy chats. Windows x64, macOS15+ Apple Silicon/Intel and Linux x64/ARM64 integration preview packages are published; all twelve public assets match their sizes/SHA256, and the Windows ZIP passes99self-tests. [Verification and installation gates](development/RELEASE_1_44_0.md). [Release1.43.3](https://github.com/Gaebabi/codex-switch/releases/tag/v1.43.3) provides complete Windows x64 and macOS15+ Apple Silicon/Intel ZIPs, plus unsigned Linux x64/ARM64 DEB/RPM/tar.gz integration previews. Keep the entire folder/app bundle. No source build or global .NET installation is required; Codex desktop/CLI is installed separately. [Downloads](../README.md#download) / [latest published Release](https://github.com/Gaebabi/codex-switch/releases/latest). diff --git a/docs/development/HOME_SIMPLIFICATION.md b/docs/development/HOME_SIMPLIFICATION.md index c58e935..9481e98 100644 --- a/docs/development/HOME_SIMPLIFICATION.md +++ b/docs/development/HOME_SIMPLIFICATION.md @@ -18,4 +18,4 @@ Four accounts keep the existing grid and fit the500×360minimum. Larger Windows The local Windows production-control renderer exports before/after previews in English/Korean, dark/light, and800×560/600×400/500×360sizes. It never shows or activates a window and uses fictional accounts/resources. These are production WPF component renders, not native window screenshots or owner account acceptance. [Images, native evidence and checks](../evidence/home-simplification/README.md). -The provider-editor/home UI harness passes54checks, including minimum-width Settings reachability, retained menu entries, real language/theme/automation persistence in disposable storage, busy restart refusal,0/1/4/8accounts, long names, warnings, enabled-state visibility and scroll access. Hosted Windows and both Mac architectures pass actual application/menu checks, including Settings keyboard focus and language overflow with a visible scrollbar. [Original Windows before/after and Mac menu screenshots](../evidence/home-simplification/README.md) include exact-source/artifact hashes. Linux ordinary installed UI must exercise Add/cancel and Settings > Start Codex/appearance through the new paths, retaining the original encrypted-data, child ownership, rendering and lifecycle assertions. Final-head six-job verification, packages, public downloads and owner installation remain distinct release gates. +The provider-editor/home UI harness passes54checks, including minimum-width Settings reachability, retained menu entries, real language/theme/automation persistence in disposable storage, busy restart refusal,0/1/4/8accounts, long names, warnings, enabled-state visibility and scroll access. Hosted Windows and both Mac architectures pass actual application/menu checks, including Windows Settings keyboard focus and language overflow with a visible scrollbar on both platforms. [Original Windows before/after and Mac menu screenshots](../evidence/home-simplification/README.md) include exact-source/artifact hashes. Linux ordinary installed UI passes71checks, exercising Add/cancel and Settings > Start Codex/appearance through real input while retaining encrypted-data, child ownership, rendering and lifecycle assertions. Six protected jobs, packages and anonymous public downloads passed for1.44.0; owner installation remains independently verified. [Publication record](RELEASE_1_44_0.md). diff --git a/docs/development/PLAN.md b/docs/development/PLAN.md index e998e89..2a6515d 100644 --- a/docs/development/PLAN.md +++ b/docs/development/PLAN.md @@ -1,6 +1,6 @@ # Current plan -Issue151 delivers [Home simplification](HOME_SIMPLIFICATION.md) and the already merged image/concurrent-load gateway corrections as1.44.0. Preserve account tanks, large quota numbers, the grid and direct switching; consolidate global actions behind the titlebar gear while retaining Add/Refresh, automatic-state visibility and warning/recovery guards. Complete native application evidence, six protected checks, review, merge, package/public-download verification and separately authorized state-preserving installation. [Release gates](RELEASE_1_44_0.md). +Issue151's [Home simplification](HOME_SIMPLIFICATION.md) and merged image/concurrent-load gateway corrections are published in1.44.0 after six protected checks, author review, matching-tree merge and public package/download verification. Tanks, direct switching, Add/Refresh and enabled automatic-state visibility remain. Keep owner installation and its idle-gateway/data-preservation receipt separate from public delivery; retain broader provider/controller/Linux acceptance in the existing issues. [Publication record](RELEASE_1_44_0.md). Issue149 strengthens concurrent gateway load with an aggregate request-buffer budget, separate upload/stream limits, sliced custom rewrites and token-based validation. Verify exact512MiB bodies, eight65MiB custom uploads,48open response streams, overload/disconnect recovery and retained-memory measurements. Require final-head protected/native CI and privacy review before repository integration. Local installation and actual provider/account acceptance remain separate. diff --git a/docs/development/RELEASE_1_44_0.md b/docs/development/RELEASE_1_44_0.md index 775a9e0..6c00535 100644 --- a/docs/development/RELEASE_1_44_0.md +++ b/docs/development/RELEASE_1_44_0.md @@ -7,3 +7,13 @@ Before promotion, require actual native Windows/Mac/Linux app/menu checks with f Stage a draft against the verified merged tree. Verify all uploaded sizes/digests before publication and all anonymous downloads/checksums afterward. Preserve past releases and historical notes. Windows/Linux are unsigned, Mac is ad-hoc signed and not notarized, and Linux remains an integration preview with supportedAccountHandoff:false. Broader external-provider/desktop-picker/controller and Linux acceptance issues retain their existing scope. The owner also authorized local Windows installation. Verify the running Switch identity and zero active/queued gateway requests immediately before a normal IPC shutdown; replace only Switch, start hidden, preserve all existing data/providers/configuration and live Codex processes, then verify the loaded published package and shortcuts. Never force Codex/account operations to manufacture an idle window or acceptance result. + +## Verified public delivery + +Published on2026-11-02KST as [v1.44.0](https://github.com/Gaebabi/codex-switch/releases/tag/v1.44.0), release ID430727292. PR152 reviewed head `d97f1bfa94b933db9e64c2242324d8ff50ab89a6` was normally squash-merged to `c58d87a8cadc3ee02930bdfff36984d549fcffd2`; both have tree `440ec10a526e6195b0d6dda23a7f58759db30269`. Strict six-check protection and resolved conversations were preserved. The recorded COMMENT is author self-review, not independent approval. + +[Verify41327918439](https://github.com/Gaebabi/codex-switch/actions/runs/41327918439) passed repository, Windows and both Mac jobs. [Linux41327918438](https://github.com/Gaebabi/codex-switch/actions/runs/41327918438) passed native x64/ARM64 jobs. Actual exports contain38Windows images,20per Mac architecture and12per Linux architecture. The separate ordinary installed Linux x64/Xfce session passes71checks, including Add/cancel, Settings > Start Codex/appearance, original vault preservation and normal lifecycle; it is not authenticated account/active-chat acceptance. [Before/after and menu evidence](../evidence/home-simplification/README.md) distinguishes capture source from the unchanged final production source. + +Collected27exact-source CI artifacts and verified nine complete packages across five RIDs, runtime10.0.13, original license/notice placement, Mac staged signing/install checks and Linux payload hashes/modes. The release includes those nine packages,315sanitized verification files in one ZIP, `VERIFICATION.json` and `SHA256SUMS.txt`: twelve assets total. Every uploaded GitHub digest/size and subsequent anonymous full download matched. All four README routes are current: eighteen direct package links plus translated Linux release-page routes. The anonymous Windows ZIP passes99/99self-tests; this does not certify a personal account switch or external provider inference. + +The tag remains fixed to the verified product tree. Later delivery-document updates do not rebuild or replace these published assets. Owner installation evidence stays private and is checked independently from release publication. diff --git a/docs/development/STATUS.md b/docs/development/STATUS.md index 6a1f566..4085349 100644 --- a/docs/development/STATUS.md +++ b/docs/development/STATUS.md @@ -1,6 +1,6 @@ # Current status -Issue151/source1.44.0 consolidates global Home controls in Settings and reduces secondary metadata emphasis while preserving the tank concept and direct Switch/Recover actions. The windowless WPF harness passes54checks with disposable data. Native popup/keyboard checks, final protected CI, actual application evidence, review/merge, public packages and owner-authorized installation are in progress and remain separate gates. [Home scope](HOME_SIMPLIFICATION.md) / [release checklist](RELEASE_1_44_0.md). +Release1.44.0 is published from reviewed source `d97f1bfa94b933db9e64c2242324d8ff50ab89a6`, merged as `c58d87a8cadc3ee02930bdfff36984d549fcffd2` with an identical tree. All six protected checks passed. Windows/Mac actual app/menu evidence, Linux ordinary installed desktop71checks, nine runtime/license/provenance-verified packages and twelve anonymous public download hashes passed. The public Windows ZIP passes99self-tests. Home retains its tanks and direct switching with global actions in Settings. [Publication record](RELEASE_1_44_0.md) / [actual screenshots](../evidence/home-simplification/README.md). Owner installation remains a separate data-preservation/idle-gateway check; public delivery does not imply authenticated provider or broader Linux support acceptance. Issue149's implementation passes184local official-client/provider checks,74separate generated-byte stress checks and99Windows package self-tests. It bounds request arrays at1GiB with small-request headroom, allows eight large uploads and48response streams, and forwards custom history/image slices without a full mutable JSON copy. Exact512MiB Responses/compaction/image-edit and eight simultaneous65MiB custom uploads succeed; token validation removes payload-sized pooled metadata retention. Actual hosted Windows before/after images confirm app continuity. Protected final-head CI, installed-process state and external-provider acceptance are separate gates. [Measurements, evidence and delivery scope](../evidence/gateway-load/README.md). diff --git a/docs/evidence/home-simplification/README.md b/docs/evidence/home-simplification/README.md index 039b355..3eef591 100644 --- a/docs/evidence/home-simplification/README.md +++ b/docs/evidence/home-simplification/README.md @@ -18,7 +18,7 @@ The core account tanks, percentage display and direct Switch/Recover actions are ![Actual macOS language submenu with scrollbar](native-language-mac-intel.png) -These are original own-application window/popup captures from disposable hosted native workers, with fictional accounts and no owner desktop or credentials. [Source revisions, workflow/artifact digests, dimensions and original PNG hashes](evidence.json) bind the baseline main revision and implementation revision. Windows exports38images; each Mac architecture exports20. Settings keyboard focus, language overflow/visible scrollbars and existing account/credit/recovery checks pass on the captured implementation. Linux ordinary installed UI uses the new Add and Settings paths; its final native lifecycle result is recorded with the release verification. Native UI evidence does not establish authenticated provider or account-switch acceptance. +These are original own-application window/popup captures from disposable hosted native workers, with fictional accounts and no owner desktop or credentials. [Source revisions, workflow/artifact digests, dimensions and original PNG hashes](evidence.json) bind the baseline main revision and implementation revision. Windows exports38images; each Mac architecture exports20. Windows Settings keyboard focus, language overflow/visible scrollbars on both platforms and existing account/credit/recovery checks pass on the captured implementation. Linux ordinary installed UI passes71checks using the new Add and Settings paths. All six final-head protected checks and public package/download verification passed; [the publication record](../../development/RELEASE_1_44_0.md) identifies exact sources/runs. Native UI evidence does not establish authenticated provider or account-switch acceptance. ## Earlier local component preview From 862786f721b9f7e520216a483d35856da631fa3a Mon Sep 17 00:00:00 2001 From: Gaebabi <45709904+Gaebabi@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:22:33 +0900 Subject: [PATCH 11/15] [1/2 Worktree integration] Preserve chat-title-date changes --- docs/development/STATUS.md | 2 ++ localization/packs/de.json | 5 +++ localization/packs/en.json | 5 +++ localization/packs/es.json | 5 +++ localization/packs/fr.json | 5 +++ localization/packs/ja.json | 5 +++ localization/packs/ko.json | 5 +++ localization/packs/pt-BR.json | 5 +++ localization/packs/zh-CN.json | 5 +++ localization/packs/zh-TW.json | 5 +++ src/core/AccountSwitchCoordinator.cs | 15 ++++++++ src/desktop/DeskWindow.cs | 45 +++++++++++++++++++----- src/windows/MainWindow.cs | 51 ++++++++++++++++++++++------ tests/account-automation/Program.cs | 21 ++++++++++-- 14 files changed, 157 insertions(+), 22 deletions(-) diff --git a/docs/development/STATUS.md b/docs/development/STATUS.md index 8229c46..10e210b 100644 --- a/docs/development/STATUS.md +++ b/docs/development/STATUS.md @@ -1,5 +1,7 @@ # Current status +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. + [Native application continuity and Windows file-lock regression](../evidence/runtime-cleanup/README.md) preserve original pixels and exact source/receipt hashes. Windows220 account/history/recovery checks pass; shared/native Mac/Linux acceptance remains distinct. Final review/public1.43.2 delivery is tracked in [PR140](https://github.com/Gaebabi/codex-switch/pull/140). Issue139/source1.43.2 waits asynchronously for transient Windows runtime-file sharing locks for at most two seconds, preserving login cancellation after release. Every attempt revalidates the private GUID path/link boundary; nonsharing and persistent failures still surface. No process termination, permission widening, account setting or background worker is added. [Release gates](RELEASE_1_43_2.md). diff --git a/localization/packs/de.json b/localization/packs/de.json index 204515c..fe44a5b 100644 --- a/localization/packs/de.json +++ b/localization/packs/de.json @@ -6,6 +6,11 @@ "culture": "de-DE", "direction": "ltr", "strings": { + "titles.button": "Chat-Datum", + "titles.help": "Datum nach koreanischer Zeit bei laufenden Hauptchats hinzufügen oder aktualisieren.", + "titles.working": "Chat-Daten werden aktualisiert…", + "titles.done": "Datum für {0} Chats aktualisiert.", + "titles.failed": "Nach {0} aktualisierten Chats ist ein Fehler aufgetreten: {1}", "credits.history": "Credits", "credits.balance": "Guthaben", "credits.observed": "Beobachteter Rückgang", diff --git a/localization/packs/en.json b/localization/packs/en.json index 173167e..1e9e04a 100644 --- a/localization/packs/en.json +++ b/localization/packs/en.json @@ -6,6 +6,11 @@ "culture": "en-US", "direction": "ltr", "strings": { + "titles.button": "Date chats", + "titles.help": "Add or update the KST date prefix on running main chats.", + "titles.working": "Updating chat dates…", + "titles.done": "Updated dates on {0} chats.", + "titles.failed": "Updated {0} chats before an error: {1}", "credits.history": "Credits", "credits.balance": "Balance", "credits.observed": "Observed decrease", diff --git a/localization/packs/es.json b/localization/packs/es.json index 6a89740..9da6a01 100644 --- a/localization/packs/es.json +++ b/localization/packs/es.json @@ -6,6 +6,11 @@ "culture": "es-ES", "direction": "ltr", "strings": { + "titles.button": "Fechar chats", + "titles.help": "Añade o actualiza la fecha coreana en los chats principales activos.", + "titles.working": "Actualizando fechas de chats…", + "titles.done": "Fechas actualizadas en {0} chats.", + "titles.failed": "Error tras actualizar {0} chats: {1}", "credits.history": "Créditos", "credits.balance": "Saldo", "credits.observed": "Disminución observada", diff --git a/localization/packs/fr.json b/localization/packs/fr.json index 21ad59b..553d38a 100644 --- a/localization/packs/fr.json +++ b/localization/packs/fr.json @@ -6,6 +6,11 @@ "culture": "fr-FR", "direction": "ltr", "strings": { + "titles.button": "Dater les chats", + "titles.help": "Ajouter ou actualiser la date coréenne des conversations principales en cours.", + "titles.working": "Mise à jour des dates…", + "titles.done": "Dates mises à jour pour {0} conversations.", + "titles.failed": "Erreur après la mise à jour de {0} conversations : {1}", "credits.history": "Crédits", "credits.balance": "Solde", "credits.observed": "Baisse observée", diff --git a/localization/packs/ja.json b/localization/packs/ja.json index a919af0..e9fc1ce 100644 --- a/localization/packs/ja.json +++ b/localization/packs/ja.json @@ -6,6 +6,11 @@ "culture": "ja-JP", "direction": "ltr", "strings": { + "titles.button": "日付更新", + "titles.help": "実行中のメインチャットの先頭に韓国時間の日付を追加または更新します。", + "titles.working": "チャットの日付を更新中…", + "titles.done": "{0}件のチャットの日付を更新しました。", + "titles.failed": "{0}件を更新した後にエラーが発生しました: {1}", "credits.history": "クレジット", "credits.balance": "残高", "credits.observed": "観測した残高減少", diff --git a/localization/packs/ko.json b/localization/packs/ko.json index 8eb37f1..52a3d2e 100644 --- a/localization/packs/ko.json +++ b/localization/packs/ko.json @@ -6,6 +6,11 @@ "culture": "ko-KR", "direction": "ltr", "strings": { + "titles.button": "채팅 날짜", + "titles.help": "실행 중인 주 채팅 제목에 한국 시간 날짜를 추가하거나 갱신합니다.", + "titles.working": "채팅 날짜 갱신 중…", + "titles.done": "채팅 {0}개의 날짜를 갱신했습니다.", + "titles.failed": "채팅 {0}개를 갱신한 뒤 오류가 발생했습니다: {1}", "credits.history": "크레딧", "credits.balance": "잔액", "credits.observed": "관측된 잔액 감소", diff --git a/localization/packs/pt-BR.json b/localization/packs/pt-BR.json index b185976..09066d3 100644 --- a/localization/packs/pt-BR.json +++ b/localization/packs/pt-BR.json @@ -6,6 +6,11 @@ "culture": "pt-BR", "direction": "ltr", "strings": { + "titles.button": "Datar chats", + "titles.help": "Adiciona ou atualiza a data coreana nos chats principais em execução.", + "titles.working": "Atualizando datas dos chats…", + "titles.done": "Datas atualizadas em {0} chats.", + "titles.failed": "Erro após atualizar {0} chats: {1}", "credits.history": "Créditos", "credits.balance": "Saldo", "credits.observed": "Redução observada", diff --git a/localization/packs/zh-CN.json b/localization/packs/zh-CN.json index 781b4d0..bf7975d 100644 --- a/localization/packs/zh-CN.json +++ b/localization/packs/zh-CN.json @@ -6,6 +6,11 @@ "culture": "zh-CN", "direction": "ltr", "strings": { + "titles.button": "更新日期", + "titles.help": "为运行中的主聊天添加或更新韩国时间日期。", + "titles.working": "正在更新聊天日期…", + "titles.done": "已更新 {0} 个聊天的日期。", + "titles.failed": "更新 {0} 个聊天后出错:{1}", "credits.history": "点数", "credits.balance": "余额", "credits.observed": "观测到的余额减少", diff --git a/localization/packs/zh-TW.json b/localization/packs/zh-TW.json index 27476f2..6f3fc1c 100644 --- a/localization/packs/zh-TW.json +++ b/localization/packs/zh-TW.json @@ -6,6 +6,11 @@ "culture": "zh-TW", "direction": "ltr", "strings": { + "titles.button": "更新日期", + "titles.help": "為執行中的主聊天新增或更新韓國時間日期。", + "titles.working": "正在更新聊天日期…", + "titles.done": "已更新 {0} 個聊天的日期。", + "titles.failed": "更新 {0} 個聊天後發生錯誤:{1}", "credits.history": "點數", "credits.balance": "餘額", "credits.observed": "觀測到的餘額減少", diff --git a/src/core/AccountSwitchCoordinator.cs b/src/core/AccountSwitchCoordinator.cs index 070bc31..50dad33 100644 --- a/src/core/AccountSwitchCoordinator.cs +++ b/src/core/AccountSwitchCoordinator.cs @@ -64,6 +64,21 @@ internal interface IAccountSwitchBackend : IAsyncDisposable Task ResumeAsync(SwitchChat chat, Action sending, CancellationToken cancellation); } +internal static class ActiveChatTitleUpdate +{ + public static async Task RunAsync(IAccountSwitchBackend backend, DateTimeOffset at, Action progress, CancellationToken cancellation) + { + var chats = await backend.CaptureAsync(cancellation); + var saved = 0; + foreach (var chat in chats) + { + await backend.BackupNameAsync(chat, at, cancellation); + progress(++saved); + } + return saved; + } +} + internal sealed class AccountSwitchCoordinator { public const int MaximumRecoveryAttempts = 2; diff --git a/src/desktop/DeskWindow.cs b/src/desktop/DeskWindow.cs index 8b13ab6..9e1b7a5 100644 --- a/src/desktop/DeskWindow.cs +++ b/src/desktop/DeskWindow.cs @@ -40,12 +40,13 @@ internal sealed class DeskWindow : Window private TextBlock _status = new(); private Button _add = new(), _cancel = new(), _refresh = new(), _settingsButton = new(), _startCodex = new(); private Button _automaticSwitch = new(); + private Button _stampTitles = new(); private IDesktopMenuBar? _tray; private readonly Dictionary _nativeItems = new(); private CancellationTokenSource? _login, _switch; private string? _switchTarget; private SwitchPhase _phase; - private bool _ready; + private bool _ready, _stampingTitles; private bool _initializing; private string _statusMessage = ""; private AccountProfile[]? _preview; @@ -169,7 +170,11 @@ private void Build() var toolbar = new Grid { Margin = new Thickness(8, 1), ColumnDefinitions = new ColumnDefinitions(nativeTitle ? "Auto,*,Auto" : "*,Auto,*") }; _cancel = _theme.Button(Loc.T("취소"), () => { _login?.Cancel(); _switch?.Cancel(); }); _cancel.IsVisible = _login != null || _switch != null; _cancel.HorizontalAlignment = HorizontalAlignment.Left; - toolbar.Children.Add(_cancel); + var actions = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Left }; + _stampTitles = _theme.Button(Loc.T("titles.button"), () => _ = StampTitlesAsync()); + _stampTitles.Name = "StampActiveTitlesButton"; _stampTitles.FontSize = 10; _stampTitles.Padding = new Thickness(5, 1); _stampTitles.MinHeight = 20; + ToolTip.SetTip(_stampTitles, Loc.T("titles.help")); actions.Children.Add(_stampTitles); actions.Children.Add(_cancel); + toolbar.Children.Add(actions); _sessionValues = new StackPanel { Orientation = Orientation.Horizontal, Spacing = nativeTitle ? 12 : 24, HorizontalAlignment = HorizontalAlignment.Center }; Grid.SetColumn(_sessionValues, 1); toolbar.Children.Add(_sessionValues); @@ -245,8 +250,9 @@ private void RenderAccounts() { _accounts.Children.Clear(); _accounts.ColumnDefinitions.Clear(); _accounts.RowDefinitions.Clear(); _cards.Clear(); if (_tray != null) UpdateTray(); - _add.IsEnabled = _ready && _login == null; _cancel.IsVisible = _login != null || _switch != null; + _add.IsEnabled = _ready && _login == null && !_stampingTitles; _cancel.IsVisible = _login != null || _switch != null; _startCodex.IsEnabled = _ready && _login == null && _switch == null; + _stampTitles.IsEnabled = _ready && _preview == null && _login == null && _switch == null && !_stampingTitles && _service?.Vault.Data.PendingSwitch == null; _refresh.IsEnabled = !_initializing && _preview == null && _host.AccountUnavailableReason == null; var profiles = (_preview ?? _service?.Accounts ?? Array.Empty()).OrderByDescending(p => p.IsCurrent).ThenBy(p => p.DisplayName, StringComparer.CurrentCulture).ToArray(); if (profiles.Length == 0) @@ -267,7 +273,7 @@ private void RenderAccounts() resume ? "switch.recover" : profile.RequiresApply ? "로그인 적용" : profile.IsCurrent ? "사용 중" : "전환"; var button = _theme.Button(Loc.T(text), () => _ = SwitchAsync(profile)); if (resume) ToolTip.SetTip(button, pending?.Error); - button.IsEnabled = _ready && _switch == null && _login == null && (!profile.IsCurrent || profile.RequiresApply || resume); + button.IsEnabled = _ready && _switch == null && _login == null && !_stampingTitles && (!profile.IsCurrent || profile.RequiresApply || resume); button.FontSize = 10; button.Padding = new Thickness(5, 2); var more = _theme.IconButton(DeskButtonIconKind.More, Loc.T("계정 메뉴"), () => { }); more.Width = 25; more.Padding = new Thickness(3, 2); more.Click += (_, _) => AccountMenu(more, profile); @@ -302,7 +308,7 @@ private async Task RunAsync(Func action) } private async Task AddAccountAsync(string? expectedAccountKey = null) { - if (!_ready || _login != null || _switch != null) return; + if (!_ready || _login != null || _switch != null || _stampingTitles) return; _login = CancellationTokenSource.CreateLinkedTokenSource(_stop.Token); RenderAccounts(); try { @@ -316,7 +322,7 @@ await RunAsync(async _ => } private async Task SwitchAsync(AccountProfile profile, bool automatic = false, bool creditPause = false) { - if (!_ready || _switch != null || _login != null) return; + if (!_ready || _switch != null || _login != null || _stampingTitles) return; var creditPending = _service!.Vault.Data.PendingSwitch; creditPause |= creditPending is { CreditProtectionPause: true, Phase: not SwitchPhase.CreditPaused } && creditPending.TargetAccountKey == profile.Key && !AccountAutomation.CanUse(_service, profile, DateTimeOffset.UtcNow); if (!creditPause && !AccountAutomation.CanUse(_service, profile, DateTimeOffset.UtcNow)) { _statusMessage = Loc.T("credits.resume_blocked"); _status.Text = _statusMessage; return; } @@ -351,6 +357,27 @@ await RunAsync(async _ => } finally { if (automatic && _service!.Vault.Data.PendingSwitch != null) AccountAutomation.Pause(_service); _switch.Dispose(); _switch = null; _switchTarget = null; if (!Exiting) { Build(); } } } + private async Task StampTitlesAsync() + { + if (!_ready || Exiting || _preview != null || _service == null || _login != null || _switch != null || _stampingTitles || _service.Vault.Data.PendingSwitch != null) return; + _stampingTitles = true; RenderAccounts(); + var saved = 0; + try + { + await RunAsync(async token => + { + try + { + await using var backend = new DesktopSwitchBackend(_service, _sessions); + await ActiveChatTitleUpdate.RunAsync(backend, DateTimeOffset.UtcNow, count => saved = count, token); + _statusMessage = Loc.F("titles.done", saved); + } + catch (OperationCanceledException) { throw; } + catch (Exception error) { _statusMessage = Loc.F("titles.failed", saved, Loc.Error(UserErrors.Describe(error))); } + }); + } + finally { _stampingTitles = false; if (!Exiting) RenderAccounts(); } + } private void OpenAutomaticSwitchMenu() { if (!_ready || _service == null) return; @@ -385,7 +412,7 @@ private void OpenAutomaticSwitchMenu() } private async Task TryAutomaticSwitchAsync() { - if (!_ready || Exiting || _preview != null || _service == null || _login != null || _switch != null) return; + if (!_ready || Exiting || _preview != null || _service == null || _login != null || _switch != null || _stampingTitles) return; if (!_sessions.Current.Available && _service.Vault.Data.PendingSwitch?.CreditProtectionPause != true) { if (!_service.Vault.Data.AllowCreditUse && _service.Accounts.Any(p => p.IsCurrent && AutomaticSwitchPolicy.Exhausted(p, DateTimeOffset.UtcNow)) && @@ -410,7 +437,7 @@ await RunAsync(async _ => } private async Task RecoverAutomaticSwitchAsync() { - if (!_ready || Exiting || _preview != null || _service == null || _login != null || _switch != null || _service.IsBusy) return; + if (!_ready || Exiting || _preview != null || _service == null || _login != null || _switch != null || _stampingTitles || _service.IsBusy) return; var pending = _service.Vault.Data.PendingSwitch; if (pending is not { Automatic: true, AutomaticRecoveryAttempts: 0, CreditProtectionPause: false }) return; var target = _service.Accounts.FirstOrDefault(p => p.Key == pending.TargetAccountKey); @@ -433,7 +460,7 @@ private void AccountMenu(Button target, AccountProfile profile) Item(Loc.T("추이 보기"), () => new HistoryWindow(_service!, profile.Key, _theme).Show(this)), Item(Loc.T("이 계정 즉시 새로 고침"), () => _ = RunAsync(token => _service!.RefreshAccountAsync(profile.Key, token, true))), Item(Loc.T("이름 변경"), async () => { var value = await DeskDialogs.Prompt(this, _theme, Loc.T("이름 변경"), profile.Alias); if (value != null) await RunAsync(_ => { _service!.Rename(profile.Key, value); return Task.CompletedTask; }); }), - Item(Loc.T("삭제"), async () => { if (_switch != null) return; if (await DeskDialogs.Confirm(this, _theme, Loc.T("삭제"), profile.DisplayName)) await RunAsync(_ => _service!.RemoveAsync(profile.Key)); }) }; + Item(Loc.T("삭제"), async () => { if (_switch != null || _stampingTitles) return; if (await DeskDialogs.Confirm(this, _theme, Loc.T("삭제"), profile.DisplayName) && !_stampingTitles) await RunAsync(_ => _service!.RemoveAsync(profile.Key)); }) }; menu.Open(target); } private void RenderSessions() diff --git a/src/windows/MainWindow.cs b/src/windows/MainWindow.cs index 6824f15..10a625d 100644 --- a/src/windows/MainWindow.cs +++ b/src/windows/MainWindow.cs @@ -35,6 +35,7 @@ internal sealed class MainWindow : Window private readonly SessionUsagePanel _sessionCount = new(); private readonly Button _refresh = new(), _add = new(), _cancelLogin = new(), _cancelSwitch = new(); private readonly Button _automaticSwitch = new(); + private readonly Button _stampTitles = new(); private MenuItem? _importCurrent; private readonly Border _loginPanel = new(); private readonly List _tankCards = new(); @@ -48,7 +49,7 @@ internal sealed class MainWindow : Window private readonly DispatcherTimer _clockTimer = new() { Interval = TimeSpan.FromSeconds(1) }; private Forms.NotifyIcon? _tray; private bool _ready, _loggingIn, _switching, _closing, _exit; - private bool _started; + private bool _started, _stampingTitles; private bool _cpuTemperatureChanging; private bool CanRefresh => !_closing && (_ready || _initialization?.IsCompleted != false); private string? _queuedKey; @@ -81,6 +82,7 @@ public MainWindow(AccountService service, Program.Options options) _add.Click += async (_, _) => await AddAsync(); _cancelLogin.Click += (_, _) => _loginCancellation?.Cancel(); _cancelSwitch.Click += (_, _) => CancelSwitch(); + _stampTitles.Click += async (_, _) => await StampTitlesAsync(); _service.Changed += ServiceChanged; _sessions.Changed += SessionChanged; _hardware.Changed += HardwareChanged; @@ -122,7 +124,7 @@ private async Task EnableCpuTemperatureAsync() } private void ApplyTheme() { - foreach (var element in new FrameworkElement[] { _automaticSwitch, _sessionCount, _hardwarePanel, _drivePanel, _status, _loginText, _add, _refresh, _cancelLogin, _cancelSwitch, _loginPanel, _scroll }) + foreach (var element in new FrameworkElement[] { _automaticSwitch, _stampTitles, _sessionCount, _hardwarePanel, _drivePanel, _status, _loginText, _add, _refresh, _cancelLogin, _cancelSwitch, _loginPanel, _scroll }) { if (element.Parent is Panel panel) panel.Children.Remove(element); else if (element.Parent is ContentControl control && ReferenceEquals(control.Content, element)) control.Content = null; @@ -163,6 +165,10 @@ private UIElement BuildRoot() toolbarRow.ColumnDefinitions.Add(new ColumnDefinition()); toolbarRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); toolbarRow.ColumnDefinitions.Add(new ColumnDefinition()); + ConfigureButton(_stampTitles, "titles.button", null); + _stampTitles.Name = "StampActiveTitlesButton"; _stampTitles.HorizontalAlignment = HorizontalAlignment.Left; + _stampTitles.FontSize = 10; _stampTitles.Padding = new Thickness(5, 2, 5, 2); + _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); @@ -479,8 +485,30 @@ private async Task RefreshAsync() await RecoverAutomaticSwitchAsync(); await TryAutomaticSwitchAsync(); } + private async Task StampTitlesAsync() + { + if (!_ready || _closing || _switching || _loggingIn || _stampingTitles || PendingSwitch() != null) return; + _stampingTitles = true; UpdateControls(); RenderAccounts(); + var saved = 0; + try + { + await RunAsync("titles.working", async token => + { + try + { + await using var backend = new DesktopSwitchBackend(_service, _sessions); + await ActiveChatTitleUpdate.RunAsync(backend, DateTimeOffset.UtcNow, count => saved = count, token); + SetStatus("titles.done", false, saved); + } + catch (OperationCanceledException) { throw; } + catch (Exception error) { SetStatus("titles.failed", true, saved, Loc.Error(UserErrors.Describe(error))); } + }); + } + finally { _stampingTitles = false; UpdateControls(); RenderAccounts(); } + } private Task ImportCurrentAsync() => RunAsync("현재 로그인 가져오는 중…", async token => { + if (_stampingTitles) return; await EnsureInitializedAsync(token); var profile = await Task.Run(() => { var p = _service.SynchronizeCurrent(); _service.Vault.Save(); return p; }, token); if (profile == null) throw new InvalidOperationException("Codex에서 먼저 로그인하거나 계정 추가를 선택해 주세요."); @@ -494,7 +522,7 @@ private Task ReauthenticateAsync(string key) } private async Task AddAsync(string? expectedAccountKey = null) { - if (!_ready || _loggingIn || _closing || _switching) return; + if (!_ready || _loggingIn || _closing || _switching || _stampingTitles) return; using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token); _loginCancellation = cancellation; _loggingIn = true; _loginText.Text = Loc.T("브라우저에서 추가할 계정으로 로그인해 주세요."); UpdateControls(); await RunAsync("계정 로그인 중…", async _ => @@ -568,7 +596,7 @@ private void RenderAccounts() switchButton.Tag = "account-switch-action"; if (pendingResume) switchButton.ToolTip = PendingSwitch()?.Error; switchButton.Margin = new Thickness(0); switchButton.FontSize = 12; switchButton.Padding = new Thickness(8, 5, 8, 5); - switchButton.IsEnabled = _ready && !_switching && !_loggingIn && !_closing && (!profile.IsCurrent || profile.RequiresApply || pendingResume); + switchButton.IsEnabled = _ready && !_switching && !_stampingTitles && !_loggingIn && !_closing && (!profile.IsCurrent || profile.RequiresApply || pendingResume); switchButton.Click += async (_, _) => await QueueSwitchAsync(profile); actions.Children.Add(switchButton); var more = NewButton("", "more"); more.Padding = new Thickness(6); more.Margin = new Thickness(4, 0, 0, 0); more.ToolTip = Loc.T("계정 메뉴"); more.Click += (_, _) => OpenAccountMenu(more, profile); actions.Children.Add(more); var details = profile.RequiresApply ? Loc.T("새 로그인 적용 대기") : ""; @@ -682,7 +710,7 @@ private void OpenAccountMenu(Button owner, AccountProfile profile) } var rename = new MenuItem { Header = Loc.T("표시 이름 변경") }; rename.Click += (_, _) => { var alias = PromptForAlias(profile); if (alias != null) _service.Rename(profile.Key, alias); }; var remove = new MenuItem { Header = Loc.T("등록 제거"), IsEnabled = !profile.IsCurrent && profile.Key != _queuedKey }; - remove.Click += async (_, _) => { if (Confirm(Loc.F("{0}의 등록 정보를 제거할까요?", profile.DisplayName), Loc.T("계정 등록 제거"), "등록 제거")) await RunAsync("등록 제거 중…", async _ => { await _service.RemoveAsync(profile.Key); SetStatus("등록 정보를 제거했습니다."); }); }; + remove.Click += async (_, _) => { if (!_stampingTitles && Confirm(Loc.F("{0}의 등록 정보를 제거할까요?", profile.DisplayName), Loc.T("계정 등록 제거"), "등록 제거")) await RunAsync("등록 제거 중…", async _ => { await _service.RemoveAsync(profile.Key); SetStatus("등록 정보를 제거했습니다."); }); }; menu.Items.Add(rename); menu.Items.Add(remove); owner.ContextMenu = menu; menu.IsOpen = true; } private string? PromptForAlias(AccountProfile profile) @@ -757,7 +785,7 @@ void ApplyProgress() private async Task QueueSwitchAsync(AccountProfile profile, bool automatic = false, bool creditPause = false) { var pending = PendingSwitch(); - if (_closing || _switching || _loggingIn || !creditPause && profile.IsCurrent && !profile.RequiresApply && pending?.TargetAccountKey != profile.Key) return; + if (_closing || _switching || _stampingTitles || _loggingIn || !creditPause && profile.IsCurrent && !profile.RequiresApply && pending?.TargetAccountKey != profile.Key) return; if (pending != null && !pending.CreditProtectionPause && pending.TargetAccountKey != profile.Key) { SetStatus("남은 채팅 재개 작업을 먼저 처리해 주세요.", true); return; } creditPause |= pending is { CreditProtectionPause: true, Phase: not SwitchPhase.CreditPaused } && pending.TargetAccountKey == profile.Key && !AccountAutomation.CanUse(_service, profile, DateTimeOffset.UtcNow); if (!creditPause && !AccountAutomation.CanUse(_service, profile, DateTimeOffset.UtcNow)) { SetStatus("credits.resume_blocked", true); return; } @@ -851,7 +879,7 @@ internal ContextMenu BuildAutomaticSwitchMenu() } private async Task TryAutomaticSwitchAsync() { - if (_options.ReportPath != null || _options.UiEvidenceDirectory != null || !_ready || _closing || _switching || _loggingIn) return; + if (_options.ReportPath != null || _options.UiEvidenceDirectory != null || !_ready || _closing || _switching || _loggingIn || _stampingTitles) return; if (!_sessions.Current.Available && PendingSwitch()?.CreditProtectionPause != true) { if (!_service.Vault.Data.AllowCreditUse && _service.Accounts.Any(p => p.IsCurrent && AutomaticSwitchPolicy.Exhausted(p, DateTimeOffset.UtcNow)) && RuntimeLocator.IsDesktopRunning(_service.Installation.DesktopPath)) @@ -877,7 +905,7 @@ await RunAsync("", async _ => } private async Task RecoverAutomaticSwitchAsync() { - if (_options.ReportPath != null || _options.UiEvidenceDirectory != null || !_ready || _closing || _switching || _loggingIn || _service.IsBusy) return; + if (_options.ReportPath != null || _options.UiEvidenceDirectory != null || !_ready || _closing || _switching || _loggingIn || _stampingTitles || _service.IsBusy) return; var pending = PendingSwitch(); if (pending is not { Automatic: true, AutomaticRecoveryAttempts: 0, CreditProtectionPause: false }) return; var target = _service.Accounts.FirstOrDefault(p => p.Key == pending.TargetAccountKey); @@ -899,7 +927,7 @@ private void RenderStatus() _status.ToolTip = _status.Text; _status.Foreground = Color(_statusWarning ? "warning" : "muted"); var empty = _service.Vault.Data.Drives.Count == 0; - _status.Visibility = _status.Text.Length > 0 && (_statusWarning || empty) ? Visibility.Visible : Visibility.Collapsed; + _status.Visibility = _status.Text.Length > 0 && (_statusWarning || empty || _statusSource == "titles.done") ? Visibility.Visible : Visibility.Collapsed; _drivePanel.Visibility = Visibility.Visible; _drivePanel.HorizontalAlignment = empty ? HorizontalAlignment.Right : HorizontalAlignment.Stretch; _drivePanel.Width = empty ? 22 : double.NaN; @@ -912,11 +940,12 @@ private void UpdateControls() _automaticSwitch.ToolTip = automaticSwitchLabel + "\n" + AccountAutomation.NextLabel(_service, DateTimeOffset.UtcNow) + "\n" + Loc.F("auto.help", _service.Vault.Data.AutomaticSwitchThreshold) + "\n" + Loc.T(_service.Vault.Data.AutomaticSwitchUsePriority ? "auto.custom" : "auto.optimal") + "\n" + Loc.T("auto.priority_help"); _automaticSwitch.IsEnabled = _ready && !_closing; + _stampTitles.IsEnabled = _ready && !_closing && !_switching && !_loggingIn && !_stampingTitles && PendingSwitch() == null; if (_automaticSwitch.ContextMenu is { IsOpen: true } openMenu) foreach (var next in openMenu.Items.OfType().Where(item => item.Name == "NextAutomaticAccount")) next.Header = AccountAutomation.NextLabel(_service, DateTimeOffset.UtcNow); - _refresh.IsEnabled = CanRefresh; _add.IsEnabled = _ready && !_loggingIn && !_closing; - if (_importCurrent != null) _importCurrent.IsEnabled = !_closing; + _refresh.IsEnabled = CanRefresh; _add.IsEnabled = _ready && !_loggingIn && !_closing && !_stampingTitles; + if (_importCurrent != null) _importCurrent.IsEnabled = !_closing && !_stampingTitles; _loginPanel.Visibility = _loggingIn ? Visibility.Visible : Visibility.Collapsed; _cancelSwitch.Visibility = Visibility.Collapsed; } diff --git a/tests/account-automation/Program.cs b/tests/account-automation/Program.cs index e9b2b77..09fb2fc 100644 --- a/tests/account-automation/Program.cs +++ b/tests/account-automation/Program.cs @@ -8,6 +8,21 @@ CreditHistoryContracts.Run(Check); await RuntimeCleanupContracts.Run(Check); await CreditProtectionContracts.Run(Check); +{ + var backend = new FakeBackend(); var saved = 0; + var count = await ActiveChatTitleUpdate.RunAsync(backend, DateTimeOffset.UtcNow, value => saved = value, CancellationToken.None); + Check("Manual title update only captures and verifies names", count == 1 && saved == 1 && backend.Names == 1 && backend.Closes == 0 && backend.Applies == 0 && backend.Resumes == 0); + backend = new FakeBackend { NameFailures = 1 }; saved = 0; var failed = false; + try { await ActiveChatTitleUpdate.RunAsync(backend, DateTimeOffset.UtcNow, value => saved = value, CancellationToken.None); } + catch (IOException) { failed = true; } + Check("Manual title update reports a naming failure without claiming success", failed && saved == 0 && backend.Closes == 0 && backend.Applies == 0); + backend = new FakeBackend { CapturedChats = new[] { new SwitchChat { Id = "one" }, new SwitchChat { Id = "two" } }, FailNameAt = 2 }; saved = 0; failed = false; + try { await ActiveChatTitleUpdate.RunAsync(backend, DateTimeOffset.UtcNow, value => saved = value, CancellationToken.None); } + catch (IOException) { failed = true; } + Check("Partial title update reports only names verified before failure", failed && saved == 1 && backend.Names == 2); + var at = DateTimeOffset.Parse("2026-10-31T15:00:00Z"); + Check("Date prefix is replaced at the KST day boundary", SwitchTitle.Stamp("[261031] 채팅", at) == "[261101] 채팅"); +} JsonElement Hello(string version, string home = "/fictional/home", string os = "linux", string family = "unix", string prefix = "codex_account_desk/") => JsonSerializer.SerializeToElement(new { codexHome = home, platformOs = os, platformFamily = family, userAgent = prefix + version + " (fixture)" }); foreach (var version in new[] { "0.162.0-alpha.17.2", "0.162.1", "0.163.0", "0.164.0" }) @@ -330,13 +345,15 @@ sealed class FakeBackend : IAccountSwitchBackend public CancellationTokenSource? Cancel; public int Launches, Applies, Resumes, Closes, Names, LaunchFailures, ResumeFailures, ResumeCalls, CloseFailures, NameFailures, ApplyFailures, ApplyCalls; public Exception? LaunchError; + public SwitchChat[]? CapturedChats; + public int FailNameAt; public bool AmbiguousContinue; public string? LastMarker; public Task CurrentAccountAsync(CancellationToken cancellation) => Task.FromResult(Current); - public Task CaptureAsync(CancellationToken cancellation) => Task.FromResult(new[] { new SwitchChat { Id = "chat", OriginalTitle = "Fictional work" } }); + public Task CaptureAsync(CancellationToken cancellation) => Task.FromResult(CapturedChats ?? new[] { new SwitchChat { Id = "chat", OriginalTitle = "Fictional work" } }); public int TargetValidations; public Task ValidateChatsForTargetAsync(SwitchChat[] chats, string target, CancellationToken cancellation) { TargetValidations++; if (Failure == "target-refused") throw new InvalidDataException("Synthetic workspace refusal"); return Task.CompletedTask; } - public Task BackupNameAsync(SwitchChat chat, DateTimeOffset at, CancellationToken cancellation) { Names++; if (Names <= NameFailures) throw new IOException("Synthetic name failure"); return Task.CompletedTask; } + public Task BackupNameAsync(SwitchChat chat, DateTimeOffset at, CancellationToken cancellation) { Names++; if (Names <= NameFailures || Names == FailNameAt) throw new IOException("Synthetic name failure"); return Task.CompletedTask; } public Task CloseDesktopAsync(CancellationToken cancellation) { Closes++; if (Failure == "close" || Closes <= CloseFailures) throw new IOException("Synthetic shutdown remains incomplete"); if (Failure == "cancel-after-close") Cancel!.Cancel(); return Task.CompletedTask; } public Task ApplyAccountAsync(string key, string? source, CancellationToken cancellation) { ApplyCalls++; if (Failure == "apply" || ApplyCalls <= ApplyFailures) throw new IOException("Synthetic apply failure"); Current = key; Applies++; if (Failure == "apply-after-write" && ApplyCalls == 1) throw new IOException("Synthetic failure after writing credentials"); return Task.CompletedTask; } public Task LaunchDesktopAsync(CancellationToken cancellation) { Launches++; if (Failure == "launch-always" || Launches <= LaunchFailures) throw LaunchError ?? new IOException("Synthetic launch failure"); return Task.CompletedTask; } From f55934647bf722abd85ffa3fc4e76a33cf3f45cd Mon Sep 17 00:00:00 2001 From: Gaebabi <45709904+Gaebabi@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:22:41 +0900 Subject: [PATCH 12/15] [1/2 Worktree integration] Preserve gateway-507-retry changes --- docs/ARCHITECTURE.md | 5 + docs/MODEL_PROVIDERS.md | 22 +- docs/development/OPERATIONS.md | 8 +- docs/development/PLAN.md | 4 + docs/development/STATUS.md | 8 + localization/packs/de.json | 2 + localization/packs/en.json | 2 + localization/packs/es.json | 2 + localization/packs/fr.json | 2 + localization/packs/ja.json | 2 + localization/packs/ko.json | 2 + localization/packs/pt-BR.json | 2 + localization/packs/zh-CN.json | 2 + localization/packs/zh-TW.json | 2 + src/application/DesktopStreamRecovery.cs | 110 +++++++++ .../DesktopStreamRecoveryMonitor.cs | 143 +++++++++++ src/application/GatewayJsonRequest.cs | 1 + src/application/GatewayUpstreamRetry.cs | 30 +++ src/application/ModelGateway.cs | 99 ++++++-- src/application/ModelProviders.cs | 10 + src/desktop/DeskWindow.cs | 19 +- .../protocol/DesktopSessionMonitor.cs | 1 + src/platforms/protocol/DesktopTurnOutcome.cs | 70 ++++++ src/platforms/protocol/SessionActivity.cs | 14 +- src/windows/MainWindow.cs | 7 +- src/windows/SessionUsagePanel.cs | 17 +- tests/account-automation/Program.cs | 1 + .../StreamRecoveryContracts.cs | 103 ++++++++ tests/model-providers-ui/Program.cs | 1 + .../SessionCounterContracts.cs | 37 +++ .../model-providers/GatewayRetryContracts.cs | 231 ++++++++++++++++++ tests/model-providers/Program.cs | 40 ++- tests/protocol/Program.cs | 41 ++++ 33 files changed, 993 insertions(+), 47 deletions(-) create mode 100644 src/application/DesktopStreamRecovery.cs create mode 100644 src/application/DesktopStreamRecoveryMonitor.cs create mode 100644 src/application/GatewayUpstreamRetry.cs create mode 100644 src/platforms/protocol/DesktopTurnOutcome.cs create mode 100644 tests/account-automation/StreamRecoveryContracts.cs create mode 100644 tests/model-providers-ui/SessionCounterContracts.cs create mode 100644 tests/model-providers/GatewayRetryContracts.cs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index eea586a..a06bd2c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,10 @@ The optional model gateway uses bounded in-memory request arrays:1GiB aggregate capacity with small-request headroom,16 upload/routing slots, eight large uploads and48independent response streams. Token validation and original-byte slices avoid full JSON history copies for native and custom routes. Array growth reserves overlap; buffers and upload slots release at response headers. Overload never replays inference. [Limits](MODEL_PROVIDERS.md) / [verification](evidence/gateway-load/README.md). +`GatewayUpstreamRetry` adds three bounded retries only for a streaming Responses request explicitly refused with HTTP507before a successful upstream response. Each attempt recreates a message around the same budgeted input and immutable route; retryable refusals dispose their response/content first. Backoff SSE comments detect caller disconnect and preserve one client turn, while authentication is rechecked before resubmission. After final headers the input and upload capacity release normally. Exhaustion is a terminal SSE failure; ambiguous transport/stream failures, images and compaction are never replayed. [Recovery contract](MODEL_PROVIDERS.md#automatic-http507-recovery). + +`DesktopStreamRecoveryMonitor` separately handles a persisted failed turn of any error type after the desktop is stopped. Eligibility uses terminal status rather than error prose, including failed completions with no message; actual interruptions remain excluded. It continues saved chat context through the current owner with inherited settings; it never replays the original HTTP request. `StreamRecoveryCoordinator` persists a three-attempt chain before each identified Continue, retaining ambiguity/exhaustion across restart. Exact SQLite metadata and bounded read-only rollout completion, current model/owner/queue and unchanged authenticated connected route gate submission. First-request failures need no fabricated successful-route record; gateway history checks still apply. Bounded fixed-code decisions expose why recovery ran or skipped without raw errors or chat text. [Stream recovery](MODEL_PROVIDERS.md#continue-after-a-failed-response-stream). + Release1.43.3 adds ModelProviderManager, ModelProviderStore, ProviderCatalog and ModelGateway in the existing application assembly, with WPF/shared desktop editors. Provider keys and connection recovery use the existing native protection identity in a separate encrypted file. The loopback gateway routes each selected alias using an immutable request snapshot; OpenAI authentication is confined to its OpenAI route. Official config RPCs atomically prepare or restore only owned settings. [Compatibility, history and acceptance](MODEL_PROVIDERS.md). Source1.41.0 adds `LinuxTerminalCommands` in the UI-neutral Application assembly. The Linux host dispatches `cli` before Avalonia/native display initialization. It composes the existing vault/account/policy/native executable services, serializes with GUI and terminal operations, refuses conflicting CLI clients, and delegates exact CLI/exec session restoration to the descriptor-bound official executable. It uses no shell, permission override, desktop adoption or plaintext credential path. [Terminal contract and current evidence](development/LINUX_TERMINAL.md). @@ -49,6 +53,7 @@ Source1.32.0 adds `src/application/CodexSwitch.Application.csproj` and `src/desk - Background reads stay asynchronous; login triggers an immediate read for that account. Credentials use serialized revisions to avoid overwriting newer authentication. - Windows vault protection is current-user DPAPI. Mac uses AES-GCM with a Keychain-held key. The existing CodexAccountDesk data paths and IPC identity are retained. - Desktop counts use observed root-chat state. Queue reads combine saved JSON and committed SQLite/WAL, filter desktop roots and deduplicate client/submission IDs. Observer protocol or schema failure hides unsupported counts. +- Errors counts currently stopped observed root chats: runtime systemError, or idle with latest turn failed. It excludes active/interrupted/recovered/child chats and stays unknown until full discovery. Both desktop presentations share this ledger result; no transcript scan or cumulative request error counter is used. - CPU/MEM/GPU/VRAM sample every 2 seconds, drives every 15 seconds, account auto-refresh every 60 seconds. Resource warning/critical thresholds are 70/85%, released 5 percentage points lower. - Windows GPU usage is the busiest aggregated engine; VRAM is the highest supported adapter ratio. Mac driver limitations remain visible as unknown. - Only the active account animates its water surface. Menus/tooltips share theme components and stable hover targets across updates. diff --git a/docs/MODEL_PROVIDERS.md b/docs/MODEL_PROVIDERS.md index f2df7db..6b99e74 100644 --- a/docs/MODEL_PROVIDERS.md +++ b/docs/MODEL_PROVIDERS.md @@ -34,13 +34,29 @@ For a model absent from that catalog or a route with different support, expand * 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. -Switch supports at most32 providers and128 selected models per provider. Discovery reads are bounded at4MiB; the complete Codex-facing catalog must fit the client's1MiB limit. Full-input conversation and image requests are bounded at512MiB, including chunked bodies. Request buffers share a1GiB capacity budget, with64MiB reserved for small requests and resize overlap included. At most eight bodies larger than16MiB or of unknown length upload concurrently, within16 upload/routing slots. Up to48 upstream responses can stream concurrently; those streams do not occupy upload slots. Overload reports503 with `Retry-After: 1` and never retries inference internally. Above-limit bodies report413; unsupported media types report415. Upstream limits still apply. A discovery result alone does not certify tool or conversation compatibility. +Switch supports at most32 providers and128 selected models per provider. Discovery reads are bounded at4MiB; the complete Codex-facing catalog must fit the client's1MiB limit. Full-input conversation and image requests are bounded at512MiB, including chunked bodies. Request buffers share a1GiB capacity budget, with64MiB reserved for small requests and resize overlap included. At most eight bodies larger than16MiB or of unknown length upload concurrently, within16 upload/routing slots. Up to48 upstream responses can stream concurrently; successful streams do not occupy upload slots. Local overload reports503 with `Retry-After: 1` and is never retried internally. Above-limit bodies report413; unsupported media types report415. Upstream limits still apply. A discovery result alone does not certify tool or conversation compatibility. 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. -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 and generation requests are not retried by the gateway. +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. + +### 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. + +While waiting, Switch sends only SSE comment keep-alives to the original caller. These are not assistant messages or model response events. Successful upstream events continue in the same request. After exhaustion or a different upstream failure, Switch emits one terminal `response.failed` event with the HTTP status and retry count; upstream error bodies are never echoed. Success alone records conversation provenance. The total request deadline remains30minutes, and request arrays remain within the existing aggregate buffer/admission limits until retry eligibility ends. + +### Continue after a failed response stream + +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. + +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. + +Cancellation, a disconnected client, sign-in changes, gateway shutdown, transport exceptions and any failure after a successful upstream response stop recovery. Images, compaction, nonstream requests and other HTTP errors retain their single-send behavior. A manual new send has a new bounded budget. An already failed turn from before this hotfix needs one manual resume; the gateway does not scan old chats or submit work on its own. This recovery cannot guarantee that a server-side507condition clears and does not prove an upstream performed no internal work. Built-in image generation and editing use the original OpenAI sign-in through `images/generations` and `images/edits`. These requests preserve their JSON body, image references and request IDs and do not change the chat's model route. A custom text model selection does not redirect built-in images to that provider or its API key. Image availability and usage limits belong to the original OpenAI account. This is separate from a text model's ability to receive image attachments. @@ -48,7 +64,7 @@ The image-route and 64MiB corrections are source hotfixes after release1.43.3. O A subsequent source correction raises the64MiB intermediary limit to512MiB because an image-heavy full-input chat can exceed it. The [OpenAI image input guide](https://developers.openai.com/api/docs/guides/images-vision?api-mode=responses) documents up to512MB total payload for the public API; authenticated Codex and custom upstream acceptance must be verified separately. This change preserves image data and encrypted history and does not silently compact or discard them. -The large-upload admission slot and request buffers are released after upstream response headers arrive, so ongoing response streams do not occupy large-upload capacity. +The large-upload admission slot and request buffers are released when final upstream response headers arrive, so ongoing response streams do not occupy large-upload capacity. Explicit retryable507refusals retain the same budgeted input until the next attempt or terminal failure; they never allocate a second full body. 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. diff --git a/docs/development/OPERATIONS.md b/docs/development/OPERATIONS.md index 55f1367..931cf9d 100644 --- a/docs/development/OPERATIONS.md +++ b/docs/development/OPERATIONS.md @@ -1,12 +1,18 @@ # Operations +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. + +The post1.44.0 HTTP507hotfix retries only an explicitly refused streaming Responses request, at2/5/10seconds and at most three additional attempts. It stays within the original client request using SSE comment keep-alives, preserving selected route/auth/input and cancelling on caller disconnect, sign-in change or shutdown. Retryable refusal responses are disposed before waiting; original request buffers remain within the existing memory budget until retry eligibility ends. A final failure reports its status/count without an upstream body. Compaction/images/nonstream/transport/partial-response failures remain single-send. Historical stopped chats are not automatically resumed. [Exact behavior and limits](../MODEL_PROVIDERS.md#automatic-http507-recovery). + Source1.44.0 keeps Add account and Refresh beside one titlebar Settings gear. Model providers, automatic/credit controls, Restart Codex, language, appearance and drive settings are in that menu. Direct per-account Switch/Recover and warning actions stay on the tanks. [Home contract](HOME_SIMPLIFICATION.md) / [release and installation gates](RELEASE_1_44_0.md). The ordinary Linux desktop-session harness follows the new three-action titlebar and Settings > Start Codex/appearance using real input. Preserve its login/cancel, approved child ownership, rendered theme, original ciphertext and normal close/reopen assertions. The Settings menu ends with Start Codex when available, then Quit; the current-account import and drive entries remain above them. A stale toolbar coordinate is a test-path failure, not evidence of product success. Built-in image generation requires `images/generations` and `images/edits` in addition to Responses/model discovery. The gateway forwards these JSON requests only with the original OpenAI identity and preserves the body and safe request IDs; custom text aliases and saved custom-provider keys do not select the image route. Browser/loopback authentication, redirect refusal and no-retry behavior still apply. -Conversation and image request bodies remain bounded at512MiB, including chunked bodies. The gateway admits eight large/unknown-length uploads within16 upload/routing slots and a1GiB aggregate request-buffer budget.64MiB stays available for small requests; unknown-length growth counts both old/new arrays before resizing. Uploads time out after two minutes, and completed uploads release their buffers/slots at upstream response headers. Up to48 responses stream independently. Bounded overload responses include503/Retry-After; no internal generation retry is added. Disconnected clients cannot fault the listener's accept loop. [Load verification](../evidence/gateway-load/README.md). +Conversation and image request bodies remain bounded at512MiB, including chunked bodies. The gateway admits eight large/unknown-length uploads within16 upload/routing slots and a1GiB aggregate request-buffer budget.64MiB stays available for small requests; unknown-length growth counts both old/new arrays before resizing. Uploads time out after two minutes, and completed uploads release their buffers/slots at final upstream response headers. Only retryable507refusals retain their budgeted input. Up to48 responses stream independently. Bounded local overload responses include503/Retry-After and never trigger inference retries. Disconnected clients cannot fault the listener's accept loop. [Load verification](../evidence/gateway-load/README.md). Token validation avoids the payload-sized metadata pool used by JsonDocument. Full history/images remain original byte slices even when custom routing changes small controls. Strict duplicate/nesting/token/property/control bounds are documented in [model-provider limits](../MODEL_PROVIDERS.md). A body above512MiB reports413; unsupported media reports415. Preserve the chat and update the gateway before retrying a local size refusal; never remove its history to bypass it. Upstream limits can differ. [Earlier hotfix evidence](../evidence/image-gateway/README.md). diff --git a/docs/development/PLAN.md b/docs/development/PLAN.md index 2a6515d..351f9d0 100644 --- a/docs/development/PLAN.md +++ b/docs/development/PLAN.md @@ -1,5 +1,9 @@ # Current plan +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). + Issue151's [Home simplification](HOME_SIMPLIFICATION.md) and merged image/concurrent-load gateway corrections are published in1.44.0 after six protected checks, author review, matching-tree merge and public package/download verification. Tanks, direct switching, Add/Refresh and enabled automatic-state visibility remain. Keep owner installation and its idle-gateway/data-preservation receipt separate from public delivery; retain broader provider/controller/Linux acceptance in the existing issues. [Publication record](RELEASE_1_44_0.md). Issue149 strengthens concurrent gateway load with an aggregate request-buffer budget, separate upload/stream limits, sliced custom rewrites and token-based validation. Verify exact512MiB bodies, eight65MiB custom uploads,48open response streams, overload/disconnect recovery and retained-memory measurements. Require final-head protected/native CI and privacy review before repository integration. Local installation and actual provider/account acceptance remain separate. diff --git a/docs/development/STATUS.md b/docs/development/STATUS.md index 4085349..207bdae 100644 --- a/docs/development/STATUS.md +++ b/docs/development/STATUS.md @@ -1,5 +1,13 @@ # Current status +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. + +Post-response stream continuation passes329account/recovery contracts and234provider/official-CLI checks. Coverage includes bounded continuation chains, persisted exhaustion, ambiguous-send prevention, identity/model/owner changes, read-only rollout completion and newer-turn invalidation. The actual CLI reports a post-response failed turn, classifies it for continuation and completes its following turn in the same chat. Actual owner rollout/SQLite/current desktop-model metadata were read without task mutation; a later completed turn prevents duplicate Continue. Installed acceptance and external server recovery remain separate checks. [Stream continuation contract](../MODEL_PROVIDERS.md#continue-after-a-failed-response-stream). + +Home adds Errors beside Run/Queue/Goals on Windows and shared desktop hosts. It counts observed main chats currently in systemError or idle with a failed latest turn, excludes children and active/interrupted/recovered work, and stays unknown until discovery is complete. Positive values use warning color. Production WPF component verification passes108checks across nine languages, both themes and unknown/zero/nonzero counts; protocol verification passes12checks including deduplication, owner filtering, recovery and patch updates. Native owner installation remains separately verified; component images are not native window acceptance. + +The post1.44.0 HTTP507recovery hotfix passes231local provider/official-CLI checks, including49isolated recovery checks. Explicit streaming Responses refusals get at most three additional attempts using original route/auth/input. The actual CLI completes the same turn after three507s and ends in failure after exhaustion without multiplying the budget. Cancellation, disconnect, sign-in change, gateway shutdown, partial-stream and excluded endpoint/error checks pass. The Windows package compiles with reviewed runtime10.0.13. Source integration, installed-process verification and public release remain separate. [Contract](../MODEL_PROVIDERS.md#automatic-http507-recovery). + Release1.44.0 is published from reviewed source `d97f1bfa94b933db9e64c2242324d8ff50ab89a6`, merged as `c58d87a8cadc3ee02930bdfff36984d549fcffd2` with an identical tree. All six protected checks passed. Windows/Mac actual app/menu evidence, Linux ordinary installed desktop71checks, nine runtime/license/provenance-verified packages and twelve anonymous public download hashes passed. The public Windows ZIP passes99self-tests. Home retains its tanks and direct switching with global actions in Settings. [Publication record](RELEASE_1_44_0.md) / [actual screenshots](../evidence/home-simplification/README.md). Owner installation remains a separate data-preservation/idle-gateway check; public delivery does not imply authenticated provider or broader Linux support acceptance. Issue149's implementation passes184local official-client/provider checks,74separate generated-byte stress checks and99Windows package self-tests. It bounds request arrays at1GiB with small-request headroom, allows eight large uploads and48response streams, and forwards custom history/image slices without a full mutable JSON copy. Exact512MiB Responses/compaction/image-edit and eight simultaneous65MiB custom uploads succeed; token validation removes payload-sized pooled metadata retention. Actual hosted Windows before/after images confirm app continuity. Protected final-head CI, installed-process state and external-provider acceptance are separate gates. [Measurements, evidence and delivery scope](../evidence/gateway-load/README.md). diff --git a/localization/packs/de.json b/localization/packs/de.json index db31912..7e8e32e 100644 --- a/localization/packs/de.json +++ b/localization/packs/de.json @@ -6,6 +6,8 @@ "culture": "de-DE", "direction": "ltr", "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_model": "Modell", "providers.reasoning_override": "Unterstützte Denkstufen festlegen", diff --git a/localization/packs/en.json b/localization/packs/en.json index 7428c16..1c160e2 100644 --- a/localization/packs/en.json +++ b/localization/packs/en.json @@ -6,6 +6,8 @@ "culture": "en-US", "direction": "ltr", "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_model": "Model", "providers.reasoning_override": "Set supported reasoning levels", diff --git a/localization/packs/es.json b/localization/packs/es.json index 0018757..3a11b10 100644 --- a/localization/packs/es.json +++ b/localization/packs/es.json @@ -6,6 +6,8 @@ "culture": "es-ES", "direction": "ltr", "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_model": "Modelo", "providers.reasoning_override": "Definir niveles de razonamiento compatibles", diff --git a/localization/packs/fr.json b/localization/packs/fr.json index 9e04e74..6ee4eac 100644 --- a/localization/packs/fr.json +++ b/localization/packs/fr.json @@ -6,6 +6,8 @@ "culture": "fr-FR", "direction": "ltr", "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_model": "Modèle", "providers.reasoning_override": "Définir les niveaux de raisonnement pris en charge", diff --git a/localization/packs/ja.json b/localization/packs/ja.json index 3a20e77..f97b28c 100644 --- a/localization/packs/ja.json +++ b/localization/packs/ja.json @@ -6,6 +6,8 @@ "culture": "ja-JP", "direction": "ltr", "strings": { + "sessions.errors": "エラー", + "sessions.errors_help": "システムエラーまたは最新ターンの失敗で停止したローカルのメインチャット数です。サブエージェント、実行中のチャット、キャンセルと復旧済みの過去エラーは除きます。", "providers.reasoning_title": "Codex で GPT の推論強度を使う", "providers.reasoning_model": "モデル", "providers.reasoning_override": "対応する推論レベルを指定", diff --git a/localization/packs/ko.json b/localization/packs/ko.json index 2607c34..61983af 100644 --- a/localization/packs/ko.json +++ b/localization/packs/ko.json @@ -6,6 +6,8 @@ "culture": "ko-KR", "direction": "ltr", "strings": { + "sessions.errors": "오류", + "sessions.errors_help": "시스템 오류 또는 최근 턴 실패로 멈춘 로컬 주 대화 수입니다. 하위 에이전트, 실행 중인 대화, 사용자 중단과 복구된 과거 오류는 제외합니다.", "providers.reasoning_title": "Codex에서 GPT 추론 강도 사용", "providers.reasoning_model": "모델", "providers.reasoning_override": "지원하는 추론 단계 직접 설정", diff --git a/localization/packs/pt-BR.json b/localization/packs/pt-BR.json index 1df57cf..18c5dad 100644 --- a/localization/packs/pt-BR.json +++ b/localization/packs/pt-BR.json @@ -6,6 +6,8 @@ "culture": "pt-BR", "direction": "ltr", "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_model": "Modelo", "providers.reasoning_override": "Definir níveis de raciocínio compatíveis", diff --git a/localization/packs/zh-CN.json b/localization/packs/zh-CN.json index 5539464..bfaa5aa 100644 --- a/localization/packs/zh-CN.json +++ b/localization/packs/zh-CN.json @@ -6,6 +6,8 @@ "culture": "zh-CN", "direction": "ltr", "strings": { + "sessions.errors": "错误", + "sessions.errors_help": "因系统错误或最新轮次失败而停止的本地主对话数。不包含子代理、正在运行的对话、用户取消或已恢复的历史错误。", "providers.reasoning_title": "在 Codex 中使用 GPT 推理强度", "providers.reasoning_model": "模型", "providers.reasoning_override": "指定支持的推理级别", diff --git a/localization/packs/zh-TW.json b/localization/packs/zh-TW.json index c2dd98e..00103e0 100644 --- a/localization/packs/zh-TW.json +++ b/localization/packs/zh-TW.json @@ -6,6 +6,8 @@ "culture": "zh-TW", "direction": "ltr", "strings": { + "sessions.errors": "錯誤", + "sessions.errors_help": "因系統錯誤或最新回合失敗而停止的本機主對話數。不包含子代理、執行中的對話、使用者取消或已復原的歷史錯誤。", "providers.reasoning_title": "在 Codex 中使用 GPT 推理強度", "providers.reasoning_model": "模型", "providers.reasoning_override": "指定支援的推理級別", diff --git a/src/application/DesktopStreamRecovery.cs b/src/application/DesktopStreamRecovery.cs new file mode 100644 index 0000000..3eae032 --- /dev/null +++ b/src/application/DesktopStreamRecovery.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace CodexAccountDesk; + +internal sealed record StreamRecoveryObservation(string Thread, string Owner, string Turn, string Status, string? Error, + string Model, string Provider, string Identity, string Route, DateTimeOffset At, bool Ready); +internal sealed class StreamRecoveryReceipt +{ + public string Thread { get; set; } = ""; + public string Failure { get; set; } = ""; + public string? SubmittedTurn { get; set; } + public string Message { get; set; } = ""; + public string Model { get; set; } = ""; + public string Provider { get; set; } = ""; + public string Identity { get; set; } = ""; + public string Route { get; set; } = ""; + public int Attempts { get; set; } + public bool Sending { get; set; } + public bool Closed { get; set; } +} +internal static class StreamRecoveryPolicy +{ + internal const int MaximumAttempts = 3; + // Eligibility comes from the persisted terminal status, never error prose. + // Even an absent message or a message mentioning cancellation can describe + // 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.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 && + a.Thread == b.Thread && a.Owner == b.Owner && a.Turn == b.Turn && Failed(b.Status) && + a.Model == b.Model && a.Provider == b.Provider && a.Identity == b.Identity && a.Route == b.Route; +} +internal sealed record StreamRecoveryDecision(string Reason, int Attempts = 0); +internal sealed class StreamRecoveryCoordinator +{ + private readonly Dictionary _receipts; + private readonly Action _save; + private readonly Func _delay; + internal StreamRecoveryCoordinator(Dictionary receipts, Action save, Func? delay = null) + { _receipts = receipts; _save = save; _delay = delay ?? ((attempt, token) => Task.Delay(TimeSpan.FromSeconds(attempt switch { 1 => 3, 2 => 10, _ => 30 }), token)); } + internal async Task CheckAsync(StreamRecoveryObservation observed, Func> refresh, + Func> send, CancellationToken cancellation) + { + _receipts.TryGetValue(observed.Thread, out var receipt); + if (observed.Status == "completed") + { + if (receipt != null && !receipt.Closed && !receipt.Sending && receipt.Identity == observed.Identity && receipt.Route == observed.Route) + { receipt.Closed = true; _save(); } + return new("completed", receipt?.Attempts ?? 0); + } + if (receipt?.Closed == true && receipt.Failure == observed.Turn) return new("already-handled", receipt.Attempts); + if (!observed.Ready || !StreamRecoveryPolicy.Failed(observed.Status)) return new("not-failed-or-not-ready"); + if (DateTimeOffset.UtcNow - observed.At > TimeSpan.FromDays(1) || observed.At > DateTimeOffset.UtcNow.AddMinutes(1)) return new("outside-recovery-window"); + if (receipt != null && !receipt.Closed) + { + if (receipt.Sending) return new("unconfirmed-send", receipt.Attempts); + if (receipt.Attempts >= StreamRecoveryPolicy.MaximumAttempts) return new("attempt-limit", receipt.Attempts); + if (receipt.Failure == observed.Turn) return new("already-handled", receipt.Attempts); + if (receipt.SubmittedTurn != observed.Turn) return new("different-turn", receipt.Attempts); + if (receipt.Model != observed.Model || receipt.Provider != observed.Provider || + receipt.Identity != observed.Identity || receipt.Route != observed.Route) return new("settings-changed", receipt.Attempts); + } + else + { + if (_receipts.Count >= 10000 && receipt == null) return new("receipt-limit"); + receipt = new() { Thread = observed.Thread, Model = observed.Model, Provider = observed.Provider, Identity = observed.Identity, Route = observed.Route }; + } + await _delay(receipt.Attempts + 1, cancellation).ConfigureAwait(false); + var current = await refresh(cancellation).ConfigureAwait(false); + if (current == null || !StreamRecoveryPolicy.Same(observed, current)) return new("state-changed", receipt.Attempts); + cancellation.ThrowIfCancellationRequested(); + // Save before IPC. An interrupted/ambiguous send is never resent on restart. + receipt.Attempts++; receipt.Failure = observed.Turn; receipt.Message = Guid.NewGuid().ToString(); receipt.Sending = true; + _receipts[observed.Thread] = receipt; _save(); + var submitted = await send(receipt.Message, cancellation).ConfigureAwait(false); + if (!Guid.TryParse(submitted, out _)) throw new InvalidDataException("Automatic continuation returned an invalid turn identity."); + receipt.SubmittedTurn = submitted; receipt.Sending = false; _save(); + return new("continued", receipt.Attempts); + } +} +internal sealed class StreamRecoveryStore +{ + private readonly string _directory, _path; + internal Dictionary Receipts { get; } + internal StreamRecoveryStore(string directory) + { + _directory = directory; _path = Path.Combine(directory, "stream-recovery.vault"); + if (!File.Exists(_path)) { Receipts = new(); return; } + var plain = VaultProtection.Unprotect(SecureFiles.ReadPrivateBytes(_path), directory); + try { Receipts = JsonSerializer.Deserialize>(plain) ?? throw new InvalidDataException("Recovery receipt is empty."); } + finally { CryptographicOperations.ZeroMemory(plain); } + if (Receipts.Count > 10000 || Receipts.Any(pair => !Guid.TryParse(pair.Key, out _) || pair.Key != pair.Value.Thread || pair.Value.Attempts is < 0 or > 3)) + throw new InvalidDataException("Recovery receipt is invalid."); + } + internal void Save() + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(Receipts); + try { SecureFiles.CreatePrivateDirectory(_directory); SecureFiles.AtomicWrite(_path, VaultProtection.Protect(bytes, _directory)); } + finally { CryptographicOperations.ZeroMemory(bytes); } + } +} diff --git a/src/application/DesktopStreamRecoveryMonitor.cs b/src/application/DesktopStreamRecoveryMonitor.cs new file mode 100644 index 0000000..a4e41e5 --- /dev/null +++ b/src/application/DesktopStreamRecoveryMonitor.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace CodexAccountDesk; + +// Continue any persisted failed turn through its current desktop owner. The +// original Responses stream is never replayed after tools/output may have run. +internal sealed class DesktopStreamRecoveryMonitor : IAsyncDisposable +{ + private readonly AccountService _service; + private readonly DesktopSessionMonitor _sessions; + private readonly Func _ready; + private readonly CancellationTokenSource _stop = new(); + private Task? _run; + private readonly System.Collections.Generic.HashSet _blocked = new(StringComparer.Ordinal); + private DateTimeOffset _lastStatus; + private sealed record Diagnostic(DateTimeOffset AtUtc, string Chat, string? Turn, string Reason, int Attempts); + private readonly Queue _decisions = new(); + internal string? LastError { get; private set; } + internal DesktopStreamRecoveryMonitor(AccountService service, DesktopSessionMonitor sessions, Func ready) + { _service = service; _sessions = sessions; _ready = ready; } + internal void Start() => _run ??= Task.Run(RunAsync); + private async Task RunAsync() + { + try + { + var store = new StreamRecoveryStore(_service.Vault.DirectoryPath); + var recovery = new StreamRecoveryCoordinator(store.Receipts, store.Save); + while (!_stop.IsCancellationRequested) + { + if (_ready() && _sessions.Current.Available) + foreach (var thread in _sessions.Threads.Where(Stopped)) + { + if (_blocked.Contains(thread.Id)) continue; + try + { + var observed = await ObserveAsync(thread.Id, _stop.Token).ConfigureAwait(false); + if (observed == null) continue; + var decision = await recovery.CheckAsync(observed, token => ObserveAsync(thread.Id, token), async (message, token) => + { + // Re-read immediately before submitting. This never opens or + // activates a window, applies credentials or changes models. + var current = await ObserveAsync(thread.Id, token).ConfigureAwait(false); + if (current == null || !StreamRecoveryPolicy.Same(observed, current)) throw new InvalidOperationException("The chat changed before automatic continuation."); + var owner = _sessions.Threads.Single(item => item.Id == observed.Thread); + var result = await _sessions.SendToOwnerAsync(owner, "thread-follower-start-turn", StartParameters(observed.Thread, message), 2, token).ConfigureAwait(false); + return JsonValue.String(result.GetProperty("result").GetProperty("turn"), "id") ?? throw new InvalidDataException("Automatic continuation has no confirmed turn."); + }, _stop.Token).ConfigureAwait(false); + Record(thread.Id, observed.Turn, decision.Reason, decision.Attempts); + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { throw; } + catch (Exception error) when (error is IOException or InvalidDataException or JsonException or InvalidOperationException or UnauthorizedAccessException or TimeoutException) + { + // A failed observation/send never triggers a retry of that IPC. + // Isolate this chat; other verified chats can still recover. + _blocked.Add(thread.Id); + LastError = "Automatic stream recovery stopped for a chat (" + error.GetType().Name + ")."; + Record(thread.Id, null, "blocked-" + error.GetType().Name); + } + } + if (DateTimeOffset.UtcNow - _lastStatus > TimeSpan.FromSeconds(10)) + { + WriteStatus("monitoring", store.Receipts.Values.Sum(item => item.Attempts), store.Receipts.Values.Count(item => item.Sending)); + _lastStatus = DateTimeOffset.UtcNow; + } + await Task.Delay(3000, _stop.Token).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (_stop.IsCancellationRequested) { } + catch (Exception error) + { + // Never resend after an ambiguous IPC or persistence failure. The + // saved Sending receipt prevents it even after an app restart. + LastError = "Automatic stream recovery stopped (" + error.GetType().Name + ")."; + // Keep the failure inspectable without writing chats, keys or raw upstream errors. + try { WriteStatus("stopped", null, null); } + catch (Exception storage) when (storage is IOException or UnauthorizedAccessException) { LastError += " Status storage unavailable."; } + } + } + private void WriteStatus(string state, int? attempts, int? unconfirmed) => SecureFiles.AtomicWriteText( + Path.Combine(_service.Vault.DirectoryPath, "stream-recovery-state.json"), JsonSerializer.Serialize(new { + schema = 1, processId = Environment.ProcessId, atUtc = DateTimeOffset.UtcNow, state, ready = _ready(), + desktopAvailable = _sessions.Current.Available, observedChats = _sessions.Threads.Length, + blockedChats = _blocked.Count, attempts, unconfirmed, error = LastError, decisions = _decisions.ToArray() })); + private void Record(string id, string? turn, string reason, int attempts = 0) + { + // Bounded fixed-code diagnostics: no chat text, raw errors, account IDs, + // provider destinations or credentials. Suppress identical polling entries. + var chat = SecureFiles.Hash(id); + var key = turn == null ? null : SecureFiles.Hash(turn); + var last = _decisions.LastOrDefault(item => item.Chat == chat); + if (last != null && last.Turn == key && last.Reason == reason && last.Attempts == attempts) return; + _decisions.Enqueue(new(DateTimeOffset.UtcNow, chat, key, reason, attempts)); + while (_decisions.Count > 128) _decisions.Dequeue(); + } + private static bool Stopped(DesktopThreadState thread) => !thread.Waiting && thread.ResumeState == "resumed" && + thread.Status is "systemError" or "idle" && thread.LastTurnStatus != "interrupted" && thread.Goal is null or "active"; + private async Task ObserveAsync(string id, CancellationToken cancellation) + { + StreamRecoveryObservation? Skip(string reason, string? turn = null) { Record(id, turn, reason); return null; } + if (!_ready() || !_sessions.Current.Available) return Skip("not-ready"); + if (_sessions.QueuedFor(id) != 0) return Skip("queued-or-queue-unknown"); + var thread = _sessions.Threads.SingleOrDefault(item => item.Id == id); + if (thread == null || !Stopped(thread)) return Skip("not-stopped"); + var outcome = await Task.Run(() => DesktopTurnOutcomes.Read(_service.CodexHome, id), cancellation).ConfigureAwait(false); + if (outcome == null) return Skip("terminal-outcome-unknown"); + if (outcome.Status is not ("failed" or "completed")) return Skip("not-failed", outcome.Id); + var result = await _sessions.SendToOwnerAsync(thread, "thread-follower-read-model-settings", new { conversationId = id }, 1, cancellation).ConfigureAwait(false); + var settings = result.GetProperty("settings"); + if (JsonValue.String(settings, "model") != outcome.Model || JsonValue.String(settings, "modelProvider") != outcome.Provider || + JsonValue.String(settings, "resumeState") != "resumed" || JsonValue.String(settings, "threadSource") != "user") return Skip("model-or-owner-settings-changed", outcome.Id); + var auth = _service.AuthPath; + if (!File.Exists(auth) || (File.GetAttributes(auth) & FileAttributes.ReparsePoint) != 0) return Skip("authentication-unavailable", outcome.Id); + var signedIn = Encoding.UTF8.GetString(SecureFiles.ReadPrivateBytes(auth)); + var identity = SecureFiles.Hash(signedIn); + // Pin the actual provider destination/key set across backoff as well as + // the chat's model-provider selection. Secrets never enter the receipt. + var providers = _service.ModelProviders.Settings; + if (!providers.Connected || !_service.ModelProviders.Running || outcome.Provider is not ("openai" or "codex_switch_router")) return Skip("route-unavailable", outcome.Id); + // A first request may fail before any successful-route receipt exists. + // Continue through the owner's unchanged current settings; do not invent + // successful provenance or bypass the gateway's portable-history checks. + 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 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); + } + internal static object StartParameters(string thread, string message) => new { conversationId = thread, + turnStart = new { request = new { threadId = thread, clientUserMessageId = message, + input = new[] { new { type = "text", text = "Continue", text_elements = Array.Empty() } } }, context = new { inheritThreadSettings = true } } }; + public async ValueTask DisposeAsync() + { _stop.Cancel(); if (_run != null) await _run.ConfigureAwait(false); _stop.Dispose(); } +} diff --git a/src/application/GatewayJsonRequest.cs b/src/application/GatewayJsonRequest.cs index 55f3a82..b34042f 100644 --- a/src/application/GatewayJsonRequest.cs +++ b/src/application/GatewayJsonRequest.cs @@ -40,6 +40,7 @@ internal GatewayJsonRequest(ReadOnlyMemory bytes) 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 static string? String(ReadOnlyMemory value) { if (value.IsEmpty || value.Length > 1024) return null; diff --git a/src/application/GatewayUpstreamRetry.cs b/src/application/GatewayUpstreamRetry.cs new file mode 100644 index 0000000..fc68840 --- /dev/null +++ b/src/application/GatewayUpstreamRetry.cs @@ -0,0 +1,30 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace CodexAccountDesk; + +// Retry only an explicit refusal before any successful upstream response. +// A fresh message wraps the same owned input and immutable route on each attempt. +internal static class GatewayUpstreamRetry +{ + internal const int MaximumRetries = 3; + internal static TimeSpan Delay(int retry) => TimeSpan.FromSeconds(retry switch { 1 => 2, 2 => 5, 3 => 10, _ => throw new ArgumentOutOfRangeException(nameof(retry)) }); + + internal static async Task SendAsync(HttpClient http, Func createRequest, bool enabled, + Func beforeRetry, CancellationToken cancellation) + { + for (var attempt = 0; ; attempt++) + { + cancellation.ThrowIfCancellationRequested(); + using var request = createRequest(); + HttpResponseMessage result; + try { result = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellation).ConfigureAwait(false); } + finally { request.Content?.Dispose(); request.Content = null; } + if (!enabled || (int)result.StatusCode != 507 || attempt == MaximumRetries) return result; + result.Dispose(); + await beforeRetry(attempt + 1, Delay(attempt + 1), cancellation).ConfigureAwait(false); + } + } +} diff --git a/src/application/ModelGateway.cs b/src/application/ModelGateway.cs index 401168f..5f9afbf 100644 --- a/src/application/ModelGateway.cs +++ b/src/application/ModelGateway.cs @@ -130,11 +130,10 @@ private async Task ServeAsync(HttpListenerContext context) var bytesIn = input.Bytes; var original = new GatewayJsonRequest(bytesIn); JsonObject? body = null; - void ReleaseInput(HttpRequestMessage outgoing) + void ReleaseInput() { - // Upload is complete once response headers arrive. Do not retain its buffers - // or admission slot during a potentially long-running response stream. - outgoing.Content?.Dispose(); outgoing.Content = null; + // Keep the budgeted input only while a pre-response 507 retry is possible. + // Successful response streams never retain request buffers/upload slots. original.Release(); body = null; bytesIn = default; input.Dispose(); if (largeBody) { _largeBodies.Release(); largeBody = false; } @@ -146,7 +145,7 @@ void ReleaseInput(HttpRequestMessage outgoing) CopyOpenAiHeaders(request, image, identity); image.Content = new ReadOnlyMemoryContent(bytesIn); image.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); using var imageResult = await _http.SendAsync(image, HttpCompletionOption.ResponseHeadersRead, cancellation).ConfigureAwait(false); - ReleaseInput(image); + image.Content?.Dispose(); image.Content = null; ReleaseInput(); if (!imageResult.IsSuccessStatusCode) { await ErrorAsync(response, (int)imageResult.StatusCode is >= 300 and < 400 ? 502 : (int)imageResult.StatusCode, "The OpenAI image endpoint returned HTTP " + (int)imageResult.StatusCode + ". No request was retried.", cancellation).ConfigureAwait(false); return; } response.StatusCode = (int)imageResult.StatusCode; response.ContentType = imageResult.Content.Headers.ContentType?.ToString() ?? "application/json"; @@ -202,20 +201,63 @@ void ReleaseInput(HttpRequestMessage outgoing) 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")); - using 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) : - new ReadOnlyMemoryContent(bytesIn); - upstream.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); - using var result = await _http.SendAsync(upstream, HttpCompletionOption.ResponseHeadersRead, cancellation).ConfigureAwait(false); - ReleaseInput(upstream); + var retries = 0; + 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) : + new ReadOnlyMemoryContent(bytesIn); + upstream.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + return upstream; + } + async Task BeforeRetry(int retry, TimeSpan delay, CancellationToken token) + { + retries = retry; + LastError = "Upstream HTTP 507; automatic recovery " + retry + "/" + GatewayUpstreamRetry.MaximumRetries + "."; + if (!started) + { + response.StatusCode = 200; response.ContentType = "text/event-stream"; + response.Headers["Cache-Control"] = "no-store"; response.SendChunked = true; started = true; + } + // Real writes during backoff detect a cancelled/disconnected caller before + // another inference request. Comments never create a response or chat turn. + await RetryCommentAsync(response, ": Switch HTTP 507 recovery " + retry + "/3\n\n", token).ConfigureAwait(false); + var until = DateTimeOffset.UtcNow + delay; + while (DateTimeOffset.UtcNow < until) + { + var remaining = until - DateTimeOffset.UtcNow; + if (remaining > TimeSpan.Zero) await Task.Delay(remaining < TimeSpan.FromMilliseconds(250) ? remaining : TimeSpan.FromMilliseconds(250), token).ConfigureAwait(false); + await RetryCommentAsync(response, ": Switch waiting for upstream recovery\n\n", token).ConfigureAwait(false); + } + var current = _authenticate(request.Headers["Authorization"], request.Headers["ChatGPT-Account-ID"]); + if (current == null || current.Token != identity.Token || current.AccountId != identity.AccountId || current.ApiKey != identity.ApiKey) + throw new InvalidOperationException("The original sign-in changed before automatic recovery."); + } + using var result = await GatewayUpstreamRetry.SendAsync(_http, CreateUpstream, + path == "/backend-api/codex/responses" && original.Streaming, BeforeRetry, cancellation).ConfigureAwait(false); + ReleaseInput(); if (!result.IsSuccessStatusCode) - { await ErrorAsync(response, (int)result.StatusCode is >= 300 and < 400 ? 502 : (int)result.StatusCode, "The selected upstream returned HTTP " + (int)result.StatusCode + ". No request was retried.", cancellation).ConfigureAwait(false); return; } + { + LastError = "The selected upstream returned HTTP " + (int)result.StatusCode + ". " + + (retries == 0 ? "No request was retried." : "Automatic HTTP 507 recovery stopped after " + retries + " of 3 retries."); + if (started) await RetryFailureAsync(response, LastError, cancellation).ConfigureAwait(false); + else await ErrorAsync(response, (int)result.StatusCode is >= 300 and < 400 ? 502 : (int)result.StatusCode, LastError, cancellation).ConfigureAwait(false); + return; + } + if (started && result.Content.Headers.ContentType?.MediaType != "text/event-stream") + { + LastError = "The selected upstream did not return the requested event stream after HTTP 507 recovery."; + await RetryFailureAsync(response, LastError, cancellation).ConfigureAwait(false); return; + } _store.RecordSuccessfulRoute(request.Headers["session_id"] ?? request.Headers["conversation_id"], identity, routeIdentity); - response.StatusCode = (int)result.StatusCode; response.ContentType = result.Content.Headers.ContentType?.ToString() ?? "application/json"; - response.Headers["Cache-Control"] = "no-store"; response.SendChunked = true; started = true; + if (!started) + { + response.StatusCode = (int)result.StatusCode; response.ContentType = result.Content.Headers.ContentType?.ToString() ?? "application/json"; + response.Headers["Cache-Control"] = "no-store"; response.SendChunked = true; started = true; + } await using var stream = await result.Content.ReadAsStreamAsync(cancellation).ConfigureAwait(false); var buffer = new byte[16384]; int count; while ((count = await stream.ReadAsync(buffer, cancellation).ConfigureAwait(false)) > 0) @@ -224,15 +266,15 @@ void ReleaseInput(HttpRequestMessage outgoing) } catch (GatewayBusyException) { if (!started) await TryErrorAsync(context.Response, 503, "The local model gateway's request buffer budget is busy. Try again when uploads finish.", cancellation).ConfigureAwait(false); } - catch (OperationCanceledException) { LastError = "The model request was cancelled or timed out."; context.Response.Abort(); } + catch (OperationCanceledException) { LastError = "The model request was cancelled or timed out."; AbortResponse(context.Response); } catch (Exception error) { LastError = "The model request or upstream connection failed (" + error.GetType().Name + ")."; - if (started) context.Response.Abort(); + if (started) AbortResponse(context.Response); else { try { await ErrorAsync(context.Response, error is InvalidDataException or JsonException or ArgumentException ? 400 : 502, LastError, cancellation).ConfigureAwait(false); } - catch (Exception transport) when (transport is IOException or HttpListenerException or OperationCanceledException or ObjectDisposedException) { context.Response.Abort(); } + catch (Exception transport) when (transport is IOException or HttpListenerException or OperationCanceledException or ObjectDisposedException) { AbortResponse(context.Response); } } } finally { if (largeBody) _largeBodies.Release(); if (workSlot) _slots.Release(); if (streamSlot) _streamSlots.Release(); context.Response.Close(); } @@ -309,10 +351,25 @@ private static async Task ErrorAsync(HttpListenerResponse response, int status, try { await response.OutputStream.WriteAsync(bytes, cancellation).ConfigureAwait(false); } finally { response.Close(); } } + private static async Task RetryCommentAsync(HttpListenerResponse response, string comment, CancellationToken cancellation) + { + await response.OutputStream.WriteAsync(Encoding.UTF8.GetBytes(comment), cancellation).ConfigureAwait(false); + await response.OutputStream.FlushAsync(cancellation).ConfigureAwait(false); + } + private static Task RetryFailureAsync(HttpListenerResponse response, string message, CancellationToken cancellation) => + RetryCommentAsync(response, "data: " + JsonSerializer.Serialize(new { type = "response.failed", response = new { + id = "switch_507_" + Guid.NewGuid().ToString("N"), @object = "response", status = "failed", output = Array.Empty(), + error = new { code = "switch_upstream_recovery_stopped", message } } }) + "\n\n", cancellation); private static async Task TryErrorAsync(HttpListenerResponse response, int status, string message, CancellationToken cancellation) { try { await ErrorAsync(response, status, message, cancellation).ConfigureAwait(false); } - catch (Exception error) when (error is IOException or HttpListenerException or ObjectDisposedException or OperationCanceledException) { response.Abort(); } + catch (Exception error) when (error is IOException or HttpListenerException or ObjectDisposedException or OperationCanceledException) { AbortResponse(response); } + } + private static void AbortResponse(HttpListenerResponse response) + { + try { response.Abort(); } + // Listener shutdown can dispose HTTP.sys before the cancelled request unwinds. + catch (ObjectDisposedException) { } } private async Task RejectAsync(HttpListenerContext context) { diff --git a/src/application/ModelProviders.cs b/src/application/ModelProviders.cs index 9dc50dd..9fc8891 100644 --- a/src/application/ModelProviders.cs +++ b/src/application/ModelProviders.cs @@ -100,6 +100,16 @@ internal void RecordSuccessfulRoute(string? session, GatewayIdentity identity, s Update(next => next.ConversationRoutes[key] = new GatewayConversationRoute { Route = route, Mixed = mixed }); } } + internal static string? RecoveryConversationKey(string? session, string auth) + { + if (string.IsNullOrEmpty(session) || session.Length > 256) return null; + using var json = JsonDocument.Parse(auth); + var root = json.RootElement; string? identity = null; + if (root.TryGetProperty("tokens", out var tokens)) identity = JsonValue.String(tokens, "account_id"); + identity ??= JsonValue.String(root, "OPENAI_API_KEY"); + if (string.IsNullOrEmpty(identity)) return null; + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity + "\n" + session))); + } private static bool IsMixed(GatewayConversationRoute? previous, string route) => previous?.Mixed == true || (previous != null ? previous.Route != route : route != "openai"); private static string? ConversationKey(string? session, GatewayIdentity identity) => diff --git a/src/desktop/DeskWindow.cs b/src/desktop/DeskWindow.cs index 77541e1..74e1fb6 100644 --- a/src/desktop/DeskWindow.cs +++ b/src/desktop/DeskWindow.cs @@ -33,6 +33,7 @@ internal sealed class DeskWindow : Window private readonly Dictionary _volumeTemperaturePressure = new(); private readonly List _operations = new(); private AccountService? _service; + private DesktopStreamRecoveryMonitor? _streamRecovery; private DeskTheme _theme; private Grid _accounts = new(), _hardware = new(); private ScrollViewer _accountScroll = new(); @@ -105,6 +106,9 @@ private async Task InitializeAsync(CancellationToken token) await _service.InitializeAsync(token); _ready = true; if (OperatingSystem.IsLinux()) _statusMessage = Loc.T(_service.HasLinuxDesktop ? "msg.linux_desktop_ready" : "msg.linux_cli_only"); _sessions.Start(); _resources.Start(); _drives.Configure(_service.Vault.Data.Drives.ToArray()); _drives.Start(); + _streamRecovery ??= new DesktopStreamRecoveryMonitor(_service, _sessions, () => _ready && !Exiting && _login == null && _switch == null && + !_providerEditing && !_service.IsBusy && _service.Vault.Data.ActivePendingSwitch == null); + _streamRecovery.Start(); UpdateTray(); RenderAccounts(); await _service.RefreshAllAsync(null, token); await RecoverAutomaticSwitchAsync(); @@ -155,7 +159,7 @@ private void Build() _cancel = _theme.Button(Loc.T("취소"), () => { _login?.Cancel(); _switch?.Cancel(); }); _cancel.IsVisible = _login != null || _switch != null; _cancel.HorizontalAlignment = HorizontalAlignment.Left; toolbar.Children.Add(_cancel); - _sessionValues = new StackPanel { Orientation = Orientation.Horizontal, Spacing = nativeTitle ? 12 : 24, HorizontalAlignment = HorizontalAlignment.Center }; + _sessionValues = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12, HorizontalAlignment = HorizontalAlignment.Center }; Grid.SetColumn(_sessionValues, 1); toolbar.Children.Add(_sessionValues); var automaticSwitchLabel = Loc.T(_service?.Vault.Data.AutomaticSwitchEnabled == true ? "auto.on" : "auto.off"); @@ -429,17 +433,17 @@ private void AccountMenu(Button target, AccountProfile profile) private void RenderSessions() { _sessionValues.Children.Clear(); var current = _sessions.Current; - var numbers = new int?[] { current.Available ? current.Running : null, current.Available ? current.Queued : null, current.Available ? current.Goals : null }; - var labels = new[] { "실행", "큐", "목표" }; - for (var i = 0; i < 3; i++) + var numbers = new int?[] { current.Available ? current.Running : null, current.Available ? current.Queued : null, current.Available ? current.Goals : null, current.Available ? current.Errors : null }; + var labels = new[] { "실행", "큐", "목표", "sessions.errors" }; + for (var i = 0; i < labels.Length; i++) { var cell = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6, VerticalAlignment = VerticalAlignment.Center }; var label = _theme.Text(Loc.T(labels[i]), 10, "muted"); label.VerticalAlignment = VerticalAlignment.Center; - var value = _theme.Text(numbers[i]?.ToString(Loc.Culture) ?? "—", 16); value.VerticalAlignment = VerticalAlignment.Center; value.FontWeight = FontWeight.SemiBold; - cell.Children.Add(label); cell.Children.Add(value); _sessionValues.Children.Add(cell); + var value = _theme.Text(numbers[i]?.ToString(Loc.Culture) ?? "—", 16, i == 3 && numbers[i] > 0 ? "warning" : numbers[i].HasValue ? "text" : "muted"); value.VerticalAlignment = VerticalAlignment.Center; value.FontWeight = FontWeight.SemiBold; + cell.Children.Add(label); cell.Children.Add(value); if (i == 3) ToolTip.SetTip(cell, Loc.T("sessions.errors_help")); _sessionValues.Children.Add(cell); } ToolTip.SetTip(_sessionValues, current.Available ? Loc.F("승인/입력 대기 {0}", current.Waiting) + "\n" + - (current.Queued.HasValue ? Loc.F("큐는 예약 메시지 수\n보류된 큐 {0}", current.PausedQueued) : Loc.Error(current.QueueProblem ?? "큐 상태 확인 중…")) + "\n" + Loc.F("목표는 진행 중인 Goal 수\n멈춘 목표 {0}", current.PausedGoals) : Loc.Error(current.Problem ?? "채팅 상태 확인 중…")); + (current.Queued.HasValue ? Loc.F("큐는 예약 메시지 수\n보류된 큐 {0}", current.PausedQueued) : Loc.Error(current.QueueProblem ?? "큐 상태 확인 중…")) + "\n" + Loc.F("목표는 진행 중인 Goal 수\n멈춘 목표 {0}", current.PausedGoals) + "\n" + Loc.T("sessions.errors_help") : Loc.Error(current.Problem ?? "채팅 상태 확인 중…")); } private void RenderHardware() { @@ -601,6 +605,7 @@ public async Task ExitAsync() { if (Exiting) return; Exiting = true; _clock.Stop(); _stop.Cancel(); _login?.Cancel(); _switch?.Cancel(); await Task.WhenAll(_operations.ToArray()); + if (_streamRecovery != null) await _streamRecovery.DisposeAsync(); if (_service != null) await _service.StopAsync(); await _sessions.DisposeAsync(); await _resources.DisposeAsync(); await _drives.DisposeAsync(); _tray?.Dispose(); _service?.ReleaseDesktop(); diff --git a/src/platforms/protocol/DesktopSessionMonitor.cs b/src/platforms/protocol/DesktopSessionMonitor.cs index d7e6039..8e667cb 100644 --- a/src/platforms/protocol/DesktopSessionMonitor.cs +++ b/src/platforms/protocol/DesktopSessionMonitor.cs @@ -35,6 +35,7 @@ public void Start() _run ??= Task.Run(RunAsync); } public DesktopThreadState[] Threads => _ledger.Threads(); + internal int? QueuedFor(string id) => _ledger.QueuedFor(id); public void RestartDiscovery() { _ledger.Reset(); Publish(_ledger.Read()); diff --git a/src/platforms/protocol/DesktopTurnOutcome.cs b/src/platforms/protocol/DesktopTurnOutcome.cs new file mode 100644 index 0000000..b2d3101 --- /dev/null +++ b/src/platforms/protocol/DesktopTurnOutcome.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; + +namespace CodexAccountDesk; + +internal sealed record DesktopTurnOutcome(string Id, string Status, string? Error, string Model, string Provider, DateTimeOffset At); + +// Read only the bounded end of the original rollout. Never modify a transcript or +// infer failure from idle/EOF alone: a persisted task_complete must name the turn. +internal static class DesktopTurnOutcomes +{ + internal static DesktopTurnOutcome? Read(string home, string id) + { + if (!Guid.TryParse(id, out _)) return null; + using var state = new ReadOnlySqlite(Path.Combine(home, "state_5.sqlite")); + var rows = state.Read("SELECT rollout_path, model, model_provider, archived, originator, source, agent_path FROM threads WHERE id = ?", id); + if (rows.Count != 1) return null; + var row = rows[0]; + if (row[3] != "0" || row[4] != "Codex Desktop" || row[5] != "vscode" || row[6] != null || string.IsNullOrEmpty(row[1]) || string.IsNullOrEmpty(row[2])) return null; + var root = new DirectoryInfo(Path.Combine(home, "sessions")); + var sessions = (root.ResolveLinkTarget(true)?.FullName ?? root.FullName).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; + static string Normal(string path) => Path.GetFullPath(path).Replace("\\\\?\\", "", StringComparison.Ordinal); + var path = Normal(row[0]!); + if (!path.StartsWith(Normal(sessions).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal) || Path.GetExtension(path) != ".jsonl" || + !Path.GetFileName(path).EndsWith("-" + id + ".jsonl", StringComparison.OrdinalIgnoreCase) || + (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0) return null; + using var file = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + var offset = Math.Max(0, file.Length - 4 * 1024 * 1024); + file.Position = offset; + var bytes = new byte[checked((int)(file.Length - offset))]; file.ReadExactly(bytes); + return ParseTail(bytes, offset != 0, row[1]!, row[2]!); + } + internal static DesktopTurnOutcome? ParseTail(byte[] bytes, bool partialFirstLine, string model, string provider) + { + DesktopTurnOutcome? latest = null; + var start = 0; + for (var i = 0; i < bytes.Length; i++) + { + if (bytes[i] != (byte)'\n') continue; + var length = i - start; var line = start; start = i + 1; + if (partialFirstLine) { partialFirstLine = false; continue; } + // Very large tool/assistant items cannot be completion metadata. If a + // completion itself exceeds the bound, leave the outcome unknown. + if (length > 512 * 1024) { latest = null; continue; } + using var document = JsonDocument.Parse(bytes.AsMemory(line, length)); + var root = document.RootElement; + if (JsonValue.String(root, "type") != "event_msg" || !root.TryGetProperty("payload", out var payload)) continue; + var type = JsonValue.String(payload, "type"); + if (type is "task_started" or "turn_aborted" or "task_interrupted") { latest = null; continue; } + if (type != "task_complete") continue; + var turn = JsonValue.String(payload, "turn_id"); + if (turn == null || !Guid.TryParse(turn, out _) || !DateTimeOffset.TryParse(JsonValue.String(root, "timestamp"), out var at)) { latest = null; continue; } + var hasError = payload.TryGetProperty("error", out var value) && value.ValueKind != JsonValueKind.Null; + var error = value.ValueKind == JsonValueKind.Object ? JsonValue.String(value, "message") : + value.ValueKind == JsonValueKind.String ? value.GetString() : null; + var status = JsonValue.String(payload, "status"); + // An explicit interruption wins over any attached error text. Missing + // error prose must not turn a persisted failure into a success. + if (status == "interrupted") latest = new(turn, status, error, model, provider, at); + else if (status == "failed" || hasError) latest = new(turn, "failed", error, model, provider, at); + else if (status is null or "completed") latest = new(turn, "completed", null, model, provider, at); + else latest = null; + } + return latest; + } +} diff --git a/src/platforms/protocol/SessionActivity.cs b/src/platforms/protocol/SessionActivity.cs index ac4d5d1..1a6c291 100644 --- a/src/platforms/protocol/SessionActivity.cs +++ b/src/platforms/protocol/SessionActivity.cs @@ -6,7 +6,7 @@ namespace CodexAccountDesk; internal sealed record SessionActivity(bool Available, int Running, int Waiting, int Observed, string? Problem = null, - int? Queued = null, int? Goals = null, int PausedQueued = 0, int PausedGoals = 0, string? QueueProblem = null); + int? Queued = null, int? Goals = null, int PausedQueued = 0, int PausedGoals = 0, string? QueueProblem = null, int? Errors = null); internal sealed record DesktopThreadState(string Id, string Owner, string Status, bool Waiting, string? Goal, string? Title, string? ResumeState, string? LastTurnId, string? LastTurnStatus, string? LastMessageId); @@ -56,6 +56,15 @@ public DesktopThreadState[] Threads() return new DesktopThreadState(pair.Key, e.Owner, e.Status!, e.Waiting, e.Goal, e.Title, e.ResumeState, e.LastTurnId, e.LastTurnStatus, e.LastMessageId); }).ToArray(); } + internal int? QueuedFor(string id) + { + lock (_sync) + { + if (!_discovered || _queues == null) return null; + return DesktopQueueState.Count(_queueOverrides.TryGetValue(id, out var update) ? update.Messages : _queues.Saved.GetValueOrDefault(id) ?? Array.Empty(), + _queues.Server.GetValueOrDefault(id) ?? Array.Empty()).Total; + } + } public SessionActivity Read() { lock (_sync) @@ -71,7 +80,8 @@ public SessionActivity Read() roots.Count(e => e.Status == "active" && e.Waiting), roots.Length, Queued: queues?.Sum(q => q.Total), Goals: available && roots.All(e => e.GoalKnown) ? roots.Count(e => e.Goal == "active") : null, PausedQueued: queues?.Sum(q => q.Paused) ?? 0, - PausedGoals: roots.Count(e => e.Goal is "paused" or "blocked" or "usageLimited" or "budgetLimited"), QueueProblem: _queueProblem); + PausedGoals: roots.Count(e => e.Goal is "paused" or "blocked" or "usageLimited" or "budgetLimited"), QueueProblem: _queueProblem, + Errors: available ? roots.Count(e => e.Status == "systemError" || e.Status == "idle" && e.LastTurnStatus == "failed") : null); } } public bool Apply(string id, string owner, JsonElement change) diff --git a/src/windows/MainWindow.cs b/src/windows/MainWindow.cs index abef9bf..120ae4e 100644 --- a/src/windows/MainWindow.cs +++ b/src/windows/MainWindow.cs @@ -23,6 +23,7 @@ internal sealed class MainWindow : Window { private readonly AccountService _service; private readonly DesktopSessionMonitor _sessions; + private readonly DesktopStreamRecoveryMonitor _streamRecovery; private readonly HardwareResourceMonitor _hardware = new(); private readonly HardwareUsagePanel _hardwarePanel = new(); private readonly DriveMonitor _drives = new(WindowsDiskTemperature.Read); @@ -72,6 +73,8 @@ public MainWindow(AccountService service, Program.Options options) _service = service; _options = options; _drivePanel = new(OpenDriveSettings); _sessions = new DesktopSessionMonitor(options.CodexHome); + _streamRecovery = new DesktopStreamRecoveryMonitor(service, _sessions, () => _ready && !_closing && !_switching && !_loggingIn && !_providerEditing && + !_service.IsBusy && _service.Vault.Data.ActivePendingSwitch == null); _theme = new Burn0Theme(service.Vault.Data.Theme != "light"); Title = "Codex Switch"; Icon = AppIdentity.WindowIcon; Width = 600; Height = 400; MinWidth = 500; MinHeight = 360; WindowStartupLocation = WindowStartupLocation.CenterScreen; @@ -373,6 +376,7 @@ private async Task StartAsync() _started = true; if (_options.UiEvidenceDirectory != null) return; _sessions.Start(); + if (_options.ReportPath == null) _streamRecovery.Start(); _hardware.Start(); _drives.Configure(_service.Vault.Data.Drives.ToArray()); _drives.Start(); if (_options.ReportPath == null) { CreateTray(); _refreshTimer.Start(); _clockTimer.Start(); } @@ -584,7 +588,7 @@ private void RenderSessions(SessionActivity? illustrative = null) _sessionCount.ToolTip = activity.Available ? Loc.T("이 PC의 로컬 Codex 주 채팅\n하위 에이전트 제외\n계정별 구분 없음") + "\n" + Loc.F("승인/입력 대기 {0}", activity.Waiting) + "\n" + (activity.Queued.HasValue ? Loc.F("큐는 예약 메시지 수\n보류된 큐 {0}", activity.PausedQueued) : Loc.T(activity.QueueProblem ?? "큐 상태 확인 중…")) + "\n" + - (activity.Goals.HasValue ? Loc.F("목표는 진행 중인 Goal 수\n멈춘 목표 {0}", activity.PausedGoals) : Loc.T("목표 상태 확인 중…")) : Loc.T(activity.Problem ?? "채팅 상태 확인 중…"); + (activity.Goals.HasValue ? Loc.F("목표는 진행 중인 Goal 수\n멈춘 목표 {0}", activity.PausedGoals) : Loc.T("목표 상태 확인 중…")) + "\n" + Loc.T("sessions.errors_help") : Loc.T(activity.Problem ?? "채팅 상태 확인 중…"); } private void RenderAccounts() { @@ -1021,6 +1025,7 @@ internal async Task ExitAsync() await Task.WhenAll(_operations.ToArray()); WriteRunningState("stopping-drives"); await _drives.DisposeAsync(); WriteRunningState("stopping-hardware"); await _hardware.DisposeAsync(); + WriteRunningState("stopping-stream-recovery"); await _streamRecovery.DisposeAsync(); WriteRunningState("stopping-sessions"); await _sessions.DisposeAsync(); WriteRunningState("stopping-service"); await _service.StopAsync(); _exit = true; _tray?.Dispose(); WriteRunningState("stopped"); Application.Current.Shutdown(); diff --git a/src/windows/SessionUsagePanel.cs b/src/windows/SessionUsagePanel.cs index 058a301..12fe040 100644 --- a/src/windows/SessionUsagePanel.cs +++ b/src/windows/SessionUsagePanel.cs @@ -7,16 +7,16 @@ namespace CodexAccountDesk; internal sealed class SessionUsagePanel : Grid { - private readonly TextBlock[] _labels = new TextBlock[3], _values = new TextBlock[3]; - private readonly StackPanel[] _groups = new StackPanel[3]; + private readonly TextBlock[] _labels = new TextBlock[4], _values = new TextBlock[4]; + private readonly StackPanel[] _groups = new StackPanel[4]; public string Text => string.Join(" ", _labels.Select((label, index) => label.Text + " " + _values[index].Text)); public SessionUsagePanel() { Height = 20; HorizontalAlignment = HorizontalAlignment.Center; VerticalAlignment = VerticalAlignment.Center; - for (var index = 0; index < 3; index++) + for (var index = 0; index < _groups.Length; index++) { ColumnDefinitions.Add(new() { Width = GridLength.Auto }); - _groups[index] = new() { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, index < 2 ? 16 : 0, 0) }; + _groups[index] = new() { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, index < _groups.Length - 1 ? 12 : 0, 0) }; _labels[index] = new() { FontSize = 11, LineHeight = 14, LineStackingStrategy = LineStackingStrategy.BlockLineHeight, VerticalAlignment = VerticalAlignment.Center }; _values[index] = new() { FontSize = 14, LineHeight = 20, LineStackingStrategy = LineStackingStrategy.BlockLineHeight, FontWeight = FontWeights.SemiBold, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(6, 0, 0, 0) }; @@ -26,14 +26,15 @@ public SessionUsagePanel() } public void Refresh(SessionActivity activity, Burn0Theme theme) { - var labels = new[] { "실행", "큐", "목표" }; - var values = new int?[] { activity.Available ? activity.Running : null, activity.Available ? activity.Queued : null, activity.Available ? activity.Goals : null }; - for (var index = 0; index < 3; index++) + var labels = new[] { "실행", "큐", "목표", "sessions.errors" }; + var values = new int?[] { activity.Available ? activity.Running : null, activity.Available ? activity.Queued : null, activity.Available ? activity.Goals : null, activity.Available ? activity.Errors : null }; + for (var index = 0; index < _groups.Length; index++) { _labels[index].Text = Loc.T(labels[index]); _labels[index].FontFamily = theme.Font; _labels[index].Foreground = theme.Color("muted"); _values[index].Text = values[index]?.ToString(Loc.Culture) ?? "—"; _values[index].FontFamily = theme.Font; - _values[index].Foreground = theme.Color(values[index].HasValue ? "text" : "muted"); + _values[index].Foreground = theme.Color(index == 3 && values[index] > 0 ? "warning" : values[index].HasValue ? "text" : "muted"); } + _groups[3].ToolTip = Loc.T("sessions.errors_help"); } public bool Fits() => _groups.Select((group, index) => _labels[index].DesiredSize.Width + _values[index].DesiredSize.Width <= group.ActualWidth + 0.5).All(fits => fits) && _labels.Concat(_values).All(element => element.ActualHeight <= 20.5); diff --git a/tests/account-automation/Program.cs b/tests/account-automation/Program.cs index 0213bb8..361a17a 100644 --- a/tests/account-automation/Program.cs +++ b/tests/account-automation/Program.cs @@ -5,6 +5,7 @@ var checks = new List(); void Check(string name, bool condition) { if (!condition) throw new Exception(name); checks.Add(name); } var now = DateTimeOffset.UtcNow; +await StreamRecoveryContracts.Run(Check); CreditHistoryContracts.Run(Check); HistoryRangeContracts.Run(Check); CreditOverlayContracts.Run(Check); diff --git a/tests/account-automation/StreamRecoveryContracts.cs b/tests/account-automation/StreamRecoveryContracts.cs new file mode 100644 index 0000000..3c25b12 --- /dev/null +++ b/tests/account-automation/StreamRecoveryContracts.cs @@ -0,0 +1,103 @@ +using CodexAccountDesk; +using System.Text; +using System.Text.Json; + +internal static class StreamRecoveryContracts +{ + internal static async Task Run(Action check) + { + const string failure = "stream disconnected before completion: An error occurred while processing your request. Please include the request ID fixture."; + foreach (var status in new[] { "active", "idle", "completed", "interrupted", "inProgress", "unknown" }) + check("Only a persisted failed terminal status permits recovery: " + status, !StreamRecoveryPolicy.Failed(status)); + var routes = new ModelProviderSettings(); + check("First-request failure does not require fabricated successful-route provenance", StreamRecoveryPolicy.RegisteredModel("fixture-openai", routes) && routes.ConversationRoutes.Count == 0); + check("A removed custom model cannot be continued to a fallback provider", !StreamRecoveryPolicy.RegisteredModel("cs_removed_model", routes)); + var id = Guid.NewGuid().ToString(); + string Complete(string? error) => JsonSerializer.Serialize(new { type = "event_msg", timestamp = DateTimeOffset.UtcNow, + payload = new { type = "task_complete", turn_id = id, error = error == null ? null : new { message = error } } }) + "\n"; + var tail = DesktopTurnOutcomes.ParseTail(Encoding.UTF8.GetBytes(Complete(failure)), false, "fixture-model", "codex_switch_router"); + check("Actual rollout completion parser retains failed turn identity and model", tail?.Id == id && tail.Status == "failed" && tail.Error == failure && tail.Model == "fixture-model"); + check("A persisted successful completion has no stream error", DesktopTurnOutcomes.ParseTail(Encoding.UTF8.GetBytes(Complete(null)), false, "m", "p")?.Status == "completed"); + string Terminal(string status, object? error) => JsonSerializer.Serialize(new { type = "event_msg", timestamp = DateTimeOffset.UtcNow, + payload = new { type = "task_complete", turn_id = id, status, error } }) + "\n"; + check("Failure without error prose remains failed", DesktopTurnOutcomes.ParseTail(Encoding.UTF8.GetBytes(Terminal("failed", null)), false, "m", "p")?.Status == "failed"); + check("An error object without a message remains failed", DesktopTurnOutcomes.ParseTail(Encoding.UTF8.GetBytes(Terminal("failed", new { code = "server_error" })), false, "m", "p")?.Status == "failed"); + check("Actual interrupted status wins over attached error prose", DesktopTurnOutcomes.ParseTail(Encoding.UTF8.GetBytes(Terminal("interrupted", new { message = failure })), false, "m", "p")?.Status == "interrupted"); + check("Unknown terminal status cannot imply completion", DesktopTurnOutcomes.ParseTail(Encoding.UTF8.GetBytes(Terminal("unknown", null)), false, "m", "p") == null); + var next = JsonSerializer.Serialize(new { type = "event_msg", payload = new { type = "task_started", turn_id = Guid.NewGuid().ToString() } }) + "\n"; + check("A newer started turn invalidates historical failure before any continuation", DesktopTurnOutcomes.ParseTail(Encoding.UTF8.GetBytes(Complete(failure) + next), false, "m", "p") == null); + check("A newer user abort invalidates historical failure", DesktopTurnOutcomes.ParseTail(Encoding.UTF8.GetBytes(Complete(failure) + "{\"type\":\"event_msg\",\"payload\":{\"type\":\"turn_aborted\"}}\n"), false, "m", "p") == null); + check("Incomplete appended JSON is not interpreted as another failure", DesktopTurnOutcomes.ParseTail(Encoding.UTF8.GetBytes("{\"type\":\"event_msg\""), false, "m", "p") == null); + check("Tail starts in the middle of an image/tool line without parsing that item", DesktopTurnOutcomes.ParseTail(Encoding.UTF8.GetBytes("partial-non-json\n" + Complete(failure)), true, "m", "p")?.Id == id); + + StreamRecoveryObservation Observation(string? turn=null) => new(Guid.NewGuid().ToString(), "owner", turn ?? Guid.NewGuid().ToString(), "failed", failure, + "fixture-model", "codex_switch_router", "identity-hash", "route-hash", DateTimeOffset.UtcNow, true); + foreach (var error in new string?[] { failure, "unexpected status 401 Unauthorized", "HTTP 403", "HTTP 413 Payload Too Large", + "HTTP 429", "insufficient quota", "context limit", "provider-encrypted compaction", "HTTP 507", "switch_upstream_recovery_stopped", + "stream disconnected before completion: The selected upstream did not return the requested event stream after HTTP 507 recovery.", + "A bounded JSON Responses request is required.", "upstream interrupted its response", "connection closed", null }) + { + var candidate = Observation() with { Error = error }; var data = new Dictionary(); var sent = 0; + var recovery = new StreamRecoveryCoordinator(data, () => {}, (_,_) => Task.CompletedTask); + var decision = await recovery.CheckAsync(candidate, _ => Task.FromResult(candidate), (_,_) => { sent++; return Task.FromResult(Guid.NewGuid().ToString()); }, CancellationToken.None); + check("Every confirmed error family gets one identified continuation: " + (error ?? "missing error message"), sent == 1 && decision.Reason == "continued" && decision.Attempts == 1); + } + var receipts = new Dictionary(); var saved = 0; var sends = 0; var delays = new List(); + var coordinator = new StreamRecoveryCoordinator(receipts, () => saved++, (attempt, _) => { delays.Add(attempt); return Task.CompletedTask; }); + var observed = Observation(); + for (var attempt = 1; attempt <= 4; attempt++) + { + var oldSends = sends; var nextTurn = Guid.NewGuid().ToString(); + await coordinator.CheckAsync(observed, _ => Task.FromResult(observed), (message, _) => + { + check("Receipt persists before send with a stable message identity: " + attempt, saved > 0 && receipts[observed.Thread].Sending && receipts[observed.Thread].Message == message && receipts[observed.Thread].Attempts == attempt); + sends++; return Task.FromResult(nextTurn); + }, CancellationToken.None); + check("Consecutive failed continuations share one three-attempt budget: " + attempt, sends == oldSends + (attempt <= 3 ? 1 : 0)); + if (attempt <= 3) observed = observed with { Turn = nextTurn }; + } + check("Automatic continuation budget is exactly three and backoff is bounded", sends == 3 && delays.SequenceEqual(new[] {1,2,3})); + var reloaded = JsonSerializer.Deserialize>(JsonSerializer.Serialize(receipts))!; + coordinator = new StreamRecoveryCoordinator(reloaded, () => saved++, (_,_) => Task.CompletedTask); + await coordinator.CheckAsync(observed, _ => Task.FromResult(observed), (_,_) => { sends++; return Task.FromResult(Guid.NewGuid().ToString()); }, CancellationToken.None); + check("Restart cannot reset an exhausted stream-recovery budget", sends == 3); + await coordinator.CheckAsync(observed with { Status = "completed", Error = null }, _ => throw new Exception(), (_,_) => throw new Exception(), CancellationToken.None); + observed = observed with { Turn = Guid.NewGuid().ToString() }; + await coordinator.CheckAsync(observed, _ => Task.FromResult(observed), (_,_) => { sends++; return Task.FromResult(Guid.NewGuid().ToString()); }, CancellationToken.None); + check("Confirmed success permits a new independent failure budget", sends == 4 && reloaded[observed.Thread].Attempts == 1); + foreach (var change in new Func[] { + o=>o with { Turn = Guid.NewGuid().ToString() }, o=>o with { Status="active" }, o=>o with { Ready=false }, + o=>o with { Owner="another" }, o=>o with { Model="another" }, o=>o with { Provider="another" }, + o=>o with { Identity="another" }, o=>o with { Route="another" }, o=>o with { Status="interrupted" }, o=>o with { Status="completed" } }) + { + var data = new Dictionary(); var candidate = Observation(); var called = false; + var recovery = new StreamRecoveryCoordinator(data, ()=>{}, (_,_)=>Task.CompletedTask); + await recovery.CheckAsync(candidate, _=>Task.FromResult(change(candidate)), (_,_)=>{called=true;return Task.FromResult(Guid.NewGuid().ToString());},CancellationToken.None); + check("Changed owner/turn/model/account/queue/runtime state prevents automatic send", !called && data.Count == 0); + } + { + var data = new Dictionary(); var candidate = Observation(); var count = 0; + var recovery = new StreamRecoveryCoordinator(data, ()=>{}, (_,_)=>Task.CompletedTask); + try { await recovery.CheckAsync(candidate,_=>Task.FromResult(candidate),(_,_)=>{count++;throw new IOException("Lost IPC response");},CancellationToken.None); } + catch(IOException) { } + var loaded = JsonSerializer.Deserialize>(JsonSerializer.Serialize(data))!; + recovery = new StreamRecoveryCoordinator(loaded,()=>{},(_,_)=>Task.CompletedTask); + await recovery.CheckAsync(candidate,_=>Task.FromResult(candidate),(_,_)=>{count++;return Task.FromResult(Guid.NewGuid().ToString());},CancellationToken.None); + check("Ambiguous continuation is persisted and never resent on restart", count==1 && loaded[candidate.Thread].Sending); + } + { + var data = new Dictionary(); var candidate=Observation();var called=false; + var recovery = new StreamRecoveryCoordinator(data,()=>throw new IOException("Disk write failed"),(_,_)=>Task.CompletedTask); + try { await recovery.CheckAsync(candidate,_=>Task.FromResult(candidate),(_,_)=>{called=true;return Task.FromResult(Guid.NewGuid().ToString());},CancellationToken.None); } catch(IOException) { } + check("Persistence failure prevents continuation submission",!called); + using var stop=new CancellationTokenSource();stop.Cancel(); + recovery=new StreamRecoveryCoordinator(new(),()=>{},(_,token)=>Task.Delay(100,token)); + try { await recovery.CheckAsync(candidate,_=>Task.FromResult(candidate),(_,_)=>{called=true;return Task.FromResult(Guid.NewGuid().ToString());},stop.Token); }catch(OperationCanceledException) { } + check("Cancellation during backoff never submits a continuation",!called); + } + var parameters=JsonSerializer.SerializeToElement(DesktopStreamRecoveryMonitor.StartParameters("fixture-thread","fixture-message")); + check("Automatic continuation inherits existing settings and uses one identified Continue",parameters.GetProperty("turnStart").GetProperty("context").GetProperty("inheritThreadSettings").GetBoolean() && + parameters.GetProperty("turnStart").GetProperty("request").GetProperty("clientUserMessageId").GetString()=="fixture-message" && + parameters.GetProperty("turnStart").GetProperty("request").GetProperty("input")[0].GetProperty("text").GetString()=="Continue"); + } +} diff --git a/tests/model-providers-ui/Program.cs b/tests/model-providers-ui/Program.cs index ddcbf9f..c6f5390 100644 --- a/tests/model-providers-ui/Program.cs +++ b/tests/model-providers-ui/Program.cs @@ -142,6 +142,7 @@ private static int Main(string[] args) checks.Add(language + ": duplicate restart action disabled while busy"); } HomeLayoutContracts.Verify(service, args[0], checks); + SessionCounterContracts.Verify(args[0], checks); File.WriteAllText(Path.Combine(args[0], "ui-verification.json"), JsonSerializer.Serialize(new { checks, diff --git a/tests/model-providers-ui/SessionCounterContracts.cs b/tests/model-providers-ui/SessionCounterContracts.cs new file mode 100644 index 0000000..64b7a49 --- /dev/null +++ b/tests/model-providers-ui/SessionCounterContracts.cs @@ -0,0 +1,37 @@ +using CodexAccountDesk; +using System.Globalization; +using System.IO; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Imaging; + +internal static class SessionCounterContracts +{ + internal static void Verify(string output, List checks) + { + foreach (var language in new[] { "en", "ko", "ja", "zh-CN", "zh-TW", "es", "fr", "de", "pt-BR" }) + foreach (var dark in new[] { true, false }) + foreach (var count in new int?[] { null, 0, 3 }) + { + Loc.SetLanguage(language); var theme = new Burn0Theme(dark); + var panel = new SessionUsagePanel(); + panel.Refresh(new SessionActivity(true, 3, 0, 6, Queued: 0, Goals: null, Errors: count), theme); + panel.Measure(new Size(476, 30)); panel.Arrange(new Rect(0, 0, panel.DesiredSize.Width, panel.DesiredSize.Height)); panel.UpdateLayout(); + if (panel.Children.Count != 4 || !panel.Fits() || panel.ActualWidth > 476 || panel.ActualHeight > 21) throw new Exception("Four session counters must fit the minimum window."); + var cell = (StackPanel)panel.Children[3]; var value = (TextBlock)cell.Children[1]; var label = (TextBlock)cell.Children[0]; + if (label.Text != Loc.T("sessions.errors") || value.Text != (count?.ToString(Loc.Culture) ?? "—")) throw new Exception("Error count/unknown translation is incorrect."); + var expected = ((SolidColorBrush)theme.Color(count > 0 ? "warning" : count.HasValue ? "text" : "muted")).Color; + if (((SolidColorBrush)value.Foreground).Color != expected || cell.ToolTip == null) throw new Exception("Errors must use warning color and explain their scope."); + if (count == 3 && language is "en" or "ko") + { + var background = new Border { Background = theme.Color("canvas"), Padding = new Thickness(12, 8, 12, 8), Child = panel }; + background.Measure(new Size(500, 40)); background.Arrange(new Rect(0, 0, 500, 40)); background.UpdateLayout(); + var bitmap = new RenderTargetBitmap(500, 40, 96, 96, PixelFormats.Pbgra32); bitmap.Render(background); + var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bitmap)); + using var file = File.Create(Path.Combine(output, "session-errors-" + language + "-" + (dark ? "dark" : "light") + ".png")); encoder.Save(file); + } + checks.Add(language + "/" + (dark ? "dark" : "light") + "/errors " + (count?.ToString(CultureInfo.InvariantCulture) ?? "unknown") + ": current count, warning color and four-counter bounds"); + } + } +} diff --git a/tests/model-providers/GatewayRetryContracts.cs b/tests/model-providers/GatewayRetryContracts.cs new file mode 100644 index 0000000..b74ac71 --- /dev/null +++ b/tests/model-providers/GatewayRetryContracts.cs @@ -0,0 +1,231 @@ +using CodexAccountDesk; +using System.Collections.Concurrent; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +internal static class GatewayRetryContracts +{ + internal static async Task Run(string root, Action check) + { + check("507 recovery has exactly three increasing cancellable delays", GatewayUpstreamRetry.MaximumRetries == 3 && + Enumerable.Range(1, 3).Select(GatewayUpstreamRetry.Delay).SequenceEqual(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10) })); + foreach (var failures in new[] { 0, 1, 2, 3, 4 }) + { + using var handler = new RetryUpstream { Failures = failures }; + using var http = new HttpClient(handler); var delays = new List(); + using var result = await GatewayUpstreamRetry.SendAsync(http, () => RetryUpstream.Request("https://chatgpt.com/backend-api/codex/responses"), true, + (retry, delay, token) => { delays.Add(retry); return Task.CompletedTask; }, CancellationToken.None); + check("Explicit 507 sequence is bounded and stops on success: " + failures, handler.Requests.Count == Math.Min(failures + 1, 4) && + delays.SequenceEqual(Enumerable.Range(1, Math.Min(failures, 3))) && (int)result.StatusCode == (failures < 4 ? 200 : 507)); + check("Rejected responses are disposed before another attempt: " + failures, handler.Errors.All(content => content.Disposed) || failures == 4 && handler.Errors.Count(content => content.Disposed) == 3); + } + foreach (var status in new[] { 400, 401, 403, 413, 429, 500, 502, 503 }) + { + using var handler = new RetryUpstream { OtherStatus = status }; + using var http = new HttpClient(handler); + using var result = await GatewayUpstreamRetry.SendAsync(http, () => RetryUpstream.Request("https://chatgpt.com/backend-api/codex/responses"), true, + (_, _, _) => throw new Exception("Unexpected retry"), CancellationToken.None); + check("507 recovery never expands to another error code: " + status, (int)result.StatusCode == status && handler.Requests.Count == 1); + } + using (var handler = new RetryUpstream { ThrowTransport = true }) + using (var http = new HttpClient(handler)) + { + var failed = false; + try { using var result = await GatewayUpstreamRetry.SendAsync(http, () => RetryUpstream.Request("https://chatgpt.com/backend-api/codex/responses"), true, + (_, _, _) => throw new Exception("Unexpected retry"), CancellationToken.None); } + catch (HttpRequestException) { failed = true; } + check("An ambiguous transport failure is never retried", failed && handler.Requests.Count == 1); + } + using (var handler = new RetryUpstream { Failures = 4 }) + using (var http = new HttpClient(handler)) + using (var stop = new CancellationTokenSource()) + { + var cancelled = false; + try { using var result = await GatewayUpstreamRetry.SendAsync(http, () => RetryUpstream.Request("https://chatgpt.com/backend-api/codex/responses"), true, + async (_, _, token) => { stop.Cancel(); await Task.Delay(TimeSpan.FromHours(1), token); }, stop.Token); } + catch (OperationCanceledException) { cancelled = true; } + check("Cancellation during backoff cannot submit another inference", cancelled && handler.Requests.Count == 1); + } + + // Full production listener: retain all bytes, selected destination and original + // auth; comments are keep-alives rather than assistant or tool events. + foreach (var custom in new[] { false, true }) + { + using var handler = new RetryUpstream { Failures = 3 }; + await using var fixture = new Fixture(root, handler, custom); + var beforeRoutes = JsonSerializer.Serialize(fixture.Store.Read().ConversationRoutes); + using var response = await fixture.Send(); + var body = await response.Content.ReadAsStringAsync(); + var requests = handler.Requests.ToArray(); + check("Fourth attempt completes through native gateway after three 507s: " + custom, response.IsSuccessStatusCode && body.Contains("response.completed") && !body.Contains("response.failed") && requests.Length == 4); + check("Every retry preserves the complete body and history: " + custom, requests.Select(item => item.Body).Distinct().Count() == 1 && requests.All(item => item.Body.Contains("fixture-call") && item.Body.Contains("original-tool-result") && item.Body.Contains("unchanged image"))); + check("Every retry preserves original account, model and destination: " + custom, requests.All(item => item.Host == (custom ? "retry.example.invalid" : "chatgpt.com") && item.Token == (custom ? "fictional-custom-retry-key" : "fictional-openai-retry-token") && item.Account == (custom ? null : "fictional-retry-account") && item.Session == (custom ? null : "retry-conversation"))); + check("HTTP 507 retries expose no upstream error body or extra response events: " + custom, !body.Contains("fictional-secret-in-error") && body.Split("response.completed").Length == 2 && body.Contains("recovery 3/3")); + check("Only the recovered success commits conversation provenance: " + custom, JsonSerializer.Serialize(fixture.Store.Read().ConversationRoutes) != beforeRoutes && fixture.Gateway.LastError == null); + } + using (var handler = new RetryUpstream { Failures = 10 }) + { + await using var fixture = new Fixture(root, handler); + using var response = await fixture.Send(); + var body = await response.Content.ReadAsStringAsync(); + check("Persistent HTTP 507 stops after the original plus three requests", handler.Requests.Count == 4 && body.Contains("response.failed") && body.Contains("3 of 3 retries") && !body.Contains("response.completed")); + check("Exhaustion never commits a route or leaks an upstream error", fixture.Store.Read().ConversationRoutes.Count == 0 && !body.Contains("fictional-secret-in-error") && fixture.Gateway.LastError!.Contains("507")); + } + using (var handler = new RetryUpstream { Failures = 1, OtherAfterFailures = 429 }) + { + await using var fixture = new Fixture(root, handler); + using var response = await fixture.Send(); var body = await response.Content.ReadAsStringAsync(); + check("A different failure after recovery is terminal without extra attempts", handler.Requests.Count == 2 && body.Contains("response.failed") && body.Contains("HTTP 429") && !body.Contains("fictional-secret-in-error")); + } + foreach (var path in new[] { "/responses/compact", "/images/generations", "/images/edits", "/responses?nonstream" }) + { + using var handler = new RetryUpstream { Failures = 10 }; + await using var fixture = new Fixture(root, handler); + using var response = await fixture.Send(path); + check("Compaction, image and nonstream requests retain single-send policy: " + path, (int)response.StatusCode == 507 && handler.Requests.Count == 1); + } + using (var handler = new RetryUpstream { Failures = 10 }) + { + await using var fixture = new Fixture(root, handler); + // A raw reset avoids HttpClient's background response draining. + var endpoint = new Uri(fixture.Gateway.BaseUrl); + using var caller = new TcpClient(); await caller.ConnectAsync(endpoint.Host, endpoint.Port); + caller.Client.LingerState = new LingerOption(true, 0); + var input = "{\"model\":\"fixture-openai\",\"stream\":true,\"input\":\"disconnect fixture\"}"; + var bytes = Encoding.UTF8.GetBytes(input); + var headers = Encoding.ASCII.GetBytes("POST " + endpoint.AbsolutePath + "/responses HTTP/1.1\r\nHost: " + endpoint.Authority + "\r\nAuthorization: Bearer fictional-openai-retry-token\r\nsession_id: retry-conversation\r\nversion: 0.163.0\r\nContent-Type: application/json\r\nContent-Length: " + bytes.Length + "\r\n\r\n"); + await caller.GetStream().WriteAsync(headers); await caller.GetStream().WriteAsync(bytes); + using var read = new StreamReader(caller.GetStream(), Encoding.UTF8, leaveOpen: true); + string? line; + do { line = await read.ReadLineAsync().WaitAsync(TimeSpan.FromSeconds(5)); } while (line != null && !line.Contains("Switch HTTP 507")); + check("The first 507 starts only a comment keep-alive", line != null && handler.Requests.Count == 1); + caller.Dispose(); + await Task.Delay(3000); + check("A disconnected caller cannot trigger another upstream attempt", handler.Requests.Count == 1); + } + using (var handler = new RetryUpstream { Failures = 10 }) + { + await using var fixture = new Fixture(root, handler); + using var response = await fixture.Send(); + fixture.SignedIn = false; + try { await response.Content.ReadAsStringAsync(); } + catch (HttpRequestException) { } + catch (IOException) { } + check("A sign-in change during backoff stops recovery on the original request", handler.Requests.Count == 1 && fixture.Store.Read().ConversationRoutes.Count == 0); + } + using (var handler = new RetryUpstream { BrokenStream = true }) + { + await using var fixture = new Fixture(root, handler); + using var response = await fixture.Send(); + var interrupted = false; + try { var body = await response.Content.ReadAsStringAsync(); interrupted = !body.Contains("response.completed"); } + catch (HttpRequestException) { interrupted = true; } + catch (IOException) { interrupted = true; } + check("Failure after successful response headers is never replayed", interrupted && handler.Requests.Count == 1); + } + using (var handler = new RetryUpstream { Failures = 10 }) + { + await using var fixture = new Fixture(root, handler); + using var response = await fixture.Send(); + fixture.GatewayDisposed = true; + await fixture.Gateway.DisposeAsync(); + await Task.Delay(250); + check("Gateway shutdown cancels pending backoff before another attempt", handler.Requests.Count == 1); + } + } + + private sealed class Fixture : IAsyncDisposable + { + internal readonly ModelProviderStore Store; + internal readonly ModelGateway Gateway; + private readonly HttpClient _upstream; + private readonly HttpClient _client = new() { Timeout = TimeSpan.FromSeconds(50) }; + private readonly string _model; + internal bool SignedIn = true; + internal bool GatewayDisposed; + internal Fixture(string root, RetryUpstream handler, bool custom = false) + { + Store = new ModelProviderStore(Path.Combine(root, "retry-" + Guid.NewGuid().ToString("N"))); + var model = new ProviderModel { UpstreamId = "fixture-openai" }; + var provider = new ModelProvider { Name = "Retry fixture", BaseUrl = "https://retry.example.invalid/v1", ApiKey = "fictional-custom-retry-key", Models = new() { model } }; + if (custom) Store.Update(next => next.Providers.Add(provider)); + _model = custom ? model.Alias(provider.Id) : model.UpstreamId; + _upstream = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; + Gateway = new ModelGateway(Store, (_, _) => SignedIn ? new GatewayIdentity("fictional-openai-retry-token", "fictional-retry-account", false) : null, _upstream); + Gateway.Start(); + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "fictional-openai-retry-token"); + _client.DefaultRequestHeaders.Add("session_id", "retry-conversation"); + _client.DefaultRequestHeaders.Add("version", "0.163.0"); + } + internal async Task Send(string path = "/responses") + { + var nonstream = path.EndsWith("?nonstream", StringComparison.Ordinal); + var body = JsonSerializer.Serialize(new { model = _model, stream = !nonstream, input = new object[] { + new { type = "function_call", call_id = "fixture-call", name = "fixture_tool", arguments = "{}" }, + new { type = "function_call_output", call_id = "fixture-call", output = "original-tool-result" }, + new { role = "user", content = "unchanged image / Korean 원문" } } }); + using var request = new HttpRequestMessage(HttpMethod.Post, Gateway.BaseUrl + path.Split('?')[0]) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; + return await _client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + } + public async ValueTask DisposeAsync() { _client.Dispose(); if (!GatewayDisposed) await Gateway.DisposeAsync(); _upstream.Dispose(); } + } + private sealed record Captured(string Body, string Host, string? Token, string? Account, string? Session); + private sealed class RetryUpstream : HttpMessageHandler + { + internal int Failures; + internal int? OtherStatus; + internal int? OtherAfterFailures; + internal bool ThrowTransport; + internal bool BrokenStream; + internal readonly ConcurrentQueue Requests = new(); + internal readonly ConcurrentQueue Errors = new(); + internal static HttpRequestMessage Request(string url) => new(HttpMethod.Post, url) { Content = new StringContent("{\"model\":\"fixture-openai\",\"stream\":true}") }; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellation) + { + if (request.Method == HttpMethod.Get) return new(HttpStatusCode.OK) { Content = new StringContent(new JsonObject { ["models"] = new JsonArray(ProviderCatalog.Generic("fixture-openai")) }.ToJsonString(), Encoding.UTF8, "application/json") }; + var body = await request.Content!.ReadAsStringAsync(cancellation); + Requests.Enqueue(new(body, request.RequestUri!.Host, request.Headers.Authorization?.Parameter, + request.Headers.TryGetValues("ChatGPT-Account-ID", out var account) ? account.Single() : null, + request.Headers.TryGetValues("session_id", out var session) ? session.Single() : null)); + if (ThrowTransport) throw new HttpRequestException("Owned ambiguous transport fixture"); + if (BrokenStream) return new(HttpStatusCode.OK) { Content = new StreamContent(new BrokenResponse()) { Headers = { ContentType = new MediaTypeHeaderValue("text/event-stream") } } }; + var code = OtherStatus ?? (Requests.Count <= Failures ? 507 : OtherAfterFailures ?? 200); + if (code != 200) + { + var content = new DisposedContent(); Errors.Enqueue(content); + return new((HttpStatusCode)code) { Content = content }; + } + return new(HttpStatusCode.OK) { Content = new StringContent("data: {\"type\":\"response.completed\",\"response\":{\"id\":\"recovered-fixture\",\"status\":\"completed\",\"output\":[]}}\n\n", Encoding.UTF8, "text/event-stream") }; + } + } + private sealed class DisposedContent : StringContent + { + internal bool Disposed; + internal DisposedContent() : base("fictional-secret-in-error") { } + protected override void Dispose(bool disposing) { Disposed = true; base.Dispose(disposing); } + } + private sealed class BrokenResponse : Stream + { + private bool _read; + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_read) throw new IOException("Owned partial-stream interruption"); + _read = true; + var bytes = Encoding.UTF8.GetBytes("data: {\"type\":\"response.created\"}\n\n"); bytes.CopyTo(buffer); return ValueTask.FromResult(bytes.Length); + } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override void Flush() { } + } +} diff --git a/tests/model-providers/Program.cs b/tests/model-providers/Program.cs index 696d5f9..a101464 100644 --- a/tests/model-providers/Program.cs +++ b/tests/model-providers/Program.cs @@ -40,6 +40,16 @@ async Task Refuses(string name, Func action) { try { await action(); } catch (Exception e) when (e is ArgumentException or InvalidOperationException or IOException or InvalidDataException) { Check(name, true); return; } throw new Exception(name); } var root = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Temp", "CodexSwitchModelProviders", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); +if (args.Contains("--retry-only")) +{ + try + { + await GatewayRetryContracts.Run(root, Check); + Console.WriteLine(JsonSerializer.Serialize(new { passed = checks.Count, checks, scope = "Isolated HTTP 507 recovery; no external inference or owner home." })); + } + finally { Directory.Delete(root, true); } + return; +} if (args.Contains("--stress")) { try @@ -332,6 +342,7 @@ async Task Send(string model, string session, JsonArray? in try { await saving; } catch (OperationCanceledException) { cancelled = true; } Check("Shutdown cancels and drains discovery before disposing state without saving a partial key", cancelled && !File.Exists(Path.Combine(cancelPath, "model-providers.vault"))); await GatewayLoadContracts.Run(root, Check, args.Contains("--stress")); + await GatewayRetryContracts.Run(root, Check); Console.WriteLine(JsonSerializer.Serialize(new { passed = checks.Count, checks, nativeClient = args.Contains("--codex"), framework = System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, scope = "Disposable encrypted storage, loopback gateway and fictional upstreams; no owner home, account or desktop." }, new JsonSerializerOptions { WriteIndented = true })); } finally @@ -349,6 +360,8 @@ internal sealed class Upstreams : HttpMessageHandler internal bool BlockDiscovery; internal bool MatchingReasoning; internal bool ImageFailure; + internal int StorageFailures; + internal int StreamFailures; internal TaskCompletionSource DiscoveryEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); internal GatedStream? Slow; internal string? BlockedKey; @@ -382,6 +395,12 @@ protected override async Task SendAsync(HttpRequestMessage if (BlockNext) { BlockNext = false; BlockedKey = request.Headers.Authorization?.Parameter; Entered.TrySetResult(); await Release.Task.WaitAsync(cancellation); } if (FailNext) { FailNext = false; return new(HttpStatusCode.TooManyRequests) { Content = new StringContent("fictional-key-a-new") }; } + if (StorageFailures > 0) { StorageFailures--; return new((HttpStatusCode)507) { Content = new StringContent("fictional-key-must-not-be-echoed") }; } + if (StreamFailures > 0) + { + StreamFailures--; + return new(HttpStatusCode.OK) { Content = new StringContent("data: {\"type\":\"response.failed\",\"response\":{\"id\":\"fixture-stream-error\",\"status\":\"failed\",\"error\":{\"code\":\"fixture_stream_failure\",\"message\":\"An error occurred while processing your request. Request ID fixture-stream.\"}}}\n\n", Encoding.UTF8, "text/event-stream") }; + } if (StreamNext) { StreamNext = false; var content = new StreamContent(Slow!); content.Headers.ContentType = new MediaTypeHeaderValue("text/event-stream"); @@ -419,7 +438,7 @@ internal static async Task Run(string executable, string home, ModelProviderMana await using var rpc = new CodexRpc(executable, home, preserveProvider: true); var diagnostics = new ConcurrentQueue(); rpc.DiagnosticMessage += line => { if (line.Contains("models", StringComparison.OrdinalIgnoreCase) && diagnostics.Count < 20) diagnostics.Enqueue(line); }; - using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(90)); var cancellation = timeout.Token; + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(150)); var cancellation = timeout.Token; await rpc.InitializeAsync(cancellation); var listed = await rpc.RequestAsync("model/list", new { includeHidden = true }, cancellation); var models = listed.GetProperty("data").EnumerateArray().Select(m => m.GetProperty("model").GetString()).ToArray(); @@ -435,7 +454,7 @@ internal static async Task Run(string executable, string home, ModelProviderMana check("Actual Codex retains the original OpenAI display name", listed.GetProperty("data").EnumerateArray().Single(m => m.GetProperty("model").GetString() == "fixture-openai").GetProperty("displayName").GetString() == "fixture-openai"); var thread = await rpc.RequestAsync("thread/start", new { model = "fixture-openai", cwd = home, ephemeral = true, approvalPolicy = "never", sandbox = "read-only", config = new Dictionary { ["mcp_servers"] = new Dictionary() } }, cancellation); var threadId = thread.GetProperty("thread").GetProperty("id").GetString()!; - async Task Turn(string target, string? model, string name, string? effort = null) + async Task Turn(string target, string? model, string name, string? effort = null, bool expectedFailure = false) { var complete = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); void Notification(string method, JsonElement value) { if (method == "turn/completed" && value.GetProperty("threadId").GetString() == target) complete.TrySetResult(value.Clone()); } @@ -444,8 +463,10 @@ async Task Turn(string target, string? model, string name, string? effort = null { await rpc.RequestAsync("turn/start", new { threadId = target, model, effort, input = new[] { new { type = "text", text = "Reply with fixture-ok." } } }, cancellation); var result = await complete.Task.WaitAsync(cancellation); - if (result.GetProperty("turn").GetProperty("status").GetString() != "completed") Console.WriteLine(JsonSerializer.Serialize(new { nativeFixtureTurn = result, gatewayError = manager.LastError, lastRoute = upstreams.Requests.Last().Host })); - check(name, result.GetProperty("turn").GetProperty("status").GetString() == "completed"); + var expected = expectedFailure ? "failed" : "completed"; + if (result.GetProperty("turn").GetProperty("status").GetString() != expected) Console.WriteLine(JsonSerializer.Serialize(new { nativeFixtureTurn = result, gatewayError = manager.LastError, lastRoute = upstreams.Requests.Last().Host })); + check(name, result.GetProperty("turn").GetProperty("status").GetString() == expected); + return result; } finally { rpc.Notification -= Notification; } } @@ -463,6 +484,17 @@ async Task Turn(string target, string? model, string name, string? effort = null check("Upstream response model names cannot redirect the next turn to OpenAI", upstreams.Requests.Last().Host == "a.example.invalid"); await Turn(threadId, "fixture-openai", "Changing another chat leaves the original chat on OpenAI"); check("Actual native follow-up requests reach all three upstream routes", upstreams.Requests.Any(r => r.Method == "POST" && r.Host == "a.example.invalid") && upstreams.Requests.Any(r => r.Method == "POST" && r.Host == "b.example.invalid")); + upstreams.StorageFailures = 3; var beforeRecovery = upstreams.Requests.Count(r => r.Method == "POST"); + await Turn(threadId, "fixture-openai", "Actual Codex completes the same turn after three gateway HTTP 507 retries"); + check("Actual Codex does not create another turn or duplicate gateway requests after recovery", upstreams.Requests.Count(r => r.Method == "POST") - beforeRecovery == 4); + upstreams.StorageFailures = 10; var beforeExhaustion = upstreams.Requests.Count(r => r.Method == "POST"); + await Turn(threadId, "fixture-openai", "Actual Codex receives terminal failure after gateway recovery is exhausted", expectedFailure: true); + check("Actual Codex cannot multiply the exhausted three-retry budget", upstreams.Requests.Count(r => r.Method == "POST") - beforeExhaustion == 4); + upstreams.StorageFailures = 0; + upstreams.StreamFailures = 1; + var streamFailed = await Turn(threadId, "fixture-openai", "Actual Codex persists an explicit post-response stream failure", expectedFailure: true); + check("Actual post-response failure is eligible for guarded same-chat continuation", StreamRecoveryPolicy.Failed(CodexAccountDesk.JsonValue.String(streamFailed.GetProperty("turn"), "status")!)); + await Turn(threadId, null, "Actual Codex completes a following turn in the same chat after stream failure"); var config = await File.ReadAllTextAsync(Path.Combine(home, "config.toml")); check("Connection preserves unrelated TOML comments and settings", config.Contains("# Fictional isolated native client") && config.Contains("shell_tool = false")); var outsideEdit = config.Replace("name = \"Codex Switch\"", "name = \"Owner edited provider\""); diff --git a/tests/protocol/Program.cs b/tests/protocol/Program.cs index 5f7bbc9..a303e89 100644 --- a/tests/protocol/Program.cs +++ b/tests/protocol/Program.cs @@ -24,6 +24,47 @@ void Rejected(Action action) where T : Exception Assert(assembly.GetName().Name == "CodexSwitch.Sqlite" && !assembly.GetReferencedAssemblies().Any(a => a.Name is "CodexSwitch" or "CodexSwitch.Core" or "CodexSwitch.Protocol" or "PresentationFramework" or "Avalonia" or "Avalonia.Controls")); }); +JsonElement Session(string status, string? turn = null, bool child = false, long revision = 1) => JsonSerializer.SerializeToElement(new { + type = "snapshot", revision, conversationState = new { parentThreadId = child ? "main" : null, sideConversation = false, + threadRuntimeStatus = new { type = status, activeFlags = Array.Empty() }, threadGoal = (object?)null, + turns = turn == null ? Array.Empty() : new object[] { new { turnId = "fixture-turn", status = turn } } } }); +Check("Error count stays unknown until all discovered chats are observed", () => +{ + var ledger = new SessionActivityLedger(); Assert(ledger.Read().Errors == null); + ledger.Discover("unread", "owner"); ledger.CompleteDiscovery(); Assert(ledger.Read().Errors == null); + ledger.Apply("unread", "owner", Session("idle")); Assert(ledger.Read().Errors == 0); + ledger.Reset(); Assert(ledger.Read().Errors == null); + ledger.CompleteDiscovery(); Assert(ledger.Read().Errors == 0); +}); +Check("Only currently failed main chats count, without duplicates or interruptions", () => +{ + var ledger = new SessionActivityLedger(); + foreach (var id in new[] { "system", "failed", "active", "cancelled", "completed", "child", "unloaded" }) ledger.Discover(id, "owner"); + ledger.Apply("system", "owner", Session("systemError", "failed")); + ledger.Apply("system", "owner", Session("systemError", "failed")); + ledger.Apply("failed", "owner", Session("idle", "failed")); + ledger.Apply("active", "owner", Session("active", "failed")); + ledger.Apply("cancelled", "owner", Session("idle", "interrupted")); + ledger.Apply("completed", "owner", Session("idle", "completed")); + ledger.Apply("child", "owner", Session("systemError", "failed", true)); + ledger.Apply("unloaded", "owner", Session("notLoaded", "failed")); + ledger.CompleteDiscovery(); Assert(ledger.Read().Errors == 2); + ledger.Apply("failed", "other-owner", Session("idle", "completed", revision: 2)); Assert(ledger.Read().Errors == 2); + ledger.Apply("system", "owner", Session("active", "inProgress", revision: 2)); Assert(ledger.Read().Errors == 1); + ledger.Apply("failed", "owner", Session("idle", "completed", revision: 2)); Assert(ledger.Read().Errors == 0); +}); +Check("Latest failure and recovery patches update the count without rescanning history", () => +{ + var ledger = new SessionActivityLedger(); ledger.Discover("main", "owner"); ledger.Apply("main", "owner", Session("active", "inProgress")); ledger.CompleteDiscovery(); + ledger.Apply("main", "owner", JsonSerializer.SerializeToElement(new { type = "patches", baseRevision = 1, revision = 2, patches = new object[] { + new { op = "replace", path = new object[] { "threadRuntimeStatus", "type" }, value = "idle" }, + new { op = "replace", path = new object[] { "turns", 0, "status" }, value = "failed" } } })); + Assert(ledger.Read().Errors == 1); + ledger.Apply("main", "owner", JsonSerializer.SerializeToElement(new { type = "patches", baseRevision = 2, revision = 3, patches = new object[] { + new { op = "replace", path = new object[] { "threadRuntimeStatus", "type" }, value = "active" } } })); + Assert(ledger.Read().Errors == 0); + ledger.Remove("main"); Assert(ledger.Read().Errors == 0); +}); var absent = Path.Combine(Path.GetTempPath(), "codex-protocol-contract-" + Guid.NewGuid().ToString("N")); var monitor = new DesktopSessionMonitor(absent); try From 1c3c1b95e58e9a2392295448c73fd79eab9d8463 Mon Sep 17 00:00:00 2001 From: Gaebabi <45709904+Gaebabi@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:35:55 +0900 Subject: [PATCH 13/15] [1/1 Integration fixes] Preserve writable handoff state and title translations --- localization/packs/de.json | 7 ++++++- localization/packs/en.json | 7 ++++++- localization/packs/es.json | 7 ++++++- localization/packs/fr.json | 7 ++++++- localization/packs/ja.json | 7 ++++++- localization/packs/ko.json | 7 ++++++- localization/packs/pt-BR.json | 7 ++++++- localization/packs/zh-CN.json | 7 ++++++- localization/packs/zh-TW.json | 7 ++++++- src/desktop/DeskWindow.cs | 4 ++-- 10 files changed, 56 insertions(+), 11 deletions(-) diff --git a/localization/packs/de.json b/localization/packs/de.json index 7e8e32e..b34c784 100644 --- a/localization/packs/de.json +++ b/localization/packs/de.json @@ -515,7 +515,12 @@ "history.selected_range": "{0:yyyy/MM/dd} – {1:yyyy/MM/dd} (KST)", "history.credit_range": "Guthabenbereich", "history.credit_reading": "Messung {0:MM/dd HH:mm:ss} (KST)\nGuthabenbereich: {1:0.##}% über dem Kontingent", - "history.retention": "Nur erfasste Beobachtungen der letzten 30 Tage werden angezeigt." + "history.retention": "Nur erfasste Beobachtungen der letzten 30 Tage werden angezeigt.", + "titles.button": "Chat-Datum", + "titles.help": "Datum nach koreanischer Zeit bei laufenden Hauptchats hinzufügen oder aktualisieren.", + "titles.working": "Chat-Daten werden aktualisiert…", + "titles.done": "Datum für {0} Chats aktualisiert.", + "titles.failed": "Nach {0} aktualisierten Chats ist ein Fehler aufgetreten: {1}" }, "plurals": { "msg.accounts": { diff --git a/localization/packs/en.json b/localization/packs/en.json index 1c160e2..b4df741 100644 --- a/localization/packs/en.json +++ b/localization/packs/en.json @@ -515,7 +515,12 @@ "history.selected_range": "{0:yyyy/MM/dd} – {1:yyyy/MM/dd} (KST)", "history.credit_range": "Credit range", "history.credit_reading": "Observed at {0:MM/dd HH:mm:ss} (KST)\nCredit range: {1:0.##}% above quota", - "history.retention": "History includes only recorded observations from the last 30 days." + "history.retention": "History includes only recorded observations from the last 30 days.", + "titles.button": "Date chats", + "titles.help": "Add or update the KST date prefix on running main chats.", + "titles.working": "Updating chat dates…", + "titles.done": "Updated dates on {0} chats.", + "titles.failed": "Updated {0} chats before an error: {1}" }, "plurals": { "msg.accounts": { diff --git a/localization/packs/es.json b/localization/packs/es.json index 3a11b10..02bf6c9 100644 --- a/localization/packs/es.json +++ b/localization/packs/es.json @@ -515,7 +515,12 @@ "history.selected_range": "{0:yyyy/MM/dd} – {1:yyyy/MM/dd} (KST)", "history.credit_range": "Zona de créditos", "history.credit_reading": "Observado {0:MM/dd HH:mm:ss} (KST)\nZona de créditos: {1:0.##}% sobre la cuota", - "history.retention": "Solo se muestran las observaciones registradas en los últimos 30 días." + "history.retention": "Solo se muestran las observaciones registradas en los últimos 30 días.", + "titles.button": "Fechar chats", + "titles.help": "Añade o actualiza la fecha coreana en los chats principales activos.", + "titles.working": "Actualizando fechas de chats…", + "titles.done": "Fechas actualizadas en {0} chats.", + "titles.failed": "Error tras actualizar {0} chats: {1}" }, "plurals": { "msg.accounts": { diff --git a/localization/packs/fr.json b/localization/packs/fr.json index 6ee4eac..a400c76 100644 --- a/localization/packs/fr.json +++ b/localization/packs/fr.json @@ -515,7 +515,12 @@ "history.selected_range": "{0:yyyy/MM/dd} – {1:yyyy/MM/dd} (KST)", "history.credit_range": "Zone de crédits", "history.credit_reading": "Relevé {0:MM/dd HH:mm:ss} (KST)\nZone de crédits : {1:0.##}% au-delà du quota", - "history.retention": "Seules les observations enregistrées au cours des 30 derniers jours sont affichées." + "history.retention": "Seules les observations enregistrées au cours des 30 derniers jours sont affichées.", + "titles.button": "Dater les chats", + "titles.help": "Ajouter ou actualiser la date coréenne des conversations principales en cours.", + "titles.working": "Mise à jour des dates…", + "titles.done": "Dates mises à jour pour {0} conversations.", + "titles.failed": "Erreur après la mise à jour de {0} conversations : {1}" }, "plurals": { "msg.accounts": { diff --git a/localization/packs/ja.json b/localization/packs/ja.json index f97b28c..ed8b898 100644 --- a/localization/packs/ja.json +++ b/localization/packs/ja.json @@ -515,7 +515,12 @@ "history.selected_range": "{0:yyyy/MM/dd} ~ {1:yyyy/MM/dd} (韓国時間)", "history.credit_range": "クレジット区間", "history.credit_reading": "実測 {0:MM/dd HH:mm:ss} (韓国時間)\nクレジット区間: 上限を {1:0.##}% 超過", - "history.retention": "直近30日間に実際に収集された記録のみ表示します。" + "history.retention": "直近30日間に実際に収集された記録のみ表示します。", + "titles.button": "日付更新", + "titles.help": "実行中のメインチャットの先頭に韓国時間の日付を追加または更新します。", + "titles.working": "チャットの日付を更新中…", + "titles.done": "{0}件のチャットの日付を更新しました。", + "titles.failed": "{0}件を更新した後にエラーが発生しました: {1}" }, "plurals": { "msg.accounts": { diff --git a/localization/packs/ko.json b/localization/packs/ko.json index 61983af..53aa568 100644 --- a/localization/packs/ko.json +++ b/localization/packs/ko.json @@ -515,7 +515,12 @@ "history.selected_range": "{0:yyyy/MM/dd} ~ {1:yyyy/MM/dd} (한국 시간)", "history.credit_range": "크레딧 구간", "history.credit_reading": "실측 {0:MM/dd HH:mm:ss} (한국 시간)\n크레딧 구간: 한도 대비 {1:0.##}% 초과", - "history.retention": "최근 30일 동안 실제로 수집된 기록만 표시합니다." + "history.retention": "최근 30일 동안 실제로 수집된 기록만 표시합니다.", + "titles.button": "채팅 날짜", + "titles.help": "실행 중인 주 채팅 제목에 한국 시간 날짜를 추가하거나 갱신합니다.", + "titles.working": "채팅 날짜 갱신 중…", + "titles.done": "채팅 {0}개의 날짜를 갱신했습니다.", + "titles.failed": "채팅 {0}개를 갱신한 뒤 오류가 발생했습니다: {1}" }, "plurals": { "msg.accounts": { diff --git a/localization/packs/pt-BR.json b/localization/packs/pt-BR.json index 18c5dad..9f2634c 100644 --- a/localization/packs/pt-BR.json +++ b/localization/packs/pt-BR.json @@ -515,7 +515,12 @@ "history.selected_range": "{0:yyyy/MM/dd} – {1:yyyy/MM/dd} (KST)", "history.credit_range": "Faixa de créditos", "history.credit_reading": "Observado em {0:MM/dd HH:mm:ss} (KST)\nFaixa de créditos: {1:0.##}% acima da cota", - "history.retention": "Apenas observações registradas nos últimos 30 dias são exibidas." + "history.retention": "Apenas observações registradas nos últimos 30 dias são exibidas.", + "titles.button": "Datar chats", + "titles.help": "Adiciona ou atualiza a data coreana nos chats principais em execução.", + "titles.working": "Atualizando datas dos chats…", + "titles.done": "Datas atualizadas em {0} chats.", + "titles.failed": "Erro após atualizar {0} chats: {1}" }, "plurals": { "msg.accounts": { diff --git a/localization/packs/zh-CN.json b/localization/packs/zh-CN.json index bfaa5aa..50345d6 100644 --- a/localization/packs/zh-CN.json +++ b/localization/packs/zh-CN.json @@ -515,7 +515,12 @@ "history.selected_range": "{0:yyyy/MM/dd} – {1:yyyy/MM/dd}(韩国时间)", "history.credit_range": "额度用尽后的点数区间", "history.credit_reading": "实测 {0:MM/dd HH:mm:ss}(韩国时间)\n点数区间:超出额度 {1:0.##}%", - "history.retention": "仅显示最近30天实际收集的记录。" + "history.retention": "仅显示最近30天实际收集的记录。", + "titles.button": "更新日期", + "titles.help": "为运行中的主聊天添加或更新韩国时间日期。", + "titles.working": "正在更新聊天日期…", + "titles.done": "已更新 {0} 个聊天的日期。", + "titles.failed": "更新 {0} 个聊天后出错:{1}" }, "plurals": { "msg.accounts": { diff --git a/localization/packs/zh-TW.json b/localization/packs/zh-TW.json index 00103e0..9bbd5bd 100644 --- a/localization/packs/zh-TW.json +++ b/localization/packs/zh-TW.json @@ -515,7 +515,12 @@ "history.selected_range": "{0:yyyy/MM/dd} – {1:yyyy/MM/dd}(韓國時間)", "history.credit_range": "額度用盡後的點數區間", "history.credit_reading": "實測 {0:MM/dd HH:mm:ss}(韓國時間)\n點數區間:超出額度 {1:0.##}%", - "history.retention": "僅顯示最近30天實際收集的紀錄。" + "history.retention": "僅顯示最近30天實際收集的紀錄。", + "titles.button": "更新日期", + "titles.help": "為執行中的主聊天新增或更新韓國時間日期。", + "titles.working": "正在更新聊天日期…", + "titles.done": "已更新 {0} 個聊天的日期。", + "titles.failed": "更新 {0} 個聊天後發生錯誤:{1}" }, "plurals": { "msg.accounts": { diff --git a/src/desktop/DeskWindow.cs b/src/desktop/DeskWindow.cs index 47569c0..55bc15e 100644 --- a/src/desktop/DeskWindow.cs +++ b/src/desktop/DeskWindow.cs @@ -324,7 +324,7 @@ await RunAsync(async _ => await _service!.CheckCredentialBackendAsync(profile, _switch.Token); await using var backend = new DesktopSwitchBackend(_service, _sessions, profile.Key, includeUnfinished: restart); var coordinator = new AccountSwitchCoordinator(backend, - plan => { lock (_service.Vault.SyncRoot) { _service.Vault.Data.ActivePendingSwitch = plan; _service.Vault.Save(); } }, + plan => { lock (_service.Vault.SyncRoot) { _service.Vault.Data.PendingSwitch = plan; _service.Vault.Save(); } }, plan => OnUi(() => { _phase = plan.Phase; RenderAccounts(); }), UserErrors.Describe); var pending = _service.Vault.Data.ActivePendingSwitch; if (pending != null && !pending.CreditProtectionPause && pending.TargetAccountKey != profile.Key) throw new InvalidOperationException(Loc.T("남은 채팅 재개 작업을 먼저 처리해 주세요.")); @@ -339,7 +339,7 @@ await RunAsync(async _ => plan.PreventDepletedSourceRelaunch = !_service.Vault.Data.AllowCreditUse && !_service.Accounts.Any(p => p.IsCurrent && p.Key == plan.SourceAccountKey && AutomaticSwitchPolicy.OrdinaryAvailable(p, DateTimeOffset.UtcNow)); if (!automatic) plan.AutomaticRecoveryAttempts = 0; - lock (_service.Vault.SyncRoot) { _service.Vault.Data.ActivePendingSwitch = plan; _service.Vault.Save(); } + lock (_service.Vault.SyncRoot) { _service.Vault.Data.PendingSwitch = plan; _service.Vault.Save(); } if (await coordinator.RunAsync(plan, _switch.Token)) { if (plan.RestartOnly) _statusMessage = Loc.T("restart.done"); } else From 2044e6199ea73673f6eb13f4c833d286ff177640 Mon Sep 17 00:00:00 2001 From: Gaebabi <45709904+Gaebabi@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:56:13 +0900 Subject: [PATCH 14/15] [1/1 Toolbar integration] Keep login cancellation in its original leading position --- src/desktop/DeskWindow.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/desktop/DeskWindow.cs b/src/desktop/DeskWindow.cs index 55bc15e..8cad742 100644 --- a/src/desktop/DeskWindow.cs +++ b/src/desktop/DeskWindow.cs @@ -162,7 +162,7 @@ private void Build() var actions = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Left }; _stampTitles = _theme.Button(Loc.T("titles.button"), () => _ = StampTitlesAsync()); _stampTitles.Name = "StampActiveTitlesButton"; _stampTitles.FontSize = 10; _stampTitles.Padding = new Thickness(5, 1); _stampTitles.MinHeight = 20; - ToolTip.SetTip(_stampTitles, Loc.T("titles.help")); actions.Children.Add(_stampTitles); actions.Children.Add(_cancel); + ToolTip.SetTip(_stampTitles, Loc.T("titles.help")); actions.Children.Add(_cancel); actions.Children.Add(_stampTitles); toolbar.Children.Add(actions); _sessionValues = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 12, HorizontalAlignment = HorizontalAlignment.Center }; Grid.SetColumn(_sessionValues, 1); From 8243ec89b7275724a0fdeec8524868423e21ce06 Mon Sep 17 00:00:00 2001 From: Gaebabi <45709904+Gaebabi@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:15:23 +0900 Subject: [PATCH 15/15] [1/1 Toolbar verification] Compare restored cancellation controls without pointer hover --- scripts/linux-desktop-session/verify-session.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/linux-desktop-session/verify-session.py b/scripts/linux-desktop-session/verify-session.py index 357f014..ac86fb7 100644 --- a/scripts/linux-desktop-session/verify-session.py +++ b/scripts/linux-desktop-session/verify-session.py @@ -240,6 +240,9 @@ def login_controls(pending): check('Ordinary account addition exposes native pending-login cancellation at minimum size', not (ordinary_home / 'auth.json').exists()) capture(window, pid, 'ordinary-pending-login-500x360') run(['xdotool', 'mousemove', '--window', window, '32', '17', 'click', '1']) + # The title-update button becomes visible beneath the pointer when Cancel + # disappears. Compare the restored toolbar with the same unhovered state. + run(['xdotool', 'mousemove', '--window', window, '250', '180']) wait_for(lambda: login_controls(False), 20) check('Ordinary Cancel restores the ready toolbar without changing existing ciphertext or creating authentication', not (ordinary_home / 'auth.json').exists() and (root / 'share/CodexAccountDesk/accounts.vault').read_bytes() == before_login_vault) run(['xdotool', 'windowsize', '--sync', window, '800', '600'])