From 137d6a7270e7ecfb1c791993800a17c0e30022d9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 06:31:28 +0900 Subject: [PATCH 01/50] chore(release): open dev at 2.46.0 before releasing 2.45.0 (#3812) Move dev to 2.46.0 before publishing 2.45.0. The owner explicitly authorized this release train and maintainer integration. Official pre-move workflow34061256762 passed the unused-version and release-version-line checks. This PR changes package.json only. Frozen release source cf9f662190c4c6770697c45c870941509cc98f9c is being validated by Cross-platform CI34061274315 and Service lifecycle34061276621; those pending runs are not claimed passing. Release publication remains gated on candidate and exact release-branch evidence. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 34b7649856..bdf30eb215 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bitkyc08/opencodex", - "version": "2.45.0", + "version": "2.46.0", "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code", "type": "module", "main": "./bin/package-main.mjs", From e963aa6bde54522c883b57da0dfd073bc4022cfa Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:35:45 +0900 Subject: [PATCH 02/50] docs: plan platform validation follow-up Record scoped roadmap and independent plan audit. Local suites are excluded by owner instruction; final remote CI will verify the combined candidate. --- .../260907_platform_validation/000_plan.md | 31 +++++++++++++++++++ .../001_plan_audit.md | 5 +++ .../010_oauth_teardown.md | 14 +++++++++ .../020_container_smoke.md | 25 +++++++++++++++ .../030_container_ci.md | 21 +++++++++++++ .../035_body_diagnostics.md | 17 ++++++++++ .../040_residual_evidence.md | 15 +++++++++ 7 files changed, 128 insertions(+) create mode 100644 devlog/_plan/260907_platform_validation/000_plan.md create mode 100644 devlog/_plan/260907_platform_validation/001_plan_audit.md create mode 100644 devlog/_plan/260907_platform_validation/010_oauth_teardown.md create mode 100644 devlog/_plan/260907_platform_validation/020_container_smoke.md create mode 100644 devlog/_plan/260907_platform_validation/030_container_ci.md create mode 100644 devlog/_plan/260907_platform_validation/035_body_diagnostics.md create mode 100644 devlog/_plan/260907_platform_validation/040_residual_evidence.md diff --git a/devlog/_plan/260907_platform_validation/000_plan.md b/devlog/_plan/260907_platform_validation/000_plan.md new file mode 100644 index 0000000000..d1b3cb632f --- /dev/null +++ b/devlog/_plan/260907_platform_validation/000_plan.md @@ -0,0 +1,31 @@ +# Platform verification follow-up + +Baseline: dev `137d6a7270e7ecfb1c791993800a17c0e30022d9` (2026-09-07). + +## Objective and authority + +Satisfy the existing platform contracts for #3383, #3449, #3522 and #3573. The owner requested ordinary manual PRs, top-of-stack CI first, lower-layer CI only to diagnose a failed final run, no local test suites, push with --no-verify, admin merge after verification, and original contributor credit in commit trailers. No native GitHub stack registration. No publish, release, global settings changes, admission-limit increases, ACL relaxation, or speculative recovery policy. + +The initial assigned checkout contains unrelated dirty work and is preserved. Work lives in an isolated worktree. No SessionStart FSM binding is available in the supplied context; this record documents the work without claiming automatic loop continuation is armed. + +## Evidence and scope + +Dockerfile, compose.yaml, docker/bootstrap-token.ts and the source-build guide already exist. Cross-platform CI has no real image build/start/recreate check. #3522 requires same-process Windows recovery evidence; #3573 requires actual rejected compact-byte evidence. Existing diagnostics must be checked before adding anything. PR #3383 is a mixed historical source: only Windows temp/teardown residuals are in scope, not picker controls. + +Original Docker contributor: Buseong Kim , verified from original #3421 commit metadata. Carry this identity in commit trailers. + +## Dependency map + +1. `010_oauth_teardown.md`: drain the asynchronous ACL fixture before deletion. +2. `020_container_smoke.md`: executable isolated container acceptance probe. +3. `030_container_ci.md`: CI consumes that probe and gates its result. +4. `035_body_diagnostics.md`: distinguish declared size, observed lower bound, and decoded size without changing admission. +5. `040_residual_evidence.md`: settle the Windows/spill/compact residuals; implement only a proven narrow gap through a plan amendment, otherwise preserve open status. + +The manual review chain contains the independent OAuth fixture carry, bounded body diagnostics, the container probe, then its dependent CI integration. Independent code is prepared in disjoint files; the top CI validates their combined tree. Existing workflow triggers remain honest: final branch workflow_dispatch supplies the complete integration result; lower PR runs are not represented as passed if skipped/cancelled. Every implemented layer is reviewed, and final head is pinned before CI. After successful final CI, merge bottom-up using merge commits so reviewed commit ancestry survives. Revalidate the resulting integration and distinguish unrelated concurrent dev changes. + +## Verification and completion + +Local suites and typecheck are NOT RUN by owner instruction. Syntax and read-only diff checks are allowed. The real verifier is GitHub Cross-platform CI on the final branch, including the new Docker job. A failed final run is diagnosed on the smallest affected scope; do not repeatedly run passing gates. Independent Astra high review covers functionality and workflow/security boundaries. Security working notes remain in scratch, not this public unit. + +Completion means verified deliverable PRs merged with commit attribution, plus explicit no-op/blocked disposition for unavailable field evidence. It does not mean every original issue is fixed. New product/security policy choices remain outside scope. Evidence and final outcome are appended to this unit; workflow run URLs and SHAs are preserved. diff --git a/devlog/_plan/260907_platform_validation/001_plan_audit.md b/devlog/_plan/260907_platform_validation/001_plan_audit.md new file mode 100644 index 0000000000..96ac86c266 --- /dev/null +++ b/devlog/_plan/260907_platform_validation/001_plan_audit.md @@ -0,0 +1,5 @@ +# Plan audit disposition + +Independent Astra high reviewer: NEAR-PASS. OAuth teardown and bounded body diagnostics passed within scope. Three Docker/CI conditions were incorporated before implementation: explicit final lane=all executed-job inventory; isolated project/image/port and bounded cleanup; concrete readiness/admission/catalog/persistence checks before and after actual replacement. + +Main judgment: pass with those amendments. Scope remains unchanged: existing Docker contract verification, test-fixture teardown, bounded diagnostics. Live spill recovery and exact historical compact-body proof remain deferred. No local suites or typecheck were run. diff --git a/devlog/_plan/260907_platform_validation/010_oauth_teardown.md b/devlog/_plan/260907_platform_validation/010_oauth_teardown.md new file mode 100644 index 0000000000..d4ee405020 --- /dev/null +++ b/devlog/_plan/260907_platform_validation/010_oauth_teardown.md @@ -0,0 +1,14 @@ +# OAuth fixture teardown carry + +Original source: #3383 commit 51726d2c7c58146defdd6088aefa2b95a1e58553. +Original contributor: x3M3x (Git commit metadata). + +## Concrete delta + +MODIFY `tests/oauth/oauth-store-multi.test.ts` only: import flushConfigDirHardeningForTests and the async ICACLS test runner; stub synchronous and asynchronous runners consistently in setup. Change teardown to await the tracked hardening work before resetting runners/caches, restoring OPENCODEX_HOME, or removing the fixture. Preserve removeTreeWithRetry and all production semantics. Add a deterministic held-async-runner regression against the actual cleanup routine if the existing fixture seams allow it without a new production test API. + +Production path proof: store reads call hardenConfigDir; config/paths tracks asynchronous directory hardening; resetHardenedStateForTests clears caches but does not drain those jobs. Deletion retries alone do not ensure ordering. The prior carry #3258 only replaced the removal function. + +## Acceptance + +No real asynchronous ICACLS escapes the fixture runner. Cleanup waits while a controlled ACL flight is unresolved and only deletes/restores environment after completion. The same OAuth test file passes in final Linux/macOS/Windows CI. Local tests/typecheck are NOT RUN by owner instruction. No numeric-open-flags change is included without current Bun reproduction. No new API/auth policy, credentials, or production runtime change. diff --git a/devlog/_plan/260907_platform_validation/020_container_smoke.md b/devlog/_plan/260907_platform_validation/020_container_smoke.md new file mode 100644 index 0000000000..17d7139d35 --- /dev/null +++ b/devlog/_plan/260907_platform_validation/020_container_smoke.md @@ -0,0 +1,25 @@ +# Container smoke executable + +## File delta + +NEW `scripts/ci/docker-smoke.ts`: bounded Bun-native TypeScript probe for the existing source-build Compose contract. Reuse the canonical compatibility generator and docker/bootstrap-token.ts; do not add an alternative token writer or deployment configuration. The probe creates a unique temporary Compose project and image, builds the actual Dockerfile, bootstraps a freshly generated throwaway token through stdin, starts the hub, verifies health and data-plane admission, recreates the container on the same named volumes, and verifies persistent state again. Cleanup is limited to the unique test project and its generated artifacts. Never use an operator project, host home, provider credentials, global docker prune, or real upstream inference. + +MODIFY owning documentation only as needed to explain the CI acceptance scope and its limits; no claim of upstream-provider validation. + +## Acceptance + +- Real image builds from the checkout with a generated compatibility manifest. +- Read-only/non-root Compose service becomes healthy; requests without a token are refused. +- A synthetic catalog in the separate Codex volume is served with the throwaway token, proving admission and persistence without provider access. +- /readyz succeeds separately from liveness, token reinitialization fails without replacement, and effective container restrictions are verified. +- Token/config/catalog persist across an actual container replacement (different container id, same volumes). +- Failures and cleanup are bounded; token/body contents never appear in logs. +- Existing Docker settings and defaults remain unchanged. + +Run only in final remote CI. Locally perform source/static inspection, not the smoke or a test suite. Read the current lifecycle/API contracts before implementing assertions. + +## Audit amendments + +Use explicit unique project on every Compose command, unique image tag via a temporary override, controlled Compose environment, and loopback ephemeral host port. Preserve pre-existing generated files; cleanup must fail the probe if it cannot remove its own project resources. Bound every child, output capture and cleanup; terminate/reap timed-out children. Never print raw runtime logs or complete inspect output. + +Before/after replacement: require readyz 200 with status ready; authenticated catalog 200 with exact synthetic fixture; missing/wrong token 401 for catalog, Responses and compact. Second bootstrap must fail and preserve the original token while rejecting the proposed replacement. Verify different container IDs, identical named-volume identities and persistent config/catalog evidence without reseeding; check effective non-root UID and read-only root. diff --git a/devlog/_plan/260907_platform_validation/030_container_ci.md b/devlog/_plan/260907_platform_validation/030_container_ci.md new file mode 100644 index 0000000000..28f3e08fc9 --- /dev/null +++ b/devlog/_plan/260907_platform_validation/030_container_ci.md @@ -0,0 +1,21 @@ +# Container CI integration + +Depends on the committed probe from phase 1. + +## File delta + +MODIFY `.github/workflows/ci.yml`: include Dockerfile, compose.yaml, .dockerignore and docker/** in relevant scope detection; add an ubuntu-latest Docker smoke job using the existing pinned checkout and setup-project-bun action; invoke the script after installing required project dependencies if the generator needs them. Preserve read-only workflow permissions and persist-credentials false. Add the job to aggregate ci needs so failures cannot silently pass. No registry publishing, credentials, native stack integration or changes to existing suite retry/concurrency policy. + +MODIFY `tests/ci-workflows/ci-workflows.test.ts`: extend the existing source-oracle checks for scope paths, direct aggregate dependency, pinned actions, and actual probe invocation. Keep existing domain/layout registration unchanged by using the owning test file. + +MODIFY `docs-site/src/content/docs/guides/remote-hub.md`: describe image lifecycle validation and separate readiness/provider-auth limitations. + +## Acceptance and verifier + +Final-branch Cross-platform CI workflow_dispatch must run the smoke and the existing platform gates. The Docker job's failures must reach ci. Local suite/typecheck NOT RUN per owner. Independent review checks full workflow event, permission, input, credential, and cleanup boundaries before publishing. Existing source-oracle tests execute remotely in CI. + +Publish branches with --no-verify; do not claim lower-layer CI if only the final tree was tested. Final failure permits narrower runs. User authorized admin merge of verified layers; original author names/emails come from source commit metadata and are included as Co-authored-by trailers. + +## Final execution inventory + +Dispatch existing Cross-platform CI with lane=all on the immutable final head. Record each expected job and actual conclusion: Docker, four Linux shards, storage-policy, api-usage, gates, two macOS shards, macos-control, six Windows shards, keyring jobs, any selected npm packaging jobs, and ci. Aggregate green alone does not prove Windows or Docker ran. Explain legitimate scope skips instead of counting them as tests. diff --git a/devlog/_plan/260907_platform_validation/035_body_diagnostics.md b/devlog/_plan/260907_platform_validation/035_body_diagnostics.md new file mode 100644 index 0000000000..5165526583 --- /dev/null +++ b/devlog/_plan/260907_platform_validation/035_body_diagnostics.md @@ -0,0 +1,17 @@ +# Bounded inbound-body diagnostic semantics + +Issue #3573 requests usable size evidence. The existing error stores a byte value but returns only the admission limit; the byte value currently mixes declared length, observed wire bytes, an artificial limit+1 lower bound, and exact decoded length. + +## File delta + +MODIFY `src/server/request-decompress.ts`: extend DecompressedBodyTooLargeError with a closed measurement category and retained limit, preserving existing constructor call compatibility. Annotate existing throw sites: declared_wire, observed_wire_lower_bound, decoded_exact, decoded_lower_bound. Append a bounded numeric/category suffix to the current message so existing core.ts error mapping carries it. No request body, path, headers, item counts, further inflate/read, admission-limit changes, or new retry semantics. + +MODIFY `tests/usage/request-decompress.test.ts`: extend small-cap fixtures to verify identity/gzip/zstd/deflate and declared/fragmented input semantics. In particular, limit+1 remains a lower bound, never exact size. Verify HTTP 413 and existing error code/type through existing handler mapping. Preserve stream cancellation. + +MODIFY `docs-site/src/content/docs/reference/proxy-formats.md`: explain wire declared length vs measured/lower-bound diagnostics, separately from compact-response limits. State that Bun listener rejection may happen before application diagnostics and that this does not measure the exact historical compact payload. + +## Acceptance + +Unchanged 256 MiB listener/decoder limit and rejection classification. No context-window wording that causes errors.ts to reclassify the failure. Message remains bounded, only fixed categories and finite numeric values. Negative tests run in final remote CI; no local test/typecheck. Keep #3573 open pending exact real compact evidence. + +This is a new diagnostic refinement of an issue, not a carry of a new contributor PR. Credit reporter @nowhere1975 in commit prose without inventing name/email. Any borrowed existing PR patches must additionally retain their actual git author trailers. diff --git a/devlog/_plan/260907_platform_validation/040_residual_evidence.md b/devlog/_plan/260907_platform_validation/040_residual_evidence.md new file mode 100644 index 0000000000..f5466a4d38 --- /dev/null +++ b/devlog/_plan/260907_platform_validation/040_residual_evidence.md @@ -0,0 +1,15 @@ +# Windows and request diagnostic residuals + +## Read-only targets + +- #3383: inspect current PR and merged descendants for Windows temp creation and OAuth teardown. Confirm current source behavior and test coverage before proposing a residual patch. No picker UI changes. +- #3522: inspect response spill telemetry and fresh-versus-memoized timeout handling. The acceptance is recovery within the same affected Windows process; generic synthetic success does not prove the reported process recovered. +- #3573: inspect decompression rejection diagnostics and exact latest issue measurements. Serialized journal size and normal requests after raising a cap do not prove the rejected compact payload size or compact success. + +## Conditional delta + +No production edit is pre-approved by this document without a source-grounded residual. If the existing code covers the measurement, record the missing field evidence and leave the issue open. If a specific content-free diagnostic is missing, amend with exact files, field flow and negative assertions before implementation. Never change admission caps, parse a rejected body to count items, relax ACLs, clear memo state, or choose a new recovery/retry policy. + +## Completion + +Record source/commit evidence, original contributor attribution where code is carried, and a separate status per candidate: already implemented, proven patch delivered, or blocked on field evidence. Do not close an original feature PR or issue merely because one residual probe passes. From eabe7ce602af27e098f9bb40b5cb2130b4d7f371 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:36:21 +0900 Subject: [PATCH 03/50] docs: plan axis1 bounded bug fixes [skip ci] --- .../_plan/260907_axis1_bugfixes/000_plan.md | 22 +++++++++++++++++++ .../260907_axis1_bugfixes/010_roadmap.md | 3 +++ .../_plan/260907_axis1_bugfixes/011_audit.md | 5 +++++ .../260907_axis1_bugfixes/012_roadmap_lock.md | 5 +++++ .../020_bounded_fixes.md | 20 +++++++++++++++++ .../260907_axis1_bugfixes/030_delivery.md | 7 ++++++ 6 files changed, 62 insertions(+) create mode 100644 devlog/_plan/260907_axis1_bugfixes/000_plan.md create mode 100644 devlog/_plan/260907_axis1_bugfixes/010_roadmap.md create mode 100644 devlog/_plan/260907_axis1_bugfixes/011_audit.md create mode 100644 devlog/_plan/260907_axis1_bugfixes/012_roadmap_lock.md create mode 100644 devlog/_plan/260907_axis1_bugfixes/020_bounded_fixes.md create mode 100644 devlog/_plan/260907_axis1_bugfixes/030_delivery.md diff --git a/devlog/_plan/260907_axis1_bugfixes/000_plan.md b/devlog/_plan/260907_axis1_bugfixes/000_plan.md new file mode 100644 index 0000000000..1fa7537164 --- /dev/null +++ b/devlog/_plan/260907_axis1_bugfixes/000_plan.md @@ -0,0 +1,22 @@ +# Axis 1: measured bug fixes and failure diagnostics + +Archetype: satisfy existing contracts. Trigger: owner assigned axis 1 (#3809, #3464, #3661). Goal: deliver reviewable fixes through a manual PR chain and merge the verified scope. Non-goals: new account/retry policy, auth defaults, multipart recovery, releases, native stacks, sibling edits. Stop: merged feasible scope plus explicit unresolved dispositions. Escalation: defer a policy-dependent or unreproducible slice; reclaim a worker slice after two failed packets. Evidence: this unit plus ignored `.tmp/axis1/` and `.codexclaw` receipts. Resources: task-owned worktree/branches and GitHub repository access; Astra high leaves within host capacity; no caller-specified token or wall-clock budget. + +Baseline: origin/dev 137d6a727; source PR #3809 at 4a1012359a522ddd6d7ff77203c9e5f3632d605c. Assigned 5cc8 checkout has pre-existing changes and remains untouched. Code lives in /tmp/ocx-axis1-20260907. + +## Cycle map +1. wp0: docs-only scope, source audit and dependency roadmap; no runtime changes. +2. wp1: bounded quota, version-guidance and recovery-diagnostic changes; independent source/security review and structural checks. Runtime verification deferred explicitly to wp2. +3. wp2: publish ordinary PR chain, run final cumulative hosted CI, resolve findings, admin merge bottom-up and verify dev ancestry. Lower CI only if final CI fails. + +## Delivery contract +The owner explicitly requests a manual delivery chain even where units are independent: quota -> CLI guidance -> recovery reasons, with each layer carrying its own tests and credit. This order is an integration order, not a fabricated runtime dependency. No native registration. Lower commits carry [skip ci] to defer duplicate workflow runs; final head does not. Skipped lower runs are never called passing. No local tests/typecheck/build suites and no hook-triggered suites; task pushes use --no-verify. Hosted ci.yml on the final head must cover all changed runtime/tests; lower-level runs are diagnostic only after final failure. Merge with --admin under the explicit owner exception; preserve original commits/trailers with merge commits, retarget each child to dev, and check integration trees against final evidence. Concurrent dev changes require fresh combined verification. + +## Work boundaries +- Quota: src/providers/quota.ts, src/oauth/anthropic-routing.ts, src/oauth/health.ts, src/server/responses/core.ts, src/images/loop.ts, src/web-search/loop.ts, focused quota tests/layout, provider documentation. +- CLI: src/cli/version-skew.ts and relevant status/doctor consumers, tests/cli/cli-version-skew.test.ts, troubleshooting documentation. No service restart or repair behavior changes. +- Recovery: src/server/responses/agent-task-recovery.ts, agent-task-recovery-cache.ts, src/lib/bounded-body.ts and existing focused tests, Responses error projection if needed, recovery documentation. No expanded admission/retry. +- Main owns shared core.ts integration and test-layout files. Workers must not touch each other's paths or git index. + +## Verification and acceptance +No local suite commands are executed. Source mapping, git diff --check and documentation structural checks are local evidence only. Hosted Cross-platform CI at final head provides runtime/typecheck/privacy and affected platform proof; inspect jobs for skipped coverage. Build completion is provisional until that run and independent audit succeed. Original PR author(s) must be named in commit Co-authored-by trailers, sourced from original commits/API; report authors may also be acknowledged accurately. Source-of-truth sync uses relevant existing structure and docs-site pages. diff --git a/devlog/_plan/260907_axis1_bugfixes/010_roadmap.md b/devlog/_plan/260907_axis1_bugfixes/010_roadmap.md new file mode 100644 index 0000000000..75e07478c0 --- /dev/null +++ b/devlog/_plan/260907_axis1_bugfixes/010_roadmap.md @@ -0,0 +1,3 @@ +# wp0: scope roadmap + +Read current source, prior issue disposition and PR #3809 before choosing changes. Independent Astra high reviewers map each bounded issue. Confirm existing launcher behavior and bounded recovery reasons are already in dev; plan only residual fixes. Record exact file boundaries and acceptance scenarios in 020. Success: all three slices have verifiable requirements, main-owned shared files, original author anchors and explicit policy exclusions. Local evidence is documentation and source inspection; no runtime claim. diff --git a/devlog/_plan/260907_axis1_bugfixes/011_audit.md b/devlog/_plan/260907_axis1_bugfixes/011_audit.md new file mode 100644 index 0000000000..cc1cb1d51e --- /dev/null +++ b/devlog/_plan/260907_axis1_bugfixes/011_audit.md @@ -0,0 +1,5 @@ +# wp0 audit disposition + +Independent Astra high reviewer Hooke: VERDICT: GO-WITH-FIXES (blockers=1). Shared-flight failure propagation was the blocker. Accepted: 000/020 now assign cache and bounded-body ownership and define shared typed outcomes, success-only cache, caller-local cancellation and capacity semantics. Source scouts independently identified and confirmed these requirements. Fixed stale CLI test path. Windows runtime proof requires final workflow_dispatch, now explicit in 030. + +No runtime code changed. Documentation source/ownership inspection and git diff --check are the wp0 evidence. Runtime verification remains wp2. diff --git a/devlog/_plan/260907_axis1_bugfixes/012_roadmap_lock.md b/devlog/_plan/260907_axis1_bugfixes/012_roadmap_lock.md new file mode 100644 index 0000000000..cf2bf0afce --- /dev/null +++ b/devlog/_plan/260907_axis1_bugfixes/012_roadmap_lock.md @@ -0,0 +1,5 @@ +# Roadmap lock + +The second independent audit returned VERDICT: PASS with no remaining blockers. The three accepted slices are ready for scoped implementation. Original quota author: Éverton Toffanetto (everton-dgn), commit identity from 4f3779c04753 and 3ef0ade296c3. Issue reporters: garysassano (10464497) and Hu9956 (282876394). Reporter acknowledgement is separate from code authorship. + +Preserve raw unequal version diagnostics. Detailed recovery outcomes must travel in the shared flight, not caller-local closures. Quota observations use immutable dispatch identity. Final verification is hosted workflow_dispatch for full Windows coverage; local suites remain prohibited. diff --git a/devlog/_plan/260907_axis1_bugfixes/020_bounded_fixes.md b/devlog/_plan/260907_axis1_bugfixes/020_bounded_fixes.md new file mode 100644 index 0000000000..836f743a73 --- /dev/null +++ b/devlog/_plan/260907_axis1_bugfixes/020_bounded_fixes.md @@ -0,0 +1,20 @@ +# wp1: implement bounded bug fixes + +## Quota +Carry only the source PR diff onto current dev, with original-author trailer. Header utilization fraction -> percentage; reset epoch -> timestamp. Creation: parser; serialization: account quota cache; deserialization: existing hydration; consumers: account ranking/health and management reading. Account-bound writer generation is captured with serving credentials, including retry/sidecar/continuation rebinds. Header observations merge model-specific windows and cannot indefinitely postpone probes. Existing 429 eligibility and retry count stay unchanged. Explicit reset evidence must not be truncated by an invented six-hour policy; any unresolved policy piece is deferred. +Scenarios: 200 and 429 on main/sidecar/continuation attribute only the serving account; generation invalidation discards writes; partial/malformed headers preserve known fields; no prior probe means model-window probe is still due; weekly rejected reset outlasts five-hour reset; absent evidence retains existing fallback. Verify with focused tests included in final hosted CI. + +## Version guidance +Compare CLI and running proxy using existing semantic-version utilities if present. CLI newer points to service restart; proxy newer points to upgrading/PATH resolution of CLI; equal/unknown retain suppression; incomparable differing builds use neutral wording. status and doctor share advice. Preserve whether requests are allowed and do not perform repair. Test both directions, prereleases, placeholders, malformed versions and consumer projection. + +## Recovery reasons +Keep existing public wrapper returning boolean and typed detailed result. Classify actual upstream HTTP refusal, transport error, timeout/caller cancellation, response-body/decode failures with a bounded vocabulary. Creation: request/collector; propagation: detailed recovery result; consumers: existing response reason projection/tests/docs. No raw upstream body/errors/tokens/ciphertext in output. Strict admission, one attempt, same credential and unchanged request mutation guarantees. Exercise each failure branch, cancellation races, malformed terminal output and successful recovery in final hosted CI. + +Main owns src/server/responses/core.ts and layout metadata. Source/security review must check public boundaries and negative cases, not only implementation-mirroring tests. Source-only C evidence does not claim runtime correctness; wp2 is mandatory. + +## Source-map clarification from independent #3464 research +Use src/lib/strict-semver.ts unchanged. Raw unequal versions remain skewed; equal precedence with different build metadata and invalid/whitespace/v-prefixed values get neutral wording, not normalization or a guessed direction. Placeholder suppression is unchanged. src/cli/doctor.ts must not call suppressed placeholders a confirmed match. Focused files: tests/cli/cli-version-skew.test.ts, tests/cli/cli-status-json.test.ts, tests/codex-integration/doctor.test.ts. Documentation: reference/cli/lifecycle.md and directly affected Korean/Russian pages. Existing launcher landed via #3616 (4e2246c32); no service runtime changes. + +## Audit refinements +Quota: observe physical responses at the existing oauthDispatch boundary before any main/continuation replacement or return. Use immutable request binding to pair response with selected account; skip when final authorization headers do not prove that bearer or credentialGeneration has changed. An active-account switch alone does not invalidate another account's in-flight observation. Native Claude passthrough and single-account expansion remain outside #3809 carry. Preserve Retry-After precedence; only reject nonfinite/unrepresentable deadlines rather than invent an anomaly ceiling. Header-only rows are probe-due; hydrated Anthropic observations must be probe-due unless probe time is proven. Failed probes settle with the most recent committed observation for all joiners. +Recovery: worker owns agent-task-recovery-cache.ts and bounded-body.ts narrow decode discriminator alongside focused tests. Shared flight carries typed outcome, cache retains only success plaintext, cancelled waiters remain local. Recognized caller cancellation precedes owned timeout, which precedes decode/transport classification. Fatal UTF-8 discriminator must identify actual decoder exceptions without reclassifying fetch/body-reader TypeErrors. Rejected-response cancellation is nonblocking best effort. Keep current public wrappers and combo error projection. Update documented reason lists in structure/04_transports-and-sidecars.md and docs-site/reference/architecture.md. diff --git a/devlog/_plan/260907_axis1_bugfixes/030_delivery.md b/devlog/_plan/260907_axis1_bugfixes/030_delivery.md new file mode 100644 index 0000000000..be851f530d --- /dev/null +++ b/devlog/_plan/260907_axis1_bugfixes/030_delivery.md @@ -0,0 +1,7 @@ +# wp2: hosted proof and manual-stack landing + +Publish task-owned branches with --no-verify. Standard PR template, source links, truthful skipped-local/lower-CI disclosure and contributor trailers. Lower layers use [skip ci], final cumulative head runs existing Cross-platform CI; never modify shared workflow filters or fabricate checks. On final failure inspect failing jobs, fix owned defects, and only then use lower CI to localize ambiguity. Leave unrelated/unresolvable slices unmerged with evidence. + +Before admin merge: source/security review findings resolved, final CI SHA/run pinned, current PR head and manual membership inspected. Record owner-authorized admin review/lower-CI exception. Merge bottom-up with original commits preserved; do not delete parent branches while children depend on them. Retarget child to dev after parent landing. Reconcile concurrent dev before claiming final integrated proof. Verify every merge SHA is ancestor of refreshed origin/dev. Close #3809 only after its accepted replacement scope lands; keep #3661 open for multipart/retry and #3464 open if broader original acceptance remains unresolved. No release/deploy. + +Final full platform evidence uses workflow_dispatch ci.yml on the final cumulative branch, because ordinary PR CI excludes the Windows runtime job. Cancel only duplicate task-owned PR CI runs; skipped/cancelled runs are not passing evidence. From 58fcb0961d7d955cc272f672290c9d2c53ae3722 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:36:51 +0900 Subject: [PATCH 04/50] fix(claude): preserve reasoning and tool result envelopes Carry PR #3815 through 76e07d181c48dca8c80167878381e1edb5642395. Preserve original contributor work and its bounded retention, signature-only reasoning and documentation follow-ups. Remote combined validation follows; local suites intentionally not run under maintainer instruction. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> Co-authored-by: Yumi --- .../src/content/docs/fr/guides/claude-code.md | 4 +- .../src/content/docs/guides/claude-code.md | 4 +- .../src/content/docs/ja/guides/claude-code.md | 4 +- .../src/content/docs/ko/guides/claude-code.md | 4 +- .../src/content/docs/ru/guides/claude-code.md | 4 +- .../src/content/docs/tr/guides/claude-code.md | 4 +- .../content/docs/zh-cn/guides/claude-code.md | 4 +- .../content/docs/zh-tw/guides/claude-code.md | 4 +- scripts/test-layout/layout.json | 2 + src/claude/inbound.ts | 28 +++++-- src/claude/outbound.ts | 83 ++++++++++++++---- src/responses/reasoning-envelope.ts | 5 +- ...laude-code-thought-signature-scope.test.ts | 12 +++ .../claude-integration/claude-inbound.test.ts | 12 +-- .../claude-outbound.test.ts | 84 +++++++++++++++++++ .../claude-source-envelope.test.ts | 30 +++++++ tests/fixtures/test-layout-expected.json | 2 + tests/responses/reasoning-envelope.test.ts | 79 +++++++++++++++++ 18 files changed, 324 insertions(+), 45 deletions(-) create mode 100644 tests/claude-integration/claude-source-envelope.test.ts create mode 100644 tests/responses/reasoning-envelope.test.ts diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 5c13e041b6..ffc3ad6e88 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -500,7 +500,7 @@ Le proxy traduit chaque requête Anthropic Messages API au format Codex Response | Texte assistant | `output_text` | | Assistant `tool_use` | `function_call` (`input` → JSON-stringifié `arguments`) | | Utilisateur `tool_result` | `function_call_output` (`is_error` → préfixe `[tool error]`) | -| Relecture de `thinking` / `redacted_thinking` | Ignorée | +| Relecture de `thinking` / `redacted_thinking` | Éléments `reasoning` avec enveloppes `ocxr1` bornées pour les signatures et les contenus masqués | | Outils fonctionnels | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, fonction nommée→`{type:"function",name}`, hébergée WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -516,7 +516,7 @@ Le proxy traduit chaque requête Anthropic Messages API au format Codex Response | `response.created` | `message_start` + `ping` | | Battement de coeur | `ping` | | Deltas de texte | `content_block_start` → `content_block_delta` (texte) → `content_block_stop` | -| Résumé ou texte de raisonnement | Bloc `thinking` avec signature synthétique | +| Résumé ou texte de raisonnement | Bloc `thinking` avec la signature relue, ou une enveloppe de secours `ocxr1` bornée | | Trames d'appel de fonction | Bloc `tool_use` avec `input_json_delta` | | Événement terminal | `message_delta` → `message_stop` | | EOF avant la borne | style 502 `api_error` | diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 5ed946c72a..b237955adb 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -513,7 +513,7 @@ The proxy translates every Anthropic Messages API request into the Codex Respons | Assistant text | `output_text` | | Assistant `tool_use` | `function_call` (`input` → JSON-stringified `arguments`) | | User `tool_result` | `function_call_output` (`is_error` → `[tool error]` prefix) | -| `thinking` / `redacted_thinking` replay | Dropped | +| `thinking` / `redacted_thinking` replay | `reasoning` items with bounded `ocxr1` envelopes for signatures and redacted payloads | | Function tools | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, named function→`{type:"function",name}`, hosted WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -530,7 +530,7 @@ name. | `response.created` | `message_start` + `ping` | | Heartbeat | `ping` | | Text deltas | `content_block_start` → `content_block_delta` (text) → `content_block_stop` | -| Reasoning summary/text | `thinking` block with synthetic signature | +| Reasoning summary/text | `thinking` block with the replayed signature, or a bounded `ocxr1` fallback envelope | | Function-call frames | `tool_use` block with `input_json_delta` | | Terminal event | `message_delta` → `message_stop` | | EOF before terminal | 502-style `api_error` | diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 8c9f433956..43b8bcee24 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -368,7 +368,7 @@ Claude Code の `/effort` 設定はアダプターでも維持されます。 | Assistant テキスト | `output_text` | | Assistant `tool_use` | `function_call`(`input` → JSON 文字列に変換した `arguments`) | | ユーザー `tool_result` | `function_call_output`(`is_error` → `[tool error]` 接頭辞) | -| `thinking` / `redacted_thinking` 再生 | 破棄 | +| `thinking` / `redacted_thinking` 再生 | シグネチャと秘匿ペイロードを境界付き `ocxr1` エンベロープに保持した `reasoning` 項目 | | Function ツール | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`、`none`→`none`、`any`→`required`、名前指定関数→`{type:"function",name}`、ホスト型 WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -384,7 +384,7 @@ role、`tool_use_id` のない `tool_result`、id/name のない `tool_use`、na | `response.created` | `message_start` + `ping` | | Heartbeat | `ping` | | テキスト delta | `content_block_start` → `content_block_delta`(text) → `content_block_stop` | -| 推論要約/テキスト | 合成シグネチャ付きの `thinking` ブロック | +| 推論要約/テキスト | 再生されたシグネチャ、または境界付き `ocxr1` フォールバックを持つ `thinking` ブロック | | Function-call フレーム | `input_json_delta` を持つ `tool_use` ブロック | | 終了イベント | `message_delta` → `message_stop` | | 終了前に EOF | 502 形式 `api_error` | diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 1368cf5698..0964f2ff49 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -406,7 +406,7 @@ Claude Code의 `/effort` 설정은 어댑터에서도 유지돼요. | Assistant 텍스트 | `output_text` | | Assistant `tool_use` | `function_call`(`input` → JSON 문자열로 변환한 `arguments`) | | 사용자 `tool_result` | `function_call_output`(`is_error` → `[tool error]` 접두사) | -| `thinking` / `redacted_thinking` 재생 | 버려요 | +| `thinking` / `redacted_thinking` 재생 | 서명과 비공개 페이로드를 제한된 `ocxr1` 봉투에 담은 `reasoning` 항목 | | Function 도구 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, 이름 지정 함수→`{type:"function",name}`, 호스팅 WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -422,7 +422,7 @@ role, `tool_use_id` 없는 `tool_result`, id/name 없는 `tool_use`, name 없는 | `response.created` | `message_start` + `ping` | | Heartbeat | `ping` | | 텍스트 delta | `content_block_start` → `content_block_delta`(text) → `content_block_stop` | -| 추론 요약/텍스트 | 합성 signature가 있는 `thinking` 블록 | +| 추론 요약/텍스트 | 재생된 서명 또는 제한된 `ocxr1` 폴백이 있는 `thinking` 블록 | | Function-call 프레임 | `input_json_delta`가 있는 `tool_use` 블록 | | 종료 이벤트 | `message_delta` → `message_stop` | | 종료 전에 EOF | 502 형식 `api_error` | diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 3f6c07a4aa..1769642bd7 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -393,7 +393,7 @@ Claude Code — это лишь учётные данные для доступ | Текст ассистента | `output_text` | | `tool_use` ассистента | `function_call` (`input` → `arguments` в виде JSON-строки) | | `tool_result` пользователя | `function_call_output` (`is_error` → префикс `[tool error]`) | -| Повтор `thinking` / `redacted_thinking` | Отбрасывается | +| Повтор `thinking` / `redacted_thinking` | Элементы `reasoning` с ограниченными конвертами `ocxr1` для подписей и скрытых данных | | Function-инструменты | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, именованная функция→`{type:"function",name}`, размещённый WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -410,7 +410,7 @@ id/name; именованный `tool_choice` без имени. | `response.created` | `message_start` + `ping` | | Heartbeat | `ping` | | Текстовые дельты | `content_block_start` → `content_block_delta` (text) → `content_block_stop` | -| Резюме/текст рассуждений | Блок `thinking` с синтетической подписью | +| Резюме/текст рассуждений | Блок `thinking` с повторно переданной подписью или ограниченным резервным конвертом `ocxr1` | | Кадры function-call | Блок `tool_use` с `input_json_delta` | | Завершающее событие | `message_delta` → `message_stop` | | EOF до завершающего события | `api_error` в стиле 502 | diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index 5450d6b748..29d96506ac 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -582,7 +582,7 @@ dönüştürür: | Asistan metni | `output_text` | | Asistan `tool_use` | `function_call` (`input` → JSON dizgeleştirilmiş `arguments`) | | Kullanıcı `tool_result` | `function_call_output` (`is_error` → `[tool error]` öneki) | -| `thinking` / `redacted_thinking` tekrarı | Bırakılır | +| `thinking` / `redacted_thinking` tekrarı | İmzaları ve gizli yükleri sınırlı `ocxr1` zarflarında taşıyan `reasoning` öğeleri | | Fonksiyon araçları | `{type: "function"}` (`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`, `none`→`none`, `any`→`required`, adlandırılmış fonksiyon→`{type:"function",name}`, barındırılan WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -599,7 +599,7 @@ kimlik/ad içermeyen `tool_use`; ad içermeyen adlandırılmış `tool_choice`. | `response.created` | `message_start` + `ping` | | Kalp atışı (Heartbeat) | `ping` | | Metin farkları | `content_block_start` → `content_block_delta` (metin) → `content_block_stop` | -| Akıl yürütme özeti/metni | Sentetik imzalı `thinking` bloğu | +| Akıl yürütme özeti/metni | Tekrarlanan imzayı veya sınırlı bir `ocxr1` yedeğini taşıyan `thinking` bloğu | | Fonksiyon çağrısı çerçeveleri | `input_json_delta` ile `tool_use` bloğu | | Terminal olayı | `message_delta` → `message_stop` | | Terminalden önce EOF | 502 tarzı `api_error` | diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 3bbe49646b..3e2824d3e9 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -344,7 +344,7 @@ Claude Code 的 `/effort` 设置会完整保留并传递给适配器: | Assistant 文本 | `output_text` | | Assistant `tool_use` | `function_call`(`input` → JSON 字符串化的 `arguments`) | | 用户 `tool_result` | `function_call_output`(`is_error` → `[tool error]` 前缀) | -| 重放 `thinking` / `redacted_thinking` | 丢弃 | +| 重放 `thinking` / `redacted_thinking` | `reasoning` 项;签名和脱敏载荷保存在有界 `ocxr1` 信封中 | | Function 工具 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`,`none`→`none`,`any`→`required`,指定函数→`{type:"function",name}`,托管 WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -361,7 +361,7 @@ role;`tool_result` 缺少 `tool_use_id`;`tool_use` 缺少 id/name;指定 | `response.created` | `message_start` + `ping` | | 心跳 | `ping` | | 文本增量 | `content_block_start` → `content_block_delta`(文本)→ `content_block_stop` | -| 推理摘要/文本 | 带合成签名的 `thinking` 块 | +| 推理摘要/文本 | 带重放签名或有界 `ocxr1` 回退信封的 `thinking` 块 | | Function-call 帧 | 带 `input_json_delta` 的 `tool_use` 块 | | 终止事件 | `message_delta` → `message_stop` | | 在终止事件前 EOF | 502 风格的 `api_error` | diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index ccfb3b9ddd..5a6fbf3a86 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -420,7 +420,7 @@ Claude Code 的 `/effort` 設定會完整保留並傳遞給適配器: | Assistant 文字 | `output_text` | | Assistant `tool_use` | `function_call`(`input` → JSON 字串化的 `arguments`) | | 使用者 `tool_result` | `function_call_output`(`is_error` → `[tool error]` 字首) | -| 重放 `thinking` / `redacted_thinking` | 丟棄 | +| 重放 `thinking` / `redacted_thinking` | `reasoning` 項目;簽名與遮蔽載荷保存在有界 `ocxr1` 信封中 | | Function 工具 | `{type: "function"}`(`web_search*` → `{type: "web_search"}`) | | `tool_choice` | `auto`→`auto`,`none`→`none`,`any`→`required`,指定名稱 function→`{type:"function",name}`,hosted WebSearch/web_search→`{type:"web_search"}` | | `max_tokens` | `max_output_tokens` | @@ -437,7 +437,7 @@ role;`tool_result` 缺少 `tool_use_id`;`tool_use` 缺少 id/name;指定 | `response.created` | `message_start` + `ping` | | 心跳 | `ping` | | 文字增量 | `content_block_start` → `content_block_delta`(文字)→ `content_block_stop` | -| 推理摘要/文字 | 帶合成簽名的 `thinking` 塊 | +| 推理摘要/文字 | 帶重播簽名或有界 `ocxr1` 備援信封的 `thinking` 塊 | | Function-call 幀 | 帶 `input_json_delta` 的 `tool_use` 塊 | | 終止事件 | `message_delta` → `message_stop` | | 在終止事件前 EOF | 502 風格的 `api_error` | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d309073a3f..255c78e916 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -312,6 +312,7 @@ "claude-outbound.test.ts": "claude-integration", "claude-shell-hook.test.ts": "claude-integration", "claude-sidecar-override.test.ts": "claude-integration", + "claude-source-envelope.test.ts": "claude-integration", "claude-system-env-auto.test.ts": "claude-integration", "cleanup-orphaned-workflows.test.ts": "ci-workflows", "clearable-deadline.test.ts": "lib", @@ -999,6 +1000,7 @@ "rate-limit-reset-credits.test.ts": "gui", "rate-limit-retry.test.ts": "providers", "reasoning-effort.test.ts": "codex-integration", + "reasoning-envelope.test.ts": "responses", "reasoning-replay-identity.test.ts": "adapters", "reasoning-replay-robustness.test.ts": "adapters", "reasoning-replay-scope-source.test.ts": "lib", diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 3ac4731385..5e876ca6cb 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -4,8 +4,8 @@ * Design (devlog/260711_claude_inbound/010, 003_evidence.md): * - translate-and-replay: the produced body MUST pass the real responsesRequestSchema * parse so routing/OAuth/pool/failover are inherited unchanged. - * - thinking/redacted_thinking blocks on replay are DROPPED (v1 policy) — routed - * providers carry reasoning in Responses items/ocxr1 envelopes instead. + * - thinking/redacted_thinking replay is preserved in Responses reasoning items; + * signatures and redacted payloads travel in bounded ocxr1 envelopes. * - thinking.budget_tokens is NEVER forwarded raw; it maps to an effort tier. * - top_k is accepted and silently dropped (no Responses equivalent, CCR parity). */ @@ -17,6 +17,7 @@ export { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, e import { AnthropicRequestError, isRec, type Rec } from "./inbound-records"; import { resolveInboundModel, effortForThinkingBudget, effortFromOutputConfig, formatFromOutputConfig } from "./inbound-model-options"; import { systemToInstructions, toolsToResponses, toolChoiceToResponses } from "./inbound-content-options"; +import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; @@ -234,9 +235,26 @@ function assistantMessageToItems(content: unknown, input: Rec[]): void { input.push({ type: "function_call", call_id: raw.id, name: raw.name, arguments: JSON.stringify(raw.input ?? {}) }); break; } - case "thinking": - case "redacted_thinking": - break; // v1 policy: dropped on replay (003 evidence — safe for routed providers) + case "thinking": { + flush(); + const thinking = typeof raw.thinking === "string" ? raw.thinking : ""; + const signature = typeof raw.signature === "string" ? raw.signature : ""; + if (signature.startsWith(OCX_REASONING_PREFIX)) { + const owned = decodeReasoningEnvelope(signature); + if (!owned) throw new AnthropicRequestError("malformed ocxr1 reasoning signature"); + if (Object.hasOwn(owned, "sig")) throw new AnthropicRequestError("OpenCodex reasoning continuity cannot be replayed as an Anthropic signature"); + } + const encrypted = signature.length === 0 ? undefined : signature.startsWith(OCX_REASONING_PREFIX) ? signature : encodeReasoningEnvelope({ sig: signature }); + if (thinking.length === 0 && !encrypted) break; + input.push({ type: "reasoning", id: `rs_${crypto.randomUUID().replace(/-/g, "")}`, summary: thinking.length > 0 ? [{ type: "summary_text", text: thinking }] : [], ...(encrypted ? { encrypted_content: encrypted } : {}) }); + break; + } + case "redacted_thinking": { + flush(); + const data = typeof raw.data === "string" ? raw.data : ""; + if (data.length > 0) input.push({ type: "reasoning", id: `rs_${crypto.randomUUID().replace(/-/g, "")}`, summary: [], encrypted_content: encodeReasoningEnvelope({ red: [data] }) }); + break; + } default: break; } diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 48bb06c15a..8c0db5b7b8 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -5,8 +5,8 @@ * - Transport-only `ping` events may appear at any point, including before * message_start. Semantic framing stays message_start -> * (content_block_start -> deltas -> content_block_stop)* -> message_delta -> message_stop. - * - thinking blocks get thinking_delta(s) then ONE synthetic signature_delta just - * before content_block_stop (CCR precedent: Claude Code does not verify signatures). + * - thinking blocks get thinking_delta(s), then one signature_delta containing the + * genuine replay signature or a bounded ocxr1 fallback envelope. * - message_delta.usage is cumulative; message_start embeds a full message snapshot. * - errors: {type:"error", error:{type,message}}; may arrive mid-stream after HTTP 200. */ @@ -20,6 +20,7 @@ import { type TranslatorBudget, } from "../lib/translator-budget"; import { sseFieldOffset, sseFieldValue } from "../lib/sse-decoder"; +import { decodeReasoningEnvelope, encodeReasoningEnvelope } from "../responses/reasoning-envelope"; type Rec = Record; @@ -214,6 +215,9 @@ interface OpenBlock { callId?: string; /** Last fixed-size reasoning identity (item + summary/content index) seen by this block. */ reasoningPartKey?: string; + thinkingBuf?: string; + thinkingBufBytes?: number; + reasoningSig?: string; } /** Streaming: Responses SSE bytes -> Anthropic Messages SSE bytes. */ @@ -253,6 +257,11 @@ export function responsesSseToAnthropicSse( const bytes = queuedLiveFrameBytes.shift(); if (bytes !== undefined) translatorBudget.releaseRetained(bytes, { kind: "live_transient" }); }; + const releaseThinkingBuffer = (block: OpenBlock | null | undefined) => { + if (block?.kind !== "thinking") return; + translatorBudget.releaseRetained(block.thinkingBufBytes ?? 0, { kind: "reasoning" }); + block.thinkingBufBytes = 0; + }; return new ReadableStream({ start(controller) { @@ -296,13 +305,14 @@ export function responsesSseToAnthropicSse( open.webSearchArgsEmitted = true; } if (open.kind === "thinking") { - // Synthetic signature: Claude Code accepts it (003 E6); inbound drops replays anyway. + const signature = open.reasoningSig ?? encodeReasoningEnvelope({ txt: open.thinkingBuf ?? "" }); emit("content_block_delta", { type: "content_block_delta", index: open.index, - delta: { type: "signature_delta", signature: `ocx${Date.now()}` }, + delta: { type: "signature_delta", signature }, }); } emit("content_block_stop", { type: "content_block_stop", index: open.index }); + releaseThinkingBuffer(open); if (open.callId) translatorBudget.closeCall(open.callId); open = null; }; @@ -315,7 +325,7 @@ export function responsesSseToAnthropicSse( ? { type: "text", text: "" } : { type: "thinking", thinking: "", signature: "" }; emit("content_block_start", { type: "content_block_start", index, content_block: contentBlock }); - open = { kind, index }; + open = { kind, index, thinkingBuf: "", thinkingBufBytes: 0 }; }; const finish = (stopReason: string, usage: unknown) => { if (terminated) return; @@ -339,6 +349,7 @@ export function responsesSseToAnthropicSse( if (terminated) return; terminated = true; if (code === "translation_buffer_limit") { + releaseThinkingBuffer(open); if (open?.callId) translatorBudget.closeCall(open.callId); open = null; // No normal close frames are valid after overflow. Emit exactly one bounded @@ -374,8 +385,10 @@ export function responsesSseToAnthropicSse( case "response.output_text.delta": { if (typeof data.delta !== "string" || data.delta.length === 0) break; ensureBlock("text"); + const active = open; + if (!active || active.kind !== "text") break; emit("content_block_delta", { - type: "content_block_delta", index: open!.index, + type: "content_block_delta", index: active.index, delta: { type: "text_delta", text: data.delta }, }); break; @@ -384,6 +397,8 @@ export function responsesSseToAnthropicSse( case "response.reasoning_text.delta": { if (typeof data.delta !== "string" || data.delta.length === 0) break; ensureBlock("thinking"); + const active = open; + if (!active || active.kind !== "thinking") break; // The JSON path joins reasoning summary/content parts with "\n\n" // (responsesJsonToAnthropicMessage); mirror that at part and item boundaries // so multi-part summaries do not glue into one run-on paragraph. Frames @@ -395,15 +410,32 @@ export function responsesSseToAnthropicSse( // components while retaining item and part equality, rather than dropping item_id and // accidentally joining distinct malformed reasoning items. const partKey = `${boundedReasoningIdentity(data.item_id)}:${slot}`; - if (open!.reasoningPartKey !== undefined && open!.reasoningPartKey !== partKey) { + const needsPartSeparator = active.reasoningPartKey !== undefined + && active.reasoningPartKey !== partKey; + const appended = `${needsPartSeparator ? "\n\n" : ""}${data.delta}`; + const previous = active.thinkingBuf ?? ""; + const previousBytes = active.thinkingBufBytes ?? 0; + const nextBytes = appendedUtf8Bytes(previous, previousBytes, appended); + const scope = { kind: "reasoning" } as const; + const reservation = translatorBudget.reserveTransient(nextBytes, scope); + try { + active.thinkingBuf = previous + appended; + active.thinkingBufBytes = nextBytes; + reservation.commitRetained(); + translatorBudget.releaseRetained(previousBytes, scope); + } catch (error) { + reservation.release(); + throw error; + } + if (needsPartSeparator) { emit("content_block_delta", { - type: "content_block_delta", index: open!.index, + type: "content_block_delta", index: active.index, delta: { type: "thinking_delta", thinking: "\n\n" }, }); } - open!.reasoningPartKey = partKey; + active.reasoningPartKey = partKey; emit("content_block_delta", { - type: "content_block_delta", index: open!.index, + type: "content_block_delta", index: active.index, delta: { type: "thinking_delta", thinking: data.delta }, }); break; @@ -499,10 +531,9 @@ export function responsesSseToAnthropicSse( if (pair.completed) webSearchRequests++; break; } - if (!open) break; // Close the matching open block (message/reasoning items close implicitly on // the next block; function_call items must close here so tool input parses). - if (open.kind === "tool_use" && item.type === "function_call") { + if (open && open.kind === "tool_use" && item.type === "function_call") { if (open.bufferWebSearchArgs && !open.webSearchArgsEmitted) { const rawArgs = typeof item.arguments === "string" && item.arguments.length > 0 ? item.arguments @@ -518,8 +549,23 @@ export function responsesSseToAnthropicSse( } closeOpenBlock(); } - else if (open.kind === "text" && item.type === "message") closeOpenBlock(); - else if (open.kind === "thinking" && item.type === "reasoning") closeOpenBlock(); + else if (open && open.kind === "text" && item.type === "message") closeOpenBlock(); + else if (item.type === "reasoning") { + const encrypted = typeof item.encrypted_content === "string" ? item.encrypted_content : ""; + const env = encrypted ? decodeReasoningEnvelope(encrypted) : null; + const red = env?.red ?? []; + if (env?.sig && open?.kind !== "thinking") ensureBlock("thinking"); + if (open?.kind === "thinking") { + if (env?.sig) open.reasoningSig = env.sig; + closeOpenBlock(); + } + if (red.length > 0) ensureStarted(); + for (const data of red) { + const idx = blockIndex++; + emit("content_block_start", { type: "content_block_start", index: idx, content_block: { type: "redacted_thinking", data } }); + emit("content_block_stop", { type: "content_block_stop", index: idx }); + } + } break; } case "response.completed": { @@ -704,6 +750,7 @@ export function responsesSseToAnthropicSse( fail(413, "upstream translation buffer exceeded the safe limit", false, "translation_buffer_limit"); } else fail(500, err instanceof Error ? err.message : String(err)); } finally { + releaseThinkingBuffer(open); translatorBudget.releaseRetained(bufferBytes, { kind: "live_transient" }); if (pingTimer !== undefined) clearInterval(pingTimer); reader.releaseLock(); @@ -717,6 +764,7 @@ export function responsesSseToAnthropicSse( cancel(reason) { cancelled = true; while (queuedLiveFrameBytes.length > 0) releaseDeliveredFrame(); + releaseThinkingBuffer(open); if (open?.callId) translatorBudget.closeCall(open.callId); if (pingTimer !== undefined) clearInterval(pingTimer); return reader?.cancel(reason); @@ -756,9 +804,12 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R if (isRec(s) && typeof s.text === "string" && s.text.length > 0) parts.push(s.text); } } - if (parts.length > 0) { - content.push({ type: "thinking", thinking: parts.join("\n\n"), signature: `ocx${Date.now()}` }); + const encrypted = typeof raw.encrypted_content === "string" ? raw.encrypted_content : ""; + const env = encrypted ? decodeReasoningEnvelope(encrypted) : null; + if (parts.length > 0 || env?.sig) { + content.push({ type: "thinking", thinking: parts.join("\n\n"), signature: env?.sig ?? encodeReasoningEnvelope({ txt: parts.join("\n\n") }) }); } + for (const data of env?.red ?? []) content.push({ type: "redacted_thinking", data }); break; } case "function_call": { diff --git a/src/responses/reasoning-envelope.ts b/src/responses/reasoning-envelope.ts index 1735f775fb..2a56563578 100644 --- a/src/responses/reasoning-envelope.ts +++ b/src/responses/reasoning-envelope.ts @@ -50,10 +50,11 @@ export function decodeReasoningEnvelope(encryptedContent: string): ReasoningEnve if (red.length > 0) envelope.red = red; } const txt = (parsed as { txt?: unknown }).txt; - if (typeof txt === "string" && txt.length > 0) envelope.txt = txt; + const hasTxt = typeof txt === "string"; + if (hasTxt) envelope.txt = txt; const krc = (parsed as { krc?: unknown }).krc; if (typeof krc === "string" && krc.length > 0) envelope.krc = krc; - return envelope.sig || envelope.red || envelope.txt || envelope.krc ? envelope : null; + return envelope.sig || envelope.red || hasTxt || envelope.krc ? envelope : null; } catch { return null; } diff --git a/tests/claude-integration/claude-code-thought-signature-scope.test.ts b/tests/claude-integration/claude-code-thought-signature-scope.test.ts index eb544dce97..b3d981c327 100644 --- a/tests/claude-integration/claude-code-thought-signature-scope.test.ts +++ b/tests/claude-integration/claude-code-thought-signature-scope.test.ts @@ -125,4 +125,16 @@ describe("Claude Code Anthropic inbound reasoning-replay scope", () => { const parsed = await drive({ promptCacheKey: " ", promptCacheKeyIsSharedCohort: false }); expect(parsed._reasoningReplayScope).toBeUndefined(); }); + + test("distinct session identities remain distinct and bounded", async () => { + const first = await drive({ promptCacheKey: "session-a", promptCacheKeyIsSharedCohort: false }); + const second = await drive({ promptCacheKey: "session-b", promptCacheKeyIsSharedCohort: false }); + const a = first._reasoningReplayScope?.clientThreadId; + const b = second._reasoningReplayScope?.clientThreadId; + expect(a).toBeDefined(); + expect(b).toBeDefined(); + expect(a).not.toBe(b); + expect(a).toBe("session-a"); + expect(b).toBe("session-b"); + }); }); diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index 32207c9019..7227bbf2f1 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -84,12 +84,12 @@ describe("claude inbound translation", () => { expect(tools[1]).toEqual({ type: "web_search" }); const input = body.input as Record[]; - // user text, assistant text (thinking dropped), function_call, function_call_output, user tail - expect(input.map(i => i.type ?? i.role)).toEqual(["message", "message", "function_call", "function_call_output", "message"]); - expect(input[1].content).toEqual([{ type: "output_text", text: "Reading it now." }]); - expect(input[2]).toMatchObject({ call_id: "toolu_01", name: "Read", arguments: JSON.stringify({ file_path: "/README.md" }) }); - expect(input[3]).toMatchObject({ call_id: "toolu_01", output: [{ type: "input_text", text: "# hello" }] }); - const tail = input[4].content as Record[]; + // user text, reasoning, assistant text, function_call, function_call_output, user tail + expect(input.map(i => i.type ?? i.role)).toEqual(["message", "reasoning", "message", "function_call", "function_call_output", "message"]); + expect(input[2].content).toEqual([{ type: "output_text", text: "Reading it now." }]); + expect(input[3]).toMatchObject({ call_id: "toolu_01", name: "Read", arguments: JSON.stringify({ file_path: "/README.md" }) }); + expect(input[4]).toMatchObject({ call_id: "toolu_01", output: [{ type: "input_text", text: "# hello" }] }); + const tail = input[5].content as Record[]; expect(tail[0]).toEqual({ type: "input_text", text: "now summarize" }); expect(tail[1]).toEqual({ type: "input_image", image_url: "data:image/png;base64,aWc=" }); }); diff --git a/tests/claude-integration/claude-outbound.test.ts b/tests/claude-integration/claude-outbound.test.ts index 299ce5135d..e78cc529d3 100644 --- a/tests/claude-integration/claude-outbound.test.ts +++ b/tests/claude-integration/claude-outbound.test.ts @@ -13,6 +13,7 @@ import { TRANSLATOR_MAX_CALL_ARGUMENT_BYTES, type TranslatorBudget, } from "../../src/lib/translator-budget"; +import { decodeReasoningEnvelope, encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; const streamBudgets = new WeakMap, TranslatorBudget>(); @@ -274,6 +275,8 @@ describe("claude outbound SSE", () => { "**A**\n\nOne.\n\n**B**\n\nTwo.", "Three.", ]); + expect(decodeReasoningEnvelope(thinkingBlocks[0].signature)?.txt) + .toBe("**A**\n\nOne.\n\n**B**\n\nTwo."); // Parity: the non-streaming translator joins the same summary parts identically. const json = responsesJsonToAnthropicMessage({ @@ -286,6 +289,59 @@ describe("claude outbound SSE", () => { expect(jsonThinking.thinking).toBe("**A**\n\nOne.\n\n**B**\n\nTwo."); }); + test("reasoning fallback buffering is bounded and releases its retained budget", async () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 8 * 1024 }); + let reasoningCommitted = 0; + let reasoningReleased = 0; + const trackedBudget: TranslatorBudget = { + openCall: id => budget.openCall(id), + closeCall: id => budget.closeCall(id), + reserveTransient(bytes, scope) { + const reservation = budget.reserveTransient(bytes, scope); + return { + commitRetained() { + reservation.commitRetained(); + if (scope.kind === "reasoning") reasoningCommitted += bytes; + }, + release: () => reservation.release(), + }; + }, + chargeRetained(bytes, scope) { + budget.chargeRetained(bytes, scope); + if (scope.kind === "reasoning") reasoningCommitted += bytes; + }, + releaseRetained(bytes, scope) { + budget.releaseRetained(bytes, scope); + if (scope.kind === "reasoning") reasoningReleased += bytes; + }, + observeAcceptedRequestCopy: bytes => budget.observeAcceptedRequestCopy(bytes), + observeExternallyCapped: (kind, bytes) => budget.observeExternallyCapped(kind, bytes), + snapshot: () => budget.snapshot(), + dispose: () => budget.dispose(), + }; + const frames = [ + sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), + ...Array.from({ length: 32 }, (_, index) => sse("response.reasoning_text.delta", { + item_id: "rs_1", + content_index: 0, + delta: `${index}:` + "x".repeat(512), + })), + ]; + const events = await collectEvents(responsesSseToAnthropicSse( + streamFromChunks(frames), + "m", + { translatorBudget: trackedBudget }, + )); + + expect(events.at(-1)).toMatchObject({ + name: "error", + data: { error: { type: "request_too_large", code: "translation_buffer_limit" } }, + }); + expect(budget.snapshot().overflows).toBe(1); + expect(reasoningCommitted).toBeGreaterThan(0); + expect(reasoningReleased).toBe(reasoningCommitted); + }); + test("same-part deltas and index-free reasoning frames never get a separator", async () => { const samePart = [ sse("response.created", { response: { id: "resp_1", status: "in_progress" } }), @@ -1109,4 +1165,32 @@ describe("sanitizeWebSearchInput (#381)", () => { data: { error: { type: "request_too_large", code: "translation_buffer_limit" } }, }); }, 60_000); + + test("redacted-only reasoning emits a standalone redacted_thinking block", async () => { + const events = await collectEvents(responsesSseToAnthropicSse(streamFrom([ + sse("response.output_item.done", { + item: { type: "reasoning", id: "rs_red", encrypted_content: encodeReasoningEnvelope({ red: ["opaque"] }) }, + }), + sse("response.completed", { response: { status: "completed", usage: {} } }), + ].join("")), "m")); + expect(events.map(event => event.name)).toEqual([ + "message_start", "ping", "content_block_start", "content_block_stop", "message_delta", "message_stop", + ]); + expect(events[2].data.content_block).toEqual({ type: "redacted_thinking", data: "opaque" }); + }); + + test("signature-only reasoning emits an empty thinking block with the genuine signature", async () => { + const events = await collectEvents(responsesSseToAnthropicSse(streamFrom([ + sse("response.output_item.done", { + item: { type: "reasoning", id: "rs_sig", encrypted_content: encodeReasoningEnvelope({ sig: "sig-only" }) }, + }), + sse("response.completed", { response: { status: "completed", usage: {} } }), + ].join("")), "m")); + expect(events.map(event => event.name)).toEqual([ + "message_start", "ping", "content_block_start", "content_block_delta", "content_block_stop", + "message_delta", "message_stop", + ]); + expect(events[2].data.content_block).toEqual({ type: "thinking", thinking: "", signature: "" }); + expect(events[3].data.delta).toEqual({ type: "signature_delta", signature: "sig-only" }); + }); }); diff --git a/tests/claude-integration/claude-source-envelope.test.ts b/tests/claude-integration/claude-source-envelope.test.ts new file mode 100644 index 0000000000..a78c9f1a15 --- /dev/null +++ b/tests/claude-integration/claude-source-envelope.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; +import { anthropicToResponsesBody } from "../../src/claude/inbound"; + +describe("Claude source envelope boundaries", () => { + test("nested tool results retain only bounded structured content", () => { + const body = anthropicToResponsesBody({ + model: "m", + messages: [ + { role: "assistant", content: [{ type: "tool_use", id: "call-1", name: "lookup", input: { q: "x" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call-1", content: [ + { type: "text", text: "ok" }, + { type: "document", title: "report" }, + { type: "future_block", payload: "secret-payload" }, + ] }] }, + ], + }) as any; + expect(body.input.map((item: any) => item.type)).toEqual(["function_call", "function_call_output"]); + expect(body.input[1].output).toEqual([ + { type: "input_text", text: "ok" }, + { type: "input_text", text: "[document: report]" }, + ]); + expect(JSON.stringify(body)).not.toContain("secret-payload"); + }); + + test("malformed tool results fail closed instead of becoming an unpaired output", () => { + expect(() => anthropicToResponsesBody({ + model: "m", messages: [{ role: "user", content: [{ type: "tool_result", content: "secret-payload" }] }], + })).toThrow(/unknown|unpaired|tool/i); + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6565f12821..928752d213 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -125,6 +125,7 @@ "claude-authmode-migration.test.ts": "claude-integration", "claude-cli.test.ts": "claude-integration", "claude-code-thought-signature-scope.test.ts": "claude-integration", + "claude-source-envelope.test.ts": "claude-integration", "claude-compatibility.test.ts": "claude-integration", "claude-context-windows.test.ts": "claude-integration", "claude-desktop-1m.test.ts": "claude-integration", @@ -1020,6 +1021,7 @@ "test-home-guard.test.ts": "ci-workflows", "test-runner.test.ts": "ci-workflows", "thought-signature-credential-scope.test.ts": "responses", + "reasoning-envelope.test.ts": "responses", "token-estimate.test.ts": "lib", "token-guardian.test.ts": "codex-integration", "tool-argument-integers.test.ts": "adapters", diff --git a/tests/responses/reasoning-envelope.test.ts b/tests/responses/reasoning-envelope.test.ts new file mode 100644 index 0000000000..75c51994f2 --- /dev/null +++ b/tests/responses/reasoning-envelope.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "bun:test"; +import { anthropicToResponsesBody } from "../../src/claude/inbound"; +import { decodeReasoningEnvelope, encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; +import { responsesJsonToAnthropicMessage } from "../../src/claude/outbound"; + +describe("reasoning and tool/result envelopes", () => { + test("preserves ordered thinking blocks and genuine signatures", () => { + const body = anthropicToResponsesBody({ + model: "m", messages: [{ role: "assistant", content: [ + { type: "thinking", thinking: "first", signature: "sig-first" }, + { type: "tool_use", id: "call-1", name: "Read", input: {} }, + { type: "thinking", thinking: "second", signature: "sig-second" }, + ] }], + }) as any; + expect(body.input.map((item: any) => item.type)).toEqual(["reasoning", "function_call", "reasoning"]); + expect(body.input[0].encrypted_content).toBe(encodeReasoningEnvelope({ sig: "sig-first" })); + expect(body.input[2].encrypted_content).toBe(encodeReasoningEnvelope({ sig: "sig-second" })); + }); + + test("rejects malformed or nested OpenCodex signatures", () => { + for (const signature of [ + "ocxr1:not-base64!!!", + encodeReasoningEnvelope({ sig: "nested" }), + encodeReasoningEnvelope({ sig: "", txt: "nested-empty-signature" }), + ]) { + expect(() => anthropicToResponsesBody({ + model: "m", messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "x", signature }] }], + })).toThrow(); + } + }); + + test("round-trips redacted thinking without exposing it as a genuine signature", () => { + const encoded = encodeReasoningEnvelope({ sig: "sig", red: ["red-a", "red-b"] }); + const message = responsesJsonToAnthropicMessage({ + output: [{ type: "reasoning", summary: [{ type: "summary_text", text: "visible" }], encrypted_content: encoded }], + }, "m") as any; + expect(message.content[0]).toMatchObject({ type: "thinking", signature: "sig" }); + expect(message.content.slice(1)).toEqual([ + { type: "redacted_thinking", data: "red-a" }, + { type: "redacted_thinking", data: "red-b" }, + ]); + }); + + test("owned fallback is bounded and decodable", () => { + const message = responsesJsonToAnthropicMessage({ + output: [{ type: "reasoning", summary: [{ type: "summary_text", text: "think" }] }], + }, "m") as any; + const signature = message.content[0].signature as string; + expect(signature.startsWith("ocxr1:")).toBe(true); + expect(decodeReasoningEnvelope(signature)).toEqual({ txt: "think" }); + }); + + test("preserves an explicitly empty fallback text", () => { + expect(decodeReasoningEnvelope(encodeReasoningEnvelope({ txt: "" }))).toEqual({ txt: "" }); + }); + + test("inbound preserves redacted-only reasoning when visible text is empty", () => { + const body = anthropicToResponsesBody({ + model: "m", messages: [{ role: "assistant", content: [{ type: "redacted_thinking", data: "opaque" }] }], + }) as any; + expect(body.input).toHaveLength(1); + expect(body.input[0].type).toBe("reasoning"); + expect(decodeReasoningEnvelope(body.input[0].encrypted_content)?.red).toEqual(["opaque"]); + }); + + test("drops an empty unsigned thinking block", () => { + const body = anthropicToResponsesBody({ + model: "m", messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "" }] }], + }) as any; + expect(body.input).toEqual([]); + }); + + test("preserves signature-only reasoning in JSON output", () => { + const message = responsesJsonToAnthropicMessage({ + output: [{ type: "reasoning", summary: [], encrypted_content: encodeReasoningEnvelope({ sig: "sig-only" }) }], + }, "m") as any; + expect(message.content).toEqual([{ type: "thinking", thinking: "", signature: "sig-only" }]); + }); +}); From b2703f87017fd36789bac6689b1ec86ca00d9950 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:37:03 +0900 Subject: [PATCH 05/50] fix(grok): filter Codex control frames for strict Responses clients Carry PR #3816 at d5e0a9a2069ac5655dc208c7261981cd8c59112d. Keep original frames on the proxy inspection branch and scope projection to the existing Grok HTTP/SSE client marker. Co-authored-by: Danh Thanh --- src/server/grok-responses-control-frame.ts | 38 +++++++++++ src/server/responses/core.ts | 10 ++- .../responses-snapshot-repair-server.test.ts | 64 ++++++++++++++++++- 3 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 src/server/grok-responses-control-frame.ts diff --git a/src/server/grok-responses-control-frame.ts b/src/server/grok-responses-control-frame.ts new file mode 100644 index 0000000000..cb572acee9 --- /dev/null +++ b/src/server/grok-responses-control-frame.ts @@ -0,0 +1,38 @@ +import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; + +const GROK_CONTROL_FRAME_TYPES: Record = { + "codex.rate_limits": true, + "codex.response.metadata": true, +}; + +/** + * Hide Codex-only control frames from Grok's strict Responses decoder. + * + * The inspection branch still sees these frames before this client-facing + * rewrite, so quota accounting and response metadata remain available to the + * proxy while Grok receives only its declared Responses event variants. + */ +export function createGrokResponsesControlFrameBlockRewrite(): SseBlockRewrite { + return (block) => { + const eventName = block + .split(/\r?\n/) + .find(line => line.startsWith("event:")) + ?.slice("event:".length) + .trim(); + if (GROK_CONTROL_FRAME_TYPES[eventName ?? ""] === true) return []; + + const payload = sseDataPayload(block); + if (payload === null || payload === "[DONE]") return [block]; + + let event: unknown; + try { + event = JSON.parse(payload); + } catch { + return [block]; + } + if (!event || typeof event !== "object" || Array.isArray(event) || !("type" in event)) return [block]; + return typeof event.type === "string" && GROK_CONTROL_FRAME_TYPES[event.type] === true + ? [] + : [block]; + }; +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3c539c6d8e..e87136b67a 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -374,6 +374,7 @@ import { type UpstreamHostAdmissionLease, } from "../../codex/upstream-host-health"; import { createGrokResponsesSparseTerminalBlockRewrite } from "../grok-responses-snapshot-repair"; +import { createGrokResponsesControlFrameBlockRewrite } from "../grok-responses-control-frame"; import { createResponsesSnapshotBlockRewrite, hasResponsesSnapshotRepair, @@ -5494,9 +5495,9 @@ async function handleResponsesInner( // Grok Build renders deltas live but reconstructs its durable assistant // turn from the completed response snapshot. Native Responses streams // may instead carry the complete items in output_item.done, so the - // explicit Grok compatibility marker enables strict terminal-only repair. + // explicit Grok compatibility marker enables strict client compatibility rewrites. // The provider's broader snapshot/lifecycle repair remains opt-in. - const grokClientSnapshotRepairEnabled = logCtx.surface === "grok"; + const grokClientCompatibilityEnabled = logCtx.surface === "grok"; const snapshotRepairEnabled = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair); const githubCopilotRepairEnabled = route.providerName === "github-copilot"; const responseModelRewrite = parsed._responseModelId !== undefined @@ -5545,7 +5546,10 @@ async function handleResponsesInner( githubCopilotRepairEnabled ? createGithubCopilotResponsesBlockRewrite(translatorBudget) : undefined, - grokClientSnapshotRepairEnabled + grokClientCompatibilityEnabled + ? createGrokResponsesControlFrameBlockRewrite() + : undefined, + grokClientCompatibilityEnabled ? createGrokResponsesSparseTerminalBlockRewrite(translatorBudget) : undefined, snapshotRepairEnabled diff --git a/tests/responses/responses-snapshot-repair-server.test.ts b/tests/responses/responses-snapshot-repair-server.test.ts index 214806b141..a7f3ec167c 100644 --- a/tests/responses/responses-snapshot-repair-server.test.ts +++ b/tests/responses/responses-snapshot-repair-server.test.ts @@ -58,12 +58,26 @@ const CODEX_SPARSE_TERMINAL_EVENTS = [ }, ]; -function sparseSseBody(events: readonly Record[] = SPARSE_EVENTS): ReadableStream { +const GROK_CONTROL_FRAME_EVENTS = [ + { + type: "codex.rate_limits", + rate_limits: { primary: { used_percent: 12, window_minutes: 60, reset_at: 123 } }, + }, + { type: "codex.response.metadata", headers: { "x-models-etag": "fixture" } }, + { type: "response.created", response: { id: "resp_control" } }, + { type: "response.completed", response: { id: "resp_control", status: "completed", output: [] } }, +]; + +function sparseSseBody( + events: readonly Record[] = SPARSE_EVENTS, + includeEventNames = false, +): ReadableStream { return new ReadableStream({ start(controller) { const encoder = new TextEncoder(); for (const event of events) { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + const eventLine = includeEventNames ? `event: ${event.type}\n` : ""; + controller.enqueue(encoder.encode(`${eventLine}data: ${JSON.stringify(event)}\n\n`)); } controller.enqueue(encoder.encode("data: [DONE]\n\n")); controller.close(); @@ -74,6 +88,7 @@ function sparseSseBody(events: readonly Record[] = SPARSE_EVENT function stubSparseGateway( origin: string, events: readonly Record[] = SPARSE_EVENTS, + includeEventNames = false, ): void { globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; @@ -82,7 +97,7 @@ function stubSparseGateway( return Response.json({ data: [] }); } if (url.origin === origin && url.pathname.endsWith("/responses")) { - return new Response(sparseSseBody(events), { + return new Response(sparseSseBody(events, includeEventNames), { status: 200, headers: { "content-type": "text/event-stream" }, }); @@ -326,6 +341,49 @@ describe("responsesSnapshotRepair through /v1/responses", () => { await server.stop(true); } }); + test("the Grok marker filters Codex control frames at the client boundary", async () => { + const gateway = "https://grok-control-frame.example.test"; + stubSparseGateway(gateway, GROK_CONTROL_FRAME_EVENTS, true); + saveConfig({ + port: 0, + defaultProvider: "sparse", + providers: { + sparse: { + adapter: "openai-responses", + baseUrl: `${gateway}/v1`, + authMode: "key", + apiKey: "test-key", + }, + }, + } as OcxConfig); + + const server = startServer(0); + try { + const request = (grokMarker: boolean) => originalFetch(new URL("/v1/responses", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + ...(grokMarker ? { "x-opencodex-grok": "1" } : {}), + }, + body: JSON.stringify({ model: "sparse-model", input: "hi", stream: true }), + }); + + const grokResponse = await request(true); + expect(grokResponse.status).toBe(200); + const grokText = await grokResponse.text(); + expect(grokText).not.toContain("codex.rate_limits"); + expect(grokText).not.toContain("codex.response.metadata"); + expect(grokText).toContain('"type":"response.completed"'); + + const ordinaryResponse = await request(false); + expect(ordinaryResponse.status).toBe(200); + const ordinaryText = await ordinaryResponse.text(); + expect(ordinaryText).toContain("codex.rate_limits"); + expect(ordinaryText).toContain("codex.response.metadata"); + } finally { + await server.stop(true); + } + }); }); test("sparse JSON completion inference precedes function repair in client output and replay", async () => { From 0d42efa2845505224f9e1eff5f73f0b9bc46bf78 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:37:44 +0900 Subject: [PATCH 06/50] docs(plan): define axis five display and CLI delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record source-delta audits, manual PR delivery and final-head CI strategy. Co-authored-by: Éverton Toffanetto Co-authored-by: Zig Zag Co-authored-by: 투린 --- .../260907_axis5_display_cli/000_plan.md | 33 +++++++++++++++++ .../001_roadmap_audit.md | 11 ++++++ .../260907_axis5_display_cli/010_delivery.md | 37 +++++++++++++++++++ 3 files changed, 81 insertions(+) create mode 100644 devlog/_plan/260907_axis5_display_cli/000_plan.md create mode 100644 devlog/_plan/260907_axis5_display_cli/001_roadmap_audit.md create mode 100644 devlog/_plan/260907_axis5_display_cli/010_delivery.md diff --git a/devlog/_plan/260907_axis5_display_cli/000_plan.md b/devlog/_plan/260907_axis5_display_cli/000_plan.md new file mode 100644 index 0000000000..222ef89899 --- /dev/null +++ b/devlog/_plan/260907_axis5_display_cli/000_plan.md @@ -0,0 +1,33 @@ +# Axis 5: display names and provider automation + +Date: 2026-09-07. Class C3, scoped satisfy-spec HOTL loop requested by owner. +Goal: deliver feasible unique changes from #3627, #2716, #3780 on dev with original author trailers. +Scope: display-only catalog metadata, discovered-model editor, optional JSONL CLI output, regression coverage and their docs. No auth/default/routing changes, native stacks, releases, or edits to existing dirty work. +Resources: existing repository/GitHub credentials and Astra high leaf agents; no user-set time/token cap. Isolated checkout /tmp/ocx-axis5-01a078d6. Owner authorizes no-verify push and admin merge. All local suites are prohibited; typecheck/build/test evidence will come from final combined GitHub CI. No invented lower-layer CI successes. +Terminal: merged, proven already delivered, or evidence-backed deferred when infeasible; finish after verifying all dispositions. New product decisions are isolated and deferred rather than guessed. +Records: this unit, .tmp/axis5-evidence, and session-bound .codexclaw goalplan. + +## Roadmap + +WP0: documentation-only source-delta and delivery plan, independent plan audit and document validation. +WP1: implement three scoped source carries with individual credited commits, publish ordinary manual PR chain, audit final tree, validate final combined head, merge verified layers bottom-up, and record dev ancestry. +The three layers are a user-requested review/integration sequence, not a claimed runtime dependency: native catalog -> JSONL CLI -> discovered editor. Source PR branches are never rewritten. +Read 010_delivery.md for diff-level scope and activation scenarios. + +## Sources + +- https://github.com/lidge-jun/opencodex/pull/3627 +- https://github.com/lidge-jun/opencodex/pull/2716 +- https://github.com/lidge-jun/opencodex/pull/3780 +- Base dev: 137d6a7270e7ecfb1c791993800a17c0e30022d9 +- Existing API display-name contract from #3212 is already on dev; only missing UI is carried. + +## CI and merge + +.github/workflows/ci.yml has pull_request triggers on all bases and workflow_dispatch lane=all for complete coverage. Pushes to feature branches do not independently trigger it. Defer/cancel only this task's lower-layer expensive runs as authorized, recording cancellation as cancellation. Dispatch all on final head; only if final CI fails use lower-layer runs to isolate. Do not edit shared workflow policy or fabricate check statuses. +Use merge commits and retain parent branches so commit identity and author trailers survive bottom-up merges. Retarget a child only after its parent lands. If dev moves concurrently, integrate the new dev into the top and refresh exact combined CI before shipping the resulting changed tree. +Review-ready requirements remain visible; local suite prohibition is explicitly documented instead of ticking a false local attestation. Admin waiver applies to the requested merge, not to truthful evidence. + +CI scope refinement: the discovered editor is the final layer so the final commit and PR diff include gui/**, activating GUI lint/build/artifact jobs. ci.yml gates always run GUI tests; docs deployment is NOT dispatched because it publishes. Public docs receive static source consistency inspection here, with docs build explicitly unverified unless an existing build-only remote path is available. + +CI scheduling refinement: lower-layer head commits may use GitHub documented [skip ci] to avoid push/pull_request suite launches; this yields missing/pending evidence, NOT green. Final head has no skip marker and receives lane=all workflow_dispatch. Source: https://docs.github.com/en/actions/how-tos/manage-workflow-runs/skip-workflow-runs (opened 2026-09-07). Admin merge records this explicit owner-requested lower-layer waiver. Do not propagate skip markers into integration merge messages. diff --git a/devlog/_plan/260907_axis5_display_cli/001_roadmap_audit.md b/devlog/_plan/260907_axis5_display_cli/001_roadmap_audit.md new file mode 100644 index 0000000000..2e4b740b4d --- /dev/null +++ b/devlog/_plan/260907_axis5_display_cli/001_roadmap_audit.md @@ -0,0 +1,11 @@ +# Roadmap audit closure + +WP0 documentation implementation, 2026-09-07. +Independent Astra high reviewers: Carver (#3627), Godel (#2716), Anscombe (#3780 and integrated roadmap). + +The integrated verdict was GO-WITH-FIXES (four blockers). The plan now distinguishes lower-layer waived CI from final combined passing evidence; removes the unreachable empty-provider CLI acceptance; requires confirmed-persistence reconciliation after GUI refresh failure; and requires timeout reachability analysis against the installed bounded-fetch wrapper before adding any timeout logic. + +Source heads: native f699ec7f998d56bf205db96762b821cd8c228a35; editor 93ed44053b68a9707f8271981d5f7e4bc25e9b70; JSONL 9b873e6f7519a022dd4658db4d1cb92689bb4663. +The physical manual chain is native -> JSONL -> GUI, enabling final GUI CI jobs. It is owner-requested integration ordering, not a claimed runtime dependency. +Native external-name preservation is qualified by existing pinned Astra normalization; existing policy remains intact. +No product tests, typecheck or build ran. WP0 checks only roadmap structure, source paths, explicit acceptance and credit records. Product verification remains WP1 remote CI. diff --git a/devlog/_plan/260907_axis5_display_cli/010_delivery.md b/devlog/_plan/260907_axis5_display_cli/010_delivery.md new file mode 100644 index 0000000000..b9cc7e2c4d --- /dev/null +++ b/devlog/_plan/260907_axis5_display_cli/010_delivery.md @@ -0,0 +1,37 @@ +# WP1: reconcile, deliver, verify and merge axis 5 + +Depends on WP0 roadmap audit. Source baseline dev 137d6a727. Previous D must confirm roadmap-only completion before production patches. + +## Layer 1 — #3627 native display names + +MODIFY src/codex/catalog/sync.ts: introduce reversible native label overlay at observed-state merge, restore original label before metadata normalization, strip marker from template clones, apply configured label to supported bare native rows only. MODIFY src/codex/convergence.ts: supply the same modelDisplayNames map as retained sync. MODIFY tests/codex-integration/codex-catalog.test.ts and provider configuration docs (English, Japanese, Korean, Simplified Chinese). +Field chain: existing providers.openai.modelDisplayNames config -> both merge call sites -> nativeDisplayNames argument -> display_name plus catalog-only opencodex_native_display_name {slug,original,applied} -> JSON catalog serialization -> restoration before next normalization. Clone consumers must remove overlay markers; source inputs remain immutable. +Activation: configured label replaces native name; removing/blanking restores owned original; external Sol rename is preserved; Astra remains subject to existing pinned-metadata normalization and docs/tests state that exception; newer native metadata upgrades after reset; repeated serialized cycles stable; account-qualified/combo/pro/custom rows unchanged. Exact model IDs and capabilities unchanged. +Credit: Co-authored-by: Éverton Toffanetto . + +## Layer 2 — #2716 discovered name editor + +NEW gui/src/components/ModelDisplayNameDialog.tsx and gui/tests/models-display-name-editor.test.tsx from source PR after current API contract comparison. MODIFY gui/src/pages/Models.tsx, models-shared.ts, gui/src/styles.css, all nine locale modules, English provider configuration docs. +Field chain: existing /api/models displayNameOverride/displayNameSource -> ModelRow optional fields -> Name action/dialog -> existing display-name save/reset endpoint -> persisted provider modelDisplayNames -> reload /api/models. No new persisted field or endpoint is needed. +Activation: save/reset/unchanged cancel; blank/too long/slash/control input; one submit under double click; save failure retains dialog; reload failure remains recoverable; focus returns after close; original selector always visible and alias action remains separate. +Credit: Co-authored-by: Zig Zag . +Browser smoke: render real isolated app, open Name dialog and observe screenshot; use mocked management responses or isolated disposable home, never mutate personal config. GUI tests/build/i18n/lint and docs build are remote CI obligations; not run locally. + +## Layer 3 — #3780 provider JSONL + +MODIFY src/cli/provider.ts and src/cli/capabilities.ts to accept --jsonl, emit existing configured-array objects one per line, reject combined --json/--jsonl before reading config. MODIFY tests/cli/cli-provider.test.ts, public CLI docs and skills/ocx/references/01_management_surface.md, 02_json_shapes.md, 03_recipes.md. Regenerate or reconcile derived surface with generator source; no unrelated output. +Field chain: argv -> consumeFlag -> output choice; no config serialization changes. JSONL entries use exactly existing JSON configured fields; no credentials added. The real config loader seeds providers; a zero-provider CLI scenario is not a reachable acceptance claim. Preserve existing loader behavior. Extend source tests to compare every emitted object with --json.configured for multiple registry/custom providers, ensure empty stdout on both conflicting flag orders, and verify escaping. Update all seven translated CLI provider tables and describe consumer-side line processing without claiming producer streaming. +Activation: multiple providers including custom names -> one parseable record each; default human and --json unchanged; both flags rejected; unknown args still rejected; conflicting flags -> empty stdout before config loading. +Credit: Co-authored-by: 투린 . + +## Verification and disposition + +Static git diff --check and independent source audits throughout. Existing focused test paths are reviewed for target coverage, but ALL LOCAL SUITES NOT RUN by owner instruction. Final ci.yml workflow_dispatch lane=all on published final SHA supplies typecheck, full tests and platform results; inspect actual job conclusions and head SHA. Add missing coverage within source scope if audit identifies a contract gap. Inspect GUI workflow coverage and obtain remote GUI/build evidence if not present in final dispatch. +Source-of-truth: provider configuration and CLI docs above; update structure/03_catalog-and-subagents.md only for native overlay contract. No new enforcement layer; tests/CI are evidence, admin bypass is owner-authorized and recorded. +Before merging: fresh heads and native membership, independent review dispositions, final CI proof, original author trailers, screenshot for GUI PR. If infeasible, record concrete cause and leave only that layer unmerged. After each merge: verify mergeCommit SHA and inclusion on fetched dev. Close superseded original PR only once its delivery is on dev and preserve attribution. + +Audit amendment: native label restoration preserves an external edit only subject to existing metadata normalization, notably pinned Astra replacement. Do not change native normalization policy. Add the Astra external-edit regression and qualify the promise consistently in all four affected docs. The native feature must preserve metadata including capabilities; English/Japanese wording is explicit. Final physical branch order is native -> JSONL -> GUI to activate final GUI gates; numeric sections above identify features, not alternate dependency claims. + +GUI audit amendment: confirmed persisted save/reset must reconcile editor snapshot and draft even when reload fails. A saved:true error is distinct from an unpersisted error. Stalled requests must not lock every dialog exit indefinitely: use existing UI request cancellation/deadline conventions, and represent uncertain write outcome without claiming rollback. Add focused source tests for first-save/reset plus reload failure, saved:true errors, duplicate protection and stalled cancellation. + +Plan audit synthesis (Astra high Anscombe): GO-WITH-FIXES, four blockers folded. (1) Lower layer CI is explicitly waived/deferred, never labeled passing; fresh head/base checks plus resulting tree equivalence tie admin merges to final combined evidence. (2) Removed unreachable empty-provider CLI scenario; loader behavior preserved. (3) Confirmed-persistence vs refresh state and tests required. (4) First rederive stalled-request reachability through installed global createBoundedFetch; reuse existing bound if it already applies, add no duplicate budget. Any remaining timeout scenario must be production-reachable. From ef54e8275d6fe00afbfa1039a79379dcf9041f68 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:38:00 +0900 Subject: [PATCH 07/50] docs: describe BigModel and Raycast integration contracts Carry architecture context for #3641 and #3733. Live BigModel discovery remains deferred; local suites are not run. Co-authored-by: jamespan Co-authored-by: Chanhee Lee --- structure/01_runtime.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/structure/01_runtime.md b/structure/01_runtime.md index 7fb1c00997..1601e5762b 100644 --- a/structure/01_runtime.md +++ b/structure/01_runtime.md @@ -166,6 +166,22 @@ OAuth presets resolve discovery against the same canonical registry transport as before any adapter-specific transport override, so a stale configured `baseUrl` cannot receive an OAuth bearer token. +The BigModel Coding Plan Responses preset uses the separately documented +`https://open.bigmodel.cn/api/v1` transport and a static catalog. Its provider row +disables live discovery: a local Codex `models.json` example does not establish an +authenticated HTTP models endpoint. Its static context and reasoning metadata are +kept in the canonical registry, including an explicit empty selectable effort +ladder for `glm-5-turbo`. + +Raycast is a managed client export, not an upstream model provider. Its YAML +contribution owns only the unique `providers/[id=opencodex]` entry, with the +existing manifest and fingerprint checks protecting user-owned provider values. +Ambiguous selector matches and incompatible containers cannot be adopted or +mutated. Catalog refresh uses the existing owned-integration activation check; +an unowned client remains disconnected. OpenCodex omits Raycast API-key fields +and exports only to eligible local targets. Pro detection is an advisory hint, +not an authentication or entitlement decision. + ## Remote Hub hardening ownership `src/remote/protocol.ts` owns pure interval/feature negotiation. `src/client/hub-client.ts` owns bounded, schema-validated remote catalog consumption and key-id probes. `src/client/hub-relay.ts` is a fixed-authority management relay with URL, header, body, redirect, and stream bounds. The public data listener remains the direct client→hub path; the loopback management ingress never serves data-plane routes. From 6a51f048e7e4eea7861f331f84dc241e61db035a Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:38:22 +0900 Subject: [PATCH 08/50] test(oauth): drain ACL flights before fixture teardown [skip ci] Carry the remaining teardown fix from #3383 (51726d2c7) and verify cleanup ordering with a held asynchronous runner. Final combined CI will validate the stack; no local suite was run. Co-authored-by: x3M3x --- tests/oauth/oauth-store-multi.test.ts | 86 +++++++++++++++++++++++---- 1 file changed, 73 insertions(+), 13 deletions(-) diff --git a/tests/oauth/oauth-store-multi.test.ts b/tests/oauth/oauth-store-multi.test.ts index 6cd3f21dbe..02d8f8024f 100644 --- a/tests/oauth/oauth-store-multi.test.ts +++ b/tests/oauth/oauth-store-multi.test.ts @@ -4,10 +4,14 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import * as atomicWrite from "../../src/config/atomic-write"; import * as oauthStore from "../../src/oauth/store"; +import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import { resetHardenedStateForTests, + setAsyncIcaclsRunnerForTests, setIcaclsRunnerForTests, + setPlatformForTests, } from "../../src/lib/windows-secret-acl"; +import { setSyntheticWindowsPrincipalForTests } from "../../src/lib/windows-user-principal"; import { getAccountCredential, getAccountSet, @@ -35,6 +39,17 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; const TEST_DIR = join(import.meta.dir, ".tmp-oauth-store-multi-test"); let previousOpencodexHome: string | undefined; +const ICACLS_OK = { success: true, exitCode: 0, timedOut: false, stdout: "" }; + +async function cleanupOAuthStoreFixture(): Promise { + await flushConfigDirHardeningForTests(); + setIcaclsRunnerForTests(null); + setAsyncIcaclsRunnerForTests(null); + resetHardenedStateForTests(); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); +} const cred = (over: Partial = {}): OAuthCredentials => ({ access: "access-1", @@ -61,21 +76,66 @@ describe("multi-account auth store", () => { mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; resetHardenedStateForTests(); - setIcaclsRunnerForTests(() => ({ - success: true, - exitCode: 0, - timedOut: false, - stdout: "", - })); + setIcaclsRunnerForTests(() => ICACLS_OK); + setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); }); - afterEach(() => { - setIcaclsRunnerForTests(null); - resetHardenedStateForTests(); - if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = previousOpencodexHome; - if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); - }); + afterEach(cleanupOAuthStoreFixture); + + test("fixture cleanup waits for a held config-directory ACL flight before restoring home or deleting files", async () => { + let release!: () => void; + const held = new Promise(resolve => { release = resolve; }); + let markStarted!: () => void; + const started = new Promise(resolve => { markStarted = resolve; }); + let deadlineTimer: ReturnType | undefined; + let cleaning: Promise | undefined; + let cleanupSettled = false; + setPlatformForTests("win32"); + // Keep SID discovery hermetic on Windows as well as on forced POSIX lanes. + setSyntheticWindowsPrincipalForTests("*S-1-5-21-1-2-3-1001"); + setAsyncIcaclsRunnerForTests(async () => { + markStarted(); + await held; + return ICACLS_OK; + }); + try { + // A real store read starts the production-tracked directory hardening flight. + expect(getAccountSet("xai")).toBeNull(); + await Promise.race([ + started, + new Promise((_, reject) => { + deadlineTimer = setTimeout(() => reject(new Error("ACL runner did not start")), INTERNAL_DEADLINE_MS); + }), + ]); + clearTimeout(deadlineTimer); + cleaning = cleanupOAuthStoreFixture().then( + () => { cleanupSettled = true; return null; }, + (error: unknown) => { cleanupSettled = true; return error; }, + ); + // An event-loop checkpoint lets an incorrectly unawaited cleanup finish; no sleep oracle. + await new Promise(resolve => setImmediate(resolve)); + expect(cleanupSettled).toBe(false); + expect(process.env.OPENCODEX_HOME).toBe(TEST_DIR); + expect(existsSync(TEST_DIR)).toBe(true); + + release(); + expect(await cleaning).toBeNull(); + expect(cleanupSettled).toBe(true); + expect(process.env.OPENCODEX_HOME).toBe(previousOpencodexHome); + expect(existsSync(TEST_DIR)).toBe(false); + } finally { + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + // Even a broken cleanup must not release the held flight into the real runner. + setAsyncIcaclsRunnerForTests(async () => ICACLS_OK); + release(); + try { + await cleaning; + await flushConfigDirHardeningForTests(); + } finally { + setPlatformForTests(null); + } + } + }, STORE_BUDGET_MS); test("legacy single-credential auth.json normalizes and round-trips without losing login", async () => { const authPath = join(TEST_DIR, "auth.json"); From 336c621a35b63e4dec5bff235f897f92adeae2c7 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:38:20 +0900 Subject: [PATCH 09/50] fix(grok): honor SSE event order and empty resets Use the last event field and preserve significant whitespace while retaining independent JSON control-type filtering. Add discriminator, order, reset, and preservation regressions to the existing Responses test file. Tests were authored but not run; validation is delegated to final combined remote CI. Co-authored-by: Danh Thanh --- src/server/grok-responses-control-frame.ts | 17 ++++-- .../responses-snapshot-repair-server.test.ts | 60 ++++++++++++++++++- 2 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/server/grok-responses-control-frame.ts b/src/server/grok-responses-control-frame.ts index cb572acee9..e910daf99d 100644 --- a/src/server/grok-responses-control-frame.ts +++ b/src/server/grok-responses-control-frame.ts @@ -14,12 +14,17 @@ const GROK_CONTROL_FRAME_TYPES: Record = { */ export function createGrokResponsesControlFrameBlockRewrite(): SseBlockRewrite { return (block) => { - const eventName = block - .split(/\r?\n/) - .find(line => line.startsWith("event:")) - ?.slice("event:".length) - .trim(); - if (GROK_CONTROL_FRAME_TYPES[eventName ?? ""] === true) return []; + let eventName = ""; + // SSE overwrites the event type on every event field, including empty resets. + // Like sseDataPayload, remove only one optional ASCII space after the colon. + for (const line of block.split(/\r?\n/)) { + if (line === "event") eventName = ""; + else if (line.startsWith("event:")) { + const value = line.slice("event:".length); + eventName = value.startsWith(" ") ? value.slice(1) : value; + } + } + if (GROK_CONTROL_FRAME_TYPES[eventName] === true) return []; const payload = sseDataPayload(block); if (payload === null || payload === "[DONE]") return [block]; diff --git a/tests/responses/responses-snapshot-repair-server.test.ts b/tests/responses/responses-snapshot-repair-server.test.ts index a7f3ec167c..f6e4ac0e92 100644 --- a/tests/responses/responses-snapshot-repair-server.test.ts +++ b/tests/responses/responses-snapshot-repair-server.test.ts @@ -6,6 +6,7 @@ import { saveConfig } from "../../src/config"; import { startServer } from "../../src/server"; import { handleResponses } from "../../src/server/responses"; import { isEagerRelaySseResponse } from "../../src/server/relay"; +import { createGrokResponsesControlFrameBlockRewrite } from "../../src/server/grok-responses-control-frame"; import type { OcxConfig } from "../../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -118,6 +119,61 @@ afterEach(async () => { removeTreeWithRetry(TEST_DIR); }); +for (const controlType of ["codex.rate_limits", "codex.response.metadata"]) { + describe(`Grok control frame ${controlType}`, () => { + test.each(["{}", "not-json"])("filters an event-only discriminator with payload %s", payload => { + const rewrite = createGrokResponsesControlFrameBlockRewrite(); + expect(rewrite(`event: ${controlType}\ndata: ${payload}`)).toEqual([]); + }); + + test("filters a data-only discriminator without an event field", () => { + const rewrite = createGrokResponsesControlFrameBlockRewrite(); + expect(rewrite(`data: {"type":"${controlType}"}`)).toEqual([]); + }); + + test.each(["{}", "not-json"])("filters the last event field with payload %s", payload => { + const rewrite = createGrokResponsesControlFrameBlockRewrite(); + expect(rewrite(`event: message\nevent: ${controlType}\ndata: ${payload}`)).toEqual([]); + }); + + test("preserves completion when the last event field overrides a control type", () => { + const block = `event: ${controlType}\nevent: response.completed\ndata: {"type":"response.completed","response":{"id":"r1","status":"completed","output":[]}}`; + expect(createGrokResponsesControlFrameBlockRewrite()(block)).toEqual([block]); + }); + + test.each(["event:", "event: ", "event"])("honors the empty reset %s", reset => { + const block = `event: ${controlType}\n${reset}\ndata: {}`; + expect(createGrokResponsesControlFrameBlockRewrite()(block)).toEqual([block]); + }); + + test("still filters the JSON type after an empty event reset", () => { + const block = `event: ${controlType}\nevent:\ndata: {"type":"${controlType}"}`; + expect(createGrokResponsesControlFrameBlockRewrite()(block)).toEqual([]); + }); + + test.each([`event: ${controlType}`, `event:\t${controlType}`, `event: ${controlType} `])( + "preserves significant event-value whitespace in %s", + eventLine => { + const block = `${eventLine}\ndata: {}`; + expect(createGrokResponsesControlFrameBlockRewrite()(block)).toEqual([block]); + }, + ); + + test("recognizes a CRLF event field without an optional space", () => { + expect(createGrokResponsesControlFrameBlockRewrite()(`event:message\r\nevent:${controlType}\r\ndata: {}`)).toEqual([]); + }); + + test("does not retain the event type across blocks or consume ordinary content", () => { + const rewrite = createGrokResponsesControlFrameBlockRewrite(); + expect(rewrite(`event: ${controlType}\ndata: {}`)).toEqual([]); + for (const block of ["data: {}", "data: not-json", ": heartbeat", "data: [DONE]", + `data: {"type":"response.output_text.delta","delta":"${controlType}"}`]) { + expect(rewrite(block)).toEqual([block]); + } + }); + }); +} + describe("responsesSnapshotRepair through /v1/responses", () => { test.skipIf(process.platform !== "darwin")( "Darwin eager-relay applies snapshot repair inline before bytes reach the client", @@ -341,9 +397,9 @@ describe("responsesSnapshotRepair through /v1/responses", () => { await server.stop(true); } }); - test("the Grok marker filters Codex control frames at the client boundary", async () => { + test.each([true, false])("the Grok marker filters Codex control frames at the client boundary (event names: %s)", async includeEventNames => { const gateway = "https://grok-control-frame.example.test"; - stubSparseGateway(gateway, GROK_CONTROL_FRAME_EVENTS, true); + stubSparseGateway(gateway, GROK_CONTROL_FRAME_EVENTS, includeEventNames); saveConfig({ port: 0, defaultProvider: "sparse", From 9cde6e735294f4605e2a8655dda4aaac195beb04 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 07:38:27 +0900 Subject: [PATCH 10/50] test(responses): cover established task delivery and compaction Add one synthetic complete send_message_to_thread envelope after a real tool pair. Cover ordinary responses, stored-ID continuation, v2 compaction_trigger and v1 compact; assert upstream content, order and pairing plus compact output contracts. Coverage motivated by issue #3807 reports from @DaveW001 and @stephen-drew, using the narrowed envelope contract documented in #3735. These are synthetic fixtures, not captured reporter requests; no original source patch is copied. Validation: git diff --check passed. Tests, typecheck and build NOT RUN by explicit instruction. Production code and missing-call-id guards are unchanged. --- .../responses-compaction-routing.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index 8e92f28715..8faca32bef 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -1776,6 +1776,130 @@ describe("external task-input envelopes (#3735)", () => { } }); +describe("established-history external task input (#3807)", () => { + // Synthetic complete envelope from the #3735 contract; #3807's history rendering + // is not a captured outbound request. Keep the real tool pair distinct from delivery. + const deliveryText = " Follow up on the earlier tool result.\n"; + const acknowledged = "Delivery acknowledged."; + const continuationText = "Continue the established task."; + const summary = "Earlier tool returned 7; follow-up delivery is pending."; + const history = () => [ + { type: "message", role: "user", content: "Read the earlier value." }, + { type: "function_call", call_id: "call_history", name: "read_value", arguments: "{}" }, + { type: "function_call_output", call_id: "call_history", output: "earlier value: 7" }, + { type: "message", role: "assistant", content: "Earlier result recorded." }, + { + type: "function_call_output", id: "fco_external_followup", + name: "send_message_to_thread", namespace: "codex_app", output: deliveryText, + }, + ]; + const requestBody = () => ({ + model: "gw/model", stream: false, store: false, input: history(), + tools: [{ type: "function", name: "read_value", parameters: { type: "object", properties: {} } }], + }); + const wireHistory = [ + { role: "user", content: "Read the earlier value." }, + { role: "assistant", tool_calls: [{ id: "call_history", type: "function", function: { name: "read_value", arguments: "{}" } }] }, + { role: "tool", tool_call_id: "call_history", content: "earlier value: 7" }, + { role: "assistant", content: "Earlier result recorded." }, + { role: "user", content: deliveryText }, + ]; + + function captureChat(text: string): Array> { + const captured: Array> = []; + globalThis.fetch = (async (_url: unknown, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body))); + return jsonResponse({ + choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 5 }, + }); + }) as typeof fetch; + return captured; + } + + function expectHistory(sent: Record, tail: Array> = []) { + const messages = sent.messages as Array>; + expect(messages).toHaveLength(wireHistory.length + tail.length); + expect(messages).toMatchObject([...wireHistory, ...tail]); + // Exactly one original pair: delivery must not acquire a synthesized tool identity. + expect(messages.flatMap(message => message.tool_calls ?? [])).toEqual(wireHistory[1]!.tool_calls); + expect(messages.filter(message => message.role === "tool")).toEqual([wireHistory[2]]); + expect(JSON.stringify(sent)).not.toContain("[tool output for unknown call]"); + } + + test("ordinary response preserves inter-task delivery after an established tool pair", async () => { + const captured = captureChat(acknowledged); + const res = await handleResponses(compactionRequest(requestBody()), + keyProviderConfig({ adapter: "openai-chat" }), { model: "", provider: "" }); + expect(res.status).toBe(200); + const json = await res.json() as { status?: string }; + expect(json.status).toBe("completed"); + expect(captured).toHaveLength(1); + expectHistory(captured[0]!); + }); + + test("stored-ID continuation replays the established tool pair and inter-task delivery in order", async () => { + const captured = captureChat(acknowledged); + const config = keyProviderConfig({ adapter: "openai-chat" }); + const first = await handleResponses(compactionRequest({ ...requestBody(), store: true }), + config, { model: "", provider: "" }); + expect(first.status).toBe(200); + const saved = await first.json() as { id: string; status?: string }; + expect(saved.status).toBe("completed"); + expect(typeof saved.id).toBe("string"); + expect(saved.id.length).toBeGreaterThan(0); + expect(captured).toHaveLength(1); + expectHistory(captured[0]!); + + // Send only the new user turn: the handler must retrieve the previous raw history. + const res = await handleResponses(compactionRequest({ + ...requestBody(), previous_response_id: saved.id, + input: [{ type: "message", role: "user", content: continuationText }], + }), config, { model: "", provider: "" }); + expect(res.status).toBe(200); + const json = await res.json() as { status?: string }; + expect(json.status).toBe("completed"); + expect(captured).toHaveLength(2); + expectHistory(captured[1]!, [ + { role: "assistant", content: acknowledged }, + { role: "user", content: continuationText }, + ]); + }); + + for (const version of ["v2 trigger", "v1 compact"] as const) { + test(`${version} preserves established-history delivery and pairing before summarization`, async () => { + const captured = captureChat(summary); + const config = keyProviderConfig({ adapter: "openai-chat" }); + const res = version === "v2 trigger" + ? await handleResponses(compactionRequest({ + ...requestBody(), input: [...history(), { type: "compaction_trigger" }], + }), config, { model: "", provider: "" }) + : await handleResponsesCompact(new Request("http://localhost/v1/responses/compact", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(requestBody()), + }), config, { model: "", provider: "" }); + expect(res.status).toBe(200); + const json = await res.json() as { output: Array> }; + expect(captured).toHaveLength(1); + expectHistory(captured[0]!, [ + { role: "user", content: expect.stringContaining("CONTEXT CHECKPOINT COMPACTION") }, + ]); + expect(captured[0]!.tools).toBeUndefined(); + expect(JSON.stringify(captured)).not.toContain("compaction_trigger"); + if (version === "v2 trigger") { + expect(json.output.filter(item => item.type === "compaction")).toEqual([{ + type: "compaction", id: expect.stringMatching(/^cmp_/), + encrypted_content: `ocx1:${Buffer.from(summary, "utf8").toString("base64")}`, + }]); + } else { + expect(json.output).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "Read the earlier value." }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: expect.stringContaining(`\n${summary}`) }] }, + ]); + } + }); + } +}); + describe("unpaired tool result boundary (#3259)", () => { function unpairedBody(item: Record): Record { return { From cb8ac02b96eb7ae055c1faab9d7ba85fac1f9412 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:39:36 +0900 Subject: [PATCH 11/50] fix(diagnostics): distinguish inbound size measurement provenance [skip ci] Report declared wire length, observed lower bounds, and decoded sizes without reading rejected payloads further or changing admission and retry policy. Follow up on #3573; thanks to @nowhere1975 for the content-free incident measurements. The issue remains open for exact compact-attempt evidence. Independent source review passed; final remote CI pending. --- .../content/docs/reference/proxy-formats.md | 25 +++- src/server/request-decompress.ts | 42 ++++-- tests/usage/request-decompress.test.ts | 127 ++++++++++++++++-- 3 files changed, 172 insertions(+), 22 deletions(-) diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 7008194e19..cb97ad7076 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -409,7 +409,30 @@ default provider is enabled and is not itself an OpenAI-family entry; account-qu such as `side/gpt-5.6-sol` still fail closed. The proxy logs one notice per provider when this fallback engages. Configurations with an enabled canonical `openai` provider are unchanged. -Native compact responses are buffered with a 32 MiB maximum, including responses whose declared +Inbound bodies on both `/v1/responses` and `/v1/responses/compact` retain the shared 256 MiB +wire/decompression admission limit. Application-level size rejection returns HTTP 413 with +`type` and `code` both `invalid_request_error`. Its message includes a bounded diagnostic suffix, +for example: + +```text +Decompressed request body exceeds 268435456 bytes [measurement=decoded_lower_bound; bytes=268435457] +``` + +| Measurement | Meaning of `bytes` | +| --- | --- | +| `declared_wire` | Numeric `Content-Length` declared by the sender; rejected before reading, not a measured decoded size | +| `observed_wire_lower_bound` | Wire bytes encountered when reading stopped; the complete body may be larger | +| `decoded_exact` | Exact size of the buffer supplied to the identity decoder or returned by a decoder | +| `decoded_lower_bound` | Admission limit plus one after inflation aborts; a lower bound, never the exact decoded size | + +The suffix contains only a fixed category and a finite numeric byte value. Rejected bodies are +not read or inflated further, parsed for item counts, or retained for diagnostics. Legacy errors +without measurement provenance retain the limit-only message. Bun's listener can reject an +oversized wire body before application diagnostics run, so not every 413 carries this suffix. +A lower-bound diagnostic cannot establish the complete compact payload size. The admission +limit and retry behavior are unchanged. + +Native compact responses are buffered with a separate 32 MiB maximum, including responses whose declared `Content-Length` already exceeds the limit. The compact-specific failures include: | Status | Type or code | Meaning | diff --git a/src/server/request-decompress.ts b/src/server/request-decompress.ts index 0710470346..297c77a9d1 100644 --- a/src/server/request-decompress.ts +++ b/src/server/request-decompress.ts @@ -27,14 +27,39 @@ export class UnsupportedContentEncodingError extends Error { } } +export type BodySizeMeasurement = + | "declared_wire" + | "observed_wire_lower_bound" + | "decoded_exact" + | "decoded_lower_bound"; + export class DecompressedBodyTooLargeError extends Error { - constructor(readonly bytes: number, limit: number = MAX_DECOMPRESSED_BODY_BYTES) { - super(`Decompressed request body exceeds ${limit} bytes`); + readonly measurement: BodySizeMeasurement | null; + + constructor( + readonly bytes: number, + readonly limit: number = MAX_DECOMPRESSED_BODY_BYTES, + measurement: BodySizeMeasurement | null = null, + ) { + // Legacy callers supply no provenance. Only fixed categories and finite + // numbers may reach the public message, including calls from untyped code. + const category = measurement === "declared_wire" || measurement === "observed_wire_lower_bound" + || measurement === "decoded_exact" || measurement === "decoded_lower_bound" + ? measurement : null; + const suffix = category !== null && Number.isFinite(bytes) && bytes >= 0 + && Number.isFinite(limit) && limit >= 0 + ? ` [measurement=${category}; bytes=${bytes}]` : ""; + super(`Decompressed request body exceeds ${Number.isFinite(limit) ? limit : "unknown"} bytes${suffix}`); + this.measurement = category; } } -function assertBodySizeWithinLimit(body: Uint8Array, maxBytes: number): Uint8Array { - if (body.byteLength > maxBytes) throw new DecompressedBodyTooLargeError(body.byteLength, maxBytes); +function assertBodySizeWithinLimit( + body: Uint8Array, + maxBytes: number, + measurement: BodySizeMeasurement = "decoded_exact", +): Uint8Array { + if (body.byteLength > maxBytes) throw new DecompressedBodyTooLargeError(body.byteLength, maxBytes, measurement); return body; } @@ -112,7 +137,7 @@ async function readRequestBodyBytesCapped( if (!value || value.byteLength === 0) continue; if (value.byteLength > maxBytes - retainedBytes) { - const error = new DecompressedBodyTooLargeError(retainedBytes + value.byteLength, maxBytes); + const error = new DecompressedBodyTooLargeError(retainedBytes + value.byteLength, maxBytes, "observed_wire_lower_bound"); cancel(error); throw error; } @@ -173,7 +198,8 @@ export function decodeRequestBody( else throw new UnsupportedContentEncodingError(encoding); } catch (err) { if ((err as NodeJS.ErrnoException | null)?.code === "ERR_BUFFER_TOO_LARGE") { - throw new DecompressedBodyTooLargeError(maxBytes + 1, maxBytes); + // Inflation stopped at the cap; the full decoded size was never measured. + throw new DecompressedBodyTooLargeError(maxBytes + 1, maxBytes, "decoded_lower_bound"); } throw err; } @@ -198,7 +224,7 @@ export async function readBoundedJsonRequestBody( // Reject an honest oversized declaration before reading. Missing, malformed, // and dishonest declarations remain bounded by the streaming reader below. if (declaredLength !== null && declaredLength > maxBytes) { - const error = new DecompressedBodyTooLargeError(declaredLength, maxBytes); + const error = new DecompressedBodyTooLargeError(declaredLength, maxBytes, "declared_wire"); cancelStreamWithoutWaiting(req.body, error); throw error; } @@ -211,7 +237,7 @@ export async function readBoundedJsonRequestBody( } finally { releaseReservation?.(); } - assertBodySizeWithinLimit(raw, maxBytes); + assertBodySizeWithinLimit(raw, maxBytes, "observed_wire_lower_bound"); const releaseRaw = budget?.observeAcceptedRequestCopy(raw.byteLength); let releaseDecoded: (() => void) | undefined; let releaseText: (() => void) | undefined; diff --git a/tests/usage/request-decompress.test.ts b/tests/usage/request-decompress.test.ts index 7a536600cc..96a8f5a67a 100644 --- a/tests/usage/request-decompress.test.ts +++ b/tests/usage/request-decompress.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { deflateRawSync, deflateSync } from "node:zlib"; import { DecompressedBodyTooLargeError, decodeRequestBody, @@ -9,11 +10,35 @@ import { } from "../../src/server/request-decompress"; import { MANAGEMENT_JSON_BODY_MAX_BYTES } from "../../src/server/management/body"; import { handleManagementAPI } from "../../src/server/management-api"; +import { decodeRequestErrorResponse } from "../../src/server/responses/core"; import type { OcxConfig } from "../../src/types"; const PAYLOAD = { model: "gpt-5.5", input: "hello", stream: true }; const PAYLOAD_BYTES = new TextEncoder().encode(JSON.stringify(PAYLOAD)); +async function captureBodyTooLarge(run: () => unknown): Promise { + try { + await run(); + } catch (error) { + if (!(error instanceof DecompressedBodyTooLargeError)) throw error; + return error; + } + throw new Error("Expected body admission to reject"); +} + +async function expectBodyLimitResponse(error: DecompressedBodyTooLargeError, message: string): Promise { + expect(error.message).toBe(message); + expect(message.length).toBeLessThan(200); + for (const label of ["responses", "responses-compact"]) { + const response = decodeRequestErrorResponse(error, label); + expect(response.status).toBe(413); + expect(response.headers.get("retry-after")).toBeNull(); + expect(await response.json()).toEqual({ + error: { message, type: "invalid_request_error", code: "invalid_request_error" }, + }); + } +} + interface TrackedBodyStats { pulls: number; cancelled: number; @@ -48,6 +73,36 @@ function trackedBodyStream( return { body, stats }; } +describe("DecompressedBodyTooLargeError", () => { + test("preserves one- and two-argument constructors without guessing measurement provenance", async () => { + const legacy = new DecompressedBodyTooLargeError(268435457); + expect(legacy).toMatchObject({ bytes: 268435457, limit: 268435456, measurement: null }); + await expectBodyLimitResponse(legacy, "Decompressed request body exceeds 268435456 bytes"); + const custom = new DecompressedBodyTooLargeError(6, 5); + expect(custom).toMatchObject({ bytes: 6, limit: 5, measurement: null }); + await expectBodyLimitResponse(custom, "Decompressed request body exceeds 5 bytes"); + }); + + test("keeps untyped categories and non-finite numbers out of the message", async () => { + const untyped: DecompressedBodyTooLargeError = Reflect.construct(DecompressedBodyTooLargeError, [ + 6, 5, "private-header-context window".repeat(100), + ]); + expect(untyped.measurement).toBeNull(); + await expectBodyLimitResponse(untyped, "Decompressed request body exceeds 5 bytes"); + for (const bytes of [NaN, Infinity, -Infinity, -1]) { + const error = new DecompressedBodyTooLargeError(bytes, 5, "declared_wire"); + await expectBodyLimitResponse(error, "Decompressed request body exceeds 5 bytes"); + } + for (const limit of [NaN, Infinity, -Infinity]) { + const error = new DecompressedBodyTooLargeError(6, limit, "declared_wire"); + await expectBodyLimitResponse(error, "Decompressed request body exceeds unknown bytes"); + } + const huge = new DecompressedBodyTooLargeError(Number.MAX_VALUE, 5, "declared_wire"); + await expectBodyLimitResponse(huge, + "Decompressed request body exceeds 5 bytes [measurement=declared_wire; bytes=1.7976931348623157e+308]"); + }); +}); + describe("decodeRequestBody", () => { test("passes identity and absent encodings through untouched", () => { expect(decodeRequestBody(PAYLOAD_BYTES, null)).toBe(PAYLOAD_BYTES); @@ -78,10 +133,11 @@ describe("decodeRequestBody", () => { expect(new TextDecoder().decode(decodeRequestBody(compressed, "x-gzip"))).toBe(JSON.stringify(PAYLOAD)); }); - test("round-trips deflate", () => { - const compressed = Bun.deflateSync(PAYLOAD_BYTES); - expect(new TextDecoder().decode(decodeRequestBody(compressed, "deflate"))).toBe(JSON.stringify(PAYLOAD)); - }); + for (const [label, compress] of [["wrapped", deflateSync], ["raw", deflateRawSync], ["Bun raw", Bun.deflateSync]] as const) { + test(`round-trips ${label} deflate`, () => { + expect(new TextDecoder().decode(decodeRequestBody(compress(PAYLOAD_BYTES), "deflate"))).toBe(JSON.stringify(PAYLOAD)); + }); + } test("is case/whitespace tolerant on the encoding token", () => { const compressed = Bun.zstdCompressSync(PAYLOAD_BYTES); @@ -104,15 +160,39 @@ describe("decodeRequestBody", () => { expect(() => decodeRequestBody(compressed, "zstd")).toThrow(DecompressedBodyTooLargeError); }); - test("aborts DURING inflation via maxOutputLength — activation per codec (injected cap)", () => { + test("reports exact identity size at the decoder boundary", async () => { + for (const encoding of [null, "", "identity"]) { + const error = await captureBodyTooLarge(() => decodeRequestBody(Uint8Array.of(1, 2, 3, 4, 5, 6), encoding, 5)); + expect(error).toMatchObject({ bytes: 6, limit: 5, measurement: "decoded_exact" }); + await expectBodyLimitResponse(error, "Decompressed request body exceeds 5 bytes [measurement=decoded_exact; bytes=6]"); + } + }); + + test("aborts DURING inflation and reports only a decoded lower bound for every codec", async () => { // Review finding (PR #96): the cap must fire inside zlib, not after full allocation. // A small injected cap keeps the test cheap while exercising the exact // ERR_BUFFER_TOO_LARGE -> DecompressedBodyTooLargeError path. const CAP = 1024; const inflates64k = new Uint8Array(64 * 1024); - expect(() => decodeRequestBody(Bun.zstdCompressSync(inflates64k), "zstd", CAP)).toThrow(DecompressedBodyTooLargeError); - expect(() => decodeRequestBody(Bun.gzipSync(inflates64k), "gzip", CAP)).toThrow(DecompressedBodyTooLargeError); - expect(() => decodeRequestBody(Bun.deflateSync(inflates64k), "deflate", CAP)).toThrow(DecompressedBodyTooLargeError); + for (const [encoding, compressed] of [ + ["zstd", Bun.zstdCompressSync(inflates64k)], + ["gzip", Bun.gzipSync(inflates64k)], + ["x-gzip", Bun.gzipSync(inflates64k)], + ["deflate", deflateSync(inflates64k)], + ["deflate", deflateRawSync(inflates64k)], + ["deflate", Bun.deflateSync(inflates64k)], + ] as const) { + expect(compressed.byteLength).toBeLessThan(CAP); + // Exercise the streaming reader too: these invalid-JSON bytes must be + // rejected by inflation before text decoding or JSON parsing. + const req = new Request("http://localhost/v1/responses/compact", { + method: "POST", headers: { "content-encoding": encoding }, body: compressed, + }); + const error = await captureBodyTooLarge(() => readBoundedJsonRequestBody(req, CAP)); + expect(error).toMatchObject({ bytes: 1025, limit: 1024, measurement: "decoded_lower_bound" }); + await expectBodyLimitResponse(error, + "Decompressed request body exceeds 1024 bytes [measurement=decoded_lower_bound; bytes=1025]"); + } }); test("injected cap still admits bodies within the limit", () => { @@ -134,6 +214,20 @@ describe("decodeRequestBody", () => { }); describe("readJsonRequestBody", () => { + test("reports a compressed declaration without reading or echoing request metadata", async () => { + const { body, stats } = trackedBodyStream([Bun.gzipSync(PAYLOAD_BYTES)]); + const req = new Request("http://localhost/v1/responses/compact?private-query", { + method: "POST", + headers: { "content-length": "00001025", "content-encoding": "gzip", "x-private-marker": "private-header" }, + body, + }); + const error = await captureBodyTooLarge(() => readBoundedJsonRequestBody(req, 1024)); + expect(error).toMatchObject({ bytes: 1025, limit: 1024, measurement: "declared_wire" }); + await expectBodyLimitResponse(error, + "Decompressed request body exceeds 1024 bytes [measurement=declared_wire; bytes=1025]"); + expect(stats).toEqual({ pulls: 0, cancelled: 1, sentinelPulled: false }); + }); + test("rejects and cancels declared over-cap bodies before reading", async () => { const { body, stats } = trackedBodyStream([PAYLOAD_BYTES]); const req = new Request("http://localhost/v1/responses", { @@ -142,7 +236,10 @@ describe("readJsonRequestBody", () => { body, }); - await expect(readJsonRequestBody(req)).rejects.toBeInstanceOf(DecompressedBodyTooLargeError); + const error = await captureBodyTooLarge(() => readJsonRequestBody(req)); + expect(error).toMatchObject({ bytes: 268435457, limit: 268435456, measurement: "declared_wire" }); + await expectBodyLimitResponse(error, + "Decompressed request body exceeds 268435456 bytes [measurement=declared_wire; bytes=268435457]"); expect(stats.pulls).toBe(0); expect(stats.cancelled).toBe(1); }); @@ -160,8 +257,10 @@ describe("readJsonRequestBody", () => { ], { sentinel }); const req = new Request("http://localhost/api/optional", { method: "POST", headers, body }); - await expect(readBoundedJsonRequestBody(req, 5, undefined, { emptyBodyFallback: {} })) - .rejects.toBeInstanceOf(DecompressedBodyTooLargeError); + const error = await captureBodyTooLarge(() => readBoundedJsonRequestBody(req, 5, undefined, { emptyBodyFallback: {} })); + expect(error).toMatchObject({ bytes: 6, limit: 5, measurement: "observed_wire_lower_bound" }); + await expectBodyLimitResponse(error, + "Decompressed request body exceeds 5 bytes [measurement=observed_wire_lower_bound; bytes=6]"); expect(stats).toEqual({ pulls: 2, cancelled: 1, sentinelPulled: false }); }); } @@ -252,8 +351,10 @@ describe("readJsonRequestBody", () => { body: oversizedWireBody, }); expect(req.headers.get("content-length")).toBeNull(); - await expect(readBoundedJsonRequestBody(req, 1024, undefined, { emptyBodyFallback: {} })) - .rejects.toBeInstanceOf(DecompressedBodyTooLargeError); + const error = await captureBodyTooLarge(() => readBoundedJsonRequestBody(req, 1024, undefined, { emptyBodyFallback: {} })); + expect(error).toMatchObject({ bytes: oversizedWireBody.byteLength, limit: 1024, measurement: "observed_wire_lower_bound" }); + await expectBodyLimitResponse(error, + `Decompressed request body exceeds 1024 bytes [measurement=observed_wire_lower_bound; bytes=${oversizedWireBody.byteLength}]`); }); test("parses an uncompressed request without touching arrayBuffer path", async () => { From c7f6ba730f6146ef1b7737157f81311fff304796 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:39:31 +0900 Subject: [PATCH 12/50] feat(providers): carry static BigModel Responses preset from #3641 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Narrowed carry of jamespan’s PR #3641, source origin/axis2-source-3641 at b675d832bff9249701ba8575ad1a23196be8f156. Reconstruct the separate Responses preset using only the two models in the official Codex example: https://docs.bigmodel.cn/cn/coding-plan/tool/codex.md (checked 2026-09-07). Keep liveModels false and preserve custom destinations and Responses reasoning replay. Map exact context windows, effort ladders, max defaults, summary support, and text modalities. Do not carry model-discovery.ts or envelopeKey/idKey; Flash Responses metadata and live discovery remain unverified. Add consumer metadata and custom-transport collision regressions; document the static roster and existing Codex export policy (compatibility ultra on GLM-5.3, omitted default field on Turbo’s empty ladder). Validation: git diff --check passed. Tests, typecheck, lint, and builds intentionally not run per worker scope; parent final CI owns execution. Co-authored-by: jamespan --- .../src/content/docs/fr/guides/providers.md | 3 +- .../src/content/docs/guides/providers.md | 27 +++++- .../src/content/docs/ja/guides/providers.md | 3 +- .../src/content/docs/ko/guides/providers.md | 3 +- .../docs/reference/configuration/providers.md | 6 ++ .../src/content/docs/ru/guides/providers.md | 3 +- .../src/content/docs/tr/guides/providers.md | 3 +- .../content/docs/zh-cn/guides/providers.md | 3 +- .../content/docs/zh-tw/guides/providers.md | 3 +- src/providers/registry.ts | 29 +++++++ .../provider-registry-parity.test.ts | 85 ++++++++++++++++++- 11 files changed, 159 insertions(+), 9 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 988ca03cf7..89b3a7c626 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -312,6 +312,7 @@ promotionnels de Cline ne sont accessibles que dans l'IDE ou la CLI Cline, pas p | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | | Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` | +| [BigModel Coding Plan — Responses (liste statique)](/guides/providers/#bigmodel-coding-plan-over-responses) | `https://open.bigmodel.cn/api/v1` | | Qwen Cloud | Forfait à jetons (par défaut) : `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · Facturation à l'usage : `https://dashscope.aliyuncs.com/compatible-mode/v1` · ou personnalisé | | Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` | | SiliconFlow | `https://api.siliconflow.cn/v1` | @@ -495,7 +496,7 @@ une barre trompeuse. > programmation interactifs. L'automatisation générale par API, les services applicatifs personnalisés et les > traitements par lots non interactifs sont interdits et peuvent entraîner la suspension de la clé du forfait. -> **Deux routes GLM :** `zai` correspond à l'abonnement international Z.AI Coding Plan ; `zhipu-bigmodel` +> **Facturation GLM :** `zai` correspond à l'abonnement international Z.AI Coding Plan ; `zhipu-bigmodel` > correspond au point de terminaison national BigModel de Zhipu, facturé à l'usage. Les hôtes, les clés et la > facturation diffèrent : une clé émise pour l'un ne permet pas de s'authentifier auprès de l'autre. diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 05f586a816..f5c4ddc190 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -367,6 +367,7 @@ free-experimentation model. | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | | Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` | +| BigModel Coding Plan (Responses, static roster) | `https://open.bigmodel.cn/api/v1` | | Qwen Cloud | Token plan (default): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · Pay as you go: `https://dashscope.aliyuncs.com/compatible-mode/v1` · or Custom | | Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` | | SiliconFlow | `https://api.siliconflow.cn/v1` | @@ -582,10 +583,34 @@ negative, or internally inconsistent billing totals produce no report rather tha > interactive coding tools only. General API automation, custom application backends, and > non-interactive batch use are prohibited and may cause the plan key to be suspended. -> **Two GLM routes:** `zai` is the Z.AI international coding-plan subscription; `zhipu-bigmodel` +> **GLM billing routes:** `zai` is the Z.AI international coding-plan subscription; `zhipu-bigmodel` > is Zhipu's domestic BigModel pay-as-you-go endpoint. Different hosts, different keys, different > billing — a key issued for one will not authenticate against the other. +### BigModel Coding Plan over Responses + +Select **Zhipu AI — BigModel Coding Plan (Responses)** (`zhipu-bigmodel-responses`) +for the `openai-responses` endpoint `https://open.bigmodel.cn/api/v1`. This is separate +from `zhipu-bigmodel-coding`, which uses Chat Completions at `/api/coding/paas/v4`. + +The preset uses a **static roster** (`liveModels: false`) taken from the +[official BigModel Codex example](https://docs.bigmodel.cn/cn/coding-plan/tool/codex.md): + +| Model | Context tokens | Upstream selectable effort | Default effort | Reasoning summaries | +| --- | ---: | --- | --- | --- | +| `glm-5.3` | 1,048,576 | `low`, `high`, `max` | `max` | Supported | +| `glm-5-turbo` | 204,800 | None (empty list) | `max` | Supported | + +Both entries declare text input. The default model is `glm-5.3`; Responses reasoning +content is preserved on replay. The existing Codex export adds its compatibility +`ultra` tier to GLM-5.3 and omits Turbo's default-effort field because Turbo has no +selectable ladder; the provider metadata still records `max` for both models. + +The example's `models.json` is a local catalog file, not a documented HTTP model-list +response. This preset does not perform live model discovery. `glm-5.3-flash` is not +seeded here because its exact Responses metadata is not verified. An existing custom +provider with the same name keeps its configured destination and metadata. + ### Multiple API keys Key-based providers can also keep multiple keys. Adding a key through the Providers page stores it diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 33be9fc694..ae692e1e2c 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -216,6 +216,7 @@ Cline IDE/CLI のみで API からは使えません。`minimax/minimax-m2.5` | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | | Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` | +| [BigModel Coding Plan — Responses (静的モデル一覧)](/guides/providers/#bigmodel-coding-plan-over-responses) | `https://open.bigmodel.cn/api/v1` | | Qwen Cloud | トークンプラン(デフォルト): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · 従量課金: `https://dashscope.aliyuncs.com/compatible-mode/v1` · またはカスタム | | Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` | | SiliconFlow | `https://api.siliconflow.cn/v1` | @@ -338,7 +339,7 @@ model ごとに capability が異なるため、provider 全体の parallel tool > コーディングツール専用としています。一般的な API 自動化、カスタムアプリのバックエンド、 > 非対話型バッチ利用は禁止されており、プランキーが停止される場合があります。 -> **GLM の経路は 2 つあります:** `zai` は Z.AI の国際コーディングプラン契約、`zhipu-bigmodel` +> **GLM の課金経路:** `zai` は Z.AI の国際コーディングプラン契約、`zhipu-bigmodel` > は Zhipu の中国国内向け BigModel 従量課金エンドポイントです。ホストもキーも課金も別で、 > 一方で発行したキーはもう一方では認証されません。 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 7781cc4f52..c49ede4ec6 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -216,6 +216,7 @@ Cline IDE/CLI에서만 제공되며 API로는 사용할 수 없습니다. `minim | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | | Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` | +| [BigModel Coding Plan — Responses (정적 모델 목록)](/guides/providers/#bigmodel-coding-plan-over-responses) | `https://open.bigmodel.cn/api/v1` | | Qwen Cloud | Token plan(기본): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · 종량제: `https://dashscope.aliyuncs.com/compatible-mode/v1` · 또는 사용자 지정 | | Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` | | SiliconFlow | `https://api.siliconflow.cn/v1` | @@ -328,7 +329,7 @@ provider 전체 parallel tool call이나 OpenAI `reasoning_effort`를 광고하 > 안내합니다. 일반 API 자동화, 사용자 애플리케이션 백엔드 및 비대화형 일괄 호출은 금지되며 > 플랜 키가 정지될 수 있습니다. -> **GLM 경로는 두 개입니다:** `zai`는 Z.AI 국제 코딩 플랜 구독이고, `zhipu-bigmodel`은 +> **GLM 과금 경로:** `zai`는 Z.AI 국제 코딩 플랜 구독이고, `zhipu-bigmodel`은 > Zhipu의 중국 내수 BigModel 종량제 엔드포인트입니다. 호스트도 키도 과금도 다르며, 한쪽에서 > 발급한 키는 다른 쪽에서 인증되지 않습니다. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 02d5ba4323..85a9ba6903 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -747,6 +747,12 @@ container usually has no unlocked keychain session, so requests would fail close `${ENV_VAR}` reference in the service environment there instead. Env references are left untouched by `store`. +The `zhipu-bigmodel-responses` preset seeds `glm-5.3` and `glm-5-turbo` with +`liveModels: false` for `https://open.bigmodel.cn/api/v1`. Its static roster and +per-model context, effort, and summary metadata come from the +[BigModel Responses guide](/guides/providers/#bigmodel-coding-plan-over-responses). +The official local `models.json` example does not establish a live `/models` API. + With `liveModels: false`, an empty or omitted `models` list seeds the configured `defaultModel` first, followed by `retainModels`; duplicate ids are removed while preserving first occurrence. A nonempty explicit `models` list instead seeds `models` followed by `retainModels`, without diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index b057cf77c6..e680f1bd91 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -229,6 +229,7 @@ opencodex поставляется с 79 встроенными пресетам | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | | Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` | +| [BigModel Coding Plan — Responses (статический список)](/guides/providers/#bigmodel-coding-plan-over-responses) | `https://open.bigmodel.cn/api/v1` | | Qwen Cloud | Token plan (по умолчанию): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · Pay as you go: `https://dashscope.aliyuncs.com/compatible-mode/v1` · или Custom | | Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` | | SiliconFlow | `https://api.siliconflow.cn/v1` | @@ -367,7 +368,7 @@ plan. Ключ создаётся в [дашборде Featherless](https://feat > в интерактивных инструментах программирования. Автоматизация общего API, серверы пользовательских > приложений и неинтерактивные пакетные вызовы запрещены и могут привести к блокировке ключа плана. -> **Два маршрута GLM:** `zai` — это международная подписка Z.AI на coding-план, а `zhipu-bigmodel` — +> **Тарификация GLM:** `zai` — это международная подписка Z.AI на coding-план, а `zhipu-bigmodel` — > внутренняя китайская конечная точка BigModel с оплатой по факту использования. Разные хосты, > разные ключи, разная тарификация: ключ от одного сервиса не подойдёт к другому. diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index c5564e74a1..6ff4f1c038 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -355,6 +355,7 @@ yalnızca Cline IDE/CLI içinde mevcuttur; `minimax/minimax-m2.5` belgelenmiş A | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Kodlama) | `https://api.z.ai/api/coding/paas/v4` | | Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` | +| [BigModel Coding Plan — Responses (statik model listesi)](/guides/providers/#bigmodel-coding-plan-over-responses) | `https://open.bigmodel.cn/api/v1` | | Qwen Cloud | Token planı (varsayılan): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · Kullandıkça öde: `https://dashscope.aliyuncs.com/compatible-mode/v1` · veya Özel | | Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` | | SiliconFlow | `https://api.siliconflow.cn/v1` | @@ -537,7 +538,7 @@ tutarsız faturalandırma toplamları yanıltıcı bir çubuk yerine hiçbir rap > **Tencent Cloud Coding Plan kullanım kısıtlaması:** Tencent bu aboneliği yalnızca etkileşimli kodlama araçları için belgeler. Genel API otomasyonu, özel uygulama arka uçları ve etkileşimsiz toplu kullanım yasaktır ve plan anahtarının askıya alınmasına neden olabilir. -> **İki GLM rotası:** `zai`, Z.AI uluslararası kodlama planı aboneliğidir; `zhipu-bigmodel`, Zhipu'nun yerel BigModel kullandıkça öde uç noktasıdır. Farklı ana bilgisayarlar, farklı anahtarlar, farklı faturalandırma — biri için verilen bir anahtar diğerine karşı kimlik doğrulaması yapmaz. +> **GLM faturalandırma rotaları:** `zai`, Z.AI uluslararası kodlama planı aboneliğidir; `zhipu-bigmodel`, Zhipu'nun yerel BigModel kullandıkça öde uç noktasıdır. Farklı ana bilgisayarlar, farklı anahtarlar, farklı faturalandırma — biri için verilen bir anahtar diğerine karşı kimlik doğrulaması yapmaz. ### Birden fazla API anahtarı diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 3fb72a4e6a..b4010cdae5 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -205,6 +205,7 @@ Cline IDE/CLI 中提供,不能通过 API 使用;`minimax/minimax-m2.5` 是 | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | | 智谱 AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` | +| [BigModel Coding Plan — Responses (静态模型列表)](/guides/providers/#bigmodel-coding-plan-over-responses) | `https://open.bigmodel.cn/api/v1` | | Qwen Cloud | Token plan(默认): `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · 按量付费: `https://dashscope.aliyuncs.com/compatible-mode/v1` · 或自定义 | | 腾讯云 Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` | | SiliconFlow | `https://api.siliconflow.cn/v1` | @@ -316,7 +317,7 @@ Bearer key。公开模型列表只保留同时报告 `model_type: chat` 和 `cha > **腾讯云 Coding Plan 使用限制:**腾讯将此订阅限定为交互式编程工具使用。禁止通用 API > 自动化、自定义应用后端和非交互式批量调用;违规使用可能导致套餐密钥被停用。 -> **两条 GLM 线路:**`zai` 是 Z.AI 的国际 coding plan 订阅,`zhipu-bigmodel` 是智谱国内 +> **GLM 计费线路:**`zai` 是 Z.AI 的国际 coding plan 订阅,`zhipu-bigmodel` 是智谱国内 > BigModel 的按量付费端点。二者主机、密钥与计费均不同,为其中一方签发的密钥无法在另一方通过鉴权。 ### 多个 API 密钥 diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 96cf91ccfb..d26d093b7e 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -273,6 +273,7 @@ IDE/CLI,不透過 API;`minimax/minimax-m2.5` 是文件列出的 API 免費 | NVIDIA NIM | `https://integrate.api.nvidia.com/v1` | | Z.AI (GLM Coding) | `https://api.z.ai/api/coding/paas/v4` | | Zhipu AI (BigModel) | `https://open.bigmodel.cn/api/paas/v4` | +| [BigModel Coding Plan — Responses (靜態模型清單)](/guides/providers/#bigmodel-coding-plan-over-responses) | `https://open.bigmodel.cn/api/v1` | | Qwen Cloud | Token plan(預設):`https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` · pay as you go:`https://dashscope.aliyuncs.com/compatible-mode/v1` · 或 Custom | | Tencent Cloud Coding Plan | `https://api.lkeap.cloud.tencent.com/coding/v3` | | SiliconFlow | `https://api.siliconflow.cn/v1` | @@ -418,7 +419,7 @@ quota probe 只會把 active key 傳送到 canonical A6API host,並拒絕 redi > **Tencent Cloud Coding Plan 使用限制:** Tencent 文件將此訂閱限定為互動式 coding tool。一般 API > automation、自訂 application backend 與非互動 batch 使用都被禁止,並可能造成 plan key 被停用。 -> **兩條 GLM 路徑:** `zai` 是 Z.AI 國際 Coding Plan 訂閱;`zhipu-bigmodel` 是智譜國內 BigModel +> **GLM 計費路徑:** `zai` 是 Z.AI 國際 Coding Plan 訂閱;`zhipu-bigmodel` 是智譜國內 BigModel > pay-as-you-go endpoint。兩者 host、key 與 billing 都不同;其中一邊發出的 key 無法在另一邊通過認證。 ### 多個 API 金鑰 diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 82f70a6a6a..1c3396a48a 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -2544,6 +2544,35 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // yields an empty picker at runtime. note: "Domestic BigModel Coding Plan endpoint (open.bigmodel.cn)", }, + // Narrowed carry of #3641: the official Codex example declares a local static catalog, + // not an HTTP /models contract. Keep Responses separate from the Chat endpoint above. + // Source: https://docs.bigmodel.cn/cn/coding-plan/tool/codex.md (checked 2026-09-07). + { + id: "zhipu-bigmodel-responses", + label: "Zhipu AI — BigModel Coding Plan (Responses)", + baseUrl: "https://open.bigmodel.cn/api/v1", + adapter: "openai-responses", + authKind: "key", + dashboardUrl: "https://bigmodel.cn/console/usercenter/apikeys", + defaultModel: "glm-5.3", + models: ["glm-5.3", "glm-5-turbo"], + liveModels: false, + jawcodeBundle: "zai", + // A pre-existing same-named custom provider must retain its destination and key boundary. + preserveCustomDestination: true, + modelContextWindows: { "glm-5.3": 1_048_576, "glm-5-turbo": 204_800 }, + modelInputModalities: { "glm-5.3": ["text"], "glm-5-turbo": ["text"] }, + modelReasoningEfforts: { + "glm-5.3": ZAI_GLM_53_REASONING_EFFORTS, + // Explicitly empty: Turbo must not inherit the generic selectable effort ladder. + "glm-5-turbo": [], + }, + modelDefaultReasoningEfforts: { "glm-5.3": "max", "glm-5-turbo": "max" }, + modelSupportsReasoningSummaries: { "glm-5.3": true, "glm-5-turbo": true }, + // Responses replay uses this provider-level flag, not the Chat-path model list. + preserveResponsesReasoningContent: true, + note: "Domestic BigModel Coding Plan Responses endpoint; static model roster", + }, { id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://nano-gpt.com/api" }, { id: "synthetic", label: "Synthetic", baseUrl: "https://api.synthetic.new/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://synthetic.new" }, // SiliconFlow publishes an OpenAI-compatible chat endpoint and a dynamic model catalog. Do not diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index be84701e0b..0d935d12a2 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -11,6 +11,7 @@ import { deriveJawcodeAliases, deriveKeyLoginMap, deriveProviderPresets, + enrichProviderFromRegistry, providerConfigSeed, } from "../../src/providers/derive"; import { PROVIDER_REGISTRY } from "../../src/providers/registry"; @@ -33,7 +34,7 @@ function nativeTemplate(): Record { const EXPECTED_KEY_PROVIDER_IDS = [ "anthropic-apikey", "openai-apikey", "meta-model", "umans", "opencode-go", "neuralwatt", "openrouter", "cline-pass", "cline", "orcarouter", "bizrouter", "groq", "google", "google-vertex", "azure-openai", "deepseek", "cerebras", "chutes", "deepinfra", "hyperbolic", "nscale", "vultr", "baseten", "commandcode", "sambanova", "nebius", "digitalocean", "scaleway", "featherless", "novita", "together", "fireworks", "firepass", "moonshot", - "huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "zhipu-bigmodel-coding", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan", + "huggingface", "nvidia", "venice", "zai", "zhipu-bigmodel", "zhipu-bigmodel-coding", "zhipu-bigmodel-responses", "nanogpt", "synthetic", "siliconflow", "qwen-cloud", "tencent-coding-plan", "volcengine", "volcengine-coding-plan", "volcengine-agent-plan", "qianfan", "alibaba", "alibaba-token-plan", "alibaba-token-plan-intl", "parallel", "zenmux", "litellm", "ollama-cloud", "mistral", "minimax", "minimax-cn", "kimi-code", "opencode-zen", "vercel-ai-gateway", "opencode-free", "xiaomi", "xiaomi-mimo", "kilo", "mimo-free", "mimo", "cloudflare-ai-gateway", "cloudflare-workers-ai", "gitlab-duo", @@ -440,6 +441,87 @@ describe("provider registry parity", () => { expect(glm53Entry?.default_reasoning_level).toBe("max"); }); + test("BigModel Responses exports only the officially documented static Codex models", () => { + // Independent oracle: https://docs.bigmodel.cn/cn/coding-plan/tool/codex.md, + // local models.json example checked 2026-09-07; not an authenticated /models response. + const id = "zhipu-bigmodel-responses"; + const registry = PROVIDER_REGISTRY.find(entry => entry.id === id)!; + expect(registry).toMatchObject({ + adapter: "openai-responses", + baseUrl: "https://open.bigmodel.cn/api/v1", + authKind: "key", + defaultModel: "glm-5.3", + models: ["glm-5.3", "glm-5-turbo"], + liveModels: false, + preserveCustomDestination: true, + preserveResponsesReasoningContent: true, + }); + expect(registry.modelDiscovery).toBeUndefined(); + expect(registry.preserveReasoningContentModels).toBeUndefined(); + expect(KEY_LOGIN_PROVIDERS[id]).toMatchObject({ + models: ["glm-5.3", "glm-5-turbo"], liveModels: false, + }); + const provider = providerConfigSeed(registry); + enrichProviderFromRegistry(id, provider); + expect(provider.liveModels).toBe(false); + expect(provider.preserveResponsesReasoningContent).toBe(true); + const models = provider.models!.map(modelId => applyProviderConfigHints(id, provider, { + provider: id, id: modelId, + })); + expect(models).toMatchObject([ + { id: "glm-5.3", contextWindow: 1_048_576, reasoningEfforts: ["low", "high", "max"], + defaultReasoningEffort: "max", supportsReasoningSummaries: true, inputModalities: ["text"] }, + { id: "glm-5-turbo", contextWindow: 204_800, reasoningEfforts: [], + defaultReasoningEffort: "max", supportsReasoningSummaries: true, inputModalities: ["text"] }, + ]); + const entries = buildCatalogEntries(nativeTemplate(), [], models); + for (const [modelId, window, efforts] of [ + ["glm-5.3", 1_048_576, ["low", "high", "max", "ultra"]], + ["glm-5-turbo", 204_800, []], + ] as const) { + const entry = entries.find(row => row.slug === `${id}/${modelId}`); + expect(entry).toMatchObject({ + context_window: window, supports_reasoning_summaries: true, + }); + // Existing export policy adds a compatibility ultra tier and omits the default + // for empty ladders. The provider/CatalogModel defaults above remain official max. + expect(entry?.default_reasoning_level).toBe(modelId === "glm-5-turbo" ? undefined : "max"); + expect((entry?.supported_reasoning_levels as Array<{ effort: string }>).map(row => row.effort)) + .toEqual([...efforts]); + } + expect(entries.some(entry => String(entry.slug).includes("glm-5.3-flash"))).toBe(false); + }); + + test("BigModel Responses name collisions preserve custom transport and metadata", () => { + const id = "zhipu-bigmodel-responses"; + // Exercise both a different destination on the same wire and the canonical URL on + // another wire. Neither may acquire this preset's transport or per-model defaults. + for (const transport of [ + { adapter: "openai-responses", baseUrl: "https://custom.example.test/api/v1" }, + { adapter: "openai-chat", baseUrl: "https://open.bigmodel.cn/api/v1" }, + ]) { + const provider: OcxProviderConfig = { + ...transport, authMode: "key", apiKey: "test-custom-key", liveModels: true, + models: ["glm-5.3"], modelContextWindows: { "glm-5.3": 32_768 }, + modelReasoningEfforts: { "glm-5.3": ["medium"] }, + modelDefaultReasoningEfforts: { "glm-5.3": "medium" }, + modelSupportsReasoningSummaries: { "glm-5.3": false }, + }; + const enriched = structuredClone(provider); + enrichProviderFromRegistry(id, enriched); + expect(enriched).toEqual(provider); + const config: OcxConfig = { port: 10100, defaultProvider: id, providers: { [id]: provider } }; + const routed = routeModel(config, `${id}/glm-5.3`); + expect(routed.provider).toMatchObject(provider); + expect(routed.provider.modelContextWindows).toEqual({ "glm-5.3": 32_768 }); + expect(routed.provider.modelReasoningEfforts).toEqual({ "glm-5.3": ["medium"] }); + expect(routed.provider.modelDefaultReasoningEfforts).toEqual({ "glm-5.3": "medium" }); + expect(routed.provider.modelSupportsReasoningSummaries).toEqual({ "glm-5.3": false }); + expect(routed.provider.preserveResponsesReasoningContent).toBeUndefined(); + expect(routed.modelId).toBe("glm-5.3"); + } + }); + test("Anthropic API-key provider mirrors the OAuth entry's models on the key flow", () => { const anthropicOauth = PROVIDER_REGISTRY.find(entry => entry.id === "anthropic"); expect(KEY_LOGIN_PROVIDERS["anthropic-apikey"]).toMatchObject({ @@ -948,6 +1030,7 @@ describe("provider registry parity", () => { "minimax-cn": "minimax", "zhipu-bigmodel": "zai", "zhipu-bigmodel-coding": "zai", + "zhipu-bigmodel-responses": "zai", }); expect(resolveMetadataProvider("gemini")).toBe("google"); expect(resolveMetadataProvider("minimax-cn")).toBe("minimax"); From 130be8dbd2b52db21f0bcced61259940b4b9246f Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:39:43 +0900 Subject: [PATCH 13/50] feat(catalog): carry native display labels with normalization contract [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry PR #3627 from f699ec7f998d56bf205db96762b821cd8c228a35, using merge base cf9f662190c4c6770697c45c870941509cc98f9c. Keep the source runtime changes and existing Astra normalization intact. Qualify external-name restoration in all four provider docs and catalog SOT. Add Astra external-edit coverage and real retained/convergence writer coverage. Local tests, typecheck and builds NOT RUN by owner mandate. Static diff inspection and git diff --check passed; remote CI belongs to parent. Co-authored-by: Éverton Toffanetto --- .../ja/reference/configuration/providers.md | 8 + .../ko/reference/configuration/providers.md | 8 + .../docs/reference/configuration/providers.md | 10 + .../reference/configuration/providers.md | 8 + src/codex/catalog/sync.ts | 46 +++- src/codex/convergence.ts | 7 + structure/03_catalog-and-subagents.md | 12 + tests/codex-integration/codex-catalog.test.ts | 210 +++++++++++++++++- 8 files changed, 306 insertions(+), 3 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index d4bfc49a6f..ddbb22d666 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -370,6 +370,14 @@ Vercel AI Gateway は、1 つのモデルを複数の基盤となる推論プロ 表示名には `modelDisplayNames` を使用します。優先順位は、運用者が設定した `modelDisplayNames`、プロバイダーカタログのメタデータ、通常の `provider/model` 表示の順です。キーはこのプロバイダー内の正確なネイティブモデル ID です。例えば `xai/grok-4.6` のキーは `grok-4.6` です。ラベルは表示専用で、正確なルーティング ID や上流モデル ID を変更しません。`config.json` の既存プロバイダー設定にこのフィールドだけを追加し、他のすべてのフィールドを残してください。`PUT /api/providers/:provider/model-display-names` に `{ "modelId": "grok-4.6", "displayName": "Grok 4.6" }` を送ると保存され、`displayName: null` を送るとその名前だけがリセットされます。 +ローカル Codex カタログでサポートされるプレフィックスなしのネイティブ GPT 行にも、 +`providers.openai.modelDisplayNames` で正確な表示名を指定できます。例えば `"gpt-6-astra": "GPT 6 Astra"` です。 +起動時の同期とローカルカタログの収束処理は、どちらもこれらの名前を再適用します。名前の設定を削除すると、行の現在の表示名が +適用済みの上書きとまだ一致する場合にのみ、元のネイティブ名が復元されます。外部で変更された表示名にも既存のネイティブメタデータ正規化が適用されます。 +例えば Astra (`gpt-6-astra`) では、固定されたネイティブ名と異なる名前は引き続きその固定名に置き換えられます。 +表示名の上書きによってモデル ID、メタデータ(機能を含む)、順序、ルーティングされたコンボのエイリアス、アカウント修飾付きの行は変更されません。 +このローカルカタログの上書きは、HTTP のモデル一覧や仮想 `*-pro` 行の表示名には適用されません。 + プレビュー GPT-5.6 フォールバック エントリは同じメカニズムを使用します。 OpenAI API キー プリセットは、ベース ID と Pro ID にコンテキスト `922000` と最大入力 `922000` をシードします。 OpenRouter は、コンテキスト `922000` を持つ `openai/gpt-5.6-sol`、`openai/gpt-5.6-terra`、および `openai/gpt-5.6-luna` をシードします。プール/ダイレクトは `922000` をアドバタイズします。同期されたカタログは、`xhigh` を区別しつつ、`max` をアドバタイズします。 ```json diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index c9c4de5ede..160d313be3 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -377,6 +377,14 @@ Vercel AI Gateway는 하나의 모델을 여러 기반 추론 공급자에 걸 표시 이름은 `modelDisplayNames`로 설정합니다. 우선순위는 운영자가 설정한 `modelDisplayNames`, 공급자 카탈로그 메타데이터, 일반 `provider/model` 표시 순서입니다. 키는 이 공급자 안의 정확한 네이티브 모델 id입니다. 예를 들어 `xai/grok-4.6`의 키는 `grok-4.6`입니다. 이름은 표시 전용이며 정확한 라우팅 id나 업스트림 모델 id를 바꾸지 않습니다. `config.json`의 기존 공급자 설정에 이 필드만 추가하고 다른 모든 필드는 유지하세요. `PUT /api/providers/:provider/model-display-names`에 `{ "modelId": "grok-4.6", "displayName": "Grok 4.6" }`를 보내 저장하고, `displayName: null`을 보내 해당 이름만 초기화합니다. +로컬 Codex 카탈로그에서 지원되는 접두사 없는 네이티브 GPT 항목에도 +`providers.openai.modelDisplayNames`로 정확한 표시 이름을 지정할 수 있습니다. 예를 들어 `"gpt-6-astra": "GPT 6 Astra"`를 사용합니다. +시작 시 동기화와 로컬 카탈로그 수렴은 모두 이 이름을 다시 적용합니다. 이름 설정을 삭제하면 항목의 현재 표시 이름이 +적용된 재정의와 여전히 일치할 때만 원래 네이티브 이름을 복원합니다. 외부에서 변경된 표시 이름도 기존 네이티브 메타데이터 정규화 규칙을 따릅니다. +예를 들어 Astra (`gpt-6-astra`)는 고정된 네이티브 이름과 다른 이름을 여전히 그 고정 이름으로 교체합니다. +표시 이름 재정의는 모델 ID, 기능을 포함한 메타데이터, 정렬 순서, 라우팅된 콤보 별칭 및 계정 선택자가 붙은 항목을 바꾸지 않습니다. +이 로컬 카탈로그 재정의는 HTTP 모델 목록이나 가상 `*-pro` 항목의 이름을 바꾸지 않습니다. + 프리뷰 GPT-5.6 폴백 항목도 같은 메커니즘을 사용합니다. OpenAI API 키 프리셋은 base와 Pro id에 컨텍스트 `922000`, 최대 입력 `922000`을 채웁니다. OpenRouter는 `openai/gpt-5.6-sol`, `openai/gpt-5.6-terra`, `openai/gpt-5.6-luna`에 컨텍스트 `922000`을 채웁니다. Pool/Direct는 `922000`을 노출하고, 동기화된 카탈로그는 `xhigh`를 구분한 채 `max`를 노출합니다. ```json diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 02d5ba4323..9bd558be44 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -230,6 +230,16 @@ all other provider settings. The example includes the surrounding required field } ``` +Supported bare native GPT rows in the local Codex catalog also accept exact labels in +`providers.openai.modelDisplayNames`, for example `"gpt-6-astra": "GPT 6 Astra"`. +Both startup synchronization and local catalog convergence reapply these labels. Removing a label +restores the original native name only when the row's display name still matches the applied +override. A newer external display name is preserved subject to existing native metadata normalization; +for example, Astra (`gpt-6-astra`) still replaces a non-pinned name with its pinned native name. +The label overlay leaves model IDs, metadata (including capabilities), ordering, +routed combo aliases, and account-qualified rows unchanged. This local catalog override does +not relabel the HTTP model listings or virtual `*-pro` rows. + The effective label order is operator `modelDisplayNames`, then provider catalog metadata, then the normal `provider/model` fallback. The routed selector remains `xai/grok-4.6`, while the upstream wire model remains `grok-4.6`. Labels are display only. They do not change authentication, adapter diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index b7339cd4c2..f121d67bc0 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -371,6 +371,14 @@ Vercel AI Gateway 可以在多个底层推理提供者之间路由一个模型 请使用 `modelDisplayNames` 设置显示名称。优先顺序是操作者设置的 `modelDisplayNames`、提供者目录元数据,然后是普通的 `provider/model` 显示。键是此提供者内精确的原生模型 id,例如 `xai/grok-4.6` 的键是 `grok-4.6`。名称只改变显示,不会改变精确路由 id 或上游模型 id。请只把此字段加入 `config.json` 中现有的提供者设置,并保留所有其他字段。向 `PUT /api/providers/:provider/model-display-names` 发送 `{ "modelId": "grok-4.6", "displayName": "Grok 4.6" }` 可保存名称,发送 `displayName: null` 只重置该名称。 +本地 Codex 目录中受支持的不带前缀的原生 GPT 条目也可以通过 +`providers.openai.modelDisplayNames` 设置精确的显示名称, 例如 `"gpt-6-astra": "GPT 6 Astra"`。 +启动时同步和本地目录收敛都会重新应用这些名称。删除名称设置时, 只有条目的当前显示名称仍与已应用的覆盖值一致, +才会恢复原始原生名称。外部更改的显示名称仍受现有原生元数据规范化规则约束。 +例如,Astra (`gpt-6-astra`) 仍会将不同于固定原生名称的名称替换为该固定名称。 +显示名称覆盖不会改变模型 ID、元数据(包括能力)、排序、路由组合别名和带账户限定的条目。 +此本地目录覆盖不会重命名 HTTP 模型列表中的条目或虚拟 `*-pro` 条目。 + 预览版 GPT-5.6 回退条目使用相同机制。OpenAI API key 预设会为基础和 Pro id 设定 `922000` 上下文和 `922000` 最大输入;OpenRouter 会为 `openai/gpt-5.6-sol`、`openai/gpt-5.6-terra` 和 `openai/gpt-5.6-luna` 设定 `922000` 上下文。Pool/Direct 会声明 `922000`;同步后的目录会声明 `max`,同时保留 `xhigh` 的独立性。 ```json diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 22d235dbcc..972b6d74c6 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -307,6 +307,11 @@ function routedDisplayName(slug: string, model?: CatalogModel, config?: Pick; + if (recoverableNativeSlug(entry) === label.slug + && typeof label.original === "string" && entry.display_name === label.applied) { + entry.display_name = label.original; + } + } + return entry; +} + /** Append missing supported native rows from trusted catalog sources only. */ export function mergeCatalogModelsWithNativeRecovery( primaryCatalogModels: readonly RawEntry[], @@ -862,6 +882,8 @@ export interface ObservedCatalogMergeInput { readonly suppressedBareNativeSlugs?: ReadonlySet; readonly policy: ObservedCatalogMergePolicy; readonly openaiContextCap?: NativeContextLimitsInput; + /** Exact display-only labels for bare native OpenAI models. */ + readonly nativeDisplayNames?: Readonly>; } /** @@ -896,12 +918,14 @@ export function mergeCatalogEntriesFromObservedState({ suppressedBareNativeSlugs = new Set(), policy, openaiContextCap, + nativeDisplayNames, }: ObservedCatalogMergeInput): RawEntry[] { // Raw catalog rows contain nested arrays/objects that normalization mutates. Detach every row at // the observed-core boundary so callers can safely retain evidence objects or repeat the merge. - const detachedCatalogModels = catalogModels.map(entry => structuredClone(entry) as RawEntry); + const detachedCatalogModels = catalogModels + .map(entry => restoreNativeDisplayName(structuredClone(entry) as RawEntry)); const detachedBaselineCatalogModels = baselineCatalogModels - .map(entry => structuredClone(entry) as RawEntry); + .map(entry => restoreNativeDisplayName(structuredClone(entry) as RawEntry)); const detachedRoutedEntries = routedEntries.map(entry => structuredClone(entry) as RawEntry); // Track this invocation's generated custom rows, not ownership markers read from disk. // Their builder already finalized exact native ladders and ordinary routed mock tiers. @@ -1256,6 +1280,17 @@ export function mergeCatalogEntriesFromObservedState({ ); applyFullModelPickerOrder(versionedEntries, modelPickerOrder); for (const entry of versionedEntries) { + // Templates and account clones must not inherit the native row's overlay marker. + delete entry.opencodex_native_display_name; + const slug = recoverableNativeSlug(entry); + if (slug !== null) { + const label = nativeDisplayNames && Object.hasOwn(nativeDisplayNames, slug) + ? nativeDisplayNames[slug]?.trim() : undefined; + if (label && label !== entry.display_name) { + entry.opencodex_native_display_name = { slug, original: entry.display_name, applied: label }; + entry.display_name = label; + } + } const kind = entry.opencodex_catalog_kind; if (trustedAccountBoundNativeCatalogSlug(entry) === undefined && kind !== CODEX_CUSTOM_MODEL_CATALOG_KIND @@ -1659,6 +1694,12 @@ export function finalizeAutoReviewModelOverride( return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels); } +/** + * Mescla o catálogo retido com os modelos visíveis e as configurações atuais, + * incluindo os nomes nativos. Tenta preservar o backup original e usa a permissão + * de escrita para publicar o resultado apenas se os bytes mudarem, retornando + * a contagem de entradas roteadas e por conta, o caminho e o estado da gravação. + */ function writeRetainedCatalogSync({ config, goModels, @@ -1880,6 +1921,7 @@ function writeRetainedCatalogSync({ accountBoundEntries, suppressedBareNativeSlugs, openaiContextCap, + nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs], diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 8b30bb9eb2..2b8a8512c6 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -226,6 +226,12 @@ function bindGatherPaths( }; } +/** + * Prepara um candidato de catálogo para convergência sem gravá-lo em disco. + * Clona a fonte e mescla as observações nativas, os modelos roteados e por conta, + * aplicando a configuração, inclusive nomes nativos, e os limites de raciocínio + * observados no runtime antes de retornar o catálogo resultante. + */ function prepareCatalog( config: Readonly, source: Extract, @@ -366,6 +372,7 @@ function prepareCatalog( accountBoundEntries, suppressedBareNativeSlugs, openaiContextCap, + nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs], diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index f9f46a0653..86aa4a81a7 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -145,6 +145,18 @@ then trusted catalog metadata such as a configured qualified provider/model alia This overlay never changes route identity or the upstream wire model, and its catalog fingerprint makes a label edit refresh Codex output. +Supported bare native GPT rows also consume `providers.openai.modelDisplayNames`. Retained sync +and convergence pass the same map to the observed-state merge. After native normalization and +ordering, the merge applies the exact nonblank trimmed label and saves +`opencodex_native_display_name: { slug, original, applied }` in the local catalog only. The next +merge detaches its inputs, removes that marker, and restores `original` only if the native slug +still matches and the current name equals `applied`. Removing or blanking the override therefore +restores the owned name before normal native metadata upgrades. Divergent external names remain +subject to those upgrades: Astra still replaces non-pinned names with its pinned native name. +Template-derived rows discard the marker. The overlay leaves model IDs, metadata (including +capabilities), ordering, routed combo aliases, custom rows and account-qualified rows unchanged; +it does not relabel HTTP model listings or virtual `*-pro` rows. + ## Native passthrough Astra has its own pinned native row: 272,000 default context, 872,000 opt-in ceiling, diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 5820edc6dd..bb3c8a880f 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, mkdtempSync, readFileSync} from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { codexAccountGatedCanonicalWireModel } from "../../src/server/responses/core"; @@ -53,9 +53,17 @@ import { import { CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, mergeCatalogEntriesFromObservedState, + syncCatalogModels, type ObservedCatalogMergeInput, } from "../../src/codex/catalog/sync"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { saveConfig } from "../../src/config"; +import { SUBAGENT_MODELS_VERSION } from "../../src/config/subagent-models"; +import { captureCatalogAdmissionSnapshot } from "../../src/codex/catalog-admission"; +import { convergeCodexCatalog } from "../../src/codex/convergence"; +import { resetCodexRuntimeResolveCacheForTests } from "../../src/codex/runtime"; +import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity } from "../../src/codex/user-identity"; +import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; const originalFetch = globalThis.fetch; @@ -3045,7 +3053,207 @@ function mergeObservedForTest( }); } +// Exercise both production callers: removing either caller's nativeDisplayNames argument +// must fail the persisted-label assertion, even if the pure merge tests still pass. +test.each(["retained", "convergence"] as const)("%s persists and restores native labels through the catalog writer", async writer => { + const envKeys = ["CODEX_HOME", "OPENCODEX_HOME", "CODEX_CLI_PATH"] as const; + const previousEnv = envKeys.map(key => process.env[key]); + const previousFetch = globalThis.fetch; + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-native-label-writer-"))); + const codexHome = join(root, "codex"); + const catalogPath = join(codexHome, "custom-catalog.json"); + let fetchCalls = 0; + try { + mkdirSync(codexHome); + mkdirSync(join(root, "ocx")); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = join(root, "ocx"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "custom-catalog.json"\n'); + const catalog = { models: [{ ...nativeTemplate(), slug: "gpt-5.6-sol", display_name: "Fixture Sol" }] }; + // Reuse the executable-fixture protocol from catalog-full-picker-order.test.ts so + // admission and a forced runtime refresh observe the same version and bundled rows. + const script = join(root, "fixture-codex.js"); + writeFileSync(script, [ + 'if (process.argv.includes("--version")) console.log("codex-cli 0.145.0");', + `else process.stdout.write(${JSON.stringify(JSON.stringify(catalog))});`, + ].join("\n")); + if (process.platform === "win32") { + process.env.CODEX_CLI_PATH = join(root, "fixture-codex.cmd"); + writeFileSync(process.env.CODEX_CLI_PATH, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`); + } else { + process.env.CODEX_CLI_PATH = join(root, "fixture-codex"); + const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; + writeFileSync(process.env.CODEX_CLI_PATH, `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(script)} "$@"\n`); + chmodSync(process.env.CODEX_CLI_PATH, 0o755); + } + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + resetCodexModelEntitlementCacheForTests(); + expect(loadBundledCodexCatalog()?.models?.[0]?.slug).toBe("gpt-5.6-sol"); + writeFileSync(catalogPath, JSON.stringify(catalog)); + globalThis.fetch = (async () => { + fetchCalls += 1; + throw new Error("native label writer fixture must not make a network request"); + }) as typeof fetch; + const config: OcxConfig = { + port: 10100, defaultProvider: "openai", + subagentModels: [], subagentModelsVersion: SUBAGENT_MODELS_VERSION, + providers: { + openai: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward" }, + }, + }; + const write = async (labels?: Record) => { + if (labels) config.providers.openai!.modelDisplayNames = labels; + else delete config.providers.openai!.modelDisplayNames; + saveConfig(config); + if (writer === "convergence") { + const result = await convergeCodexCatalog(captureCatalogAdmissionSnapshot(config), { + action: "converge", scope: "catalog", reason: "management-mutation", mode: "explicit", deadlineMs: 5_000, + }); + expect(result.catalogRefresh.status).toBe("committed"); + } else { + const result = await syncCatalogModels(config); + expect(result.path).toBe(catalogPath); + expect(result.skippedReason).toBeUndefined(); + } + return (JSON.parse(readFileSync(catalogPath, "utf8")) as { models: Record[] }).models; + }; + const original = await write(); + const renamed = await write({ "gpt-5.6-sol": "Custom Sol" }); + const renamedBytes = readFileSync(catalogPath, "utf8"); + const native = renamed.find(row => row.slug === "gpt-5.6-sol")!; + expect(native.display_name).toBe("Custom Sol"); + expect(native.opencodex_native_display_name).toEqual({ + slug: "gpt-5.6-sol", original: "Fixture Sol", applied: "Custom Sol", + }); + const { opencodex_native_display_name: marker, ...withoutMarker } = native; + expect(marker).toBeDefined(); + expect({ ...withoutMarker, display_name: "Fixture Sol" }) + .toEqual(original.find(row => row.slug === "gpt-5.6-sol")!); + expect(await write({ "gpt-5.6-sol": "Custom Sol" })).toEqual(renamed); + expect(readFileSync(catalogPath, "utf8")).toBe(renamedBytes); + expect((await write({ "gpt-5.6-sol": "Changed Sol" })).find(row => row.slug === "gpt-5.6-sol")?.display_name) + .toBe("Changed Sol"); + expect(await write()).toEqual(original); + expect(fetchCalls).toBe(0); + } finally { + try { + const database = resolveCodexCatalogSerializationDatabasePath(resolveEffectiveUserIdentity(), codexHome); + for (const suffix of ["", "-journal", "-wal", "-shm"]) rmSync(`${database}${suffix}`, { force: true }); + } finally { + globalThis.fetch = previousFetch; + envKeys.forEach((key, index) => { + const value = previousEnv[index]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + }); + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + resetCodexModelEntitlementCacheForTests(); + removeTreeWithRetry(root); + } + } +}, 30_000); + describe("Codex catalog routed normalization", () => { + test("reapplies native display names after repeated catalog merges without changing model metadata", () => { + const input = { + catalogModels: [{ ...nativeTemplate(), slug: "gpt-5.6-sol" }], + routedEntries: [], + }; + const original = mergeObservedForTest(input); + const labels = { "gpt-5.6-sol": "GPT 5.6 Sol" }; + const renamed = mergeObservedForTest({ ...input, nativeDisplayNames: labels }); + const row = renamed.find(entry => entry.slug === "gpt-5.6-sol")!; + expect(row.display_name).toBe("GPT 5.6 Sol"); + expect({ ...row, display_name: undefined, opencodex_native_display_name: undefined }).toEqual({ + ...original.find(entry => entry.slug === "gpt-5.6-sol"), display_name: undefined, + }); + const regenerated = mergeObservedForTest({ + ...input, catalogModels: renamed, nativeDisplayNames: labels, + }); + expect(regenerated.find(entry => entry.slug === "gpt-5.6-sol")?.display_name).toBe("GPT 5.6 Sol"); + const changed = mergeObservedForTest({ + ...input, catalogModels: regenerated, + nativeDisplayNames: { "gpt-5.6-sol": " Sol 5.6 " }, + }); + expect(changed.find(entry => entry.slug === "gpt-5.6-sol")?.display_name).toBe("Sol 5.6"); + expect(JSON.stringify(regenerated)).toBe(JSON.stringify(renamed)); + for (const nativeDisplayNames of [undefined, {}, { "gpt-5.6-sol": " " }]) { + const restored = mergeObservedForTest({ ...input, catalogModels: changed, nativeDisplayNames }); + expect(restored).toEqual(original); + } + }); + + test("native display names preserve external label changes when clearing the overlay", () => { + const renamed = mergeObservedForTest({ + catalogModels: [{ ...nativeTemplate(), slug: "gpt-5.6-sol" }], routedEntries: [], + nativeDisplayNames: { "gpt-5.6-sol": "Custom Sol" }, + }); + renamed.find(entry => entry.slug === "gpt-5.6-sol")!.display_name = "Updated upstream Sol"; + const restored = mergeObservedForTest({ catalogModels: renamed, routedEntries: [] }); + const row = restored.find(entry => entry.slug === "gpt-5.6-sol")!; + expect(row.display_name).toBe("Updated upstream Sol"); + expect(row.opencodex_native_display_name).toBeUndefined(); + }); + + test("native display names preserve pinned metadata upgrades and restore pinned names", () => { + for (const slug of ["gpt-5.6-sol", "gpt-6-astra"]) { + const input = { catalogModels: [{ ...nativeTemplate(), slug, display_name: slug }], routedEntries: [] }; + const original = mergeObservedForTest(input); + const renamed = mergeObservedForTest({ ...input, nativeDisplayNames: { [slug]: "Custom name" } }); + expect(renamed.find(entry => entry.slug === slug)?.display_name).toBe("Custom name"); + expect(mergeObservedForTest({ catalogModels: renamed, routedEntries: [] })).toEqual(original); + } + }); + + test("clearing a native label keeps Astra external edits subject to pinned metadata normalization", () => { + const original = mergeObservedForTest({ + catalogModels: [{ ...nativeTemplate(), slug: "gpt-6-astra", display_name: "gpt-6-astra" }], + routedEntries: [], + }); + const renamed = mergeObservedForTest({ + catalogModels: original, routedEntries: [], + nativeDisplayNames: { "gpt-6-astra": "Custom Astra" }, + }); + const external = JSON.parse(JSON.stringify(renamed)) as Record[]; + const astra = external.find(entry => entry.slug === "gpt-6-astra")!; + astra.display_name = "External Astra name"; + astra.context_window = 123; + const restored = mergeObservedForTest({ catalogModels: external, routedEntries: [] }); + const row = restored.find(entry => entry.slug === "gpt-6-astra")!; + expect(row).toEqual(original.find(entry => entry.slug === "gpt-6-astra")!); + expect(row.display_name).not.toBe("External Astra name"); + expect(row.context_window).toBe(272_000); + expect(row.opencodex_native_display_name).toBeUndefined(); + expect(astra.display_name).toBe("External Astra name"); + expect(astra.opencodex_native_display_name).toBeDefined(); + }); + + test("native display names do not leak overlay markers through catalog templates", () => { + const template = { + ...nativeTemplate(), + opencodex_native_display_name: { slug: "gpt-5.6-sol", original: "Sol", applied: "Custom" }, + }; + const entries = buildCatalogEntries(template, ["gpt-5.5"], [{ provider: "local", id: "qwen3-coder" }]); + expect(entries.length).toBeGreaterThanOrEqual(2); + for (const entry of entries) expect(entry.opencodex_native_display_name).toBeUndefined(); + expect(template.opencodex_native_display_name).toBeDefined(); + }); + + test("native display names do not relabel a routed combo occupying a native slug", () => { + const routed = { + ...nativeTemplate(), slug: "gpt-5.6-sol", display_name: "My combo", + owned_by: "combo", description: "Routed via opencodex → combo (combo).", + opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND, + }; + const rows = mergeObservedForTest({ + catalogModels: [], routedEntries: [routed], + nativeDisplayNames: { "gpt-5.6-sol": "GPT 5.6 Sol" }, + }); + expect(rows.find(entry => entry.slug === "gpt-5.6-sol")?.display_name).toBe("My combo"); + }); + test("pending re-registration cannot recover ON rows from a degraded old catalog", () => { const old = { ...nativeTemplate(), slug: "vendor/model-0", owned_by: "vendor", opencodex_catalog_kind: CODEX_PROVIDER_MODEL_CATALOG_KIND }; const input = { From 1ee829d2c541a36272d40d4b0fcddc017e7ee819 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:38:34 +0900 Subject: [PATCH 14/50] feat(cli): carry provider list JSONL output [skip ci] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carry the net diff of #3780 at 9b873e6f7519a022dd4658db4d1cb92689bb4663. Strengthen multi-record JSON parity, escaping, and conflicting-flag coverage; synchronize all seven translated CLI pages and clarify consumer-side processing. Local tests, typecheck, and builds NOT RUN by owner mandate. Regenerated the capability surface and inspected the static diff. Lower-layer CI is owner-requested deferred evidence, not passing evidence; final combined CI belongs to the integration owner. Co-authored-by: 투린 --- .../fr/reference/cli/providers-accounts.md | 5 +- .../ja/reference/cli/providers-accounts.md | 5 +- .../ko/reference/cli/providers-accounts.md | 5 +- .../docs/reference/cli/providers-accounts.md | 8 +- .../ru/reference/cli/providers-accounts.md | 5 +- .../tr/reference/cli/providers-accounts.md | 5 +- .../zh-cn/reference/cli/providers-accounts.md | 5 +- .../zh-tw/reference/cli/providers-accounts.md | 5 +- .../ocx/references/01_management_surface.md | 1 + skills/ocx/references/02_json_shapes.md | 5 ++ skills/ocx/references/03_recipes.md | 1 + src/cli/capabilities.ts | 5 +- src/cli/provider.ts | 42 +++++++---- tests/cli/cli-provider.test.ts | 74 +++++++++++++++++++ 14 files changed, 147 insertions(+), 24 deletions(-) diff --git a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md index 284c8c20c3..ac4e42bbc2 100644 --- a/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/fr/reference/cli/providers-accounts.md @@ -14,7 +14,7 @@ Gestion des fournisseurs non interactive. Les entrées de registre sont classée | Sous-commande | Drapeaux pris en charge | Actions | | --- | --- | --- | -| `list` | `--json` | Répertoriez les fournisseurs configurés et les entrées de registre restantes. | +| `list` | `--json`, `--jsonl` | Répertoriez les fournisseurs configurés et les entrées de registre restantes. `--jsonl` émet un objet JSON par fournisseur configuré et par ligne. | | `add ` | `--adapter `, `--base-url `, `--api-key `, `--default-model `, `--set-default`, `--force`, `--json`, `--sync` | Ajoutez un fournisseur registry/custom. `--force` écrase ; `--sync` actualise un proxy en cours d'exécution en mode sortie humaine. | | `edit ` | indicateurs de champ du fournisseur, `--headers `, `--json` | Modifiez les champs de fournisseur en direct validés sans remplacer les pools de clés. `--headers` fusionne les en-têtes de requête personnalisés ; passez `{}` ou `-` pour les effacer. | | `test ` | `--json` | Sondez le véritable point de terminaison du modèle en amont. | @@ -28,6 +28,7 @@ Gestion des fournisseurs non interactive. Les entrées de registre sont classée ```bash ocx provider list --json +ocx provider list --jsonl ocx provider test ark ocx provider add anthropic --api-key sk-ant-... --set-default --sync ocx provider add local-dev --adapter openai-chat --base-url http://localhost:11434/v1 @@ -36,6 +37,8 @@ ocx models --provider anthropic --json ocx models live --provider ark --json ``` +`--jsonl` écrit uniquement les fournisseurs configurés, un objet JSON par ligne. Chaque objet contient les mêmes champs qu’un élément du tableau `configured` de `--json`, sans le résumé `registryCount`. Les scripts peuvent traiter les objets ligne par ligne. `--json` et `--jsonl` ne peuvent pas être combinés. + :::caution[Les en-têtes personnalisés ne sont pas un canal d'identification] `--headers` est destiné aux métadonnées de requête non secrètes : conseils de routage, locataire ou sélecteurs de projets, identifiants de traçage. Ce n'est **pas** un endroit pour mettre l'authentification diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index d20483a8a2..594d1fc30e 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -13,7 +13,7 @@ description: プロバイダー構成、資格情報、クォータ、および |サブコマンド |サポートされているフラグ |アクション | | --- | --- | --- | -| `list` | `--json` |構成されたプロバイダーと残りのレジストリ エントリを一覧表示します。 | +| `list` | `--json`, `--jsonl` |構成されたプロバイダーと残りのレジストリ エントリを一覧表示します。 `--jsonl` は設定済みプロバイダーごとに1行の JSON オブジェクトを出力します。 | | `add ` | `--adapter `、`--base-url `、`--api-key `、`--default-model `、`--set-default`、`--force`、`--json`、`--sync` |レジストリ/カスタムプロバイダーを追加します。 `--force` は上書きします。 `--sync` は、実行中のプロキシを人間出力モードで更新します。 | | `edit ` |プロバイダーフィールドフラグ、`--headers `、`--json` |キー プールを置き換えずに、検証済みのライブ プロバイダー フィールドを編集します。`--headers` はカスタム要求ヘッダーをマージします。`{}` または `-` を渡すとクリアします。 | | `test ` | `--json` |実際の上流モデルのエンドポイントを調査します。 | @@ -27,6 +27,7 @@ description: プロバイダー構成、資格情報、クォータ、および ```bash ocx provider list --json +ocx provider list --jsonl ocx provider test ark ocx provider add anthropic --api-key sk-ant-... --set-default --sync ocx provider add local-dev --adapter openai-chat --base-url http://localhost:11434/v1 @@ -35,6 +36,8 @@ ocx models --provider anthropic --json ocx models live --provider ark --json ``` +`--jsonl` は設定済みプロバイダーのみを、1行につき1つの JSON オブジェクトとして出力します。各オブジェクトのフィールドは `--json` の `configured` 配列の要素と同じで、`registryCount` の集計は含みません。スクリプトは各行のオブジェクトを順に処理できます。`--json` と `--jsonl` は同時に指定できません。 + :::caution[カスタムヘッダーは認証情報の経路ではありません] `--headers` は秘密ではないリクエストメタデータ用です — ルーティングヒント、テナントや プロジェクトのセレクター、トレース ID など。認証情報を入れる場所ではなく、バリデーターは diff --git a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md index 250b3d7b1b..ed3c6a565d 100644 --- a/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ko/reference/cli/providers-accounts.md @@ -13,7 +13,7 @@ description: 제공자 설정, 자격 증명, 할당량, 모델 카탈로그 명 | 하위 명령 | 지원 플래그 | 동작 | | --- | --- | --- | -| `list` | `--json` | 설정된 제공자와 남아 있는 레지스트리 항목을 나열합니다. | +| `list` | `--json`, `--jsonl` | 설정된 제공자와 남아 있는 레지스트리 항목을 나열합니다. `--jsonl`은 설정된 제공자마다 JSON 객체를 한 줄씩 출력합니다. | | `add ` | `--adapter `, `--base-url `, `--api-key `, `--default-model `, `--set-default`, `--force`, `--json`, `--sync` | 레지스트리/사용자 지정 제공자를 추가합니다. `--force`는 덮어쓰고, `--sync`는 사람이 읽는 출력 모드에서 실행 중인 프록시를 새로 고칩니다. | | `edit ` | 제공자 필드 플래그, `--headers `, `--json` | 키 풀을 바꾸지 않고 검증된 실시간 제공자 필드를 수정합니다. `--headers`는 사용자 지정 요청 헤더를 병합하며, `{}` 또는 `-`로 지울 수 있습니다. | | `test ` | `--json` | 실제 상위 모델 엔드포인트를 확인합니다. | @@ -27,6 +27,7 @@ description: 제공자 설정, 자격 증명, 할당량, 모델 카탈로그 명 ```bash ocx provider list --json +ocx provider list --jsonl ocx provider test ark ocx provider add anthropic --api-key sk-ant-... --set-default --sync ocx provider add local-dev --adapter openai-chat --base-url http://localhost:11434/v1 @@ -35,6 +36,8 @@ ocx models --provider anthropic --json ocx models live --provider ark --json ``` +`--jsonl`은 설정된 제공자만 JSON 객체 하나당 한 줄로 출력합니다. 각 객체의 필드는 `--json`의 `configured` 배열 항목과 같으며, `registryCount` 요약은 포함하지 않습니다. 스크립트에서 각 줄의 객체를 순서대로 처리할 수 있습니다. `--json`과 `--jsonl`은 함께 사용할 수 없습니다. + :::caution[커스텀 헤더는 자격증명 통로가 아닙니다] `--headers`는 비밀이 아닌 요청 메타데이터용입니다 — 라우팅 힌트, 테넌트나 프로젝트 선택자, 추적 id 같은 것들이요. 인증 정보를 넣는 자리가 아니고, 검증기는 표준 자격증명 diff --git a/docs-site/src/content/docs/reference/cli/providers-accounts.md b/docs-site/src/content/docs/reference/cli/providers-accounts.md index b31c0f3159..3d602799bb 100644 --- a/docs-site/src/content/docs/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/reference/cli/providers-accounts.md @@ -14,7 +14,7 @@ both `--adapter` and `--base-url`. | Subcommand | Supported flags | Action | | --- | --- | --- | -| `list` | `--json` | List configured providers and the remaining registry entries. | +| `list` | `--json`, `--jsonl` | List configured providers and the remaining registry entries; `--jsonl` emits one configured provider object per line. | | `add ` | `--adapter `, `--base-url `, `--api-key `, `--default-model `, `--set-default`, `--force`, `--json`, `--sync` | Add a registry/custom provider. `--force` overwrites; `--sync` refreshes a running proxy in human-output mode. | | `edit ` | provider field flags, `--headers `, `--json` | Edit validated live provider fields without replacing key pools. `--headers` merges custom request headers; pass `{}` or `-` to clear them. | | `test ` | `--json` | Probe the real upstream model endpoint. | @@ -29,6 +29,7 @@ both `--adapter` and `--base-url`. ```bash ocx provider list --json +ocx provider list --jsonl # one configured provider object per line ocx provider test ark ocx provider add anthropic --api-key sk-ant-... --set-default --sync ocx provider add local-dev --adapter openai-chat --base-url http://localhost:11434/v1 @@ -37,6 +38,11 @@ ocx models --provider anthropic --json ocx models live --provider ark --json ``` +`--jsonl` writes only configured providers, one JSON object per line, and omits the +`registryCount` summary from `--json`. Each object has the same fields as an item in the `configured` array. +Use it for scripts that process one configured provider object per line. +`--json` and `--jsonl` cannot be combined. + :::caution[Custom headers are not a credential channel] `--headers` is for non-secret request metadata — routing hints, tenant or project selectors, tracing ids. It is **not** a place to put authentication diff --git a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md index 56bde92668..4ae2bc7b5f 100644 --- a/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ru/reference/cli/providers-accounts.md @@ -15,7 +15,7 @@ pool'ами и контролируют каталог моделей, кото | Подкоманда | Поддерживаемые флаги | Действие | | --- | --- | --- | -| `list` | `--json` | Показать настроенных провайдеров и оставшиеся записи registry. | +| `list` | `--json`, `--jsonl` | Показать настроенных провайдеров и оставшиеся записи registry. `--jsonl` выводит по одному JSON-объекту настроенного провайдера на строку. | | `add ` | `--adapter `, `--base-url `, `--api-key `, `--default-model `, `--set-default`, `--force`, `--json`, `--sync` | Добавить registry/custom-провайдера. `--force` перезаписывает; `--sync` обновляет живой прокси в human-output mode. | | `edit ` | provider field flags, `--headers `, `--json` | Изменить валидированные live-поля провайдера, не заменяя key-pool'ы. `--headers` объединяет пользовательские request-header'ы; передайте `{}` или `-`, чтобы очистить их. | | `test ` | `--json` | Пробный запрос к реальному upstream model-endpoint'у. | @@ -29,6 +29,7 @@ pool'ами и контролируют каталог моделей, кото ```bash ocx provider list --json +ocx provider list --jsonl ocx provider test ark ocx provider add anthropic --api-key sk-ant-... --set-default --sync ocx provider add local-dev --adapter openai-chat --base-url http://localhost:11434/v1 @@ -37,6 +38,8 @@ ocx models --provider anthropic --json ocx models live --provider ark --json ``` +`--jsonl` выводит только настроенных провайдеров: один JSON-объект на строку. Поля каждого объекта совпадают с полями элемента массива `configured` в `--json`; сводка `registryCount` не включается. Скрипты могут обрабатывать объекты построчно. Флаги `--json` и `--jsonl` нельзя использовать вместе. + :::caution[Пользовательские заголовки — не канал для учётных данных] `--headers` предназначен для несекретных метаданных запроса — подсказок маршрутизации, селекторов тенанта или проекта, идентификаторов трассировки. Это не diff --git a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md index f611d7be73..2d58adae3b 100644 --- a/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/tr/reference/cli/providers-accounts.md @@ -16,7 +16,7 @@ bir ad hem `--adapter` hem de `--base-url` gerektirir. | Alt komut | Desteklenen bayraklar | Eylem | | --- | --- | --- | -| `list` | `--json` | Yapılandırılmış sağlayıcıları ve kalan kayıt defteri girdilerini listeleyin. | +| `list` | `--json`, `--jsonl` | Yapılandırılmış sağlayıcıları ve kalan kayıt defteri girdilerini listeleyin. `--jsonl`, yapılandırılmış her sağlayıcı için satır başına bir JSON nesnesi üretir. | | `add ` | `--adapter `, `--base-url `, `--api-key `, `--default-model `, `--set-default`, `--force`, `--json`, `--sync` | Bir kayıt defteri/özel sağlayıcı ekleyin. `--force` üzerine yazar; `--sync`, insan çıktısı modunda çalışan bir proxy'yi yeniler. | | `edit ` | sağlayıcı alan bayrakları, `--headers `, `--json` | Anahtar havuzlarını değiştirmeden doğrulanmış canlı sağlayıcı alanlarını düzenleyin. `--headers` özel istek başlıklarını birleştirir; temizlemek için `{}` veya `-` iletin. | | `test ` | `--json` | Gerçek yukarı akış model uç noktasını araştırın. | @@ -30,6 +30,7 @@ bir ad hem `--adapter` hem de `--base-url` gerektirir. ```bash ocx provider list --json +ocx provider list --jsonl ocx provider test ark ocx provider add anthropic --api-key sk-ant-... --set-default --sync ocx provider add local-dev --adapter openai-chat --base-url http://localhost:11434/v1 @@ -38,6 +39,8 @@ ocx models --provider anthropic --json ocx models live --provider ark --json ``` +`--jsonl` yalnızca yapılandırılmış sağlayıcıları, her satırda bir JSON nesnesi olacak şekilde yazar. Her nesne, `--json` çıktısındaki `configured` dizisinin bir öğesiyle aynı alanları içerir; `registryCount` özeti eklenmez. Betikler nesneleri satır satır işleyebilir. `--json` ve `--jsonl` birlikte kullanılamaz. + :::caution[Özel başlıklar bir kimlik bilgisi kanalı değildir] `--headers`, gizli olmayan istek meta verileri içindir — yönlendirme ipuçları, kiracı veya proje seçicileri, izleme kimlikleri. Kimlik doğrulama materyali diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md index 85633f54f5..a6fe332390 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/providers-accounts.md @@ -14,7 +14,7 @@ description: 提供方配置、凭据、配额,以及模型目录命令。 | 子命令 | 支持的标志 | 操作 | | --- | --- | --- | -| `list` | `--json` | 列出已配置的提供方以及剩余的注册表条目。 | +| `list` | `--json`, `--jsonl` | 列出已配置的提供方以及剩余的注册表条目。 `--jsonl` 为每个已配置的提供方输出一行 JSON 对象。 | | `add ` | `--adapter `, `--base-url `, `--api-key `, `--default-model `, `--set-default`, `--force`, `--json`, `--sync` | 添加一个注册表/自定义提供方。`--force` 会覆盖;`--sync` 会在有人类输出模式运行的代理上刷新配置。 | | `edit ` | 提供方字段标志,`--headers `,`--json` | 在不替换密钥池的情况下,编辑经过校验的在线提供方字段。`--headers` 会合并自定义请求头;传入 `{}` 或 `-` 可清空。 | | `test ` | `--json` | 探测真实的上游模型端点。 | @@ -28,6 +28,7 @@ description: 提供方配置、凭据、配额,以及模型目录命令。 ```bash ocx provider list --json +ocx provider list --jsonl ocx provider test ark ocx provider add anthropic --api-key sk-ant-... --set-default --sync ocx provider add local-dev --adapter openai-chat --base-url http://localhost:11434/v1 @@ -36,6 +37,8 @@ ocx models --provider anthropic --json ocx models live --provider ark --json ``` +`--jsonl` 仅输出已配置的提供方,每行一个 JSON 对象。每个对象的字段与 `--json` 输出中 `configured` 数组的元素相同,不包含 `registryCount` 汇总。脚本可以逐行处理这些对象。`--json` 与 `--jsonl` 不能同时使用。 + :::caution[自定义请求头不是凭据通道] `--headers` 用于非机密的请求元数据 —— 路由提示、租户或项目选择器、追踪 ID 等。它不是 存放认证信息的地方,校验器会拒绝标准凭据请求头名称(`Authorization`、`X-Api-Key`、 diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md index c50a6d54ae..fbf1ff186c 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/providers-accounts.md @@ -13,7 +13,7 @@ description: 供應商設定、憑證、配額與模型目錄指令。 | 子指令 | 支援的旗標 | 動作 | | --- | --- | --- | -| `list` | `--json` | 列出已設定的供應商與剩餘的 registry 項目。 | +| `list` | `--json`, `--jsonl` | 列出已設定的供應商與剩餘的 registry 項目。 `--jsonl` 為每個已設定的供應商輸出一行 JSON 物件。 | | `add ` | `--adapter `, `--base-url `, `--api-key `, `--default-model `, `--set-default`, `--force`, `--json`, `--sync` | 新增 registry/自訂供應商。`--force` 覆寫;`--sync` 在人類輸出模式下重新整理執行中的代理。 | | `edit ` | 供應商欄位旗標, `--json` | 編輯已驗證的即時供應商欄位而不替換金鑰池。 | | `test ` | `--json` | 探測真實上游模型端點。 | @@ -27,6 +27,7 @@ description: 供應商設定、憑證、配額與模型目錄指令。 ```bash ocx provider list --json +ocx provider list --jsonl ocx provider test ark ocx provider add anthropic --api-key sk-ant-... --set-default --sync ocx provider add local-dev --adapter openai-chat --base-url http://localhost:11434/v1 @@ -35,6 +36,8 @@ ocx models --provider anthropic --json ocx models live --provider ark --json ``` +`--jsonl` 僅輸出已設定的供應商,每行一個 JSON 物件。每個物件的欄位與 `--json` 輸出中 `configured` 陣列的元素相同,不包含 `registryCount` 摘要。指令碼可以逐行處理這些物件。`--json` 與 `--jsonl` 不能同時使用。 + ## 認證 ### `ocx login ` diff --git a/skills/ocx/references/01_management_surface.md b/skills/ocx/references/01_management_surface.md index d5711f3cac..10b0cd9e89 100644 --- a/skills/ocx/references/01_management_surface.md +++ b/skills/ocx/references/01_management_surface.md @@ -67,6 +67,7 @@ Drives no management route. | Flag | Value | Meaning | |---|---|---| | `--json` | boolean | Emit the provider list as JSON. | +| `--jsonl` | boolean | Emit one configured provider per JSON line. | JSON mode: `envelope`. diff --git a/skills/ocx/references/02_json_shapes.md b/skills/ocx/references/02_json_shapes.md index a91e35a2e1..261c8be5a2 100644 --- a/skills/ocx/references/02_json_shapes.md +++ b/skills/ocx/references/02_json_shapes.md @@ -52,6 +52,11 @@ to `requestedModel` is how you get a wrong answer about which provider served it `displayMetrics.cost.estimate.estimateReasons` lists why — for example `usage_estimated`, `cache_detail_missing`, `expected_price_overlay`. +## `ocx provider list --jsonl` + +One configured provider per line. Each object has the same fields as an item in the +`configured` array from `ocx provider list --json`; the `registryCount` summary is omitted. + ## `ocx logs explain ` ```json diff --git a/skills/ocx/references/03_recipes.md b/skills/ocx/references/03_recipes.md index 424ad34a53..9159751424 100644 --- a/skills/ocx/references/03_recipes.md +++ b/skills/ocx/references/03_recipes.md @@ -157,6 +157,7 @@ exist for them — do not attribute usage to either. ```bash ocx provider list --json +ocx provider list --jsonl # one configured provider per line ocx provider add --json # registry providers auto-configure by name ocx provider test --json ocx provider set-default --json diff --git a/src/cli/capabilities.ts b/src/cli/capabilities.ts index 1b5cfd6283..ff1f5fb9a2 100644 --- a/src/cli/capabilities.ts +++ b/src/cli/capabilities.ts @@ -148,7 +148,10 @@ export const CAPABILITIES: readonly Capability[] = [ summary: "Configured providers with connectivity and selected models.", // Local config + PROVIDER_REGISTRY. Does not call GET /api/providers. routes: [], - flags: [{ name: "--json", value: "boolean", summary: "Emit the provider list as JSON." }], + flags: [ + { name: "--json", value: "boolean", summary: "Emit the provider list as JSON." }, + { name: "--jsonl", value: "boolean", summary: "Emit one configured provider per JSON line." }, + ], mutates: false, json: "envelope", details: ["Reads local config; drives no management API route."], diff --git a/src/cli/provider.ts b/src/cli/provider.ts index 55c654d8d7..47f23fee62 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -79,26 +79,37 @@ function validateAndSave(config: ReturnType): void { function handleList(args: string[]): void { const wantsJson = consumeFlag(args, "--json"); - rejectUnknownArgs(args, "Usage: ocx provider list [--json]"); + const wantsJsonl = consumeFlag(args, "--jsonl"); + rejectUnknownArgs(args, "Usage: ocx provider list [--json|--jsonl]"); + + if (wantsJson && wantsJsonl) { + console.error("Use only one of --json or --jsonl."); + process.exit(1); + } const config = loadConfig(); const configured = Object.keys(config.providers); + const entries = configured.map(name => { + const prov = config.providers[name]; + const registryEntry = getProviderRegistryEntry(name); + return { + name, + adapter: prov.adapter, + baseUrl: prov.baseUrl, + authMode: prov.authMode ?? "key", + defaultModel: prov.defaultModel ?? null, + isDefault: name === config.defaultProvider, + source: registryEntry ? "registry" : "custom", + models: prov.models ?? [], + }; + }); + + if (wantsJsonl) { + for (const entry of entries) console.log(JSON.stringify(entry)); + return; + } if (wantsJson) { - const entries = configured.map(name => { - const prov = config.providers[name]; - const registryEntry = getProviderRegistryEntry(name); - return { - name, - adapter: prov.adapter, - baseUrl: prov.baseUrl, - authMode: prov.authMode ?? "key", - defaultModel: prov.defaultModel ?? null, - isDefault: name === config.defaultProvider, - source: registryEntry ? "registry" : "custom", - models: prov.models ?? [], - }; - }); console.log(JSON.stringify({ configured: entries, registryCount: PROVIDER_REGISTRY.length }, null, 2)); return; } @@ -444,6 +455,7 @@ Subcommands: Examples: ocx provider list + ocx provider list --jsonl ocx provider add anthropic --api-key sk-ant-... ocx provider add my-ollama --adapter openai-chat --base-url http://localhost:11434/v1 ocx provider show anthropic --json diff --git a/tests/cli/cli-provider.test.ts b/tests/cli/cli-provider.test.ts index b83bc8d514..ea29c5535f 100644 --- a/tests/cli/cli-provider.test.ts +++ b/tests/cli/cli-provider.test.ts @@ -109,6 +109,80 @@ describe("ocx provider", () => { } }); + test("provider list --jsonl matches JSON configured records with escaped model values", () => { + const escapedModel = 'model-"quoted"\\path\nnext\r\ttab-한글'; + const { dir } = freshConfig({ + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + "custom.models-1": { + adapter: "openai-chat", + baseUrl: "https://models.example.test/v1", + defaultModel: escapedModel, + models: ["plain-model", escapedModel], + }, + }, + defaultProvider: "custom.models-1", + }); + try { + const result = runCli(["provider", "list", "--jsonl"], { OPENCODEX_HOME: dir }); + const json = runCli(["provider", "list", "--json"], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(0); + expect(json.status).toBe(0); + // Keep every physical line: embedded newlines must be escaped, and only + // the final record terminator may produce an empty split element. + const lines = result.stdout.split(/\r?\n/); + expect(lines.pop()).toBe(""); + expect(lines).toHaveLength(2); + const records = lines.map(line => JSON.parse(line)); + const envelope = JSON.parse(json.stdout); + expect(records).toEqual(envelope.configured); + expect(envelope.registryCount).toBeGreaterThan(0); + expect(records).toEqual([ + { + name: "openai", + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + defaultModel: null, + isDefault: false, + source: "registry", + models: [], + }, + { + name: "custom.models-1", + adapter: "openai-chat", + baseUrl: "https://models.example.test/v1", + authMode: "key", + defaultModel: escapedModel, + isDefault: true, + source: "custom", + models: ["plain-model", escapedModel], + }, + ]); + } finally { + removeTreeWithRetry(dir); + } + }); + + test.each([ + ["--json", "--jsonl"], + ["--jsonl", "--json"], + ])("provider list rejects %s %s without stdout", (first, second) => { + const { dir } = freshConfig(); + try { + const result = runCli(["provider", "list", first, second], { OPENCODEX_HOME: dir }); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Use only one of --json or --jsonl"); + } finally { + removeTreeWithRetry(dir); + } + }); + test("provider add registry provider seeds config", () => { const { dir } = freshConfig(); try { From e00d5c341778b81cc09dca7cef56e20d776316ee Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:41:25 +0900 Subject: [PATCH 15/50] test(container): verify build startup and volume recreation [skip ci] Exercise the source-build contract from #3421/#3604 and separate-home persistence from #3747 using disposable Docker resources. Preserve original token and both named volumes through container replacement; verify admission without upstream inference. Independent source/security review completed; actual Docker networking and runtime acceptance remain pending final CI. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- scripts/ci/docker-smoke.ts | 338 +++++++++++++++++++++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 scripts/ci/docker-smoke.ts diff --git a/scripts/ci/docker-smoke.ts b/scripts/ci/docker-smoke.ts new file mode 100644 index 0000000000..0745f6bd9e --- /dev/null +++ b/scripts/ci/docker-smoke.ts @@ -0,0 +1,338 @@ +/** Hosted Linux Docker acceptance only; never uses provider credentials or inference. */ +import { spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, rmdirSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const root = resolve(import.meta.dir, "../.."); +const project = `ocx-smoke-${randomBytes(12).toString("hex")}`; +const image = `${project}:local`; +const cancelled = new AbortController(); +const outputLimit = 8 * 1024 * 1024; +let stage = "initialization"; +let scratch = ""; +let composeArgs: string[] = []; +let env: Record = {}; + +class SmokeFailure extends Error {} + +function check(ok: unknown, message: string): asserts ok { + if (!ok) throw new SmokeFailure(message); +} + +// Do not include arguments, child output, HTTP bodies, or arbitrary error messages in diagnostics. +function progress(name: string): void { + stage = name; + console.log(`docker-smoke: ${name}`); +} + +async function run(args: string[], input?: string, timeout = 30_000, cleanup = false) { + if (!cleanup) cancelled.signal.throwIfAborted(); + return await new Promise<{ code: number | null; out: string }>((accept, reject) => { + const child = spawn(args[0]!, args.slice(1), { + cwd: root, env, detached: true, stdio: ["pipe", "pipe", "pipe"], + }); + const chunks: Buffer[] = []; + let bytes = 0; + let failed = false; + let killTimer: ReturnType | undefined; + let reapTimer: ReturnType | undefined; + const killGroup = (signal: NodeJS.Signals) => { + if (child.pid) { + try { process.kill(-child.pid, signal); } catch { /* already exited */ } + } + }; + const stop = () => { + if (failed) return; + failed = true; + killGroup("SIGTERM"); + killTimer = setTimeout(() => killGroup("SIGKILL"), 1_000); + // A daemon/plugin retaining a pipe must not keep the harness alive indefinitely. + reapTimer = setTimeout(() => { + child.stdout.destroy(); child.stderr.destroy(); child.stdin.destroy(); + finish(); + child.unref(); + reject(new SmokeFailure("child did not close within the termination deadline")); + }, 4_000); + }; + const timer = setTimeout(stop, timeout); + const finish = () => { + clearTimeout(timer); clearTimeout(killTimer); clearTimeout(reapTimer); + cancelled.signal.removeEventListener("abort", stop); + }; + if (!cleanup) cancelled.signal.addEventListener("abort", stop, { once: true }); + const collect = (data: Buffer, stdout: boolean) => { + bytes += data.length; + if (bytes > outputLimit) stop(); + else if (stdout) chunks.push(data); + }; + child.stdout.on("data", (data: Buffer) => collect(data, true)); + child.stderr.on("data", (data: Buffer) => collect(data, false)); + child.stdin.on("error", () => { /* EPIPE is possible on the refused bootstrap. */ }); + child.on("error", () => { finish(); reject(new SmokeFailure("child could not start")); }); + child.on("close", code => { + // A terminated CLI can close its pipes before its plugin exits. + if (failed) killGroup("SIGKILL"); + finish(); + if (failed) reject(new SmokeFailure("child exceeded time/output limit or was cancelled")); + else accept({ code, out: Buffer.concat(chunks).toString("utf8") }); + }); + child.stdin.end(input); + }); +} + +async function command(args: string[], input?: string, timeout?: number, cleanup = false) { + const result = await run(args, input, timeout, cleanup); + check(result.code === 0, `command exited ${result.code ?? "by signal"}`); + return result.out.trim(); +} + +function compose(args: string[], input?: string, timeout?: number, cleanup = false) { + return command(["docker", ...composeArgs, ...args], input, timeout, cleanup); +} + +async function build() { + const directory = join(root, "src/generated"); + const manifest = join(directory, "compatibility-version.json"); + const directoryStat = lstatSync(directory, { throwIfNoEntry: false }); + const hadDirectory = directoryStat !== undefined; + check(!directoryStat || directoryStat.isDirectory(), "unsafe generated directory"); + const originalStat = lstatSync(manifest, { throwIfNoEntry: false }); + check(!originalStat || originalStat.isFile(), "unsafe existing manifest"); + check(!originalStat || originalStat.size <= 8 * 1024 * 1024, "existing manifest exceeds limit"); + const original = originalStat ? readFileSync(manifest) : undefined; + try { + progress("generate compatibility manifest"); + await command([process.execPath, "scripts/generate-compatibility-version.ts"]); + progress("build Docker image"); + await compose(["build", "hub"], undefined, 600_000); + } finally { + if (original && originalStat) { + writeFileSync(manifest, original); + chmodSync(manifest, originalStat.mode & 0o777); + utimesSync(manifest, originalStat.atime, originalStat.mtime); + } else { + rmSync(manifest, { force: true }); + } + if (!hadDirectory && existsSync(directory)) rmdirSync(directory); + } +} + +const fixture = JSON.stringify({ models: [{ + slug: "smoke/synthetic", display_name: "Smoke fixture", description: "Synthetic catalog only", + priority: 1, visibility: "list", base_instructions: "Synthetic", input_modalities: ["text"], +}] }); +const token = randomBytes(32).toString("hex"); +const replacement = randomBytes(32).toString("hex"); +const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); + +interface Container { + Id: string; + State: { Running: boolean; Health?: { Status: string } }; + HostConfig: { ReadonlyRootfs: boolean; CapDrop: string[]; SecurityOpt: string[]; Privileged: boolean }; + Config: { Image: string; Labels: Record }; + NetworkSettings: { Ports: Record | null> }; + Mounts: Array<{ Type: string; Name?: string; Destination: string; RW: boolean }>; +} + +async function inspect() { + const id = await compose(["ps", "-q", "hub"]); + check(/^[a-f0-9]{64}$/.test(id), "expected exactly one container"); + const rows = JSON.parse(await command(["docker", "inspect", id])) as Container[]; + check(rows.length === 1, "unexpected inspect result"); + const container = rows[0]!; + check(container.Id === id && container.Config.Image === image + && container.Config.Labels["com.docker.compose.project"] === project, "container identity mismatch"); + check(container.State.Running && container.State.Health?.Status === "healthy", "container not healthy"); + check(container.HostConfig.ReadonlyRootfs && !container.HostConfig.Privileged + && container.HostConfig.CapDrop.includes("ALL") + && container.HostConfig.SecurityOpt.some(value => /^no-new-privileges(?::true)?$/.test(value)), "restrictions missing"); + const ports = Object.entries(container.NetworkSettings.Ports).filter(([, entries]) => entries?.length); + check(ports.length === 1 && ports[0]![0] === "10100/tcp", "unexpected published port"); + const bindings = ports[0]![1]!; + check(bindings.length === 1 && bindings[0]!.HostIp === "127.0.0.1", "non-loopback publication"); + const port = Number(bindings[0]!.HostPort); + check(Number.isInteger(port) && port > 0 && port <= 65535, "invalid host port"); + const volumes = [".opencodex", ".codex"].map(home => { + const mounts = container.Mounts.filter(mount => mount.Destination === `/home/bun/${home}`); + check(mounts.length === 1, "missing home mount"); + const mount = mounts[0]!; + check(mount.Type === "volume" && mount.RW && mount.Name?.startsWith(`${project}_`), "unexpected home volume"); + return mount.Name; + }); + check(volumes[0] !== volumes[1], "homes share a volume"); + return { id, volumes, url: `http://127.0.0.1:${port}` }; +} + +// This runs as the image's user. Only hashes/metadata leave the container, never file bytes. +const stateProbe = ` + import { readFileSync, statSync, writeFileSync } from 'node:fs'; + import { createHash } from 'node:crypto'; + const homes = ['/home/bun/.opencodex', '/home/bun/.codex']; + const uid = process.getuid(); + if (uid === 0) throw new Error('root user'); + const status = readFileSync('/proc/self/status', 'utf8'); + if (!/^CapEff:\\s+0+$/m.test(status) || !/^NoNewPrivs:\\s+1$/m.test(status)) throw new Error('effective restrictions'); + for (const home of homes) { + const s = statSync(home); + if (s.uid !== uid || (s.mode & 0o777) !== 0o700) throw new Error('home permissions'); + } + try { writeFileSync('/home/bun/app/.smoke-root-write', 'x'); throw new Error('writable root'); } + catch (e) { if (e.code !== 'EROFS') throw e; } + const paths = [homes[0] + '/config.json', homes[0] + '/service-api-token', homes[1] + '/opencodex-catalog.json']; + const hashes = paths.map(path => { + const s = statSync(path); + if (s.uid !== uid || (s.mode & 0o777) !== 0o600 || s.size > 65536) throw new Error('file permissions/size'); + return createHash('sha256').update(readFileSync(path)).digest('hex'); + }); + console.log(JSON.stringify(hashes)); +`; + +async function state() { + const hashes = JSON.parse(await compose(["exec", "-T", "hub", "bun", "-e", stateProbe])) as string[]; + check(hashes.length === 3 && hashes.every(hash => /^[a-f0-9]{64}$/.test(hash)), "invalid state evidence"); + check(hashes[1] === sha256(`${token}\n`) && hashes[2] === sha256(fixture), "token/catalog changed"); + return JSON.stringify(hashes); +} + +async function request(url: string, path: string, secret?: string) { + const controller = new AbortController(); + const abort = () => controller.abort(); + cancelled.signal.throwIfAborted(); + cancelled.signal.addEventListener("abort", abort, { once: true }); + const timer = setTimeout(abort, 5_000); + try { + const post = path !== "/healthz" && path !== "/readyz" && path !== "/v1/catalog"; + const response = await fetch(`${url}${path}`, { + method: post ? "POST" : "GET", redirect: "error", signal: controller.signal, + headers: { ...(secret ? { "x-opencodex-api-key": secret } : {}), ...(post ? { "content-type": "application/json" } : {}) }, + // Never send an authorized inference request, even with synthetic input. + body: post ? '{"model":"smoke/synthetic","input":[]}' : undefined, + }); + const reader = response.body?.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + try { + while (reader) { + const next = await reader.read(); + if (next.done) break; + size += next.value.length; + check(size <= 64 * 1024, "HTTP body exceeds limit"); + chunks.push(next.value); + } + } finally { controller.abort(); reader?.releaseLock(); } + return { status: response.status, body: Buffer.concat(chunks).toString("utf8") }; + } finally { + clearTimeout(timer); + cancelled.signal.removeEventListener("abort", abort); + } +} + +async function acceptance(url: string) { + check((await request(url, "/healthz")).status === 200, "liveness failed"); + const deadline = Date.now() + 60_000; + while (true) { + const ready = await request(url, "/readyz"); + const body = JSON.parse(ready.body) as { status?: string }; + if (ready.status === 200 && body.status === "ready") break; + check(ready.status === 503 && body.status === "pending" && Date.now() < deadline, "readiness failed"); + await Bun.sleep(500); + } + for (const path of ["/v1/catalog", "/v1/responses", "/v1/responses/compact"]) { + for (const secret of [undefined, replacement]) { + const result = await request(url, path, secret); + check(result.status === 401, `${path} ${secret ? "wrong" : "missing"} token returned ${result.status}, expected 401`); + } + } + const catalog = await request(url, "/v1/catalog", token); + check(catalog.status === 200 && catalog.body === fixture, "catalog not served exactly"); +} + +async function cleanup() { + let failed = false; + const attempt = async (action: () => Promise) => { + try { await action(); } catch { failed = true; } + }; + if (composeArgs.length) { + await attempt(() => compose(["down", "--volumes", "--remove-orphans", "--timeout", "10"], undefined, 45_000, true)); + for (const kind of ["container", "volume", "network"]) { + await attempt(async () => { + const remaining = await command(["docker", kind, "ls", "-q", ...(kind === "container" ? ["-a"] : []), + "--filter", `label=com.docker.compose.project=${project}`], undefined, 15_000, true); + check(!remaining, "project resources remain"); + }); + } + await attempt(async () => { + const ids = await command(["docker", "image", "ls", "-q", "--filter", `reference=${image}`], undefined, 15_000, true); + if (ids) await command(["docker", "image", "rm", image], undefined, 30_000, true); + check(!await command(["docker", "image", "ls", "-q", "--filter", `reference=${image}`], undefined, 15_000, true), "image remains"); + }); + } + try { if (scratch) rmSync(scratch, { recursive: true, force: true, maxRetries: 0 }); } catch { failed = true; } + check(!failed, "cleanup incomplete"); +} + +async function main() { + check(process.platform === "linux", "requires a disposable Linux Docker runner"); + scratch = mkdtempSync(join(tmpdir(), `${project}-`)); + mkdirSync(join(scratch, "docker"), { mode: 0o700 }); + writeFileSync(join(scratch, "empty.env"), "", { mode: 0o600 }); + writeFileSync(join(scratch, "override.json"), JSON.stringify({ + services: { hub: { image, restart: "no" } }, + // Block upstream egress even if an admission regression reaches a provider path. + networks: { default: { internal: true } }, + }), { mode: 0o600 }); + env = { + PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", TMPDIR: scratch, + DOCKER_CONFIG: join(scratch, "docker"), DOCKER_HOST: "unix:///var/run/docker.sock", + COMPOSE_DISABLE_ENV_FILE: "1", OPENCODEX_BIND_ADDRESS: "127.0.0.1", OPENCODEX_PORT: "0", + }; + composeArgs = ["compose", "--project-name", project, "--project-directory", root, + "--env-file", join(scratch, "empty.env"), "-f", join(root, "compose.yaml"), "-f", join(scratch, "override.json")]; + progress("validate and build"); + await compose(["config", "--quiet"]); + await build(); + progress("bootstrap and seed synthetic catalog"); + await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "run", "docker/bootstrap-token.ts"], `${token}\n`); + await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "-e", + "import { writeFileSync } from 'node:fs'; writeFileSync('/home/bun/.codex/opencodex-catalog.json', await Bun.stdin.text(), { mode: 0o600, flag: 'wx' });"], fixture); + progress("start and check admission"); + await compose(["up", "--no-build", "--wait", "--wait-timeout", "120", "hub"], undefined, 150_000); + const first = await inspect(); + await acceptance(first.url); + const before = await state(); + progress("refuse token replacement"); + const refused = await run(["docker", ...composeArgs, "run", "--rm", "-T", "--no-deps", "hub", + "bun", "run", "docker/bootstrap-token.ts"], `${replacement}\n`); + check(refused.code === 1, "bootstrap did not refuse replacement"); + check(await state() === before, "state changed after refused bootstrap"); + await acceptance(first.url); + progress("replace container and verify persistence"); + await compose(["up", "--no-build", "--force-recreate", "--wait", "--wait-timeout", "120", "hub"], undefined, 150_000); + const second = await inspect(); + check(second.id !== first.id && JSON.stringify(second.volumes) === JSON.stringify(first.volumes), "replacement/volume identity failed"); + check(await state() === before, "persistent state changed"); + await acceptance(second.url); +} + +const abort = () => cancelled.abort(); +process.once("SIGINT", abort); +process.once("SIGTERM", abort); +const deadline = setTimeout(abort, 16 * 60_000); +try { + await main(); +} catch (error) { + const reason = error instanceof SmokeFailure ? error.message : "unexpected failure; details suppressed"; + console.error(`docker-smoke: failed at ${stage}: ${reason}`); + process.exitCode = 1; +} finally { + clearTimeout(deadline); + try { await cleanup(); } catch { + console.error("docker-smoke: cleanup incomplete"); + process.exitCode = 1; + } + process.removeListener("SIGINT", abort); + process.removeListener("SIGTERM", abort); +} +if (!process.exitCode) console.log("docker-smoke: build/start/recreate acceptance passed; cleanup complete"); From 92c95fafa0eb5605ae8414274af493a51e55ee06 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:40:59 +0900 Subject: [PATCH 16/50] feat(gui): carry discovered model name editor with recoverable saves Carry the net diff from PR #2716 at 93ed44053b68a9707f8271981d5f7e4bc25e9b70. Reconcile confirmed saved/reset receipts, including saved:true errors, with the editor draft, current label and reset availability. Preserve reset intent on retry and retry only the list read after a successful mutation and failed read. Reuse createBoundedFetch for a single 60-second write-and-refresh budget. Timeouts retain the draft, release the modal lock and leave persistence unknown; retry reads current state before another mutation. Keep global fetch unchanged. Add focused regression coverage, nine-locale recovery copy and workflow docs. Local tests, typecheck, build and browser smoke NOT RUN by owner mandate. Static diff inspection only; parent owns remote CI and browser verification. Co-authored-by: Zig Zag --- .../docs/reference/configuration/providers.md | 14 + gui/src/components/ModelDisplayNameDialog.tsx | 172 +++++ gui/src/i18n/de.ts | 23 + gui/src/i18n/en.ts | 23 + gui/src/i18n/fr.ts | 23 + gui/src/i18n/ja.ts | 23 + gui/src/i18n/ko.ts | 23 + gui/src/i18n/ru.ts | 23 + gui/src/i18n/tr.ts | 23 + gui/src/i18n/zh-TW.ts | 23 + gui/src/i18n/zh.ts | 23 + gui/src/pages/Models.tsx | 161 ++++- gui/src/pages/models-shared.ts | 24 +- gui/src/styles.css | 46 ++ gui/tests/models-display-name-editor.test.tsx | 657 ++++++++++++++++++ 15 files changed, 1276 insertions(+), 5 deletions(-) create mode 100644 gui/src/components/ModelDisplayNameDialog.tsx create mode 100644 gui/tests/models-display-name-editor.test.tsx diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 9bd558be44..78d97cd79c 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -249,6 +249,20 @@ label. A management client can set or reset one label with `{ "modelId": "grok-4.6", "displayName": "Grok 4.6" }`; send `displayName: null` to reset it. Provider `PATCH` does not edit this map. Use this dedicated `PUT` endpoint to change or remove labels. +The dashboard exposes the same durable setting on **Models**. Expand the provider, find a +discovered model, and choose **Name**. The dialog keeps the exact `provider/model` selector visible +while you save a friendly label. Choose **Reset name** to return to provider metadata or the normal +selector fallback. **Name** changes presentation only; the separate alias pencil changes the +short routing alias and is not a display name editor. Native OpenAI and custom model rows keep their +existing controls. + +If the change is saved but refreshing fails, the dialog reflects the saved override and keeps +**Retry** available. Retry repeats catalog convergence when the server reported it failed, or +reloads the list when only the list request failed. Reset recovery keeps the reset operation; +it does not restore the old name. Requests have a 60-second deadline covering the write and its +follow-up list refresh. A timeout does not undo a write: use **Retry** to check the current name +before making another change. + ## Codex catalog and root `config.toml` settings These settings belong in the root of `$CODEX_HOME/config.toml`, alongside diff --git a/gui/src/components/ModelDisplayNameDialog.tsx b/gui/src/components/ModelDisplayNameDialog.tsx new file mode 100644 index 0000000000..c24b6612d7 --- /dev/null +++ b/gui/src/components/ModelDisplayNameDialog.tsx @@ -0,0 +1,172 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { useT, type TKey } from "../i18n/shared"; +import { + modelDisplayNameValidationKey, + type ModelRow, +} from "../pages/models-shared"; + +interface ModelDisplayNameDialogProps { + model: ModelRow; + saving: boolean; + requestError: string | null; + currentNamePending?: boolean; + onRetry?: () => void; + onEdit?: () => void; + onSave: (displayName: string) => void; + onReset: () => void; + onClose: () => void; +} + +const SOURCE_LABEL_KEYS: Record, TKey> = { + operator: "models.displayNameSourceOperator", + provider: "models.displayNameSourceProvider", + fallback: "models.displayNameSourceFallback", +}; + +export default function ModelDisplayNameDialog({ + model, + saving, + requestError, + currentNamePending = false, + onRetry, + onEdit, + onSave, + onReset, + onClose, +}: ModelDisplayNameDialogProps) { + const t = useT(); + const dialogRef = useRef(null); + const inputRef = useRef(null); + const wasSavingRef = useRef(saving); + const titleId = useId(); + const helpId = useId(); + const errorId = useId(); + const [draft, setDraft] = useState(model.displayNameOverride ?? ""); + const [validationKey, setValidationKey] = useState(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (dialog && !dialog.open) dialog.showModal(); + inputRef.current?.focus(); + return () => { if (dialog?.open) dialog.close(); }; + }, []); + + useEffect(() => { + const saveFailed = wasSavingRef.current && !saving && Boolean(requestError); + wasSavingRef.current = saving; + if (saveFailed) inputRef.current?.focus(); + }, [requestError, saving]); + + // Parent replaces this snapshot only after a confirmed mutation, not catalog polling. + useEffect(() => { + setDraft(model.displayNameOverride ?? ""); + setValidationKey(null); + }, [model]); + + const validationError = validationKey ? t(validationKey) : null; + const visibleError = validationError ?? requestError; + const sourceKey = model.displayNameSource + ? SOURCE_LABEL_KEYS[model.displayNameSource] + : "models.displayNameSourceFallback"; + + const requestClose = () => { + if (!saving) onClose(); + }; + + return ( + { + event.preventDefault(); + requestClose(); + }} + > + + + +
+ {t("models.displayNameModelId")} + {model.namespaced} +
+ +
+ {t("models.displayNameCurrent")} + {currentNamePending ? t("models.displayNameCurrentUnavailable") : model.displayName ?? model.namespaced} + {!currentNamePending && {t(sourceKey)}} +
+ + + { + onEdit?.(); + setDraft(event.target.value); + setValidationKey(null); + }} + /> +

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

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

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

A0+6Mo1P(PAj7!Qi250`_gDOyCJRWxN-CLFYa6 zu~yE|I}nreLoWk{phZ6@wb2LzIYF9dub3Uar43Y7_wQRR;^lc-Vq;XcfumwsyTMqQqWj^d;UilE1FH5A zcTJXt$lb4phuynqMM&)MTZLBd_jPV|TvH#`OwP-~WaBrt>~6h0Zz9zGA(4-veeDev z*EJxX9Yl!HNcdK@w6qj(pWVMHzetBf5}!8%?MmY*v>>|hu_Jo&wWDR_+VZ-gvE_uy ziZr>)K`eMxC*%nKf`W);X9O%@E?r&@5B*UUlniR;#Ct@o-a!`=nEK=&cYBKSLb7~w z4P*O?z)4F?M1&Hn3$bFsZdCdaU?9?GU-zE;dcQ`@auJ%{Gp}qP7SL9nQDD`JA`CK% zOiFO^U%OB=bZcZDy%*^+AE7d)U)_S+@TN7_JS)1RdH7%jdbm^++3N#Nrl3tq@$`y~ z3$w+II+sF1P99njJDIn=wk5^Qecdwy81u2z0+_ng0N2fmKYnZJ8kc&sQTlv{ahQI` zT4^u-^M!Z*Wo;Qf-Q9g6#5xV$H<`X%XU<2YMPddU#||4N89nVM9_F35C|4m13NZRe zzmf79&Cm6GruQy>tdw=oOV*UUa;=P(fuBPAE4_7e8B>Pb-xl{wW8&Bk)$oXjst6ah zH>r%~_h6=U=N_~N1HGx*w<6q`T3ObcD`L}QhxN-r!}eV*qU}xPF|eAzqLbFS1!yR1 zYikQ)Fo6WF4d(RldEz`&A2w-_6Idg#E#3hYC)z(~vCgO7=L`K8=KavE7Spfg*i^>% zb`=-!5ySY{*f7`eEtuUTWM{`Fku9`lW@ds+pJG9A{SFFlfCp#K6OSkk;QtyJ?DapW z)@O?bynvcg$ZjYHN6gY2zjLdTp<=eyMP#6n|2ilW4j)8yGZC{>_y_~5K>8}J+dg#i z2$jmp%gGVb`}>|_Lc+rM2ImOIheDg;_K7=AZ-mDqLN`N5RxT`=~+K zEKb80?)IsXGR2?=95p?J;cGcnTboHd7JA->>)JjoTr zCq=B@H*exIA^G9o#H9=}7w*$;pc^6?;?jt;^3=G!hHbeT1V=6aJoNhnu{{+{4uHjv zpivs2@`^GqSwq?3N0{Bn6jXj5rTGSso2JhduSq;L?s`YwRFZOw0FSIk53CVLf5-wO zelknOriMMJ0$=&6A~4Mp{N+AMo1!^|`K^Neu{SV+3D|ca*@+5rNV(5`tE}}pv9pWv zm1)rDx=OWCFRBugDMc<|#)ZY!p(a4fPZ{W=^R!6DF-E**Seg6x-j1RSCw(YzRS(_8 zo=Hpc8){qOY~Z=2_qlx+d^x0GVhhmgA}?o=WY2EZ?%V(f#9@WVrE2+NFpSP;v3s> zEY=tLqj6w#?W-47H{c`YXc``i$+&qsMCv>HBMj|(f-%4Z5LKGFWuM ziFlr&Njd}l%}EWFBhF@}53)eam;@*q{=8c!FcbVihBauCa58=``5qL>`e4~fl4My||C%W(^1JL2Ll@h{SHuGj ztf*QD*%gMbF!}9INS)q&{K&u!OgK71LRV-J>{ZS;9HoSiSX zaP=#aY!2(v`|8_ikxlEV01Rku1L7hhn$xkk{y@8g=hm?_-{za3hYW% zUIMP25D`(t8=2Aa+-xwuDtMdjO%2oT4A9svUAiQ1N9-&@NC}z@Vs9{3)_~OgGzgU1L@k@oSYF0C6u}$BK4FWGu@l@^Y-@KoI==yatZS{M>k(w^_OP)=>BUE$Xw~-cljKC5d|Ct z8w5uw9^}57*Rzs&G2N+XlS*+Yj=1eop2Uwk9?mGXAqMiw)X;8q2a5Ntn&;{WQj?Q& zLX1z50}iG~CR(0b;I4E${T*y;L&L*Cp*9Z#K~rL~TcE%m)`?8@@bXfa?f5yoqaics z5+l%do<>SQK!D(xzou4hKXHO&bvymZ#L8AK#En$*T%*2-0*dOmf_inko`2GNn4c{_ zNOWH4C|!d9{q|N=7Rk6ESnxrx7?g|8KklWAk!Af**4B%#5;*0+mRVVQ?|vN(dvu8< zrpRb!X^|C5yOFP55J@2}8Y&IaQRWbA3jAEQZ?Bkv0h%2}#o0TmhjO!q8M z&$chM_VyHc-ksS0wG-8!St9spuoy@u;zV?5hx$@F(PV`yblN~IHqtgX&qK0e?P&PuU{W!8|W{mr8N?d`_J@xX(OvU=jnV&-}#mFJ73jGsS1;ZZV z(6#~#!fsH-5=cG?4(X@T^765e41^Ied|6&5V_268)g-Li-yCK~QU&nl=GhlP5Vj zIsGURaEXT3ud^hZB&v-8Ed@$@NqPBTU!NFo8_suFH$bobeI||0Pesii9qJ_(MMIF= zK~<>?bP!Y3r6Ex58${Xv7CgYyV#2qj>XO~rlq;g%do{@IlG;GiYu6&+F`PI-v?m1; zN@(x>{z?e5=hou=I_fJyL&J4bgIB=7Lqcw3BIl%JF@{T6`no_#k*deT$k{xnCu)u7 z4dd?W9!y>Ty&}JV8FSC1<%m8Xa2ny;m1|@c>X5;lxLV|;oXOpQNs)b^UD~tTZBDAB z^<+*>%XpsdjeBEhN%%iY`uhizhlnC)x{-v}0ZBx2kXK!&&H!ybMig1z^QEU?qFp?G z#amw&hZOa@egFFxBO(vfe^4g|B_TBU2;Wp5s2RoEs~$Uq*09jyt-skae+c?{dR<6D zCjh8^bHsZz2|Q4aXFNa^f{d%NwQVg++anx1*H~I3`sd~RS(<9$RaZM&_m}0Iloq{-|9)xCniGcq#ZmuT<27z~ zB{ujjV}Ip%HTi#iFzWxm4`x8RWRFC@rt%84%V?zFpRXcb{&2Zj;Ky_RKgy~;D&-~g zi*fWSf2a9pwf^&~1A{!FQZR)FPVa=p_3DSRKm+XPxNCQz26-GH`SNE;zn zj`gHK0zTc}llW)z6+HZS?ILsD9ffKnVcxBOKkGF*_<^HUM_UeF?x_FwXBwNcV=mcQ zAwz;Q+y3=TaE{}EMF@=Bk(}#`^Ggq%N&mg%#bLp&cA1JTrsdst6OK*&N;$NF`*a=X3wf}Wq2x%?Lu(zBOXRN=~#Ff8wk0bfJk>U4*UgGBtLza&( z!2fjfUK2_v$xZ8M`@~XBtn{xp-e3wVS7`rzcHBvf5DT^p`XdFyxhHJ5;D>KxRc;0G z^M#G@fbqYZe1F4DE#m)oHKP@t+F43sd4NMY`U?H~RhxF?`I^9LfopW;peo+C%Z(Pd zS?!Y+LcC^F=ekyDoZuvm0%17FS~82rt%M8|GViO}X~B0F83dPrXnM!+{N!_X}?}cydaZ)?Wd8?3ZuQj7^xiT#adK`w0Y#zDh5(`QH@GP~n%DBpP`YxW7@2#nS@UQSotS zbE5{$lB;Clz6#H@!gaq=6o_sJhGBZe6>O;G%EUVNBMFzvL;~el9l-KWH>1_U?J&&S z={B1yD9i@HK^G6mOSN+tgrJ>*xiw=z>@fpHGn%?cPC=2my9@H@J8MH{@t_3tZ2|S$ z2O!>mne*G&aA3RM8`^k#lJEaQiRO=PK|-}rOPX*oVilK|%f%^sImLp6<90hi@u zgX*^h??UtMg-Ni(g=h8bn}ePMn7nVzZmpEv#Z7?~Ed-6FFE`uu>gDR0rGn@XM#|?O$WsH$=t6lzB%13IC9?AQWITmq|;H`9C zTdz9GFS<)W6LkoyK^gBTi^+ml9*_@cz@&b=U@BJq#|uLFKdCMv;xq<++3y`VAvDzbMFbH zk!TUgpD!b&;O^$o((2FlOu8-HWeGt_I>j;=qvJHpa^*pzCZC4pF9=Q9S4`W-t~!@E z;-zZ59;-6Ej!cphnh*zxPVrh1#|g;s-#7U^H1IguN%;QVmnS47B)$0wZYT&=U?eOU z4=;nHlbeI1)dk4@P;YVmv#o*FbbDmx(`wB56Lii}?uKI3?dj2oUKI<+ymT%2KQAC; z=4s)I(PBE%Y0y_`eki?YZ7o+W45YJuetwj|m$$awgaJDe5_7KCmDr!joX0|I<$-bw zgjlq^3rN!U@BbXVAr<@B71-54qThlJ_7K%5{W=KyL?O^N_u=>*&$J3DJ&egm~TuxOKMfet`KD_jOZ z4J>4?mcb z`NDN~xfgoT+yS6bd)^itj&Z@u!Oo6YD9EF3DNY=0ya0oA#raQwPe@rrAu~ZpJJ92X za>N8NZCiqOeid=g0%PQoYn3E4`QiqKjnZ3l!$7&giIoT983KHK@S8!?O#!9+m9Mt7 z`6WwAijPnIq`#x%ym5C=Ps{OY7NEatm}ntfZP#-O9)kx@c4WGQc}{9ZHPKJ#Q^x+a^kLOnw>C~1bqks>MQa*e!ji{njvg<0kQ%mNAD~s zeyJ4#Nk;w3gM-gU5#sscFn|jTaPm@q0qYWrYz|Z~m86b0!588`yUI`qk}?_(SR5o_ zRvR*o0vRcyg2x{o%nAxn-c^q(ii@sQ^^!O^%zpV*1^b=7r0~XIbU1Lk4tI9aCH(Fv zEBC@|y5(5V`|-UL>Fe(Yq|pDkFJ-N$2zWH_VFpUr`Qd>qkxQ2^zi4j04?*eKyGL$! z+}l$@JwiZ$15VnRAEe2AVN$anu=BvRjNeM}?&caL1H<<6vW}tQ@y{O&5WSSknOK%G z4Zu$@ffRLedWu#DAp!gV`(G=VmO4h)bi>XL`)zJfk@rd;x$loQ1(4j>*xEk1>O*21 zu^Sl^4^xEeFBSla z!4~AZ=w##`r0moKe>V}_+|Wbz#h05^UB8^5%kC;KuV{89-5P_Ng@u`Q54i4XTBg#{ ztrB-X@~)Fy`T1j}w5uUQ9y)U`0dT+hb2kkTHJIZ2QR>&hrAnBzgPaNa!fbl5S`&kV<)D6o_zi8W!B5OQhPwyP?;l*p z!U_j^rgB522#A%JK(kz+mzd$h64=q-Z_G0_Jq=px4j{sVE*PLY^*eV`4O*;v-i9;$ zR0eEIF@_2_jKV7%EMRs3;<{Z}{fOhd8kmN2XR{86wA)Gw7$uW{Dez|WwzYkQ4jH2V ziT@!rt{TsCI6F{RU_3QXuVgp)8J3s#*)TTcznuj146sJHY z$P%!P14S_yZ&v0jvCUaCFV;@gU90>G9u~sRGlCW;-Ybt!Pfxpo0Qqn~MNfdu=mrOz z<98w-yjehR>>vR(DT1g$*bi?7c%z^$G=PzDHv+zE|L692 zllo68B?+}6J{+$3YmJ&l5u!*|@Q9E|991flO6o9s>FgWqWbmlT?R7|acy#EiC?Bnm zQIW}z@X*Lm5S3TPyMKA~q5G4z_Q8a;wKaSk^ITx`zy`YM=vbD|$Jr(iFx)**`={K7 z&K{4*d)O`z;YGw6qQ{u;I6FIQS>0DGZ#1q1DfZHyRZ0r^qPy7LFJ-l9a4|v9W_W)4 znzC}+FVHpvKV12Ww%uCnIVoF>x9jU^%dh@4;Nez&NoPcxm?`J@3w;-52eo{4t2Lr5 zuxzuh@PLd!aB8zk(L+SH=EC(NQ-?CP+rW=B8w$EeZ zOXp$ehRumA~QXnFe&|+1c48Y1od_z*;9IA^E-^`EHP|<^HL!;52w!zOJu_ z>u!6o3or5MxpOC7(v%y~f@jadWpHkIL5=*oyO9t*<2r^bBB1~WV;k7qt#*A~&_!UK zUD%jGsVVE~rVM|yln^HF(77g}RC0MnBc=jvU~@}v;thhu7nnJxpwg3Hi-EKfLLxZn zBxhdsSj7juEr{aL7c1Ps_V(*&PGBfFOBfnXFDAnp7EoXl5+d++2YA%gMP`g4Qfs5N z;-kh^p4Vb#W@xiS#l^4RTBM@=`ve7=0}DfftFGx_)`C8%yeJZvWC}|{EMn_$>6uWw0PxeAnjuoQvMag<{ARtX3x!>2d<-~4XJ z)xD5`lL@auPEHoG8<6~az_UDhgzQMGtFMo=SBayOh;u6F4hhSI2?5KtQa`8({lVzq z@l_u!M|;#}xFK}2_$Xk~3=@Tlit|9oE?mc1kLfHu6Uls5pcRn(nLw#AVb98%!?yB) z`;a(+xVWEA;NM4s-!olV!U0Tp!?CJU^3HTbt)C2^E85w?ew(oC^>%-;$kerZ^5c=ziwLokq!8;(3it*AkGWrg# z6?%GlAn9WRUtu*Ed9prg%X%FImVky6;XyX_>Fv`9@S_&FD2IcGC*rRUoJK;z?-_~< z3$ahb<5<~U$;inUo;`p5oa#V+0tSUPO@aWFB`_cSx_n!rfR<(Rhc$|Ro*I?gfex4- z0_2+(hjiLRFmH`DFp}e`dt}Q6Qx>${dM5NY1rD;}HRKeh>&3ksnD6xTW&Kr+7D`YM8( zo14uFOho`1u0+#&Zrr%!(>++5k-eo{b+K;+iSflUaf_C@YVG+z|Vpc*{h7FSeIaId%tVG_~v zxU8?NNLY4aH_?Fu1}IJ}EMPj=R7xZLQ?z-;Z;}3=ms`NAN0P|I%*@?Yg*#FiCW220 z5u79g~tPu&7|6r9nLZU&Wh2rbsay;RpgLI?Lb{5^CE z1@s4Q(Dw3Z-B^~bC@yaEy?giJ#s(KSgfhv6sXo36_D;pQxfWpD3R$J)C&(>8V{agR zHgCYq8KPzLQd|ays+(p}f`DyjIx_+I6QYcfG+0|O1)!zn7xe4gRt4iC0OQ_ z-K~izC}33<3F>;f@43bptq zuK-fM5BFK05UPWaP7I$x@;y9LR$M_Aa(OQlFW~gOM<4I#$OxJa2i6K?Wc%QK4{bz< zms7JMgvDC$D&QqYMd23Xe4Y&0-rABdZ#@I8pdVkdo}YlAKRejDARex1Fu(DEX)7b; zs#(H=u5547a|1v+IBNV*2(l>xG7y+0Vn-ZD>lp+t?D2knkVQdk(x5Nu-2{(xW|) zJph9#k}LjiS&9Dr+8zy+Dm$}3HZw~+?;PPoRx1;U5ae7Xs`D>UesYa9DCe)H=GlFx>hpWLaQ> zYW!mUC-BH}J70+SJcu`LABIwHcMne;LN!p%3Rhi${0WjuU9FKfusl(lu`@F>VB2^P z3{zl33%qZxsQ3ax(?>$L^+3+35QZDob(u~{gs~?P0v)^yGz_71Lrtyg)-ExfFGT#G zyZyiDuX<}hWYNIM$e08b1SBj1?#tKylTQ`#7Ev;f7+sEPt7fo}dzqYVJ(<(Tam@A4 zjx3USDueRQcf?6ixnUeW%qnT2EQ9fK{KnD?bf>Wlgx^}$)~tE^6IX#nheXny-t(7e zYE-$df{TmyIWzrZCx?hgEm~D(Fx3+9>$eQX7|wHRYinOg3A%&x-M1G>NEioZdwM!L zkf@jr9)?rEcH8>=*;r5SgZB(Ut)!EZ68j?u=z-tAFXQiTDq!sE_XFKHd3i$)v@(uR zb%1*w9={72S*`nR%7`2#@pD)Sx;3%d+rT~+a6w-GdV!<0rGZ7JE)l8Ig?vRJnl(HRpttT zXdkUcGQ6YbRjt_5WOv(cMM12vrJ$zfA`44LGH-Nr)@(WWy+AX*@`3Cu_|NX@L(T{n zzrhf9z+W^bZ=%iRj#jC;+)FY*LC+#Xekb)U=uZVO=$}vZC+(TZ9T*q@hmf4_02_P< zYyVp|xAG7uadF`0sAmbQjZ{bKT%@QnfI`~NuBfFY0Gx*0+`fKYVh2=ab90l0xxFWb zn)_n%zd&B#?qU|{!lFd|ag{@-EGN9+z?OIv^7LYHQny8uc2U(0n8^(r=!F5_rRQ(@ z`=N58;Jg699Vpi-=gwX$Q-PH53(T+qRD@@8Yz$iW7pn?}C&pW%1>GmNTB zz>R|0K>{)=t)*p2h`b50aD%M_d~*N(eTZK2Gog27fQ`n<%UfPjk~o6}4n=kJZosDS zp5#UEojEgn{Pf7L1WQx#JeJP}g8&V$hHqXALaeVi*9Xzwl4q3WUnoKzF+XYuTwI>O9)IxQL1N+}yu6c>bIRodw;l9R=dcol>lzv)zrFYHHba|9 zB*u1BL)3*toXOE8opTdVJrHDC1q1w1W>l@>plig{JzE4o57^#LK}iX${-+e)(1iT@ zSU_C-N5HQiPa-c-1CJ3B4C&oHm22TtCOHXHKMD8e84_@*ZR{%B2|%60PCUTpD|AyGs;F%tJFNKVZ_bzBu&I?XX3wg^BaILuR zbboYrG7ICs-Mn z3d}wShKKnD1V&x2o2){K9!tClaB9KCtMP*-(-CT@277f_Qt&~Zn3zzh;kWUAKX$(SW1V^Hk~`u_l-6J%x6`)9Za2nb-^l8ShB_4N7-WooiM z%gM_Z(BFTRWOM=j+W+_i{SE`=IZn|D(A$%MXzz9SJ8PN4U7R?Y=3?5L?V6mVl$y2F zAQ-jJp>h$2TI%e1OmyHr4}-@mER5bjTirCjTIpri)d3P;Xghwtk>z*U~Czpfk2^(Lx>6riws0h@>jZ2=57 zsOwKC1i>_X4QR=Zj+NmfD<3p)(3&=w#UBE9#Mn=F>I~ODQ z2Q_$|g4;-GDYwe}zG(~$~JyWo_p~2_`*Y+n!VXmvKeJFnoVrIU!wN^|h$RZ>C zUwcL>lD6N6DR7!XaAo#60mcW>Ki>o#oiiZuzFr%ok`S}<(R2F)9p!5X@5zuz!X2)D zzN-#Msz7>KNrg&AAtS=3Y8neaBLEh`1cWPN&ttFGU3Hx zdFtxq!P!w)Cydg(REfDoP_F*-6X;QbKSOA056!>3)PQy?vyqK?`0yQY&t>K0hNp(G zHHBbrS2=cOLgxjnXdq)dS8h~KUmFd$_aiP43O_uQ0%fdQzVs! zTy=X}$4oW{?qI_ul`(XwDJft`*bsX68Z&d=p?YJz_BwYRNz2D#L&s~WB%(;J&M{pc z)S%ZL0yB*zw&Db-PTGJ{yFKU`kaI;kwYlGGPmKD_!%+8da$7QrY=G1;`9jrlczeraH%2wTa`jo-32qGl0EyoSDlMC9(#@bBKH zp8S%1_tLpml$?A5Sedy-pd}(7@MUgqp2eGz9tllC6s813V^=o)Rrva~Ye(R9lL871K1; zH|~%V%^6rLpz3yBp(%g(kSHz=?-yW+8rZw3P?buXTIo;WkB3}Q($@c?WOlf>hc+^) z#I%h&*Y*K$4}SC32QX^RWcs2e8LH~d1}3&4>Q$!z5Z zpbo0PYYkx`%w4)UHY(lo$>`L^MBUk$@B2A18ZH`Bb0}Z>+(`#%^+VK~z_lNsu;`LB zPu(yAAelQe_-*p*Xf&$>2OVZvBFcw@kc9ghWa{AK3}-y~Gf?Dc=;)ek`YBda_Uquv zzSeNUq=cn}q~!gS<{7|Ih!H3)tU;?eDO2skXLDW_3h0iSvk zvc-MiSffV*$jF=})HgEXa=8i?m{380oPr+EgwU`D-@pul*itFtJLC?#w~pA=Ujmp9 zmqM~W6tLu>k@2ryrmB@ujuOS@z^Ghi8Y(^&j5#?uIYf%mILGr-D5%Krus`LmeE|C# zZN*E{_MP3`k5O*2h8M4l8JU|i(i{Ro7W8^xV_T@2G4jgxM%6JSSN&Na9NkLMH$(Je zcT0g5ArS{`GqyH1W>jfN<3i1U0joP$F8>j`T4o3hh{IRG7>SRPMTJO8PFD6l*aPP& zo6{!u0)zu9Oo^*k<>L}A!|0y}56%b%gB!{G%9J;ZOR=M0qvJ5}3FkNgt?2ywWE^Cj z>+pqS1ujC~9I+RH!=({%ap3iG4N@7H635o<-)4_iNc1g}w9U==Lk3l}2pup3PZ5)I z4y2c){J&SdXWii*ZUoyMNq!w1E;o<_!udksZd@0=gH6L5MX073a~xQTwx&|RO*0FN z;vA9y$8%Oi<^=t`T9_7}OOO-U$KBM_1dXaVfn%7#w!;nSzhKOd?G}buideOb)8IX{ z$LCigPoMoun+#6iUBav&u7o#CyM^W?@wBy`sm2v%MRUF||7m5k05ePGUc1)pt2A_S zHiIQE7qFZ31RN4E-Fm>+PU>Jx+nxAZ#p^G=FhLJdBY2N|aRrilxCk5_YA%ox#qJ0! z=0Ih@}+Z-H;r_dn%LkWzWB-p`jse(5oOL?c@laE}V>B52xthH7DAQ z$Em~UY((qWY=VMpI$K%Qp3Z|;*)4#Ypl+c++^xz0Wc_->v2y_hEld`;Q!0`gOcL}R zc-q-0!TV5lUI9Hev;d3q^TnXm_Zrer7{LQK*}}^15RD~I251cg&uh*UsR^R3LV)L- z%iAjs)7iUAU+{kqYJ3SNedHncj3>0od)}elC0`xFWFH9RV=ul8AzX*RX!K35R$UbI z`Z4j~{&#WsbsiJn8ytBxgX_#ZktVONNSuqs8e9bl_u`W}hbq-7F+rx@d#K8jlY3-w za8Mer@bM82ntf?J=j@Ljp#zj`@2wdjWswSHxYvQ0?Tq4ZT>-GlR5mdGRdkX(0R<&x z{*$Ohv{VV+evz(Vk?`3DU!}a+2L!!Z*iUDR$)$Bm|EBgE)`VZXKjMz1J19RDyi#)k z59%?eKTM%9Gfz13m3J^wV7*K+!VtI77aS;`&_gdUVh|5O343;N0y{rSqp1s|?Fd{2 zX_^G}mG27C6iqwrczs`KVQ4DrI`h}QL03yim*u8(PxSP(wOPV5E_A+Po0BKk%0dXd zc*B3|?4%9qRaoMBmw|!7)R-qdM>r%+OciA&oR+h`304>>0Iq4wxohT}y!QHBI6x9V zP>c>inX=xmZ?@crUSrXfN)k7>#+qYfnz6g5XQ#7=hr64-qDX75re_}+b-u0LSx@jb zA(lT)na1=5e`MZw=UCPgiC!pv|Juo+!r`+*#;%f=9i8@>PIhEaAkV}HraQnvTsd&!G@Qy?gESp!j3*yqU&56iVfVD_p1=RoEPi$E8o?NfXrZI>h!R}L zocrQMBP>{F1Y@|)HS88q`P$kg8+H{@B1Fhw=vKM0NI*aUh<|~ou`-0Gf^7=P1xqS4 zX9?F=PdI(|G}6s36*2&;_8t_eaQ_iq-8oYs0{+0jD5$&Bghb%M{B}Gg9rZ9v>{uki z_hcUoZK*d1rY$SMfpF;)Ol8QLE-b#7n|78FlyeRxs`4|j7SSZ(J81j&tH34z*GtV| zxbG94-#&2%gQ$L7q^+^3snd~>Ajz=NzPkJO{dM2qzMKIF{FyUSG{Iwn$(Lz1pa}q) zL{A7Yg7j5LPoE}d=LSgq7b(J1S&Sqr_`p6ByiZ(l>7nWZ-&v6715Vhm=88f)QI|4) zxKJcW{J{FO!_$M+4G&5#m1S4KqFpaPWi2OM0%dIp2B=-ixUc^KphHPob;9|4mOh6!R=@O$+U)OpbL7GkIs4o!d5m3)>;{!8dW zDut5kY=q$_D?R?zPoG2%xHp#}N|P*cgEf?MW>?y~4@+`-Qt-XtHy_o9Q4O;0cBzKkAe#%EOZ=H)#yB%g$Y z=|G;Ap&{q$4Sq*ijs{?W2{JOGy?DX_&{eqJfx}^ofW^<7RbJV?YxJ$ii0hYWK`{y^ zST*&f_Oa2}(cVqZJIcHcE6hcT|1i?F6hg=A8J%Jiau3-b8qsuVH~Db^6A!`bOz^w% z&pXUBQ>us2Rs|WZsKrhQ)JoTMMN`6TI={`S9V@=Z9tHROs7gU=ORDoB`cG%o9#Oqj zx|H{RtmynI1q*YJ+9~dWZD$Q6(9pnU==boES5l%K%xlGj8yoF-|NQx-yiG7o=)hSc zsIl;EO82r;7SZdxD+bUQgnBA|%TAbT4*WF9`yXS#E`jM$0J!&tEW|oLC`|`%ES2r* zNi%K2u;E>dIGA_C&i>4J^&?fB67z(h&}S6^^4RZ*F$8l=GwBvw=Y8GaF4a`xV9?P9 zecPTP*03bUdpZ+;P2e2#Xdokk85H>$u`mq@ExF(c zY6sE@lpLRO<-+Cp57pK)bI9*Wa# zNPN-W|5#YGKZU^$$wKo)#l<}d09eL~k07M+QG<&0epp!8#+FPOqNS+`!~tIgxVTj6 zM?QRz>~_yF*fOfF$^ik7=vVS>zIT{12ePxfE=uf2slNK)D?hz^0xdy0uq|vHnVqFg zn1o`KD?C)1qX8OmFdwGxEH80};<6A6i=4}u$k|0eYZAZA9s~YmNg!#IdD-F|RGGCR zrBqq$m;=&7MrLR)SJ2}Y*0i`T$igrZ6(Zf>oN5c)px?{?b*AO#!E=~1kvz%B^%2Gw z6i2QLbiS8>!dhS;6Ry)b0SGDjkz|ms%&TN0v@ZAZWwavCmqm*z3hGfJ zqO@O;D-evqdI)+o*KWZ~Ee9y=OQbWd4MPnFTsU2QeOkhmpgsvub5!^PJ~T4eN!K5d zjaFN#7t8oS|7W_bO~&Q=FhZjpdX2j(WRqx0lM=m;{1pZodZV|`15R(-2#394z9Ke< zukHu|x=vueB!$4>hK&u|7cRR7J1fOcHxgomwjp8Ir}@3BVQdTy4_=DxCnk@G_s+a} z39B^0n3sSkQy@0j^wE4YpTS^^=Fe@MfPiI;=whC-N8Jq8bg!!|&LuEPmXrqp^g&DMPPs z1x(S!tB3_jFc$*24f5pPy-)AbQc@ycoB}4jw79r`Xm82SJhHvA!nS%FP>gVhOccNu za?@D31%Mlk_#Ga?Er9kiGdC6%0tMuebKppk+uTfVgSvO*D&OYzR`JvBDK$tbs7o z{Hytgww5-Wf43$MjC=ESP}|$^D=I04hlK%}e7#`|Lv--Mb8=CPa}FD5rWG`uK?54K z+eSFdD$j)0pB9v?$$Gv+D4?~aOn`_Pa%qM_)0ufAaDuoai=e)Md zccWVx;i|MeA)`wJ$P=90LP6Z^8*v7cE;ppw{W>w~<0ox*nv~lM0I~%82iT!sNGA%K z+)6^GOakHy2xufHIP!~QM4AVCKLYQEeoRFUI+I0A7(K%5H`uyK1$xu_u*&gcMER1f z+Fb+4{W1zwbpQ8^%#SzwZ53O%u8qYS=I!-u-Iq^hauRy+;)PZSSK?J28ujL)AaH4C z|IrFXd;1t=X5)&o`M^x**{b&KCANnNN#+mm{(bJUg?I;Zwx@3WkcLvxS9TC zn}^{5$`L6Opo!soczSdMK}2qlVkXA6MST_YYj1Ut=b)x3!y9L94c=FRL7J)5; z6G#C@)^jJ%=BHV1fsrj}#6QJ;`M7bnG8wEpv^>?m)C6Lek^1REx_1vYj@32S;z#`a zWV80Q!#D2Z&%+D6m_v86Eb*1DPG{l-8hm8_6s&&fcGy8;6Z{+;cwwiR2Z8PEjZ5fh zoE!|Ee#k`|C+-6JUE~0ukb0%n=bIN;;&m+Fdqmd()Eh$xqNcmd`c70#%vW3BIMerx1!{xfC9f*KXi3A=y6EL)D z;p|mVS|$cBO0ke&F<&XOAjg7Vc|=?MNYS`B%bd)}K3UbEne+UhlJAzadtU*-bsxMIb*kReA=|^fT`x|)qLyl>__X} zpqCftBz@9iIj{NrLHSz{=tG8*4)_GU+Wl4(S6FBPgQ}M=KMj3}hKY^nSTiJK-HP^d zifQ&XEGKcFkTlUVD2fI4x=2j3b4Y&Q#ObZa{3jkA`5u3zD=Ux@fmAm4`w_+)NfqJl z%m5#!PSCeLA%UqvPZxY9vO8)q3X1dnr z9tSTvE2)Lo>{XQ2i-Y}0rmYh$&e4vJkCO!5>y8y&iWnDZ*zAzN(4G~X8EBeu#RqP} z7dDtga?IDZJ%xIDJq$pcewhB`F!%#{7Ilbo6tG;e=KR*1@W( zYOsuWMzmL3+Y$?>3Q5w?PQntqHCe6tMctTZV54uy_MRalc-2C04%h>dcLWK?$Za#* z>u-)v0#`WDY>`ykrg%t3X9ReP_?5;#YZgx2$Bc{xSjgi6)_X`+`~^Bmz>=VujL2(+ z@VI;0Ups&!9!dR7As4PwK0xcaxl!$%H%Yl3GBA9utR$R_P^yA9SaA*5&{yE`pOc6O z$rTXCF-=?H9)cjtIQDve!vip}hTftaqPK%mZYE-fwF2+|^!OMihC z|E$G*Z%k*9HU*$P_TwY#(10H;A-bUnX?Vy#8^VYCa0Q5&$T#&bKlAS;;evxyE6H!r zf%fO$&!Z%B!b`W=)$m~pAPFB`x1E*wcL(_I$5Uu9-{U_uYOo+Q1dGhTl6`eW)G+^V zH;Vk{;qwaoYHxrp@P2RxO^{8F1oC-DY7MPx&PT3cHVP5{6#e(cz*!?dVa&5!q@wxn zifyUB2>h46?lHjzuIHYqJ z!S2tKwWG4P3zm8W1-os}|NJj0m&xT{mUQqy!gWB3UXiYQZZ%g&K`jBU3 zKc9B|hdUnZ-G5Y_CWnb+8;v~ND97B#Z+RWR@>X)??04fKPcO|r^>gOO(wc(Q$_Kfr zzohJC?U)5^hts@K_ggO&IlZ3KV8qyeAnECqb=&rdU2Egt#U{HU*iO`>T3u2jhv_lT zNkjKPhiWqHQcF5VrESopg~Wvb86tTgXa7JTn9P;5g=A}pQ6+1T5kq?Q-T>i$oXqHr zvq%480sq&Dt^9Lh+2_Q4O|?dmxoQs{SkTeO_;(REfBE}-{ZFrL-!a^!=U5{yp3-_K+_HF! zsOpx?*dKkW3H4le`B^kC`!|8+=>nrj)6kUpp81!g{>Zh;m#KLIZGaTb!omWUjj?d3 zT-{(Lkrp*1<2mLDrdZxs+eS5b|DZ}lZ^sSRHgw!(qJ@<{^|?SFo%uIJyM zdgafGn(azNzC~$d@s}BDd+Hl#QX|*~V`TQsj~>RPUr;x^42P=k-NH|nT3=U|nt$&9 z4*23m>fdy-gK2n`J*tVaxWCN#AM~Sfxa*@)BqRQ#-6Qf?d{#ZP{+fW~*L#$IvhvhPSkcj@isxOcMKypG zgM!)eX9a;T=UkuyMZUGK8GJtH-rP~WImT!r0ph@MbArM zMFZi9J>!$eS*#5m=BW^8jNn=U) z=O0_Q?hVQ^^iHKw5_ipp-s3Q^(MTftz?7=98fsru+rObd$xZs7OKRFUu-R?|8mR~C zIm37PK<)q;Gl*_HE8N`pS}z#;}ydJq|MnhGL>^)5eJ#g z5fKg!<=@r*&mQy`>J}6?+EHs#r-o-BYWVkHpvw7;{9gU2xPWpHt^wKj?>+wWy~t|# z4q@*X(xwBj)fAV;q%pp^B@_+RvtuwXvoEfLL`&%z2`Q=Ym_-?6Ajm(T0d(Ygn*5U6 zdjoh|aJ8id2&X)mK#unJAA8A78XPX$FFgk53F%JZ89e_AbC;HY_0+KZ7@1HzR>p`A zGp2BefBla$q2H$ex`?xYUpsQcc*AKZLub@DKN@(2#Cg`rZv$pc)?piMat>WoX5f`o z!5Jr+d;m{{c}J_*OrP{jrFKa+Qc3%R=#k-TrEw5y`kKwn&HddS{v7-xGGt(wq+oPey*yTRRQ_&Z(LklVCn&)Dv(sc_!kR!xuBx0 zlxJZn1ge1j+RyH`EKrb6fv-3)gZcnwfl5>2t6>b3+Kd$;F!SZHrIY*i;|Jhj)Q}1d z5F(^EARnVqbXC=ucbDoXCcx2_!OuLmy}eJs^(&a8LcXBd7nH{ijYB`r&UgYzc$w}$ zZft2e?qX2|?yw>i;|n#GpFiJ{k*)&<`dm#7lGdzp_*bUuW9sXty>C_DMEKJ}E&Bc_ zXi?-+>2k|+3De>9`}>`8re%gkM#C@2L^><*wSve(ow3Cyo8xr8#A995p^ydZi z3=Be)iM%mj64`G$Y<2DEWdvu- z-+dLi#V`z%?=0<_aHorbj))~paz6SpHg6OdA=u3}F4`2!UN{|z&@1jv@--Etwu0MBN(-HuaWupgeRrAV5!zOG%i)3NKx zAi|}+&wns7spj}Aj-dZ_@pccddkeSVPBX?_*xugYAuTPI)9>zHz?)2Mu{=D;rrl0i zj^BEP2pl+fK>K~Iep}c;+S1PI*uDj|L(ErvNz=l$*m%%f(k}buP}!OM2X|{5jAu$L zNrEYL25pyL;r>~o6m6gn+6W1vvGkqg`~Z4%B+n__96w54@Lc%|3QWVng*vaWa2g(1 zfcQYZ0sIbD+vPs^09mU;l@-S5VYQGh4a3SXNGt(C0X*-h*RBN;vZke`zJQlPUs_ze z4UNJ@!O&u1X$e%PUm#@{&@Aw~iDNbJp1lsL7tOrhjt*{cfrG{lgvx-?t|Oj*j9lX`knQEmM>6IAW2F5g@vgcvrj#bokwMehf`XuMpMFSAWXVtzC?s z`@XSZ@tYzkGsRM~sH8-M5}eGT3TaRW_W5D1S~FTrn8e_ zgo&GU(-kWlJ4uOU55#7TO=p6|gR_fMvS*saf^J+?o2mtd%x7og7z8Xa(b1Cm-FpL- z+5kFBOLr~0r`^z+)cTxj3NQ9Tlr<)BF!&45C)G`)#nzq{{{C!pzq`_!rUYDX^YUs^ zQzh~mj%NhlM(-tX*8X~B-hvt|_xN|!HLZFb>vN~#ii)L0#^Hr1S$>-Qr4?f-?O4-Y zLl;7}*G=BqB#+nMxOKHk*;_YjV;=J4?9|bsIAGqqz7m9b9e{w~bxq-}{ngDdvR=I3(K^fQaJbo@ zX;H|8U4zf>+8){r>)Lz=?UJghip6BPyb0SJpK9UM7o#nl9J5$W;?>h8ii_N{rS(x< z0;Wm>#Kk;ho+R5Q5att@R*#bkx*c4cx&zz|$<)+kBu}5xKxz>$U*uv2W6nxe2A%-F zrs9iK3Sx$-(AdiJ>5GG3acl(5UvhJ$9UZqlv3fQ~*$z?=E}J=77s@(1^rKuh7$5#_ zz3p_SGRP`KN>1+a31jVEz^hk^#wN6m9UJi$Ez{GwOz_gu4ZB08SEu`9c6M0V5RYAu zglnMZgEnm8T!J$~6`+1loM+3tn}n$vumOPY80icy2PqK|xcEiIAP)vay!_4RBj82B zpxfKW2ZFW#cz`gnxnJCDwq;~sKOMieqh+)2Wnzh$ z*VmO5#KaB;KCPLUu9Ba0Bo@zmml2uu zd!hm=J$HC2BZ`0oj}10nr~^bFK_jUowc4L9ZEG7#m<8mqo$J@FrroNASg0PE zjV)ikI;UEI9MNMnneT5-+O>z)0sFK1>(`7=+N1S|qs>X5mcn1XDS);q!>gfo(WUEV zwQDG)uHFK(wt_Pa3yWS!+=$z^osW*~D&1<2zQ+!OYUk(G!K_}$y%Q_^xo`!f0Yq$061_Uw!v&-F zNTR5nvr54zE{SV8UH3f{tnZu#6&*0e7^(m^9RN?pACG2jWL-w=Aeauv6+~bwk;z= z=qvG-TB!T^nHQLhY_KH`r@A8`-}S)H7ky`Mue~PPbz#|<{bp?ly`q#-W}v^H-#7Ky zMi!l#8vT~mw6xoBDG(Qu^70PFfjXWkS#p^g4>`VzaRgW*{JJhZM6f1@vo~tX;W&j zo_<`PRC~gfTf@zbW6xo?x*Qv8c}8Y^pfwi9mNQ;K0n5dw@qd79MfS@KjM>; z+<)yI*PaFw*UFdkxVQx!OjkQpEHC%vs+K^ZSpTZ~CyXF#yJeWf)YJt!7MY*+ zHepTUkxw136%HqIg;5o*!*T{|L+Whp(VQj+hhXAHl7rmlr)rdqQD@Q84u~oSs~W6N z_-lh`w+Ig-LIVSt5+9nQd-q8`|@)BAzL2WpFU|nl?CA}Hg6J4XZNKX^oy>pxc07}$uZsf|{ z3ag`AlAIq}0n7dc&K@%}*oB_*w+eJxXgRW=zi~M~v0H2fT^2tutwHDt)(KO-WSwSE zoYQDu@tL)-vECCjhrEku+Z477a0`ivpMg;?G$~fMP*;`Vnc5TT5tBl?x=;4Uyc{)oMl?dR6STL~S{Cq1sejmAdDDPMamODHQZFZYZ5%pbq_ zsi`Xw6c+NLxu+#MI2cpyx!W{xe|3!RiR_E4;P3}GAzbv*<0muQ$4k1;nRF0QfyZ2m zo0~wTny9|pes0R$gZt0NR(3P3uJ~Tsr|8p6tb*-I_z^S?Zx^GmuyZ49)18KX3?Irb zvsy1ad0T*gS+UjQ>O4+)eP+NWA%0mx!WyiHrlMoEr#T{fuVGJWZ&yfP3f!$nT<{ht zsH%qSZvA9l4d!ii-R=0Y*i}Ew`0eOuv;KTzo-Vglq_Vn`#Uk@0@DZOLhCS8F`?pwp z4@vO+V>KitjU^;#*X>98>JUdu)xK{ee~CEa*?EkR=o=p&2Wxwh_4JpoX zldU6j-w`0~$5_+d8QvXC>471vZ$>VcE#tM{sF*))J)Vat2|>NKF}1yZBM*j!7|v#rXrOv}qI+P5~lpTFYbWU-Kc;Y*usatEUX?Pk75}<5s4Wo zhW+N=^Im*ycjQu)qf6h`5enE79UGesG=)Gev9+^nSz^KAfibdnoy&29C#QfSRGRUS zY<~6`Xf{9-h7=ptDQ}sJ_TFXZ`%O`ESMTTSH0?agxlZhS3Y3J5Ta(qCVpkq$y*7`` zQ_6a_lVW&Z)CzkQ>P6R;Pluq4Hp;?S0v?5+cpkbH#)syMosf!RC}PgX3$&@-535xp zg&v8vKrNSoV!v8xw|2o{S`K&sJ`TGRtIspR_WP543oO+-ZIg(STrm-5Q&b~AGLU*pgf&>#Az|c1{*C7 zHR`gLZxO0s#hX#ny&&Aq?W!g629-nsS8|qSA)KWq>CIXPr?+ptrdp}R_UgClcz1Vi zOuJj`6we!BD~jA(S;->c+y1ux`E&e_+zL9*iytiR;G~pXW?BK4{FVhZ4en?8M>%~= zqmq`EGfje3xF7Sd1k}Emjk|qVQ1lKFaN?v2kQa1Cl=Rlo#bN#MaTq>69)5a&3O-fe zVTI}2op!OAg|>Qo>6wd*!<{X#&#?Lo{CIikYHND_t3o>$=gy2iH?QAZ_pRhubs8=p zu_yxl40*F_F(VXW8h2h;@7^t~NI743%ezBF+tJ~P!@h*up;kR z*y|z$PG0AX;F+L#l^YKxJ4|bT=qvQ`IjudE6Is}AKwCPSM%2E)^{d+%pWut_3W-`W z@1b$WGa;c1Uj(I^FFsXaGySAZ?@)-KPPxNY|JfPIns^ZLgqq*5w7~hBbq|!Y2s(As#sm==&{fA z^Hqq&cy|p#CW9FHt*Bo1r)~8lR?V!e1YbH!51HC;#E?hKVj=h)Hq(Y)q262$vUZ#G zQtcnaAt!&7v85zG$*I5aj#|98onGiZetPn&)y3%VUq7`UYzf*gB}L%kIy0yqY&g%S z`1x5`BN6EA!a@go`>9XT`P@sv(BIF8^p2NW4ab*__YGFt2Q@0aSZKfOe$+GwslW2a zJ;v>jLHl`}gYn1n*fMot%h@Jr-b6u&Q-x>5L1T3&$em`&d&!V&*6X7(v2Yr!-wS zT6q-IlYI4(TNQiKF^`1b^U9{Mz8c8LfW)x_*kc>7Do!~LWeZjIzQC${0zl->yf~AZ zWm{quE*6=9af1j*hO*`u`t+cB@EX4x&q}-yNMry;?zrl~zqfmIv!7 zi%zsgS<`2~UVhlb_NHfKYMP!zYoYt*p#v5)MS(f z!dD~I%Og@#FMZIMerwd<8I>UFg7MNT`kl0|-}SA&2F*v{*4c(DKM{cPp{a$K9c%sV z{%2-7$;4-_uKa2J;=zgdSFi7FO>4d>SLfsm@*VGJ7ChYK_kZ@oY{GOPBMdzSv0-81 zFC|0`V(wE_U5?ffFb<*A@w)#ZS4{1^?_)$Ic8(Qh^P5un#aW=EoZdfZ?x;DOQk@Gp zeXUI4Ub8-K!Fl(_xQtT;N~Rp}IF@Xaq#@14Wi6kj?)aDUHRAr;+Z6trD_L7@_Yto9 z^FbsI>3ZpJQ%R`$d>Nl|>5}&}*!G^y35m8rKzoTw?&EZa87CYaQ07fP@wiFyvqFj1 z{n*f4T|d92h?;sL_#NlmdG_;b3TvyY+qtlB)tv&USkQA6H53h%`Ng&m0w8Eqm3OaT zy)?&>-u%g|G1c0v|AzV3)HB1JoR~h|_wUapQM8F<(20#Dq_qH@w1Iz9 zoom~t=U%}!n-eX=(??o+wi8W=)9H|Hwd)wBi7%wz7+BeDO}pO|#67(TO{OY9x1;5d zxvNk9#RB@1tP-@#-z3F`N{0{ZdT!?)g7(51K=Mot<87{)_qgH`VxA4|C;Ft>5V}=*x%&S9G_X+2e z8r?6M2B94-UZ?e&@h@FmIE!p0b^&^>&vdG|q4DdO zKwLt)w1Pbk9j%^X@;fGQj(Pa7Z+LikaIhp!2sxf@A9Hl(^hU#O3e`wJf7;S^_+;T~ z17-1TMcgxou3>+i#%WgvoH?`3eKj_HQN*TyakE$LsoNbqkcC-82R@kfENZ7{vKk90 ze*BN9M8D!SJ1#VI@Y-%~j&WE6_>=eyHjHvJLEoyy6vGKn|F+cBXZcWtc9sxAcf#+B~)o#Ulx z@FdYlP7yXa8?(CR?+Q7EPSY=84ucx$plODwEQh>kWnByh>!WS@v#g3Q z1qI~8@4s#z9;zf)Q7ZE@XfUOd<9ra|8XOShoT8S5mcHy$t}5HU6kgaQH1uPix`O!J1_O%AVx6VK zX79Vqd;{+mAFx4!3){+rM~?~Ft3x|GlZymXgz0nNSaZt2n2)p5=yLFB2W{8Iaqm(y znTm;!9rde}xVZ5u8%Rg+ZI8xQ-I?`~hG|EIwbPANz`)h^xWYy#+1@xZ18C@(f>E!Q zQ{mRss!c71=^GR@R%umHv%9-!8W$(X23}kam73?LXJT6ihciR>A0i@U%%;L3w z-4IItDvr~lHI9w*Hpi7KJivDqeG2}096tp`6kSl{ML13FKuc$Sy5C z>tb<-19mjDk^}J*;~BX6V*&aKtGqy%&$Ejh%o=379vLg-KU$@ zQUbLko9^~qnIxZ%;C%pSPnfX0|RApa&SfnGz3VOrU(UP6? zG;m_jt~+bud|liCPI@$x6HNo%>UK2R*513mF!u5GG|{SYEC$^$cqo9+isO6`=YpgP zT!9~-0^EicfQ1c?rfyc(j4t+v1y9&XNr2$(Z`Pbm4)}>7zmybl)6HFnbj|*9vr|w? z6XN1t^ot|liV|G;w$!7|PD(WeP@;&fEny>}FZ0SdPypFk3LoD6*4x%59oHP1l$1Lm zli?Z}eg6764o;kn?bm&0t}#F;nkMj{II$A7#FAE_Yw=y=D}P`7YcEfjSInb%Vp)gY4jj{aVY{e?4 z>Zf-ea#noXwuFYh*2xaWwnALk;>tQWV;f)X#_t1tPEqOtdFEsNT2wH3YeFIK^R0i2vv zYgaa!pBguLzf^AfZeut7W}>IRb^Ur{9AS^Z~XGet1+lt}IQ?YL- zv*=4Rz8-GyKDPO#rZx0Qn&`-eBqky%&Us~k!?xh^RVSck&c%7523C(Yc+&2l#OO7d zQkLGZQuIXK??1(pQX5c-uzvaV!HVe0E9u|i-yJ$$SOob<)PI({A`bh_TN#W|;Aai_|k??d8T%gU?j+H*Z{sfuZt@W7rlx)y9VKe}3PZPB^3cYU!SHqkTt#2MUZ#Ko1iZV#!*9O{#0C4mJ6V)qQZO{z zZx0*X?R<`SBYtt>Y-KfC zWkb-DTiUeZDq{V!rWPnJv*Jnnys)}KHvQITp0&)vg?B82%73rNj9R!xW znZmxd(R?M|zr6(-gj$^gF@;bB7_s0eW3G}yrW9Pv!3v8B->|EqSI~r=K+k~gyx2-_ zGVxG24~^hG+sbgEYRs}F@9BNSz0>9NPoQ-W^P{{Oh{$Noj4hizO=7GcH!S6il`!vB zQQ=tX%?db3jEHdWA`lbzVbm?SH)f-(9BYXA7L%GaCx|jElR8dI-%NBbmD`5)aoJ^_ zbUqdlkZcbYn$~J2uC$D3Xjn_**fi_~Z+VmO##=$}z86zd(#8NR1bDXXfwgXI?R6xJrID`3_vNVbUj^y$;BdPJS|MPzAdDe!CB+SkhfB#mNo8bBH-`&{M1Zd{;oDHL@D#06j805nCN-P(++d`nr z2VT)5BOv8qWmOdT8O^Lo4hGwc(T=?P(DTi{K@uVqvaHaT6XqR33&pHkr)6MK1y6VB zWt?Eii12uMP{>l>{e~r^mHzhnBWF)b39!L4Ep+|jxU(wcdD7g{LKW|oefG`6^AfE-ezp3A;&#t_2gZQ^~Q3bQ+0r8}Y zn3#5cU&>=45r~LLmd<-WmZiE^KHF&ckqEKA@5sbeHt_sTg3@7wv6FGyZHc9`dmG;gN=#o- zOhWAG5~DYWi`u5f_sF3w@CXnGbDH5#EoWJ3wm%!<^b@%T*@@}GR-&gjf6&tM2|5=e zkQy=jqTot@*E+~te18WYpK|78vpN9iFG1#gXWd2laXNwdpJ*x~TXRH$6Unoyug=J8 zPc+vd+_EyHZwfE-P<})cNFr#mUN`l;gErx~6H}~{`92~dJ*Gn3%q)qJtBVp;Hf#VF`{PE3K>L?wwl`<^oq>Sp#`K*vd7x zB5g2L3kBRSYOG6u>2r&m52WJiH}T`c!@odeIoQ1rCZir~L!U=`|2~r6Ha=brpa(Ov z8ED-=eCkayA8|MFcZOud$sxr%{(yBz9_wVw>diYde`##Y=;j&F&)|Fst-^ z_mlrn=6s$)HzNMsw%GGm<_b%f-W0~qg8Ef{IL?V1jS#c<7&P z*j4}A%zVGhg%;A3TY4>`-4UZp({#gK9SyJ4OF<*0D3X>Y3_v3jfkmN?Hq4<5v$M}e zdoSMm#@ARW(4O~mVLOZUIjxUC>ck$uy8zi@pkwkEYbC{boQ4s1jjbx>-y^ZfROJ!`NzlT;;&^P@S6^Fo#XL>`|Fk;mL^RvAwBG1u+({Di^Qu6g{ zu@2aP(f_32v9+FS4y)7yH*}2di@uLa&rEG>ZI>sw6N0!VNN5vtx9u*T7Hkl=Vn40=7G*oV>SJ!;7Mg)=uuC=v*=Wvw7sxZ;x zvS-Q9^SsCgMXRb%fF2OXZ8Py6rQ~4ipK>XyZu$Uea@Q0FEG}tLn;s9zA5s-z}w~GHv{qaDmwfNZk94-T+*~Nr~V5jl46ccnRn(^NF=JHDI)O3s)A|(SMGNQ7zD| zH&`7~tUvBH@MR^;B>NuEak=ZulFCmRFyfdH;93PC|+I{8bMy9V%9mxX1A@Yv_ zuyS_jWvmSc)SsH;>fiRB)&zsM=ND`=9Q@ff-!dd61Mpq5v_kp>(yWYnb&AG%t+2Pj zVQ{4RI<>}-(s)3 z!{gqW6hhdqYw;OJQJu~NrbJCES7r2`@0V@LH5dNONA#~d3kc|N4x)cQl|~iUD%Nk( zc94w|bZ>>u5B$wCT3Ng}$@LF6sXNzi`G#!WUlE{FE|~c8vUj9dM6o@|lX#H#vBgC{ z`ZeE$*$RW!)Mp2bM%)b?K=oW1%K5G9QY`F(#!t(uOT;lmR#3g0g1%?JavNBwO!C*> zX-t(IwjcjqDJ~A1ON?SF&>H--N<0usd41Wh^9=*Q&I=1d1Km$DSNeVxrxb1XG&FdK z(Q0AKFtO6q4m{j>{Ns*dz9$+jU45MezYJ6B^Pxt~PLHc?R&BShlSW1TSSHY~m6(Cb z_RWjbX}|fU&Q5cF*(emt@w4?3ikF_Uu@AxY7O%AE>*1DQbRr*i1^UE-d?J@BSbzSU zU(J-KusNJm%J>;8_;ibYbjWE)IZN)s6P4!8M5XjIRbgQ)+v4}{DFiN~-`L$6%qs5H zskBVb?%s&qrN#PnaU3tq?$bK!!%hPg4v{DynW(h{*aiyIlvaSgkTLDQgCmw;(v>r0Z zeBrrx&=x>gZ1QsTe8*%ZY$(_Icd((6F=>~HAZyW4&H7L^N>1x{>ACIA@D^Wa_Z)>;W!+3ubm^ZU*sAV9MNGbsp`|23WY z!eS)9ed-~Q4vdrKzz1c|4UgQ-Akr=|Y8GRs!wx!0`<`{AIwRp-`*VByWFMb#7|BN^ zvZIGt7f%fjqkZ&s-b(tZFulD$P_}zOwd|!f97&(voQkfemxR~}(Oq%PVW&6lgN^8* zo!X(xbbGcdl+v}>a!%C0W_RGz%^cS4L8ardC`L}oKDS}*2pq03Rj7!OgE+^mk2lZM z5&#vWP9Ec}8;40zjCkPo?(Wf#LUJp6`$juI7MHHaE3F^EW%U%Ne)OwZ$`lvE$$o=J zSwZ1L=m6{?5e~tPh!y#zHM6*|uv&M-4lHNcU|xatmjB9A>6p4=%UMQiX3m!8Rnvf^ zM4lf`6;M26UXQGXnvLw_$69pz6eUq(NJGXSONPaprHivn$ z2)=#9L7R0~pl%%CsJ?a}KNX&Bz`Lb5hP1GR8Hx3$yYCJoQocWx^>az;iebZv!$4mr z#M^JW^DvvM)M&Aw4^=@6s??IBsG8eRgg7|w5BJm5?oFoNRgbF*e-QtmtQZJQ=*AAq zGw16iSXE!A&`EiCcmxhc__>OK!j!^oaFBX88}rVP?!_4fjy&G`_g4gtW+=L1>b(ey z46XenWn;YkDsnPYsf}JPg2j^K=7i*~ROu8rg!@G_yDfx-Q@9=VsOp}(vc=JuHAm{d zdCFY#2@0&N%#5d^*V);?sA!fz%m(FZt=68Yg z(9}5TAlt$%@oPfmT39Y^at|=4hMjqU6oncz2lLhIn3$2OV8nEv-uUi`@bl;MvXb{0 zZV~ora=Q$PV08!i9`!5G+=t1M+Fj8ho;*QRc|+E24hvr1d=!#0E?)jEyI%%zWr6;S zSyi9)#|QC)0>4qxzfufSjuUK=xSZwj0x~71vph78X4E`#+)^ z%>0fZpRAt2_DyZqW0NG{CgHUo*;`46kt1wg7cdUN$BP2#^K2@Tl;VCi58{KW5gQ== z=~wPLOsRk1N;Ay2v~xchG-avZOQ_zA4HcSA$IP4y)C2%OtiO&wIah>n3? z`<~}VGGt@*(FKWRZq_+>!d?c0nH#rmtq#y!3G0U~4~^jwIXLv_IIYwIdnOLX$CTy% zPn_lKbqtl&dXrhRQ(->+UfBFX?f!FJ&2_{1)=A$R)Y*?;JmC|5p2TwuzNBCbr&SyO ziBl=i4;a-jVh&9x;=W;vxP)VsUoetorkp&zgr1w9}e`$plRakHz%N zN52r@tqwMrZcU{-dD_3Q0G`Pj0D>RmI!gesu`P!^p@eD~QCN9_`ym z+37aWdEL&}#H}c+s*XWv!22u#Ni71Zt8S`VUnsVD+s2ik8Ha}@WN%~7fLk3)5XKZMlT z0|=s`A`1aX02VSZe(v)WEpSl?6%;O4dosx5c9xexrQ~0XS9TWt#R5?b!~Kj_TOW$# z`>*9jq6m*OgUr`sSad~<+-JSA`t*B~sz-8FIZLnQxzF$SF76n}yOq3hz>Ye$tB5H**FBP;oxGA>@@GRhE*(R#~r1< zdFz3#xHh9jn4uSMI+f_rg1@a}BoM9Mm96HM*x{QOV}F}#L0x%>!fpOBTvSJEYimnO zOGCp23b?@_PLGL+nNOLk02~Er^@nWDJ|7KUE#RlNaPh>&unNO;KgoDz!jF#1cev&* zo1DlF1SHYRPd|%3f3;Z^S;|s>_JHlTxX9kT2zIjpwj&AGU2;k@(K|qr3-)b+)k($N z{$RaD_k1-4rr=kaTbi0kriJfDdR^LHD|C;Jh+whsMsH>;ba^-IuZWhIXkOzZD!Q~7 zBlzSU8f9ru5=mA;dvAT6?aCOmqEbJZ85y^{1nUmAn;bwBV>guD@XR3ei__%SNeb7! zve_kZx>kN?NN2$kULTD_pFaK_gPqdaqsZi_BI4Tg(UVr^bUG`9-#%pXP;kBe>(^gE zz?+}-*%Hov^X*-$MT&tz^T3q5dof?M2%Ah^UHM1{@G!9bTuxR{(0%=5=?*+&_>LD` zYE9WEVMvAZdTXcy0mM#U%k?dHL<)?KJ%9<^8cfC@xBlCU5!#{DiwufEKXr`#w&r!)>WT>6#PT!n%* z>Pul4jH-`XQc>~?>gr}UD(au0r99NTW}cv?WLs<4ubNwKKAtu4J#y6?wE(jMTyBIH z*$qdo3h$qz?V0PDBS~kOKer~G_ZDNO>kp?u(b%A0dA$wi#ZT9v}dQ!Td3>OeSI z4yl;f-Th<=z{)C%+s->ax7$72-JAprSSC(w0JgooIF11~^pC#2p}^dCeEU}7m2f}{ zNo-bfyr9jm5#Sl&-O&Q3KGn)2#I~)R6me{{JsyI{{2jrZuw`{{P*AgbS(%-q^2g#1 zlJv!y>qiOGeE4K!_(Y>zD1;jAz!$ZD^F|r?h`Tne&1f&XK$oBa5^sqn&;;*;NGV9| z6>}3QW>@isu@^G@Is*^JI=+E{Ff%kgCGxmRN=n)qLXiSqF{HnhOwNc07rihD@bU4b zL~W~|ii^)98#fxNudd{=6GwJau0}G%ann~E?(=rb+BY`l?41m**y$gy<#XioV*1=v z^munk@Y;P@zaEH{f&z!5KB%NbNkT%wPG!IBd*UkrexWB z%!~F`wzfC1wgNS+UcEABtF1gO z8!$%|_0f3UW16&R(3yvEyk5H&T!>hBK>z6RLO<89i(wp6-4&YYc z^nEr}jHx6V)bfiX2X7MXGZl=NKr6#$hZM~|n@sw;^Y{t5#XLNI{1ld9wsEBSykR$R z=(!(T!=SQjh(6ulUkb__$Ur$4bUwUHt`dcCKg$gJa9dac9ouQE)^8xgpYHAmhHu(t zLBU!y^DA#Uhq3XGEOGE!*JIP zY|JLWeXWaoDVeuwdMi=$6}!3Cur=TH76xf5DuBkj_|LYJaKb?9)O*+Y>XQP;=o?oE zd!Ow%^GTzqD!lS)Dp9TxA7@;Yn7MT665XBCS^5-Se&(0b`q39dXVLrbHGg9Cr{KTS ze`x!6i&*5fK2 zK0SRK$Rw*?b``wravP}#$%?v6r%Bb=wK3)TqRD&Xx%wBowN041f@_oc{;I6HdJHrC z#BV7$;%Ev_gdi9S|57R!$Lqc)^;l$7pRYw`^m`iYIWLw|$z@oQyEWajEVaF7HZtgL z#V66?h6msJ#Qf{y`f!r-i#%L>g%2(Sk1?8=8+uW+(lLsJxR@BXQD=*mJ!(XY@!n%$ zu*|Ibd8_iEXu|hxM@t`gpS`rLobRAlHRrdG{5}@$II43K{ug|n#NocKlDy=PbDb1^ zr}K5gM8i9&wtshZUinQ5B60*r?=teQxqNqEWlOW!u6k!WC3Knd#nW%M==J-D--d+$ zj>eLHFvH;YT$Ly68~ojwTT4D!3&kVR&3V1H4Uc8q+?IP<`e!^4a>kEt5VD}>iooYR?NVfpSLLX zF44>~UKn4a%E%@cU1k`uoY5Y7F&WH=BfH zpRzd{p`REV{k1ig3*%2P@P0upaaH5^`zr)TE-;h`$r$Nt4ZOY#S>NeVB&Uo+GW!(&%e2 zFg9xQ;Gfj-&qfvdPwohu8vG3JvD~aZt-m*BT&_MF)yleC6c^uPa$j^Kc>?RW+9^uh zWJK)>!D{=0FyS>$_#Iwz#no(Ex6!>L=IL#}#WZh@!gR9czat=yRSypa1&6D?HD_1e zajQY*mr8yC6e|T(?Ps7qweh}0?PKv&{s@?zZg&nuL;E5e^mRMLf z>f5sIZS#dy#N_sEKfHV+wDU!_7=GhyL@=I!E!XGVb1cX35Sm_RZ}XvRbLrZ|G9-9! za_6fa9~5LfqQ7^~YOIP1)W33$-X)1tm(iHL(AZ6apg;|LI-f z_Dn1cN5@+9XgKk}XwXd>J-mox|E$5<^C@S!P}(N)?5`QS%CkPPiSlnlUh)2%`A`)w zsWPP9IUV?yGs`0BoJZMjuqF3G`%aAe{YWc=w#ajVrzwg>GeF`ex_@0;%SvqN{so1# zy=9iYy{f0+u}mWC3yoKUP*=OiJ3CQpM$+U>i3C}_LiHx5H2-ATjD;}6lVz)g1~)4E z0fe^T$nT*r7s8|CRk0U>GGP2Y5B*N1)1!YM;7OuGhAr{Mo%TRm%O`6Nn7!o@*uOa` zxIaI!ruF(JTCeCIUS#{u1XJ#HKKJQM^t5$M;#Une)?IzL*w`tC%j4BQi^s~V%E_{B zw7WtH-Zqktg>d<-9?~-`ZtQQ8K>@n{jEk!)xBwZlt(T1+4{l_D^s(0s(=@dsLlj?s zBv_&KOC`0^-Le}SyUGg8zzd!m6iQn)!}Bi#dcs3O%y*WoS?@o(Q?6V#b5OEeNUrK& z*HJXelp}nJ@|9Mw>XE?2$Zy`DkGf`t*Z8vq){Pb)ix3eJ%G=*9lIkY3gl))QVutzw zHI3Y!+M7zATYD(j4_dKk-D+~N9(;5DpN}Z;uSZl2&Y@X1X|nmrxL|$y&LzWLUg5P9 zRs2T7eb(7#`B-gK_kPd-N@a^GX$#C7pK_2sT{zIM+(YWynJ8wyR}s z?~YzXj#zyrG{<+Ea&al98RH*O&2Om_5Aon0A+H`XUn~7+zGt)nfZZ>|mwm>`_xM2d z4VD|3NBOt?%Gb#>{(;`H#LKX`(SaJo|6Qisr_awO0BZ41%ru0EFy{^cJrA&g%s+&( zBbmxgJsDw8nQze)s4SIa4;=o^Wvc|?T)K+7=wBnScqyuSky^EQP#MG+nT>E>48D()170e>j~(=IeCF8w`gY?obY7 ze>g0+e$%3O?b-zJf}#Q0i83=9ovfNn;`G`Ry(s%z+@&3Fm_?xM2IE`;Xv^oRqI3%~ zbh=X$LY2pJX1b(+uJ4P#58rl5e#qWbIrHE(f|4(h2hH*@(zgUR2^ z8rGC=PEUGMI-WoK?B`9Pm~B)GnIhhj{e&9SOeg_9c@K0!jux$srOJ?ZPc6yuJuubO zAJh?(Cb$Hep50}#StCEwJxVp^E1+#cy|% ze3O@B*rGnQV&&%6`SsNaep#gKd*O~-%fS9Q9D5stFjWRi9qm;Tne{JED+US-sfdW| z7P><8y;&t>R&6itBK4*vMzCEt?B0+7I z17D($k7#IXV<`m%aXdD0jB37C0PQs*hs&CQ4056l2#xfI*|F87)ZfkbO)6|IMQ|4x zjArhup6&?DUd2)<(Anrk@n`NOPhaPa)@aeuA7EP^E*k`*gBn{rVt49KOdQB%I({DA zX`Qn)GFpR@AZX&uY=%8UI#0vajezmx2siZP9gohm@GNK0eLfi+^_D4D3IjVhCD=~4 zOhtBgpoihhN%Q(zi$vekm6;ZD*;MJDk%bgKY1t;Qh(3>$S*;_2_6u~n{~WENR2OZP za0u=J`yT_l<+U5y@aPS+RQ=50`)&xl?Z7M-TO+ z+r6Bq_L@gU$_{o#b<`-;Z?9@qiVQ>wa0*=RM;*WFJKN`lh_>h!uW)~m(6;j&R?H%; zqt4di;IW#`!DgEe?741OgS2>4z(a|@H}6c0fcQn6Sk`G)H*0zC&+{UI&E}85`@Vs! zTLFcoOypko@pO|=<#x{$EXjF-Hw8K}V3)&!D>`cRakaru)5c`Ua`br7WV*U?Z-QN9 z)MoLcY&)o;EY^C?1843~P;~X`__+&P*V%Q7t7~)}`pO{$0ci?dQ^YJVN^jlvlLU@? z5iW2l^6gdWd!&Q+swQ-NOTJf(S#hH(jg!k_0uF5y7D~8`QFmJ17q=j(WPbMTcs{8Q z+dQij8zV?5uwHjK+G|J?8W0hg+e*o!etTrnjVSPV+dZsoIn1ji-Uj7)4(qiXCHl{n zry9w{qL3&1mePl*=*e`no#nIm&66x^=gwCu*u;R(Ix!gOkk2Dq9!6$EsRq<0Kv^HU zucZ&#;D%Qpm=gLs|sBBE|^C^kBYSOg#z<((1_Wa{^{j|9&Thxi;i>}yZ2xMi!8S~x+^SoI zLwPh$NHgd>K}-AUYNevV$yU4{fMq(Y%e77zq|wif&%YyZs}-ks_;0{*a=Q<}^^=rq z)YLp`zpB7o3bj^U?CL3tP=hj{5^Ox#@}mu80752z=%mf(>t$+=mCpT5xo`o8r#U*< zOQSb-LEv3qVN_*C4poYJ=2}lLHDi0w-z(%z(bQO4ZaldIvuru;HN8hL!F8jEP28GB z*W;62W@tsfiEUXRI!rSUKm?y-OA_N`)xkm8>4V^>?T&PLJdgnL`})b%;IJ*EXt z_U4wBsKsze5(n14;15PQ*oUi7a%LxOz8oeuGAK(5f)rB`u;QsdxNPt3cZ#7RAM0m| zCc9Brb|Rv|vMJ@M^MGREk}RX4WtW87L<}t9w=}953eC=hLo>NY3*BKrObhEQyk;!X zY;!u@Jsd? znv6_}rwDB9D?)p0_56mOhjsU5rkbeevhD5DlYWf)O72e9{AeP`r}k$%3pRysU>k2E z_cylTNlYS9I^8d8QKq&{jREB0r$f+2{iNta4fjmj*RN{_+qx0_b}Ml{NnFep>uLvw z)V3+^5Q=cOfz!dL@7=oa*?hhJcc>)MK4Q*0B6NQUrskfbC#PecFf9+!3W(+vox%%d4WvF0%D93hnK)iK1Z!$=cn~a zrJY-4MBa-c_i$FEQn0ieAfqs$3DlbWT3v2Y z*k>oF^tX7OR8?xO7$KL*s@5DldOFRVb)IXQub5+O$acG1DF|olZTKEkIVOg_5XVFj zsz=_AwX>jV{@vWZs?kDT`T%7zZ8Iz2LnR_2!gu59jmYm;46T2cHEv|97izV?LXA?M zhNzgRY(PKWq7%-^m?YjlWa>|UI=_aM0gf~|nh>$6?|p-!nXl^z@%slKJRUJNKlIM54I{UQM|YJ%e@m8 zre&nx50hz$n*ni%5CM{JP^#L4tE`rjYq^Sv^el{z_ouK-F^D+7yZRGa0hw;c*z9lk zk}<`jm;Eb!zz}dEoifVjYSem|qvuI~(KY|#)9j}3t}dbjhq$0bfV+)2CAEr4ZKHd^hl0 z(9vjZxU&)_`kL!1xuT$_HqHY=;@sQ~tq^MpQH24)p!0ZyEm0`7uHSkD%Ceu(kJai|_)DNjx>=B9WM(6Z7BK5kf&QYsgRR=eX#EbxnPiQJ5eB_lyTh zc-s=%{h;pR`4wiG`?FU(-C?~V&UE1u zfXRwQD*&m%V}Kc-a$pyZFaUcT$DdSNM{>=3A(B!o&~cXO7}}Kc=yt^HRYvLy)z-@t z%Kq%{KL@7nWtR61Mk_5hXCk1UzHRp#{>?69`!d$f3k$tj5rar6Nl|D#K3&}zbOV7j zbBIO`UW=jfHChtI_(QZ`YSAT6)`{uhGqI9<5wKg(o1PQS$&rzi60_;hYkpcXOGGT` zea>7<0|*~*7@4=m5(3Ar>gs2^_o*yy*w={XEWR?o$xWyvvDY(au0B2Z$eh#WwVRYn zQZrN_G%hk>s`9+7n5z3!I$%IINl~%h-e9WPR~803or$ zER%dCrD}7&b9Uoo;-tYZi9se6>Ph`t`lwcx=+f0T0j@!mU3sT~F_dGeqd-4RR%DCy z!x5RPNSN-vu#wTF(=rR#U?4O6xE2NHN1nxUARrBpoD)FK92q)r^h|Y5=^r2HxAAV~ zP6tfZiI~e-IHU-h`G0hhvMxWKYwwa%u|9<(L-wv-d#v$nYX-^EYDdB{^rosAh^?`h zJlLF@OaA`-eGo4Odx@|F$s^XhrK|KC`j78Z;E<4$e4sHb9vK7{hBjE;D`pvoU z#vv2pXHVtkZZjjA`~tNJl633|59qH@o_zT>I9Q-NQfcSOuKRd<&UkvC9x_-(WNYPU z{BiO8lo6WzTSLO1m;O#o%)e8!IaAsFKpCaNxEr+4Ba)5Vn*OkEH#9`UiFauz1n9dK zP(VX+e*XaQ;eQ24IWr0SsDD~}zoYKXv3~l9<6}$aRXq56tL#q4S+zamaSLFU2 zxAM609xgG+Vm9$YNb>TV4pYo7xO1mbTq7dX`1Faptu3y9X&}N%ooHKEUQjs0X0VWN zg*A^^^FA$AE-$34_W|j2>;6h921a7?Nb=RIZ0}t)h0@tcYhr1tfazAhz?7QTqN3k? ze7<>lzJAU1fy5V%s3P6oa?Q5Z#U6c-xPPvp(F68+%W`-F4G<6e+x(xgv(M@?yaLB> zwAS)af!Jw=YG=iBPttGUvzGH6ztz3nV4WZygnk#V5DC*@V#cTF>)g2IRP*3ZhGJPc zneIUT&w^g)75Q2;t5oX0KA)t@KtV;Nk|~*hD~ma=Y|Wf)IQsP1f<7=fc<$x5MFrO@ zS0!2z;qU6^ut%Hamw59`riJA;5gzNO^~yX%+lQgL10hb-dJr8zsaL^9xP^a6{&7# zpUq3U%C8*{tp2?UA<)p3IDU+F}|}i?TGxJX}Ug{1Xc;D(I9ldhmJ#F zckfA-SpH3pgm98DoY#6_qtTy!bdI13_=uOftfpo{VCG5%L&9aZ0Ff1A)8TSc;IYw` zmQED^umQw{$iyBah&(EfHoife_S)1T2C&?~tY)Njx>CtQWX{U)APzPTC2#O20p`1^ z9;4JE!tE~(cNjNRI__c8%^@eh*}c72X|a5BAga0fK@-E_E_ETr$A@5XrRbUsH1e@* z`amSnjT`G-QiS_s{LSNc8$A5MS}vvZ?w#tEX3)3<6v=w~w*6wyFqDq9ELT9^n+!hi z&~AJ}?8p{&x!CQn$IEP{xC%3O4Q?bmRvg37Mc@#wBp^Vp%spiCbsm@5++0V3fV6k! z*5Rmt9<}tlcXNFp)@ablw!KHc+HqH*P|vJanWNvbPMRNNII?I#>(r2W@`G+T|1l4v z_2IHJkAjfmw_c^rk;wjZ`5K54JIbx& zmIod5o<&Bb27X)$OlY$IGh@G#SGGM1gKTNvT_gJ2B>sHGMq`v4!LlZ7KIbt^ILBb) z+2eyhU2-=`X>bI7-$mz3grn%CDpO<`XTFfz93RbE4zl(F(k@=I+r{6!BhB~m_fc_i zFlbr%knj3L@a$4@8oj{m?dwC}rkpIFR)BBbdiiMPtpgAh**HQuB)w1F(Sh?04!;Zq zcG;>!HHCw+ReO!9vBF--<0qtVNr`xy=&|hF!qog5L2Ms8r1P?%D4Y(~r0oyvh-OEr|FEU+dGW`X5?k`hVMUY_!gxZ+Q zdfSPF_JDCEih|n=(9gFhyT?t4V(ZO7^ZAaKKM#TDoVzkH`B5=n%iq^G#=Iw0W*DIX z1-N@q4kriaW)#~z3LJ(}RxA}#NwT=%)coN`L#BqGG&E**e&1lNl+r?3t}?*^K9H+k zWN?GsuqWG~!UZ^{!F^I}glR#31P@V^^^AnRO`UfIJbHI^=;^>Ie?AV@dVGJfQ^i(? z3mhciL3=b8N5-K)U<}EEKt}SE_dc`(xP94PsJHsSGuhz!wk%1_wBaO z0p=OhoGyigIF z#dtz`IO957vL?V{gzVR5eHJ05(+W?r&N4FtDX!8V7Gq(Cq(0QAB|DuPJ*p=M3q)dG z5)Enokl9hQR_{*5N7G3@e2-Jn>6rHL<-uKuy@?#wJ(11cina3{30@#X`uynuaPeIw zn*r_2xy+hb3I*#Cmi)%{TCP0HQ+{G6i$N%g?(C<_+~zt8-V#L8mIJ0`fq2q0Pzr+D zpAk}MsB+?Kel95*#m%$t7lKEYksq|4-5>011EjB+I3;&}S2)NBGASR_kQ=^jJdEC0 zTOOiGsS_JPu0HIHFSrz;4pucYxxsE3;QEUXl{+i21iF2DlCNrVz3kkd@a)giV9fV$ zx(~(^Vzj}-zgt~$ed3NIN64BO*1IV3F-j@0u6|7Wv65&Hv3!o={9a=w6{&Jb5dor~cg-tL@b zo8d71n3+0ULV+&W=5Uz^7tU#i&q{3Bu}G)GU*rYn}}!Fi=BKao>(O zzl`!&TpRy1`xMWX_RgJh*i9g=h!c_m=4x!8+lYF=E55*FYJ4OBstA%kaVn$DHw8|jU>H8uozQb}$+SGUeH(lLAJESS-&8a(T+e3L zVaed4P25P4()6L^v4^ZhTCnvyc_vt2+~KhQrD`!+pB%aJl5QSJx5!mSysh<^#_Nj2 z#5jr5-Mn&a@6dAz&yi<9%yk~@A-+FNZ z$l3ECHnYhMzMmoCxnJDDm;Qi^Y9*fFKFzd?{f2V$rWCvCzQvA=H{XiFox_&5-ZwSRs?;~p-Q`~vl@Vl z5C0E%J8}8@;4>T3O_L-0TR}cP3%#cUmB*QUW_0vyZ22$0aaNcC^)?$t9W+Qp2RqT( z$8q%U-m@iNsWzNB>~1;45)Xi(;99C~t2gB#oE%WBzjV!(3g^nhofST-iC^)=Ar2PS1>QL>d<2VBUtLQYv*^R?xl_Yv_dPe)- zMXw7Cj7Hqid$PcYrjFi;c&w9Zyod~&yw=3p%M@8 zZKk{S(@;0J!Xd%_Fk=h@R0=h-6zcQ7>z#=GO2UB;H&xo!~WK^w1#u&`1IY+v54~t zks4611atEjTNXKm4F8iv2*kXoaqrA+H>o_r1fvSbtzp;-NBt(6`22R~3xCw+ z8gX>-Lt-3TpBPSI_X#Su8Z|oa4kz?%byFkK+s}cg4ZDPglWew z=sV#DXB5AR+hA({Ch!5Y^;IS|1^MW8CHulGJQCYzXzpidr4WeF=87y*W@yb4xC%QP zhSR-NTk*}(pBhW}cC@|sP3`#Mbw%mbiTxMJsZqB1lat~<9H)odsdtq`{Wyo0+D#Wo z%A{&Q)8Oz*783+7U_u!B5H$#>5S-nh`8Z6j4oYOei&&%_WYH~V+`6(-+!Cuy|c>ID0kh^j=hM+DEX$O^k5t=1=%Cl zh=@#2j`l$Z8uk*)&P0)rkRxY&47vb#H=#LOZ)azw6?=SMeZ?34iqBH^(ok?B`5z1{lf241PYPH6pg(!~Zy5?F!@cnBhU{d2d(% zA?{#nS?0rHzB0r$iw`OXP)GJgpjo`KJz`}?&>o}em zcI!~=^3+n|QQGy5YgE1Qy$1GU4DoOa%D1DTR=#zK7*5SYk9L)RwUfnNeD`AZ{duL$ zFS``CpABG9B;Qk#nJc^!n~a)vfZI_2((tLB1>rC8hq!gQ)>nQA}n_ zeJqb2<-!Dmje#E&W~p9Z1}j+TP3#~DjvdMVFc~X|2WpzJAyALp6An)}Vlk*6%4xYX zG(g?MB%eD4sSkji^gw!megp^@>iI-nrKZkuWruo$h~VI0@Bscs&Oya%WdxL>LN}yW zyYscBDoSBT74#%!R4jbJ#U=j+MC^-nx`h?dW(V?OpvnRnZCQ~|pFU}gg9?Spp=ydG zO-t{~yLaxejZ*D_)cID78&#@A+)G*69xy;9WmZWqn)?jfHhV<^ST91;eQPT%Ghw}F ztJbls>Icj<0WxNFYTm#c^U+$V*<2=drIJ`zm-DV))+ET$6YtUdjsB!IbxP5OzECUU z`QMxFLd^4~#n{na7t_aaAHOL z40?1GT}`*V^@rJapFsjGuf@qO5M-@kkJj@1p?tROT6!x=kRbWC{ErWXkbvt-f& zPn60mT3}q4Jg$vB?5;^>F_Y9`qFz*>TKXv3{cB=|h2o*o3f8R9@77AB!# z0%c#p2aIAVxQyPoaRb)7-R`huOEXyF!jU!QghTbm3@CASedSBSuEC6u03s@A(>Mq- zthAnRzyCJ4AMMHsvo_8h?NY*{p#h5VYH-ro9c;BfEifFlhkXEWNJ4)?^AevM^L!n8 z0%BkY(hX|*rt3w0^LU~~Ab?vugU=isbD)i9^z83=2+$h!&0{~ZT6>@N3jg}UF|aBx zz|{w%W6*Q$&@UR+kbDj{7D%3ZG&GC~`C7xJ<{D<-Y}vs81&aBI!1mg>wSyB*Ly%c` z=}mEilvEaFe~eH5m=mQ7^Q*O=Ja96F3F-u>hkH?A&bbUitVVC&dWA@VIA(?v5#*JN z@;DyUy}Au5;gHjGSUK8RTU!H}5_#|NYdpxBU!>%7Y8AqK`e8F#=IYvW)s3wqxz=l;aJ_M?U3=7nh8u4H4_3NGJdn*#y~ zD2q3h3@pxyUrm`@bIccIkvAYazH7{{1b*qTbohCB!T0EGXQ))B64&OB8yA0>c(sjh zWWwrzJkFm#Q3`?(^)*Mq8%t9v$!*#Iv2lebZfInD_W4e30;POk2O&E!l2sS%tdRYcxaw69!Y5@sk{F$cNb%hRA9X7*}a!{`T)WB*@ zzOqz6Lqk(i9?#GOb~ZXa>Dp~MevdD3PZd`S2OvV4e*6p9Kk+`R3p5}!;rxCMaY>FO zo^fycA_e-~LOGyhh!=V?bgyxGv_Xf7EccM@W((-0U&O=fBux?t3*0S(n*J>wK0dxW zId)Y%Fb`JPZZl94?A_%m9}(UIRS!m8%HB*k`V|3!gKTOm2dlj^%Pe?*$wjC?=gMO9fyd z3vY;tsnza^=h~VPE5?_=21`DWFl6s1hgt*qa;tUFcxa0fFjP6H6F9j_N~*zZJ@@A} zE9^!1L71ZXgfM4JBSC;ho^`!nGb%%+V&Lz}fx2Yf|8+aMct2&e#o-NsKkIx^6GfWK zWorH-dAGeit6e!ba7>Z5?mT|sAf~u*GRzGbog%@Jn zhv~i%&FJra-g`u@-}I#qRffPDgxg2J2^z5ur}`7s;`V)!h62UE zE~kEY^Y$$p5je%0i5_0W#|Izr;E{lr%Gq?^+K`W*8%>dri8(k>AjF^5EY$Wea1}q8WF?(IV ze38wf8U?L>aAwQS&Ia@f)7f@!ZTuiZ9Xvz;(RG4v$R_ErHOvE@J4{TXhFR}&CP4Y0 zO{#OC$i?NnjEj9zQc@I;WhShBj$t!DdKa2Ef$4C}r{Ck}uLK9R(7tl(yoR)F(Ab93 zP6Q`sF|aF;@P%@j4qEVBPuvdn{DpQz-W$is%f^j?k%4j5AIR4RYzw|;0PWz!P30OW zl}(^jEH;Xf--8#e(3gI?1VvO#N`KM-fR;{r^W@tvFZ}*S?&s&nh*2VS8a=An8lLmd zh+VHFmlzRJRQqasbZ;z_1Gmv-_Q3$w2_Y=K=@HUr86EF^pO2W@0_*lBtMXt0dSc+f z(dkNNQTuf<2pfir>qE5*2t5s<j1@NOVGz&m80Kr~_)oi(0@Quu$mUgeNo1w!R6( z>~^{;${xgUsFr*SAaeBgC(bDMtO1Qd2I=JYGrDmKsLg73cX!x@D?x8%aIGF8X|BOAHx48929W; z?=z{w6oJzZ0=WS$E-12orlzI_?+WAxN+7ifXha^H?B-2y>lVq_2g|r1Cgoh*xn9(X zIqdL&^G~2&uIacNmjn^aX=Y-QdtV5*O6yOwlRZe=)c>=Ajd@YP4NvSCK|@+1W7Q?t zQB8F4PWCR6AYHrwY4U+UlB_Sn8z&2Z8~DHd*_@7`&X{-r+Ih7<5iW4hfvNpe&J(Ee zx~T^!1ylU@D_%2@>4J- ztA}lYY>4H?R0EXM*8cuosN1^&zDDF62IaQ4>Xs~P>So2ho4jVm}q!iccgp&KvV43Upcs6cJyg}bbq`xt&gYxCV!Ozal zjCE_bq3ZP;e7wBavOuZk1oQ%1;PAm9zIs-Ug1+M^kEG;Vj_XsG`)nUy#L$WL=_S)Ot2zeSN)^mG#jh4G&iSHTLhQ72Oj|-YZfL(e?0DU1eIoyrL6x znP)N^xohyRk#zj=sI6GH505j50aJ%CxRQ zKCSfV?<5t*grDas!2T~14ZbgY{Xc_TMF>qTSl5AkgC3?m^z_Iq{I9P&I^1$QhY542 zuYUY&4Nq&~opl-g{d-aWbgE^ab^E}MANF6w8W4Ry&e|>hes}FYG!tRK-;&LOZlj?Y z7y7Rs5_tZ%qv>BibO+t`^Url0v_Gaj#}a)Cr^?QKKE(t0x*9^^y);2M^7jG%(rIU3 zK7!iYTaGNmIC@aj7$yE%{l9p?DxWjJNPwd^Wi)nQTC%_PEYWv%ouk8~G2-HTUQz7F zR7s!F7ZU$_$zwQnIHG$ppwjp8mkAhz_(yJ6&sZe%rBCR8io+zhT#HHm-1zKgpP{Qi z{(k6WY&k`n9k9TF$vXUqHoVJS2Su8wv9)utc+Df zFyr$m)31@-yg5*Az1az}q?|@oSRT({kAV{daG$p)8MA^TPU9<~_?j)hHgdp*x}fd= zf0*M#h3I(rJK{ST@$zLv3A5x~seu*mUI>dKTfY?L~I&&*@Tl zGgu8szCUAP{Uw04DBl^-1!zMFpKI4lKsgp-C-~2L1Nrj+Xl3c8A!h;iP}lWh=}%WU!JB9HV1^OCD7i< zW(?5|porPR;8K4G5P;EGaVF%?KKfx(4pEXk5d@wmaG+(xMsxOqrW@P(bdV}74UP64 zTH2X*A!->^XmBtma35)oLvruL0d-OHT4gsiqmK=d+0Z*G3nb^pMq2oGhor#84qeXI z7*MB{C|}wvtv#0-6dqpwq2_|XB_q=Oy9n>!N^i}~SN_B}>%>4T4x*xR4b&!mP+pQ+ zg}77!!QChdbvz5|qXhy=c)}3?`je@)U$3@sjOLF`K`7iY9~c>VQjNz9$auQUHf2uC zgjYY~^V-CQO%r1gz~+xlfs7FE=VKJt@5s|J3~G*ZshO@7oV|tuKtrJN-LpZM8h88o7a)_mtu^@E;Oqs*NJ|JTWN-ia{W}$3R}EnDN*2Vuk9m2O ziVjI?EF!Z4S07=L>AablN>!;ifcFPP@%KqJk3?M-K}|-7gbVKp?%lih3vtJyEzYmC zIYm6?vpfp-7lLz_gjD*|i%;GWA0YT?L7yW~?>Y(ZA&t$`$H&Lr9ehP4M?6C`LMGpU zJLd}2o#tzInk=!{rbkms7_-ex{DFU0G^}8M^r#~;?wsEEpCUt5k`v(87)K@32V9)m zB6yNaZ`07!PWc)!u8CPI^}|t{l^5-wNJ&Pm{Nioh^_8*LSZ=4g_e&xQu_tP$+0i(X z%XTjt`oAfzW)vq`VM%AEi?y7G~)uhk7ybdxKn`VtFQEyD9_96yBx7png%s(MFR!KLTk0 zB<8SCm*Jo2pB1|!X~~7vf~EVqF^-UK?S2d%%_DZZ%BvHYFglO=$wAE_#MEjD;5vX! z*1M+pP;(1EoBFiL4IuSCh?~k|zF2R^p|ZL~Lo*GF2w_X$_IW5LCnqp2ku4+m$|Z_@ zh3WSJ`66MDL3e0K9k2l(IKm{qe+Q%q>Uc*H1`mv>56xqgN=(pbTwz;;WB3!*!iRwH zs>QFVa#;qs9K`w&Fwk!nreM_oZPE|Z8ST1EL$kkO+wS@n1p$L{b(NDGHeKEh_dCpc zMV+LMs<(E_tUmvX3vfF}2ZJ{De9Fi_k}*L0Cs036LfRq7fjvi84QMEjh+>Cv87EWM z*}UA`MwtDMHY2)tCfU5Q(XJKm_zg>ak6|~`x&wg?8(S9#`LWF_$#jrko8(A?w!Oy;tz$pZZX)-@7LB*dc zWB9qo-ZzLd!8f4SB>X9@x1@_lfhk?;1mlJdBIP%nH50(ADT<0oH%Pc!evw@#Ylk@w zY%1TTE#{`zmm$!MnX*7&;~cJJekQ|~jE0cT1^9%SJyK@Tj;nWHCL6Uz37qyQ8kP{+ zf*G-^JJJ1>kqX7mUL{kJku-!i)14xrrzG|*wKVFKR7~K?s;9VzZzfF#C+$K$71tdRr)GM{&QJY=ery&Kv1Ss?HB zC?2@I13U0;#>OL^DtrrMO{}12ChFt>hPy48rKVDbTci29s|z%@lmUGTVo0x9hC|tP z7hY~cGy`#@8yqMzoWv+nE!ao_%uh^CnxiCt9zyzLwJ)b;8tDWm8%iK>*wFz5U0@F! zBCwD2rphb=SXMx@dxDSd)i|BQc~FP1E*M>)fV2lca&Vbt%L5!tf{^Ako^ZkaFj8*4 z(^+6X#%&8`;0^E#!@WaUssbc(Ha4Zz(iRF{n>mZ5g*}N^ibpCn+o*Qg*}&up;#&pD znDsaFL6VYE(!w5jGn}dAjjBy)M{#j+5P200s5b@nx6K-~$JWBiNs~@dJCXjWb3>$g zU!%20Uy;!;B^LC4lrvhUjI3A)AR*^(Kf6O|S&$E_p2C453r#V<-g(V70DQ_1!A(2_n2QC_ zS`dMVy*c{`mxPBo3pBJuYFL{V%j+{b@IC?0?I#dvJF9FNO5t7YRTHzBAhO6%s~-6_ zUP^L1xs>jx9zT_gBwZnwKvf1DL%7(b1C~KMGAY2OmrHA!n?{B*wK6;VVT4_o#d0Vl znPi@{>WeK(Q_jEWoDW>(a1?PXF`JhRGR7`fiHSLx8qm!upm=TNYTfAUAzI-#5NTcL z$~Wpqr8w-#eWTm8m0;I!8GslF!{!9u6?swcu`LVy4|j%mbEm)w+?}8_bgFLvuZx=W zN2U^YGpPG(k1mDmY#Ttt4==LYI=<5Z&Q;LVa+QB#o67HMAUiY_PK)9?SyD%!;44{l4=lv8hHsIxX+>|(nZ#W~ry&%U z5=FwKjb-h1psxALVJMfW234FjsOgBiW(DXzXQIB`#W-hc*pnVY+tnc&$ys9Eq+!=* zWK9=5J6s^VA%{8$pluF##^-LAU*py7tu-dt^%2v23{rT@DV*(lzr@q#e2F^Js zad*%|R}`sj^a<=*nVm5NKPdcGR?K zSX008q2kMzTX@Nq@%)c)*8|gZUBa{uWhyy~-Cck`GD7j8kc0S=Ny~jl*$WN*|Iav)TjP%M*ot|mj2xm`^;o%dxr-P3y zHO#}Teto@KdTKBuXa1*R27M5%aZ!{;>C&3r?T$>*AGTS{Xnigm{B7Yv+OtOp+|8G@ zeZ&b+q#1{#5cS0vPLdTD`am-K*_>-ueuZqhx=bo) zNo;Tg$pezhteX{c6w-S|!xmv`ay#Ln1Uiqg8^?_dku$%#1L; zLg_m#^KmD;EkB%_n`9Nj6(Zh0$$Kf`k3T{AO!77wanFHJwWb8Ag?* z-n^tzrUwgYS@~^D$L^WB;GdnHS`|qo$CxQrz>k1BC7Y(oP(|#eOw)4GcXB9)z>g%s zA4@h{c_Kz>6!NgT6C>un$=aKFK_qFONhTG!AlmKD6~#PacgKXQskyOW77jhx@IIQ_ z9$1GZ?V;W8w|sxA^L7qi}}kBW+{?3J5#KVzLI{$I!v>MM@CK}^IRuHv?`>va7Q9eYTu)fFKxNI=X#RA!lFaD$;TjVHeJsRNvD6%ggNTE0ha zFC-|29B94=%mcyfA3n3;JG~j2!#q95B7%#cS2U43uV_$`U^3mv3Yvc5@jms2v02!1f(d~7(9&anMnPXj+Ow>b)3robrlP3v{jddp#XQRWN_u%xa* z`6l{;zPDwf5E2{{P*lU@y0S#mEsM?}vp+0w5Y&O3Q)XPJJ;EklJ(z^*oK14lJE#9$zotc=Uo~07@qyhp*r&i(cOn@4Wx`x}QsNM_h>WlWrN=T-7O) zDi_ujH%8|!p?@90u#E5m+FK-zs)j&kp#r7KK)WEfyj1(hPFzF;#L{t{Wyk|=e=+w~ZZyqal3d0o^_lFLd=MdJfl5ciMk~*1d{Pn*s@88s0jZXWO^a;4 z%%DCJDKwh=C23{Y#7iYn42zq+i&loSbZE$YR(av`1HxpLeDc)Kkd`rhF(A_&*GeXn ze4t6`i^SV1@9cat^PqD-!A8PUW>NA|a_*L*qTGvql|Iil4W_Q&Td!Y>DBQMP4r|3O z$T>MasFU%O>W4;i&z&DC(g}IxSxc?8X&G94kZ6`z7JB&-v6ZHQ z`JVw7{05qXtLCkG#!z2_W3bw1RF*VxVQOn}5GWl5b2HK<(( z%)F8NOo7di<$V=~2$Z)6SvU%Z^=y=SW*nw^Jg3u(dzZL*0LVI)CvPW07Q4`#Re9Zh z_*gEUb<{9<_Nk?n$V*oPn!&q#bCCr%ltEOu!Q!5zWxjdmeoh@#=kU9V+9AiD2zAOh z+1T$#kUk6V)x!!@NIHH;I!w(`mw9Uo#LimY4yX5qnlB9`<2K$i(sG?CdCZ;P#(&s+ z)Em!Jk8l`%)HD2{wo00I=i(YJGPrvH^FoG{uX7!>gHq0SFnF6QJfXok^mI?6NROQ~ zCva;NFX}Q!Ri-%OD(&u?k6C+V3TXYg#hCIEotYWbzJkyxhy~&gv6S(!0Vq0cE?ki% zKI49x%_Mt<)LCmMx;x1tV!;o8=1c0!C1eCbrPR~h_j@w;Y39wC|3zGmL!~QYCa^(^Ql?6fQW6jb#rf6Kpm6RX< zS%!NT4N3FfRTA3D40ehxEJxXHxK}f3B;I-M@gBNC1mx;fHP^u~Q{6iKA%pb&xRED^ z=|reC!YqlselPE_qkHsiNWBOw$ZvGXM+ea5FNNnPsnq9NAn~~;S4s{W<#O2Ts2Wt$ zhJoC)`2oah=tvbWeRTBk2j4%hHBAnO+#2Pw^&xSk@#$_D6DGK_871bm!QV<)+ z#mi#~@Fb^vo_hNA<-fQ9N$Tlw*O~~jZ~dG6ouK}hNNEp8jna>!W{z6(&_R-*sp!Jg zAV?4TTVe?%2nRf<)4Y`CD6NE>7@BmQZbK&)6&U9!&S10EE(BOQGkODu+IH-SiLgw! zb3g48{k5frf+xdGz^&*;);|OSRq6%q{;KgGdxgl7`NS~d0Xcm8qxCwAwiF*sT#qyO zUg33nezkt%2XCM3U4v>BGf(Zq@8c3*QSbzohDJ|5OaMPbzFX^H}f{_QIc2b zqeExEkxWK@C~$=OEg8MR=G{z0#I&8dWuksR$-RU4)Rq)C3Ckq0BmusUnSb02{~ul7 z0nX+A{*Th2v}k@%$<~l$lO#eRtISdf4SUZLq9T>O6+%WvRwxqLD#5T@ zeSg>WcU|XP=X9Ls^L(D?{l4G#{kmVT*R7-zTfPL0sxq9zaGEZjK9pYY*7W6OW?9~& z`_D6mX@%`qi&#FzGyUUyla6n&?CHB_I??tuV+be77&$$eH5hLD)O)k^@jk(lj5Tx* z9QM`3(q}v_VQ{Eet$vWtXv`d1MbCLCdgqJYpENH;CR>J+GDsb-Eaq%qFLTy7m`VCr3@ceBX*DJ< zlpeB*&b;+iFF#g4|L7>VXdK6GHY;%N+@@>AP^O(bi;Q{&Oc={KhHT5~R!UX2!iw5Z zeKXF+YvFV}DQY)Xip)kb)V#EA7_UUV?bAQg_MXq%MDYxvW^P$ac|>zrCv|Kw$lXiB zi_CWye+zTB_-vz&br)sg2L{31EVNIc9e`s=|Js_Kv*H(pc*VKHfF5I-sgv@~6W0b9 z6qt4(RQrjS-T1UEG%vORF*log@`LgWwQiVe9VgCVI; z4{lJY1jX&7-C8EhPqqg}BB+QJ*j(nUJLso;Ifj~y`~ zN44K|#OTh~ji+NzU!9ZwoV5^fG(k1-Xxpo}qY2K~c}I$l2e=Dmu^x!a$XfeVZZ_X( z#c`JO9JJYZBjx(MzMvZwq-kYvVKRktf1kWL)yh514)R|9HMfuV5rwpG0%bdN+ z+|||9w>whA+(1t-EnRQ=_53}(y7&;A=0Poi^8HL1c0?9EXN~Di>U4Lj%w1nslEy@X z1rkup1{$+JE^^-lR~H_`Ju_=Lk`wBv1sSzwq*#-8^}Ne|MdkGKc&M)6MlC%#`>{#@b~0L&3{Z`mI^7Suk@|$p2VA z`bqLj9PV8;ROVV~z1ApV1OG2z);m}EsE!%kscW*I9=?lQ;VvvJG@n#nA*~tmAb;mH zu;$UI=D>D03Tc}Siqv~M1Lf)c(+=6Crg)HNiM>~Uy?WN@M0~hnRuY;|Gg_vU!UWk! zYhy`{g7=Gs01J}l$8%OIH0ixrgMdnv`ePIOZ=rj&YV9Kbc5?;e$CFQ+==NH9IrnW5 z;Bhx_%i=S?uKAiYlD4b(eTDLO@jKg)eM<948F7~+9W~Ke0U`&Qs9C>2_K3@EvxHey zy5@z5l$Y(=h>ZlZ=gl&brth=c&!oN;jj&yI&@K1j*A>WL7@p%Nwa8OOT2O0{Z*6N+ z760P>NR7XLVaz{CW7Z|KK0;$i&)nAEIWzKt&wA?{%p4mNth4x}Z^_a{zB>)l!g{4* z{tI5Uw5^;c$;m=_LGiK-Gtf1q;%LRBMk)(__hZ7ZWHO4-2Gd|rIu@;4>`6)Q{v4Q& zACrC$X}bzn3OF1byyt3j|D%-C;w%;VQcF92C?vGLO0~R-Ot;S{! zJiC#Z2cR{#AgBI4$GT)S0B+?%qgfJ^Q3^krbewL{)qp`ToK7v~IKqF7tHH6=#q%3V z`3%Nbl^O<(_re*M>2kl_uZoRb&lx|Q#`9P%=&olQa}v-Yz(Sq&me%cz+n=4wL{`x3 z1lOuFky7+NpOiviwX{U<5Ns#o{^s@!F;Z+)>grK7+nMhMyCWl+Ro2aE^P4KyeEe<^ z9+%*;FcGhtsB@N5913fCkH0+a zRiS%`T$yvWtESSP@};?xW~Lim1K-?!G?FTR*cs_9jS4;OZvM%l7;%DW?I}NR^Khg^ z6{Ld02^yI>;w-uPvCbYpjHJyrc1yW7J{>vae)*)L3XUv)AtL`&Lh_)}@A!qut`4LYRs zJ)_lo>g0H(Z#CV1JoD7e)UGt=GYJH-^QJ;xSjhU;By0AxWfGeyhm+TXO7$8(HFl4 zm5OsR?tF>avQ$ILnn`vq9E1KR7BXlGKyxL%^c(8KQprWcL})9|t7os_wD)gxE= zHp(sBJmebGnC)k|?`+27Syv`0%3Brf(}AAieb$t7qq$?s1%lk@lZYC3zC+V;$n<$w z*^-O6*PgoUIMeZQCU+jC(rn)pOBL4`ri|gj@s!*{Z1s8lT5WTw@++;T;(_NrdSLf zBFP>=qH;DJ7+pV-z02;zdxN%mwarneimA@A@n042eWBZI^XkJxBOQ|P%}17wmR|3V z4-e@ds-o?(1|6Gjh3M&jGd@V-l6r9&)Vr_^sfjxu_WMT1L|f|ao*3WI8J#-h2qoI5%7a*1yR#x<0UGA$=W?%77a>=Zd zJQSXhL&lDaPuo1WxutwcD3uQz zWE@hQ(aovW&zWtVpFxgjJ;BCo_N~WcKGYQBoHZY@NmOmUY zZb;Jls@Xc}=qO__CU4q3{i!!~O=x#^qWEEf*(=(bO~|liwtVbzUKy99a9=)K=fIX+ zFiB2U{NCPQF;jJy=PEX#@2|7yn5q}-CUg4r^IM&urt$YTl{HW455ADG^e)DzVClFw z>kY6gE*4)(a9N6_@GSUQe7Iq78e`qT#I2I$uot!eX zxLfPT8+2nEkY=K>q@Icp!ogY0ar{=x)x%d9Coh)8TgD1g2}@K{?}Q#hPaW85z?3Di{%({ZX=XQkZM2o9(O z5=w|xPJYb(d`T4JIzh4J?29b*Jkyz?Q43kSUx^*HS*c{5Ao7ED8BLNzJ2e7wL~|0vRPlaI6teg#YD*N3Q$Uh{PJ_1rB{?U@VbB^ z)6NkAiugkY5%u?4%LMgx|T-+vGog@$aU{uC%&brAw!- z*!FhZ))hHuJ|IvPN>4jxR@Q`wS9ggXp4o+SYLE#4%$=AhC@R~K99WI z7_gdVTiJP51ICDW2@#hlP4%N=ol(2abv#(EG4ssewAZKeYRemDTqNaM*$FfBbDe?J zF>Gb?jk6}BxOdou$E6Gqn|@zz@cC_ZlGo7>Yq=-5BHW(fO%e>9{%Fs*v<=FYgaD3~ zo-+YaX|i>pHlO%(uJcLw$9*%kS7Gk{#AH&Lq#7SEd`Ypde7>FDr|q2Zu9B#iqQM3a zWYL60yD+KAaMSz|1-R8_JVMrISOo#Ztt$P3c$tKkVnOjUX7(Q+32tC8t z89KZ3lKT2D=j7i}kcmB~k>OJ?cM1unKCvq@_Y=V-M$HTT&BVFRnvqLS?S>e%QyG~@ z3-7g>uWH#q5vdRu82;qRw-8yy{g@~fwNMByvSP*geQWz87c1=2W@$6%XY2{m-0XF>0<`WCtIS=n_D%v zGQBYsU`v}QEiDBfXVcCjd-w0xt%(qS%5pZ&d$6YaMe1qUxE z`tFvfil_3OdQZ2F2Dr1V*QVP|h6hVFZ_z$rYz}5V_(idhd3S5Nnepk|oDLd28X{t+ zDxTbG8{gWOd*qh9P~6FJduzA-I$hYIz^LCktt?A1Iw;3hL*&2oagPygpQgg90i2sN zkw};YZARps+ozj7nT{LOor>g_?8rE0)+gxA5jmu=sPQ@Sv+YDWee!}t_-kqzRtqor z{YN&+tsNDv2~B$IX@bGheI{MT{gPJ>#fU0}&QPuObwXwUI_%9gGK$?5&nKG_1naNp z>NceHHgoSDnq;*5&ONOhZ(i>o^t^QBWyU7k2fp>|E*HPQH)l3nMBNaMBu@EOi$zbx ztBzcz^FpbXW-528`!UJCmJb(=|3T0PJfByq`-Q7St2DS9S+<7` zeh{{adStcaF@M^jM@ggZnmVIQW!fdjpO>}TzjFMu4g>uwR4SH+Uah!kni^BvYC#6f z_GqX3s^ti4BQn=}_a5hwzYC9ZInSr^^EyK5!Nw%5gpQ57kCk3&=l~co(3rx4G_c>1 zQ_1RNV0_42Fu+nh61d4Lb;aBHCxDlU`YZE%o_X>)c(dFK^tuw9n zSn2L4y~xIa>-RO~Gb`y;!nJQF$A$(jt+{o4%MLpK1+7l-nQXQuD%0A7UdBS})4ht_ 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 z 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 BVSoSt literal 0 HcmV?d00001 diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index c65531a4d3..c70d471f39 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Intégrations -description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness et MiniMax Code depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture. +description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code et Raycast depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture. --- L'onglet **Intégrations** écrit le bloc fournisseur d'opencodex dans le fichier de configuration du client, -puis peut le retirer. Neuf clients fonctionnent ainsi, chacun avec son propre commutateur : +puis peut le retirer. Dix clients fonctionnent ainsi, chacun avec son propre commutateur : | Client | Fichier de configuration | Format | Prise d'effet de la modification | Identifiant | |---|---|---|---|---| @@ -17,6 +17,7 @@ puis peut le retirer. Neuf clients fonctionnent ainsi, chacun avec son propre co | Gajae Code | `~/.gjc/agent/models.yml` | YAML | dans les nouvelles sessions ou à l'ouverture de `/model` |`OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (`~/.dsh/settings.yaml` par défaut) | YAML | rechargement à chaud | jeton porteur fictif et non secret pour le bouclage | | MiniMax Code | `~/.minimax/config.yaml` | YAML | dans les nouvelles sessions ou après l’ouverture du sélecteur de modèles | valeur fictive de bouclage | +| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immédiatement à l'enregistrement — Raycast surveille le fichier | aucun — bouclage uniquement | La prise en charge gérée de DSH exige au minimum **DSH 0.1.0-rc.6**. OpenCodex ne possède que le fragment `llm-pi-ai.providers.opencodex` : **Appliquer** et **Actualiser** remplacent ce fragment, **Désactiver** ne @@ -33,6 +34,27 @@ L’actualisation de l’intégration met également à jour les fenêtres de co d’effort de raisonnement faisant autorité ; les capacités inconnues sont omises et l’effort courant, qui appartient à la session MCode, est préservé. +Raycast a deux prérequis. Les fournisseurs personnalisés (Custom Providers) sont une fonctionnalité +**Raycast Pro** : avec un forfait gratuit, le fichier est tout de même écrit, mais +`ocx integration client status --client raycast` et la page Intégrations signalent un avertissement, +car Raycast ne le lira pas. Et Raycast ne crée son dossier `ai` que lorsque vous ouvrez une fois +Raycast → Settings → AI → **Reveal Providers Config** ; opencodex utilise ce dossier comme signal +d'installation et indique que le client n'est pas installé tant qu'il n'existe pas. Raycast lit +`~/.config/raycast/ai/providers.yaml` aussi bien sur macOS que sur Windows et n'honore pas +`XDG_CONFIG_HOME` ; ce chemin ne peut donc pas être déplacé. + +Le bloc géré est un seul élément, `id: opencodex`, dans la séquence `providers` du fichier : +`name: OpenCodex`, `base_url: http://:/v1`, et chaque modèle routé avec ses `abilities` — +`tools` et `system_message` sont toujours pris en charge, `vision` suit les modalités d'entrée du +catalogue, `reasoning_effort` est défini lorsque le modèle dispose d'une échelle d'effort, et +`temperature` est désactivé pour les modèles de raisonnement. Les autres fournisseurs du fichier sont +préservés, et la désactivation ne retire que l'élément OpenCodex. Raycast prend en compte la +modification dès l'enregistrement du fichier, sans redémarrage ; les modèles apparaissent dans le +sélecteur de modèles de Raycast regroupés sous **OpenCodex**. Le fichier n'a aucun emplacement pour +un identifiant, ce client est donc limité au bouclage : aucune entrée `api_keys` n'est écrite et une +liaison hors bouclage est refusée. Le format est documenté sur +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). + Les chemins respectent les variables de remplacement propres à chaque client, lorsqu'elles existent. Pour OMP, la présence de `OMP_PROFILE` l'emporte sur `PI_PROFILE`, même si sa valeur est explicitement vide. Un profil nommé emploie `PI_CONFIG_DIR` comme nom de répertoire relatif au dossier personnel de l'utilisateur @@ -93,7 +115,7 @@ niveaux. Dans ces cas, le commutateur est verrouillé afin que rien ne soit modi **OMP** n'est pas affecté non plus par les modifications voisines, mais pour une autre raison : son outil d'écriture ne modifie, octet par octet, que sa propre plage `providers.opencodex` ; le reste du fichier n'est jamais réécrit. Pour les autres formats susceptibles de contenir des commentaires (Hermes, OpenClaw, -Kimi Code, Gajae Code et MiniMax Code — documents YAML, JSON5 et TOML réécrits en entier), ou lorsque les propres entrées +Kimi Code, Gajae Code, MiniMax Code et Raycast — documents YAML, JSON5 et TOML réécrits en entier), ou lorsque les propres entrées d'opencodex ont été modifiées, le commutateur se verrouille et la désactivation est refusée plutôt que de deviner quelles modifications vous appartiennent. @@ -169,9 +191,11 @@ ocx integration client enable --client mcode ocx mcode ``` -Une fois l’intégration connectée, `ocx sync` actualise également le bloc MCode géré avec les fenêtres de -contexte et les niveaux d’effort de raisonnement actuels. Les blocs absents, modifiés par un tiers, non sûrs -ou jamais gérés restent intacts ; réactivez explicitement l’intégration lorsque vous souhaitez la reconnecter. +Une fois l’intégration connectée, `ocx sync` et `POST /api/sync` actualisent les catalogues MCode, +Pi, Aside et Raycast gérés. Le démarrage du proxy actualise aussi le catalogue Raycast géré. +Les changements de visibilité, de fournisseur ou de préréglage actualisent Pi, Aside et Raycast. +Les blocs absents, modifiés par un tiers, non sûrs ou supprimés manuellement restent intacts ; +réactivez explicitement l’intégration lorsque vous souhaitez la reconnecter. Le CLI distinct de la plateforme MiniMax (`mmx`) n’est pas une intégration à commutateur de fichier. Ses commandes textuelles utilisent le point de terminaison compatible avec Anthropic de MiniMax ; OpenCodex diff --git a/docs-site/src/content/docs/fr/reference/cli/agents.md b/docs-site/src/content/docs/fr/reference/cli/agents.md index e1501048c6..749119f70a 100644 --- a/docs-site/src/content/docs/fr/reference/cli/agents.md +++ b/docs-site/src/content/docs/fr/reference/cli/agents.md @@ -164,7 +164,7 @@ Gérez et appliquez la clôture du modèle Grok Build. ## Exportation de la configuration client -### `ocx export --client ` +### `ocx export --client ` Imprimez une configuration client connectée au proxy en cours d'exécution. La commande sérialise le bloc fournisseur `opencodex` — URL de base, liste de modèles et référence d’identifiant du client @@ -175,7 +175,7 @@ les modèles Codex peuvent actuellement voir. | Option | Actions | | --- | --- | -| `--client ` | Requis. Sélectionne le dialecte de configuration client. | +| `--client ` | Requis. Sélectionne le dialecte de configuration client. | | `--json` | Imprimez le document généré en tant que JSON sur la sortie standard pour les scripts. Il s'agit de JSON même lorsque le format natif du client sélectionné est YAML, TOML ou JSON5. | | `--out ` | Écrivez le format de configuration natif du client dans ``. Refuse de remplacer un fichier existant. | | `--force` | Autoriser `--out` à remplacer un fichier existant. | @@ -205,6 +205,17 @@ propres valeurs par défaut à ces lignes. | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, puis l'ancien `MAVIS_DATA_DIR`, l'emportent une fois définis ; une valeur relative est refusée) | `mcode-config.yaml` | aucun — espace réservé de bouclage | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` l'emporte une fois défini ; une valeur relative est refusée) | `config.json` | aucun — espace réservé de bouclage | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` l'emporte une fois défini ; une valeur relative est refusée) | `prime-models.json` | aucun — espace réservé de bouclage | +| `raycast` | `~/.config/raycast/ai/providers.yaml`, sur macOS comme sur Windows (Raycast n'honore pas `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | aucun — bouclage uniquement, aucune entrée `api_keys` n'est écrite | + +L'exportation Raycast est un document `providers.yaml` autonome contenant un seul élément `id: opencodex` +dans la séquence `providers` : `name: OpenCodex`, l'URL de base `/v1` du proxy et chaque modèle routé avec +ses `abilities` (`tools` et `system_message` toujours pris en charge, `vision` d'après les modalités d'entrée +du catalogue, `reasoning_effort` lorsque le modèle dispose d'une échelle d'effort, `temperature` désactivé +pour les modèles de raisonnement). Les fournisseurs personnalisés sont une fonctionnalité Raycast Pro, et +Raycast surveille le fichier : une modification enregistrée prend effet sans redémarrage. Le format est +documenté sur [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). +Aucune entrée `api_keys` n'est écrite ; cette exportation est donc limitée au bouclage et une liaison hors +bouclage est refusée. L'exportation DSH gérée nécessite DSH 0.1.0-rc.6 ou plus récent et ne possède que `llm-pi-ai.providers.opencodex`. DSH recharge à chaud ce fournisseur ; le modèle par défaut de l'utilisateur et diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 0c95908206..bb8cea7001 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Integrations -description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent and Aside from the dashboard — one switch per client, with a backup taken before every write. +description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside and Raycast from the dashboard — one switch per client, with a backup taken before every write. --- The **Integrations** tab writes opencodex's provider block into a client's own config -file, and removes it again. Twelve clients work this way, each with a switch: +file, and removes it again. Thirteen clients work this way, each with a switch: | Client | Config file | Format | When the change takes effect | Credential | |---|---|---|---|---| @@ -20,6 +20,7 @@ file, and removes it again. Twelve clients work this way, each with a switch: | Prime Agent | `~/.prime/agent/models.json` | JSON | new sessions | loopback placeholder | | ZCode | `~/.zcode/v2/config.json` | JSON | on restart | loopback placeholder | | Aside | `~/.aside/u//models.json` | JSON | after fully quitting and reopening Aside | loopback placeholder | +| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immediately on save — Raycast watches the file | none — loopback only | Generated catalogs include only enabled models from each provider selection. This applies to both downloads and managed integrations, including Pi and Aside. The management model list still shows @@ -61,6 +62,28 @@ One caveat specific to Aside: the running app rewrites `models.json` itself, so fully quit and reopen Aside after applying, the same way Claude Desktop needs a restart. Aside's block is loopback-only and never carries a real credential. +Raycast has two prerequisites. Custom Providers is a **Raycast Pro** feature: on a +free plan the file is still written, but `ocx integration client status --client +raycast` and the Integrations page report a warning, because Raycast will not +read it. And Raycast only creates its `ai` folder when you open Raycast → +Settings → AI → **Reveal Providers Config** once; opencodex uses that folder as +the install signal and reports the client as not installed until then. Raycast +reads `~/.config/raycast/ai/providers.yaml` on macOS and Windows alike and does +not honor `XDG_CONFIG_HOME`, so that path is not relocatable. + +The managed block is one element, `id: opencodex`, in the file's `providers` +sequence: `name: OpenCodex`, `base_url: http://:/v1`, and every +routed model with its `abilities` — `tools` and `system_message` are always +supported, `vision` follows the catalog's input modalities, `reasoning_effort` +is set when the model has an effort ladder, and `temperature` is turned off for +reasoning models. Other providers in the file are preserved, and disable removes +only the OpenCodex element. Raycast picks up the change as soon as the file is +saved, no restart needed; the models appear in Raycast's model picker grouped +under **OpenCodex**. The file has no place for a credential, so this client is +loopback-only: no `api_keys` entry is written and a non-loopback bind is refused. +The format is documented at +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). + Cursor has a tab but is not one of these switches. Regular Cursor calls custom endpoints from its own backend, so a loopback proxy is unreachable without a public tunnel, and Cursor's separate Private Inference build is configured inside Cursor. The **Cursor** tab is read-only: @@ -130,7 +153,7 @@ than 1000 levels — which locks the switch instead, so nothing is silently chan **OMP** is unaffected by sibling edits too, for a different reason: its writer patches only its own `providers.opencodex` range byte-wise, so the rest of the file is never rewritten. For the remaining formats that can carry comments -(Hermes, OpenClaw, Kimi Code, Gajae Code, MiniMax Code — YAML, JSON5 and TOML +(Hermes, OpenClaw, Kimi Code, Gajae Code, MiniMax Code, Raycast — YAML, JSON5 and TOML written as whole documents), or whenever our own entries were edited, the switch locks and disable refuses rather than guessing which edits were yours. @@ -216,10 +239,12 @@ ocx integration client enable --client mcode ocx mcode ``` -Once connected, `ocx sync` refreshes owned MCode, Pi, and Aside catalogs with the current -model selection, context windows, and reasoning-effort ladders. Changes to model visibility, -provider selection, or presets also refresh connected Pi and Aside catalogs. Foreign-edited -or unsafe blocks stay untouched, as do previously owned blocks you removed manually. +Once connected, `ocx sync` and `POST /api/sync` refresh owned MCode, Pi, Aside, and +Raycast catalogs with the current model selection, context windows, and reasoning-effort +ladders. Proxy startup refreshes an owned Raycast catalog. Changes to model visibility, +provider selection, or presets also refresh connected Pi, Aside, and Raycast catalogs. +Missing, foreign-edited, or unsafe blocks stay untouched, as do previously owned blocks +you removed manually. An enabled Aside profile is an exception to the usual owned-only refresh: if its account directory exists and it has never had an owned block, sync may create its first block when that slot is empty. A prior Aside connection enables this behavior for all registered diff --git a/docs-site/src/content/docs/ja/reference/cli/agents.md b/docs-site/src/content/docs/ja/reference/cli/agents.md index cd1b4fa30f..a223362a56 100644 --- a/docs-site/src/content/docs/ja/reference/cli/agents.md +++ b/docs-site/src/content/docs/ja/reference/cli/agents.md @@ -125,7 +125,7 @@ Grok Build モデル フェンスを管理および適用します。 ## クライアント設定のエクスポート -### `ocx export --client ` +### `ocx export --client ` 実行中のプロキシに接続するクライアント設定を出力します。このコマンドは、ベース URL、モデル一覧、およびクライアントに応じた認証情報参照または `opencodex-loopback` プレースホルダーを含む `opencodex` プロバイダーブロックを、選択したクライアントのネイティブ形式でシリアル化します。 @@ -133,7 +133,7 @@ Grok Build モデル フェンスを管理および適用します。 |旗 |アクション | | --- | --- | -| `--client ` |必須。クライアントの設定形式を選択します。 | +| `--client ` |必須。クライアントの設定形式を選択します。 | | `--json` |構成 JSON のみを標準出力に出力するため、リダイレクトはバイト正確な出力をキャプチャします。 `--out` 書き込みメモを含むすべての診断は stderr に送られます。 | | `--out ` |設定を `` に書き込みます。既存のファイルの置き換えを拒否します。 | | `--force` | `--out` が既存のファイルを置き換えることを許可します。 | @@ -160,6 +160,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`、次に旧 `MAVIS_DATA_DIR` が設定時に優先。相対値は拒否されます) | `mcode-config.yaml` | なし — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` が設定時に優先。相対値は拒否されます) | `config.json` | なし — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` が設定時に優先。相対値は拒否されます) | `prime-models.json` | なし — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml` (macOS と Windows で同じ。Raycast は `XDG_CONFIG_HOME` を尊重しません) | `raycast-providers.yaml` | なし — loopback のみ。`api_keys` エントリは書き込まれません | + +Raycast のエクスポートは、`providers` シーケンスに `id: opencodex` 要素を 1 つだけ持つ独立した `providers.yaml` 文書です。内容は `name: OpenCodex`、プロキシの `/v1` ベース URL、および `abilities` 付きのルーティング済み全モデルです (`tools` と `system_message` は常にサポート、`vision` はカタログの入力モダリティから、`reasoning_effort` はモデルに effort ラダーがある場合、`temperature` は推論モデルではオフ)。Custom Providers は Raycast Pro の機能で、Raycast はこのファイルを監視しているため、保存した変更は再起動なしで反映されます。形式は [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) に記載されています。`api_keys` エントリは書き込まれないため、このエクスポートは loopback 専用で、loopback 以外のバインドは拒否されます。 opencode は `{env:OPENCODEX_OPENCODE_API_KEY}` を補間します。opencodex が生成する Pi のエクスポートには環境変数が不要で、リテラルのプレースホルダー `opencodex-loopback` が入ります。この値は必須です。Pi はモデル リストを構築する際に `apiKey` を解決し、既存の設定に未設定の環境変数参照がある場合はプロバイダー全体を隠すためです。ループバックでは、生成されたプレースホルダーをプロキシが検査することはありません。 diff --git a/docs-site/src/content/docs/ko/reference/cli/agents.md b/docs-site/src/content/docs/ko/reference/cli/agents.md index a229551a83..3624a2a803 100644 --- a/docs-site/src/content/docs/ko/reference/cli/agents.md +++ b/docs-site/src/content/docs/ko/reference/cli/agents.md @@ -131,7 +131,7 @@ Grok Build model fence를 관리하고 적용합니다. ## 클라이언트 설정 내보내기 -### `ocx export --client ` +### `ocx export --client ` 실행 중인 프록시에 연결할 client config를 출력합니다. 이 명령은 base URL, model list, 그리고 client에 따라 credential reference 또는 `opencodex-loopback` placeholder를 포함한 `opencodex` provider block을 선택한 client의 네이티브 형식으로 직렬화합니다. @@ -139,7 +139,7 @@ Grok Build model fence를 관리하고 적용합니다. | 플래그 | 동작 | | --- | --- | -| `--client ` | 필수입니다. 클라이언트 설정 형식을 선택합니다. | +| `--client ` | 필수입니다. 클라이언트 설정 형식을 선택합니다. | | `--json` | config JSON만 stdout에 출력하므로, redirect가 byte-exact 출력을 캡처합니다. `--out` write note를 포함한 모든 진단 메시지는 stderr로 갑니다. | | `--out ` | config를 ``에 씁니다. 기존 파일이 있으면 덮어쓰지 않습니다. | | `--force` | `--out`이 기존 파일을 덮어쓰도록 허용합니다. | @@ -166,6 +166,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, 그다음 레거시 `MAVIS_DATA_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `mcode-config.yaml` | 없음 — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `config.json` | 없음 — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR`가 설정되면 우선. 상대 경로는 거부됩니다) | `prime-models.json` | 없음 — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml` (macOS와 Windows 모두 동일. Raycast는 `XDG_CONFIG_HOME`을 따르지 않습니다) | `raycast-providers.yaml` | 없음 — loopback 전용. `api_keys` 항목은 쓰지 않습니다 | + +Raycast 내보내기는 `providers` 시퀀스에 `id: opencodex` 요소 하나만 담은 독립 `providers.yaml` 문서입니다. 내용은 `name: OpenCodex`, proxy의 `/v1` base URL, 그리고 `abilities`가 붙은 라우팅된 모든 모델입니다(`tools`와 `system_message`는 항상 지원, `vision`은 카탈로그의 입력 모달리티를 따름, `reasoning_effort`는 모델에 effort 사다리가 있을 때, `temperature`는 추론 모델에서 꺼짐). Custom Providers는 Raycast Pro 기능이며, Raycast가 이 파일을 감시하므로 저장한 변경은 재시작 없이 적용됩니다. 형식은 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)에 문서화되어 있습니다. `api_keys` 항목은 쓰지 않으므로 이 내보내기는 loopback 전용이며, loopback이 아닌 bind는 거부됩니다. opencode는 `{env:OPENCODEX_OPENCODE_API_KEY}`를 보간합니다. opencodex가 생성한 Pi 블록에는 환경 변수가 필요 없으며, 리터럴 placeholder인 `opencodex-loopback`이 들어갑니다. 이 값은 필수입니다. Pi는 모델 목록을 만들 때 `apiKey`를 해석하고, 기존 config에 설정되지 않은 env 참조가 있으면 provider 전체를 숨기기 때문입니다. 루프백에서 proxy는 생성된 placeholder를 검사하지 않습니다. diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index e6470eae0e..300bb7d5e2 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -207,7 +207,7 @@ Manage and apply the Grok Build model fence. ## Client config export -### `ocx export --client ` +### `ocx export --client ` Print a client config wired to the running proxy. The command serializes the `opencodex` provider block — base URL, model list, and the client's credential @@ -218,7 +218,7 @@ models Codex can currently see. | Flag | Action | | --- | --- | -| `--client ` | Required. Selects the client config dialect. | +| `--client ` | Required. Selects the client config dialect. | | `--json` | Print the generated document as JSON on stdout for scripts. This is JSON even when the selected client's native format is YAML, TOML, or JSON5. | | `--out ` | Write the client's native config format to ``. Refuses to replace an existing file. | | `--force` | Allow `--out` to replace an existing file. | @@ -248,6 +248,7 @@ client applies its own defaults for those). | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` wins when set; a relative value is refused) | `config.json` | none — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` wins when set; a relative value is refused) | `prime-models.json` | none — loopback placeholder | | `aside` | `~/.aside/u//models.json` for the account Aside's own `accounts.json` names as current; an unreadable manifest is refused rather than defaulting to an account | `aside-models.json` | none — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml` on macOS and Windows alike (Raycast does not honor `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | none — loopback only, no `api_keys` entry is written | The managed DSH export requires DSH 0.1.0-rc.6 or newer and owns only `llm-pi-ai.providers.opencodex`. DSH hot reloads that provider; the user's default model and @@ -260,6 +261,15 @@ hide the whole provider when an existing config contains an unset env reference. checks the generated placeholder on loopback. OMP supports provider-level headers, but this initial integration deliberately remains loopback-only; remote `x-opencodex-api-key` wiring is deferred. +The Raycast export is a standalone `providers.yaml` document with one `id: opencodex` element +in the `providers` sequence: `name: OpenCodex`, the proxy's `/v1` base URL, and every routed model +with its `abilities` (`tools` and `system_message` always supported, `vision` from the catalog's +input modalities, `reasoning_effort` when the model has an effort ladder, `temperature` off for +reasoning models). Custom Providers is a Raycast Pro feature, and Raycast watches the file, so a +saved change takes effect without a restart. The format is documented at +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). No +`api_keys` entry is written, so this export is loopback-only and a non-loopback bind is refused. + The MCode, ZCode and Prime exports are loopback-only for the same reason and likewise carry the `opencodex-loopback` placeholder rather than a real credential. Prime Agent reads the same `models.json` contract Pi does, so the two exports produce the same document; only the destination diff --git a/docs-site/src/content/docs/ru/reference/cli/agents.md b/docs-site/src/content/docs/ru/reference/cli/agents.md index b49162dbc6..8df8175173 100644 --- a/docs-site/src/content/docs/ru/reference/cli/agents.md +++ b/docs-site/src/content/docs/ru/reference/cli/agents.md @@ -152,7 +152,7 @@ override, но файлы на диске никогда не меняются. ## Экспорт client config -### `ocx export --client ` +### `ocx export --client ` Печатает client config, направленный на работающий прокси. Команда сериализует блок провайдера `opencodex` в нативном формате выбранного клиента: base URL, список моделей и, @@ -163,7 +163,7 @@ override, но файлы на диске никогда не меняются. | Флаг | Действие | | --- | --- | -| `--client ` | Обязателен. Выбирает формат конфигурации клиента. | +| `--client ` | Обязателен. Выбирает формат конфигурации клиента. | | `--json` | Печатать только JSON-конфиг в stdout, чтобы redirect сохранял побайтно точный вывод. Вся диагностика, включая заметку о записи через `--out`, идёт в stderr. | | `--out ` | Записать конфиг в ``. Перезаписывать существующий файл не позволит. | | `--force` | Разрешить `--out` заменить существующий файл. | @@ -193,6 +193,17 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, затем устаревшая `MAVIS_DATA_DIR`, имеют приоритет, если заданы; относительное значение отклоняется) | `mcode-config.yaml` | нет — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` имеет приоритет, если задана; относительное значение отклоняется) | `config.json` | нет — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` имеет приоритет, если задана; относительное значение отклоняется) | `prime-models.json` | нет — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml` одинаково на macOS и Windows (Raycast не учитывает `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | нет — только loopback, запись `api_keys` не создаётся | + +Экспорт для Raycast — это отдельный документ `providers.yaml` с одним элементом `id: opencodex` в +последовательности `providers`: `name: OpenCodex`, базовый URL прокси с `/v1` и каждая маршрутизируемая +модель с её `abilities` (`tools` и `system_message` поддерживаются всегда, `vision` берётся из входных +модальностей каталога, `reasoning_effort` задаётся, когда у модели есть шкала усилий, `temperature` +отключена для рассуждающих моделей). Custom Providers — функция Raycast Pro, а Raycast следит за файлом, +поэтому сохранённое изменение вступает в силу без перезапуска. Формат описан на +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). Запись +`api_keys` не создаётся, поэтому этот экспорт работает только через loopback, а привязка вне loopback +отклоняется. opencode интерполирует `{env:OPENCODEX_OPENCODE_API_KEY}`. Сгенерированный opencodex экспорт для Pi не требует переменной окружения и несёт литеральную заглушку `opencodex-loopback`. Это значение diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index fea4b37dd4..ff0dcaa41e 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Entegrasyonlar -description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness ve MiniMax Code'u opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. +description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code ve Raycast'i opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. --- **Entegrasyonlar** sekmesi, opencodex'in sağlayıcı bloğunu istemcinin kendi -yapılandırma dosyasına yazar ve tekrar kaldırır. Dokuz istemci bu şekilde +yapılandırma dosyasına yazar ve tekrar kaldırır. On istemci bu şekilde çalışır, her biri bir anahtarla: | İstemci | Yapılandırma dosyası | Format | Değişiklik ne zaman geçerli olur? | Kimlik bilgisi | @@ -18,6 +18,7 @@ yapılandırma dosyasına yazar ve tekrar kaldırır. Dokuz istemci bu şekilde | Gajae Code | `~/.gjc/agent/models.yml` | YAML | yeni oturumlarda veya `/model` açtığınızda | `OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (varsayılan `~/.dsh/settings.yaml`) | YAML | çalışırken yeniden yükleme | gizli olmayan geri döngü bearer yer tutucusu | | MiniMax Code | `~/.minimax/config.yaml` | YAML | yeni oturumlarda veya model seçici açıldıktan sonra | geri döngü (loopback) yer tutucusu | +| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | kaydedildiği anda — Raycast dosyayı izler | yok — yalnızca geri döngü | Yönetilen DSH desteğinin en düşük uyumlu sürümü **DSH 0.1.0-rc.6**'dır. OpenCodex yalnızca `llm-pi-ai.providers.opencodex` bölümünü yönetir: Uygula ve Yenile bu bölümü değiştirir, Devre Dışı @@ -35,6 +36,29 @@ Entegrasyon yenilendiğinde model başına doğrulanmış bağlam pencereleri ve çabası seçenekleri de yenilenir; bilinmeyen yetenekler atlanır ve MCode oturumunun yönettiği geçerli çaba seçimi korunur. +Raycast'in iki ön koşulu vardır. Özel sağlayıcılar (Custom Providers) bir **Raycast Pro** +özelliğidir: ücretsiz planda dosya yine yazılır, ancak Raycast onu okumayacağı için +`ocx integration client status --client raycast` ve Entegrasyonlar sayfası bir uyarı +bildirir. Ayrıca Raycast `ai` klasörünü yalnızca Raycast → Settings → AI → +**Reveal Providers Config** seçeneğini bir kez açtığınızda oluşturur; opencodex bu +klasörü kurulum sinyali olarak kullanır ve klasör var olana kadar istemciyi kurulu değil +olarak bildirir. Raycast, `~/.config/raycast/ai/providers.yaml` dosyasını macOS ve +Windows'ta aynı şekilde okur ve `XDG_CONFIG_HOME` değerini dikkate almaz; bu nedenle bu +yol taşınamaz. + +Yönetilen blok, dosyanın `providers` dizisindeki tek bir öğedir: `id: opencodex`, +`name: OpenCodex`, `base_url: http://:/v1` ve `abilities` alanıyla birlikte +yönlendirilen her model — `tools` ve `system_message` her zaman destekli, `vision` +kataloğun giriş modalitelerini izler, `reasoning_effort` modelin bir çaba merdiveni +varsa ayarlanır ve `temperature` akıl yürütme modelleri için kapatılır. Dosyadaki diğer +sağlayıcılar korunur ve devre dışı bırakma yalnızca OpenCodex öğesini kaldırır. Raycast +değişikliği dosya kaydedilir kaydedilmez, yeniden başlatma gerekmeden alır; modeller +Raycast'in model seçicisinde **OpenCodex** altında gruplanmış olarak görünür. Dosyada +kimlik bilgisi için bir yer yoktur, bu yüzden bu istemci yalnızca geri döngü içindir: +hiçbir `api_keys` girdisi yazılmaz ve geri döngü dışı bir bağlama reddedilir. Format +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) +adresinde belgelenmiştir. + Yollar, varsa her istemcinin kendi ortam geçersiz kılmalarını dikkate alır. OMP için `OMP_PROFILE`, açıkça boş olduğunda bile varlığıyla `PI_PROFILE`'a üstün gelir. Adlandırılmış bir profil, `PI_CONFIG_DIR`'i kullanıcının ev dizinine göre @@ -112,7 +136,7 @@ hiçbir şey sessizce değiştirilmez veya düşürülmez. **OMP** de yanındaki düzenlemelerden etkilenmez, ama başka bir nedenle: writer'ı yalnızca kendi `providers.opencodex` aralığını bayt bayt yamalar, dosyanın geri kalanı hiçbir zaman yeniden yazılmaz. Yorum taşıyabilen diğer biçimlerde (Hermes, OpenClaw, -Kimi Code, Gajae Code, MiniMax Code — bütün belge olarak yazılan YAML, JSON5 ve TOML) veya +Kimi Code, Gajae Code, MiniMax Code, Raycast — bütün belge olarak yazılan YAML, JSON5 ve TOML) veya kendi girdilerimiz düzenlenmişse, anahtar kilitlenir ve hangi düzenlemelerin size ait olduğunu tahmin etmek yerine devre dışı bırakmayı reddeder. @@ -192,10 +216,12 @@ ocx integration client enable --client mcode ocx mcode ``` -Bağlandıktan sonra `ocx sync`, yönetilen MCode bloğunu güncel bağlam pencereleri ve -akıl yürütme çabası seçenekleriyle de yeniler. Eksik, dışarıdan düzenlenmiş, güvenli -olmayan veya hiç sahiplenilmemiş bloklara dokunmaz; yeniden bağlamak istediğinizde -entegrasyonu açıkça yeniden etkinleştirin. +Bağlandıktan sonra `ocx sync` ve `POST /api/sync`, yönetilen MCode, Pi, Aside ve +Raycast kataloglarını yeniler. Proxy başlangıcı da yönetilen Raycast kataloğunu +yeniler. Model görünürlüğü, sağlayıcı veya ön ayar değişiklikleri Pi, Aside ve +Raycast kataloglarını günceller. Eksik, dışarıdan düzenlenmiş, güvenli olmayan +veya elle kaldırılmış bloklara dokunmaz; yeniden bağlamak istediğinizde +entegrasyonu açıkça etkinleştirin. Ayrı MiniMax platform CLI'si (`mmx`) bir dosya anahtarı entegrasyonu değildir. Metin komutları MiniMax'ın Anthropic uyumlu uç noktasını kullandığı için OpenCodex, diff --git a/docs-site/src/content/docs/tr/reference/cli/agents.md b/docs-site/src/content/docs/tr/reference/cli/agents.md index a3e184661d..04e72a766c 100644 --- a/docs-site/src/content/docs/tr/reference/cli/agents.md +++ b/docs-site/src/content/docs/tr/reference/cli/agents.md @@ -191,7 +191,7 @@ Grok Build model çitini yönetin ve uygulayın. ## İstemci yapılandırma dışa aktarma -### `ocx export --client ` +### `ocx export --client ` Çalışan proxy'ye bağlı bir istemci yapılandırmasını yazdırın. Komut, `opencodex` sağlayıcı bloğunu — temel URL, model listesi ve istemcinin kimlik bilgisi @@ -203,7 +203,7 @@ yalnızca Codex'in şu anda görebildiği modelleri yayınlar. | Bayrak | Eylem | | --- | --- | -| `--client ` | Gerekli. İstemci yapılandırma lehçesini seçer. | +| `--client ` | Gerekli. İstemci yapılandırma lehçesini seçer. | | `--json` | Betikler için stdout üzerinde oluşturulan belgeyi JSON olarak yazdırın. Bu, seçilen istemcinin yerel formatı YAML, TOML veya JSON5 olsa bile JSON'dur. | | `--out ` | İstemcinin yerel yapılandırma formatını `` konumuna yazın. Mevcut bir dosyanın üzerine yazmayı reddeder. | | `--force` | `--out`'un mevcut bir dosyanın üzerine yazmasına izin verin. | @@ -233,6 +233,18 @@ için kendi varsayılanlarını uygular) gelir. | `mcode` | `~/.minimax/config.yaml` (ayarlandığında `MINIMAX_DATA_DIR`, ardından eski `MAVIS_DATA_DIR` öncelikli; göreli değer reddedilir) | `mcode-config.yaml` | yok — geri döngü yer tutucusu | | `zcode` | `~/.zcode/v2/config.json` (ayarlandığında `ZCODE_DATA_DIR` öncelikli; göreli değer reddedilir) | `config.json` | yok — geri döngü yer tutucusu | | `prime` | `~/.prime/agent/models.json` (ayarlandığında `PRIME_AGENT_CODING_AGENT_DIR` öncelikli; göreli değer reddedilir) | `prime-models.json` | yok — geri döngü yer tutucusu | +| `raycast` | `~/.config/raycast/ai/providers.yaml`, macOS ve Windows'ta aynı (Raycast `XDG_CONFIG_HOME` değerini dikkate almaz) | `raycast-providers.yaml` | yok — yalnızca geri döngü, `api_keys` girdisi yazılmaz | + +Raycast dışa aktarımı, `providers` dizisinde tek bir `id: opencodex` öğesi içeren bağımsız +bir `providers.yaml` belgesidir: `name: OpenCodex`, proxy'nin `/v1` temel URL'si ve +`abilities` alanıyla birlikte yönlendirilen her model (`tools` ve `system_message` her +zaman destekli, `vision` kataloğun giriş modalitelerinden, `reasoning_effort` modelin bir +çaba merdiveni varsa, `temperature` akıl yürütme modelleri için kapalı). Özel sağlayıcılar +bir Raycast Pro özelliğidir ve Raycast dosyayı izlediği için kaydedilen bir değişiklik +yeniden başlatma gerekmeden etkili olur. Format +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) +adresinde belgelenmiştir. Hiçbir `api_keys` girdisi yazılmaz; bu yüzden bu dışa aktarım +yalnızca geri döngü içindir ve geri döngü dışı bir bağlama reddedilir. opencode `{env:OPENCODEX_OPENCODE_API_KEY}` değerini enterpole eder. Üretilen Pi ve OMP dışa aktarımları bir ortam değişkeni gerektirmez: her biri değişmez diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index 11e1c38ee1..89203420e9 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -132,7 +132,7 @@ ocx claude desktop import [--apply] Validate and import JSON ## Client config export -### `ocx export --client ` +### `ocx export --client ` 输出连接到正在运行代理的客户端配置。此命令会以所选客户端的原生格式序列化 `opencodex` provider 块,其中包含基础 URL、模型列表,以及该客户端适用的凭据引用或 `opencodex-loopback` 占位值。 @@ -140,7 +140,7 @@ ocx claude desktop import [--apply] Validate and import JSON | 标志 | 动作 | | --- | --- | -| `--client ` | 必需。选择客户端配置格式。 | +| `--client ` | 必需。选择客户端配置格式。 | | `--json` | 仅在 stdout 打印配置 JSON,这样重定向即可捕获字节级精确输出。包括 `--out` 写入提示在内的所有诊断信息都会输出到 stderr。 | | `--out ` | 将配置写入 ``。拒绝替换已存在的文件。 | | `--force` | 允许 `--out` 替换已存在的文件。 | @@ -167,6 +167,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (设置后 `MINIMAX_DATA_DIR` 优先,其次是旧的 `MAVIS_DATA_DIR`;相对路径会被拒绝) | `mcode-config.yaml` | 无 — loopback placeholder | | `zcode` | `~/.zcode/v2/config.json` (设置后 `ZCODE_DATA_DIR` 优先;相对路径会被拒绝) | `config.json` | 无 — loopback placeholder | | `prime` | `~/.prime/agent/models.json` (设置后 `PRIME_AGENT_CODING_AGENT_DIR` 优先;相对路径会被拒绝) | `prime-models.json` | 无 — loopback placeholder | +| `raycast` | `~/.config/raycast/ai/providers.yaml`(macOS 与 Windows 相同;Raycast 不遵循 `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | 无 — 仅限回环,不会写入 `api_keys` 条目 | + +Raycast 导出是一份独立的 `providers.yaml` 文档,在 `providers` 序列中只有一个 `id: opencodex` 元素:`name: OpenCodex`、代理的 `/v1` 基础 URL,以及每个已路由模型及其 `abilities`(`tools` 与 `system_message` 始终支持,`vision` 取自目录的输入模态,`reasoning_effort` 在模型有 effort 阶梯时设置,`temperature` 对推理模型关闭)。Custom Providers 是 Raycast Pro 功能,且 Raycast 会监视该文件,因此保存后的更改无需重启即可生效。格式见 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。不会写入任何 `api_keys` 条目,所以该导出仅限回环,非回环绑定会被拒绝。 opencode 会插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。opencodex 生成的 Pi 导出不需要环境变量,而是携带字面占位值 `opencodex-loopback`。这个值是必需的:Pi 在构建模型列表时会解析 `apiKey`,如果已有配置包含未设置的环境变量引用,它就会隐藏整个 provider。回环上的代理从不校验生成的占位值。 diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 54751d5620..ebf2e6f0de 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -1,9 +1,9 @@ --- title: 整合 -description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、Gajae Code、DeepSeek Harness 與 MiniMax Code——每個客戶端一個開關,每次寫入前都會先備份。 +description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、Gajae Code、DeepSeek Harness、MiniMax Code 與 Raycast——每個客戶端一個開關,每次寫入前都會先備份。 --- -**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有九個客戶端以這種方式運作,每個都有一個開關: +**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十個客戶端以這種方式運作,每個都有一個開關: | 客戶端 | 設定檔 | 格式 | 變更生效時機 | 憑證 | |---|---|---|---|---| @@ -16,6 +16,7 @@ description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、 | Gajae Code | `~/.gjc/agent/models.yml` | YAML | 新 sessions,或當你開啟 `/model` 時 | `OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml`(預設 `~/.dsh/settings.yaml`) | YAML | 熱重載 | 非秘密的 loopback bearer 佔位符 | | MiniMax Code | `~/.minimax/config.yaml` | YAML | 新 sessions,或開啟模型選擇器後 | loopback 佔位符 | +| Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | 儲存後立即生效——Raycast 會監看該檔案 | 無——僅限 loopback | 受管理 DSH 支援的相容性下限是 **DSH 0.1.0-rc.6**。OpenCodex 只擁有 `llm-pi-ai.providers.opencodex`:Apply 與 Refresh 會取代該片段,Disable 只移除該片段, @@ -30,6 +31,22 @@ MiniMax Code 依序遵循 `MINIMAX_DATA_DIR`、`MAVIS_DATA_DIR`,最後才回 逐模型 context window 與 reasoning-effort 選項;未知能力會省略,而 MCode session 目前選取的 effort 不會被覆寫。 +Raycast 有兩個前提。Custom Providers 是 **Raycast Pro** 功能:免費方案下檔案仍會被寫入,但 +`ocx integration client status --client raycast` 與整合頁面會回報警告,因為 Raycast 不會讀取它。 +另外,Raycast 只有在你開啟一次 Raycast → Settings → AI → **Reveal Providers Config** 後才會建立 +`ai` 資料夾;opencodex 以該資料夾作為安裝訊號,在它存在之前都會回報客戶端尚未安裝。Raycast 在 +macOS 與 Windows 上同樣讀取 `~/.config/raycast/ai/providers.yaml`,且不遵循 `XDG_CONFIG_HOME`, +所以該路徑無法搬移。 + +受管理區塊是檔案 `providers` 序列中的單一元素 `id: opencodex`:`name: OpenCodex`、 +`base_url: http://:/v1`,以及每個路由模型及其 `abilities`——`tools` 與 +`system_message` 一律支援,`vision` 依目錄的輸入模態而定,`reasoning_effort` 在模型有 effort +階梯時設定,`temperature` 對推理模型關閉。檔案中的其他 provider 會被保留,停用只移除 OpenCodex +元素。檔案一儲存 Raycast 就會套用變更,不需重新啟動;模型會在 Raycast 的模型選擇器中歸在 +**OpenCodex** 群組下。該檔案沒有存放憑證的位置,因此此客戶端僅限 loopback:不會寫入任何 +`api_keys` 項目,非 loopback 的 bind 會被拒絕。格式說明見 +[manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。 + 路徑遵循客戶端自己的環境覆寫(environment override)。對 OMP 而言,`OMP_PROFILE` 以存在與否優先於 `PI_PROFILE`,即使明確為空也一樣。具名 profile 會把 `PI_CONFIG_DIR` 當作相對於使用者家目錄的目錄名稱,並忽略 `PI_CODING_AGENT_DIR`;沒有具名 profile 時,`PI_CODING_AGENT_DIR` 勝出。OMP 支援 provider 層級的 headers,但這個最初的整合刻意只支援 loopback;遠端 `x-opencodex-api-key` 的連線設定被延後。搬移過的 `HERMES_HOME`、`KIMI_CODE_HOME` 與 `XDG_CONFIG_HOME` 路徑同樣會被遵循,而非猜測。表格列出每個客戶端的預設值。 對原生 OpenAI 模型,產生的 OMP 區塊會選用其模型層級的 Responses API,保留圖片輸入與 reasoning-effort 控制。路由模型則維持 provider 的 Chat Completions 方言,讓它們既有的 adapters 保持相容。 @@ -52,7 +69,7 @@ opencodex 從自己的環境讀取這些變數。如果你的 gateway 以 profil - **Restore this point…** 會出現在較舊的操作上,或當檔案在那次操作之後有變更時。跨過這樣的變更做回復會再詢問一次,才覆蓋你的較新編輯——並且也會備份它們,所以那次的回復本身也可以復原。 - 每個客戶端保留十份備份。超過之後,最舊的快照檔案會被移除,其歷史列顯示為 **Backup expired**。 -停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP** 同樣不受旁邊編輯影響,但原因不同:它的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(Hermes、OpenClaw、Kimi Code、Gajae Code、MiniMax Code——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 +停用只移除 opencodex 記錄為自己寫入的條目。如果你的檔案在我們寫入之後有變更,後續行為取決於我們自己的條目是否完好,以及檔案的格式。對於嚴格 JSON 設定檔(OpenCode、Pi),在我們的區塊**旁邊**進行的編輯——例如新增 MCP 伺服器或你自己的 provider——會顯示為**需要更新**:重新整理會在保留你的條目的前提下合併寫入,但格式可能會被正規化。例外情況是 JSON 無法精確重寫的內容——例如 `1e999` 這類非有限數字、重寫會被四捨五入的數字(極大的整數,或小到會塌縮成零的數字)、`-0`、同一個物件裡重複出現的鍵,或巢狀層數超過 1000 層——此時開關會鎖定,確保沒有任何值被悄悄改動或刪除。**OMP** 同樣不受旁邊編輯影響,但原因不同:它的 writer 只逐位元組修補自己的 `providers.opencodex` 範圍,檔案其餘部分從不會被重寫。至於其餘可以包含註解的格式(Hermes、OpenClaw、Kimi Code、Gajae Code、MiniMax Code、Raycast——以整份文件寫出的 YAML、JSON5 與 TOML),或當我們自己的條目被編輯過時,開關會鎖定,停用會拒絕執行,而不是猜測哪些編輯是你的。 ## 誠實的預期 @@ -98,9 +115,11 @@ ocx integration client enable --client mcode ocx mcode ``` -完成一次連接後,`ocx sync` 也會以目前的 context window 與 reasoning-effort 階梯更新 -OpenCodex 已擁有的 MCode 區塊。若區塊已刪除、遭外部修改、不安全或從未由 OpenCodex -建立,sync 會保持原檔不動;只有在你確定要重新連接時才再次執行 enable。 +完成一次連接後,`ocx sync` 與 `POST /api/sync` 會更新 OpenCodex 已擁有的 +MCode、Pi、Aside 與 Raycast 目錄。proxy 啟動也會更新已擁有的 Raycast 目錄。 +模型可見性、provider 或 preset 變更會更新 Pi、Aside 與 Raycast。若區塊已刪除、 +遭外部修改、不安全或由你手動移除,sync 會保持原檔不動;只有在你確定要重新 +連接時才再次執行 enable。 另一個 MiniMax 平台 CLI(`mmx`)不是檔案開關整合。其文字命令使用 MiniMax 的 Anthropic 相容端點,因此 OpenCodex 提供憑證隔離、僅限 loopback 的 launcher: diff --git a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md index 497d2e4252..d04c099ebf 100644 --- a/docs-site/src/content/docs/zh-tw/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-tw/reference/cli/agents.md @@ -130,7 +130,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON ## 客戶端設定匯出 -### `ocx export --client ` +### `ocx export --client ` 印出連接到執行中代理的客戶端設定。此指令會用所選客戶端的原生格式,序列化含有 base URL、模型清單,以及適用的環境變數參考或 loopback 佔位符的 `opencodex` provider 區塊。 @@ -138,7 +138,7 @@ ocx claude desktop import [--apply] 驗證並匯入 JSON | 旗標 | 動作 | | --- | --- | -| `--client ` | 必填。選擇客戶端設定格式。 | +| `--client ` | 必填。選擇客戶端設定格式。 | | `--json` | 僅在 stdout 印出設定 JSON,使重導向能擷取逐位元組輸出。所有診斷訊息(含 `--out` 寫入提示)皆送至 stderr。 | | `--out ` | 將設定寫入 ``。拒絕覆寫既有檔案。 | | `--force` | 允許 `--out` 覆寫既有檔案。 | @@ -165,6 +165,9 @@ ocx export --client opencode --out ~/opencodex-opencode.json | `mcode` | `~/.minimax/config.yaml` (設定後 `MINIMAX_DATA_DIR` 優先,其次為舊的 `MAVIS_DATA_DIR`;相對路徑會被拒絕) | `mcode-config.yaml` | 無——loopback 佔位符 | | `zcode` | `~/.zcode/v2/config.json` (設定後 `ZCODE_DATA_DIR` 優先;相對路徑會被拒絕) | `config.json` | 無——loopback 佔位符 | | `prime` | `~/.prime/agent/models.json` (設定後 `PRIME_AGENT_CODING_AGENT_DIR` 優先;相對路徑會被拒絕) | `prime-models.json` | 無——loopback 佔位符 | +| `raycast` | `~/.config/raycast/ai/providers.yaml`(macOS 與 Windows 相同;Raycast 不遵循 `XDG_CONFIG_HOME`) | `raycast-providers.yaml` | 無——僅限 loopback,不會寫入 `api_keys` 項目 | + +Raycast 匯出是一份獨立的 `providers.yaml` 文件,在 `providers` 序列中只有一個 `id: opencodex` 元素:`name: OpenCodex`、proxy 的 `/v1` base URL,以及每個路由模型及其 `abilities`(`tools` 與 `system_message` 一律支援,`vision` 依目錄的輸入模態而定,`reasoning_effort` 在模型有 effort 階梯時設定,`temperature` 對推理模型關閉)。Custom Providers 是 Raycast Pro 功能,且 Raycast 會監看該檔案,因此儲存後的變更不需重新啟動即可生效。格式說明見 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。不會寫入任何 `api_keys` 項目,所以此匯出僅限 loopback,非 loopback 的 bind 會被拒絕。 opencode 會插值 `{env:OPENCODEX_OPENCODE_API_KEY}`。Pi 與 OMP 的匯出不需要環境變數, 而是帶有字面值 `opencodex-loopback`。DSH 匯出需要 DSH 0.1.0-rc.6 或更新版本,且只擁有 diff --git a/gui/public/provider-icons/README.md b/gui/public/provider-icons/README.md index 1fc7c57857..f5e64b568b 100644 --- a/gui/public/provider-icons/README.md +++ b/gui/public/provider-icons/README.md @@ -47,6 +47,16 @@ Export-client marks (used by the API tab's connect rows, not the provider list): on the web (`aside.com/favicon.svg` is a 404), so the shipping application is the first-party source. +- `raycast.svg` — fetched 2026-09-04 from + `https://fz1sd71lwhbqy6sh.public.blob.vercel-storage.com/press/images/logo/raycast-logo-dark.svg`, + the "Logo (dark)" download Raycast's own press kit (`raycast.com/press`) links. + `raycast.com/favicon.svg` and the other conventional paths are 404s, so the + press kit is the first-party source. Path data and the `#FF6363` fill are + verbatim; the fixed `width`/`height` are dropped in favour of the `viewBox`, + and the `` wrapper — a full-frame white `` the export tool left + behind — is removed because the path never leaves the frame and the rect + would read as a second ink to the mark tooling here. + - `minimax.svg` — fetched 2026-08-31 from `https://raw.githubusercontent.com/MiniMax-AI/MiniMax-01/main/figures/minimax.svg`, MiniMax's own symbol as committed in their own model repository. The API-docs @@ -135,6 +145,9 @@ Decisions that are not obvious from looking at the file: - `aside.svg` **is masked.** It already paints with `currentColor`, so it would follow the theme either way; masking keeps it consistent with the other silhouettes rather than depending on inherited color. +- `raycast.svg` **is not masked.** One ink, but that ink is #FF6363 — Raycast + red, the same case as `openai.svg` and `deepseek-harness.svg`. Legible on both + surfaces as an image. Both directions are enforced in `gui/tests/integration-marks.test.ts`, including a luminance check that fails any single-ink near-neutral mark left as an image. That diff --git a/gui/public/provider-icons/raycast.svg b/gui/public/provider-icons/raycast.svg new file mode 100644 index 0000000000..b6a40c7ba2 --- /dev/null +++ b/gui/public/provider-icons/raycast.svg @@ -0,0 +1,3 @@ + + + diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index bab41b1eee..c5971ffb6b 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -100,6 +100,7 @@ export const INTEGRATION_TAB_HASHES = [ "integrations/zcode", "integrations/prime", "integrations/aside", + "integrations/raycast", ] as const; export function hashBelongsToPage(rawHash: string, page: Page): boolean { diff --git a/gui/src/components/apikeys-workspace/client-config-clients.ts b/gui/src/components/apikeys-workspace/client-config-clients.ts index c7c42d3e56..afd4484551 100644 --- a/gui/src/components/apikeys-workspace/client-config-clients.ts +++ b/gui/src/components/apikeys-workspace/client-config-clients.ts @@ -8,7 +8,7 @@ * with EXPORT_CLIENT_IDS by hand; adding a client server-side renders no row * until this tuple changes. */ -export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"] as const; +export const CLIENTS = ["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"] as const; export type ExportClientId = (typeof CLIENTS)[number]; export const CLIENT_LABEL_KEYS = { @@ -24,6 +24,7 @@ export const CLIENT_LABEL_KEYS = { zcode: "api.clientConfig.clientZcode", prime: "api.clientConfig.clientPrime", aside: "api.clientConfig.clientAside", + raycast: "api.clientConfig.clientRaycast", } as const; /** @@ -70,6 +71,8 @@ export const CLIENT_MARKS: Partial> = { zcode: "/provider-icons/zcode.svg", prime: "/provider-icons/prime-agent.svg", aside: "/provider-icons/aside.svg", + // Raycast red (#FF6363) is the brand, so like `dsh` it stays an image. + raycast: "/provider-icons/raycast.svg", }; /** diff --git a/gui/src/components/integration-marks.ts b/gui/src/components/integration-marks.ts index e8786224ec..eca38510bd 100644 --- a/gui/src/components/integration-marks.ts +++ b/gui/src/components/integration-marks.ts @@ -57,6 +57,7 @@ export const INTEGRATION_MARKS: Record = { zcode: CLIENT_MARKS.zcode ?? null, prime: CLIENT_MARKS.prime ?? null, aside: CLIENT_MARKS.aside ?? null, + raycast: CLIENT_MARKS.raycast ?? null, }; /** diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 9086c9bf42..af5546775a 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1093,6 +1093,7 @@ export const de: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside-Profile", "integrations.aside.profilesHint": "Wähle, welche Profile die ausgewählten Modelle erhalten. Das aktive Aside-Profil bleibt unverändert.", "integrations.aside.all": "Alle Profile synchronisieren", @@ -1252,6 +1253,10 @@ export const de: Record = { "integrations.semantics.zcode": "Verwaltet nur provider.opencodex in ~/.zcode/v2/config.json. Z.ai-Anmeldung und andere Provider bleiben unverändert. ZCode nach Änderungen neu starten.", "integrations.semantics.prime": "Verwaltet nur providers.opencodex in der models.json von Prime Agent — ~/.prime/agent, sofern PRIME_AGENT_CODING_AGENT_DIR sie nicht umleitet. Andere Provider und Modell-Overrides bleiben unverändert. Gilt für neue Sitzungen.", "integrations.semantics.aside": "Verwaltet nur providers.opencodex in der ~/.aside/u//models.json dieses Profils. Andere Provider bleiben unverändert. Beende Aside nach dem Anwenden vollständig und öffne es erneut.", + "integrations.semantics.raycast": "Fügt einen OpenCodex-Provider-Eintrag in die providers.yaml von Raycast ein, damit jedes geroutete Modell in der Modellauswahl von Raycast AI erscheint. Raycast Pro erforderlich.", + "integrations.raycast.proRequired": "Custom Providers ist eine Funktion von Raycast Pro. Die Datei wird geschrieben, aber Raycast ignoriert sie, bis ein Pro-Abonnement aktiv ist.", + "integrations.raycast.planUnknown": "Der Abonnementstatus von Raycast konnte nicht gelesen werden; Custom Providers erfordert Raycast Pro.", + "integrations.raycast.revealConfig": "Öffnen Sie Raycast → Einstellungen → AI und klicken Sie einmal auf „Reveal Providers Config“, damit der Providers-Ordner existiert.", "codexAuth.mainAccount": "Hauptkonto", "codexAuth.logLabel": "Log-Kennung", "codexAuth.codexApp": "Codex App", @@ -1580,6 +1585,7 @@ export const de: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Konfiguration kopieren", "api.clientConfig.download": "Herunterladen", "api.clientConfig.loading": "Client-Konfiguration wird erstellt…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 0d2f1d05d4..71bfd5e2c3 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1600,6 +1600,7 @@ export const en = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside profiles", "integrations.aside.profilesHint": "Choose which profiles receive the selected models. Aside’s active profile stays unchanged.", "integrations.aside.all": "Sync all profiles", @@ -1799,6 +1800,10 @@ export const en = { "integrations.semantics.zcode": "Manages only provider.opencodex in ~/.zcode/v2/config.json. Your Z.ai login and other providers stay unchanged. Restart ZCode after changes.", "integrations.semantics.prime": "Manages only providers.opencodex in Prime Agent's models.json — ~/.prime/agent unless PRIME_AGENT_CODING_AGENT_DIR redirects it. Your other providers and model overrides stay unchanged. Applies to new sessions.", "integrations.semantics.aside": "Manages only providers.opencodex in this profile’s ~/.aside/u//models.json. Your other providers stay unchanged. Fully quit and reopen Aside after applying.", + "integrations.semantics.raycast": "Adds an OpenCodex provider entry to Raycast's providers.yaml so every routed model appears in the Raycast AI model picker. Raycast Pro required.", + "integrations.raycast.proRequired": "Custom Providers is a Raycast Pro feature. The file will be written, but Raycast ignores it until a Pro subscription is active.", + "integrations.raycast.planUnknown": "Could not read the Raycast subscription state; Custom Providers requires Raycast Pro.", + "integrations.raycast.revealConfig": "Open Raycast → Settings → AI and click Reveal Providers Config once so the providers folder exists.", "codexAuth.mainAccount": "Main Account", "codexAuth.logLabel": "Log label", "codexAuth.codexApp": "Codex App", @@ -2138,6 +2143,7 @@ export const en = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Copy config", "api.clientConfig.download": "Download", "api.clientConfig.loading": "Building client config…", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index b6eb03f0b0..028e910bc3 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1572,6 +1572,7 @@ export const fr: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Profils Aside", "integrations.aside.profilesHint": "Choisissez les profils qui recevront les modèles sélectionnés. Le profil actif dans Aside reste inchangé.", "integrations.aside.all": "Synchroniser tous les profils", @@ -1731,6 +1732,10 @@ export const fr: Record = { "integrations.semantics.zcode": "Gère uniquement provider.opencodex dans ~/.zcode/v2/config.json. Votre connexion Z.ai et les autres fournisseurs restent inchangés. Redémarrez ZCode après toute modification.", "integrations.semantics.prime": "Gère uniquement providers.opencodex dans le models.json de Prime Agent — ~/.prime/agent, sauf si PRIME_AGENT_CODING_AGENT_DIR le redirige. Vos autres fournisseurs et surcharges de modèles restent inchangés. S'applique aux nouvelles sessions.", "integrations.semantics.aside": "Gère uniquement providers.opencodex dans le fichier ~/.aside/u//models.json de ce profil. Vos autres fournisseurs restent inchangés. Quittez complètement Aside et relancez-le après application.", + "integrations.semantics.raycast": "Ajoute une entrée de fournisseur OpenCodex dans le providers.yaml de Raycast afin que chaque modèle routé apparaisse dans le sélecteur de modèles de Raycast AI. Raycast Pro requis.", + "integrations.raycast.proRequired": "Custom Providers est une fonctionnalité Raycast Pro. Le fichier sera écrit, mais Raycast l'ignore tant qu'un abonnement Pro n'est pas actif.", + "integrations.raycast.planUnknown": "Impossible de lire l'état de l'abonnement Raycast ; Custom Providers nécessite Raycast Pro.", + "integrations.raycast.revealConfig": "Ouvrez Raycast → Réglages → AI et cliquez une fois sur « Reveal Providers Config » pour que le dossier des fournisseurs existe.", "codexAuth.mainAccount": "Compte principal", "codexAuth.logLabel": "Libellé du journal", "codexAuth.codexApp": "Application Codex", @@ -2057,6 +2062,7 @@ export const fr: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Copier la configuration", "api.clientConfig.download": "Télécharger", "api.clientConfig.loading": "Génération de la configuration du client…", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 4b16912324..864b498483 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1513,6 +1513,7 @@ export const ja: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Asideのプロファイル", "integrations.aside.profilesHint": "選択したモデルを同期するプロファイルを選んでください。Asideで使用中のプロファイルは変わりません。", "integrations.aside.all": "すべてのプロファイルを同期", @@ -1672,6 +1673,10 @@ export const ja: Record = { "integrations.semantics.zcode": "~/.zcode/v2/config.json の provider.opencodex のみを管理します。Z.ai ログインと他のプロバイダーは変更しません。変更後は ZCode を再起動してください。", "integrations.semantics.prime": "Prime Agent の models.json 内の providers.opencodex のみを管理します。場所は ~/.prime/agent ですが、PRIME_AGENT_CODING_AGENT_DIR が設定されている場合はそちらが優先されます。他のプロバイダーとモデルオーバーライドは変更しません。新しいセッションから適用されます。", "integrations.semantics.aside": "このプロファイルの ~/.aside/u//models.json 内の providers.opencodex のみを管理します。他のプロバイダーは変更しません。適用後は Aside を完全に終了してから開き直してください。", + "integrations.semantics.raycast": "Raycast の providers.yaml に OpenCodex のプロバイダーエントリを追加し、ルーティングされたすべてのモデルを Raycast AI のモデル選択に表示します。Raycast Pro が必要です。", + "integrations.raycast.proRequired": "Custom Providers は Raycast Pro の機能です。ファイルは書き込まれますが、Pro サブスクリプションが有効になるまで Raycast はこれを無視します。", + "integrations.raycast.planUnknown": "Raycast のサブスクリプション状態を読み取れませんでした。Custom Providers には Raycast Pro が必要です。", + "integrations.raycast.revealConfig": "Raycast → 設定 → AI を開き、「Reveal Providers Config」を一度クリックして providers フォルダを作成してください。", "codexAuth.mainAccount": "メインアカウント", "codexAuth.logLabel": "ログラベル", "codexAuth.codexApp": "Codex App", @@ -2005,6 +2010,7 @@ export const ja: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "設定をコピー", "api.clientConfig.download": "ダウンロード", "api.clientConfig.loading": "クライアント設定を生成中…", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a87bf608e5..364805943e 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1117,6 +1117,7 @@ export const ko: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside 프로필", "integrations.aside.profilesHint": "선택한 모델을 동기화할 프로필을 고르세요. Aside에서 사용 중인 프로필은 바뀌지 않습니다.", "integrations.aside.all": "모든 프로필 동기화", @@ -1276,6 +1277,10 @@ export const ko: Record = { "integrations.semantics.zcode": "~/.zcode/v2/config.json의 provider.opencodex만 관리하며 Z.ai 로그인과 다른 프로바이더는 변경하지 않습니다. 변경 후 ZCode를 재시작하세요.", "integrations.semantics.prime": "Prime Agent의 models.json에서 providers.opencodex만 관리합니다. 위치는 ~/.prime/agent이며 PRIME_AGENT_CODING_AGENT_DIR가 설정되면 그쪽이 우선합니다. 다른 프로바이더와 모델 오버라이드는 변경하지 않습니다. 새 세션부터 적용됩니다.", "integrations.semantics.aside": "이 프로필의 ~/.aside/u//models.json에서 providers.opencodex만 관리합니다. 다른 프로바이더는 그대로 유지됩니다. 적용 후 Aside를 완전히 종료하고 다시 여세요.", + "integrations.semantics.raycast": "Raycast의 providers.yaml에 OpenCodex 프로바이더 항목을 추가해 라우팅된 모든 모델이 Raycast AI 모델 선택기에 표시되도록 합니다. Raycast Pro가 필요합니다.", + "integrations.raycast.proRequired": "Custom Providers는 Raycast Pro 기능입니다. 파일은 기록되지만 Pro 구독이 활성화될 때까지 Raycast는 이를 무시합니다.", + "integrations.raycast.planUnknown": "Raycast 구독 상태를 읽을 수 없습니다. Custom Providers에는 Raycast Pro가 필요합니다.", + "integrations.raycast.revealConfig": "Raycast → 설정 → AI를 열고 「Reveal Providers Config」를 한 번 클릭해 providers 폴더를 만드세요.", "codexAuth.mainAccount": "메인 계정", "codexAuth.logLabel": "로그 라벨", "codexAuth.codexApp": "Codex App", @@ -1607,6 +1612,7 @@ export const ko: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "설정 복사", "api.clientConfig.download": "다운로드", "api.clientConfig.loading": "클라이언트 설정 생성 중…", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 70eb364002..bfd6e0182e 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1583,6 +1583,7 @@ export const ru: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Профили Aside", "integrations.aside.profilesHint": "Выберите профили, в которые будут добавлены выбранные модели. Активный профиль Aside не изменится.", "integrations.aside.all": "Синхронизировать все профили", @@ -1742,6 +1743,10 @@ export const ru: Record = { "integrations.semantics.zcode": "Управляет только provider.opencodex в ~/.zcode/v2/config.json. Вход Z.ai и другие провайдеры не меняются. Перезапустите ZCode после изменений.", "integrations.semantics.prime": "Управляет только providers.opencodex в models.json Prime Agent — ~/.prime/agent, если PRIME_AGENT_CODING_AGENT_DIR не переопределяет путь. Другие провайдеры и переопределения моделей не меняются. Применяется к новым сессиям.", "integrations.semantics.aside": "Управляет только providers.opencodex в файле ~/.aside/u//models.json этого профиля. Другие провайдеры остаются без изменений. После применения полностью закройте и снова откройте Aside.", + "integrations.semantics.raycast": "Добавляет запись провайдера OpenCodex в providers.yaml Raycast, чтобы каждая маршрутизируемая модель появилась в выборе моделей Raycast AI. Требуется Raycast Pro.", + "integrations.raycast.proRequired": "Custom Providers — функция Raycast Pro. Файл будет записан, но Raycast игнорирует его, пока не активна подписка Pro.", + "integrations.raycast.planUnknown": "Не удалось прочитать состояние подписки Raycast; для Custom Providers требуется Raycast Pro.", + "integrations.raycast.revealConfig": "Откройте Raycast → Настройки → AI и один раз нажмите «Reveal Providers Config», чтобы папка провайдеров появилась.", "codexAuth.mainAccount": "Основной аккаунт", "codexAuth.logLabel": "Метка журнала", "codexAuth.codexApp": "Codex App", @@ -2075,6 +2080,7 @@ export const ru: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "Копировать конфигурацию", "api.clientConfig.download": "Скачать", "api.clientConfig.loading": "Формируется конфигурация клиента…", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ca233f452e..83ad09002b 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1590,6 +1590,7 @@ export const tr: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside profilleri", "integrations.aside.profilesHint": "Seçili modellerin hangi profillere aktarılacağını seçin. Aside’ın etkin profili değişmez.", "integrations.aside.all": "Tüm profilleri eşitle", @@ -1748,6 +1749,10 @@ export const tr: Record = { "integrations.semantics.zcode": "Yalnızca ~/.zcode/v2/config.json içindeki provider.opencodex bölümünü yönetir. Z.ai oturumu ve diğer sağlayıcılar değişmez. Değişikliklerden sonra ZCode'u yeniden başlatın.", "integrations.semantics.prime": "Yalnızca Prime Agent'ın models.json dosyasındaki providers.opencodex bölümünü yönetir — PRIME_AGENT_CODING_AGENT_DIR ayarlı değilse ~/.prime/agent. Diğer sağlayıcılar ve model geçersiz kılmaları değişmez. Yeni oturumlarda geçerli olur.", "integrations.semantics.aside": "Yalnızca bu profilin ~/.aside/u//models.json dosyasındaki providers.opencodex bölümünü yönetir. Diğer sağlayıcılarınız değişmez. Uyguladıktan sonra Aside’ı tamamen kapatıp yeniden açın.", + "integrations.semantics.raycast": "Raycast'in providers.yaml dosyasına bir OpenCodex sağlayıcı girdisi ekler; böylece yönlendirilen her model Raycast AI model seçicisinde görünür. Raycast Pro gerekir.", + "integrations.raycast.proRequired": "Custom Providers bir Raycast Pro özelliğidir. Dosya yazılır, ancak bir Pro aboneliği etkin olana kadar Raycast bunu yok sayar.", + "integrations.raycast.planUnknown": "Raycast abonelik durumu okunamadı; Custom Providers için Raycast Pro gerekir.", + "integrations.raycast.revealConfig": "Raycast → Ayarlar → AI bölümünü açıp sağlayıcı klasörünün oluşması için „Reveal Providers Config“ seçeneğine bir kez tıklayın.", "integrations.semantics.omp": "Kataloğu yüklemek için OMP'yi yeniden başlatın.", "codexAuth.mainAccount": "Ana Hesap", "codexAuth.logLabel": "Günlük etiketi", @@ -2082,6 +2087,7 @@ export const tr: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "JSON Kopyala", "api.clientConfig.download": "İndir", "api.clientConfig.loading": "İstemci konfigürasyonu oluşturuluyor…", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 2b7e6ac6ba..4e137ebdd6 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2178,6 +2178,7 @@ export const zhTW: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside 設定檔", "integrations.aside.profilesHint": "選擇要接收所選模型的設定檔。Aside 目前使用的設定檔不會改變。", "integrations.aside.all": "同步所有設定檔", @@ -2337,6 +2338,10 @@ export const zhTW: Record = { "integrations.semantics.zcode": "僅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不會變更 Z.ai 登入狀態或其他供應商。變更後請重新啟動 ZCode。", "integrations.semantics.prime": "僅管理 Prime Agent 的 models.json 中的 providers.opencodex;預設位於 ~/.prime/agent,若設定 PRIME_AGENT_CODING_AGENT_DIR 則以其為準。不會變更其他供應商或模型覆寫設定。對新工作階段生效。", "integrations.semantics.aside": "僅管理此設定檔的 ~/.aside/u//models.json 中的 providers.opencodex。其他供應商維持不變。套用後請完全結束並重新開啟 Aside。", + "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中新增一個 OpenCodex 供應商項目,讓所有已路由的模型出現在 Raycast AI 模型選擇器中。需要 Raycast Pro。", + "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。檔案會被寫入,但在 Pro 訂閱生效之前 Raycast 會忽略它。", + "integrations.raycast.planUnknown": "無法讀取 Raycast 訂閱狀態;Custom Providers 需要 Raycast Pro。", + "integrations.raycast.revealConfig": "開啟 Raycast → 設定 → AI,點一次「Reveal Providers Config」,以便建立 providers 資料夾。", "codexAuth.pinned": "已固定", "codexAuth.pinnedHint": "你手動選取了此帳號,因此較高的選擇順序不會越過它。此固定會持續到該帳號用盡、你改選其他帳號,或你變更任一選擇順序為止。", "codexAuth.requestUserInput": "在 Default 模式中要求輸入", @@ -2378,6 +2383,7 @@ export const zhTW: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "cws.tabsLabel": "Combo 詳細區段", "cws.field.nativeAlias": "原生 OpenAI 別名", "cws.field.nativeAliasHint": "讓此 combo 擁有受支援的未限定原生 OpenAI 模型 ID。帶有帳號或供應商限定的 OpenAI 路由仍保持獨立。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 42ac3941d4..67400eaaea 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1110,6 +1110,7 @@ export const zh: Record = { "integrations.tab.zcode": "ZCode", "integrations.tab.prime": "Prime Agent", "integrations.tab.aside": "Aside", + "integrations.tab.raycast": "Raycast", "integrations.aside.profilesTitle": "Aside 配置文件", "integrations.aside.profilesHint": "选择要接收所选模型的配置文件。Aside 当前使用的配置文件不会改变。", "integrations.aside.all": "同步所有配置文件", @@ -1269,6 +1270,10 @@ export const zh: Record = { "integrations.semantics.zcode": "仅管理 ~/.zcode/v2/config.json 中的 provider.opencodex,不会更改 Z.ai 登录状态或其他提供商。更改后请重启 ZCode。", "integrations.semantics.prime": "仅管理 Prime Agent 的 models.json 中的 providers.opencodex;默认位于 ~/.prime/agent,若设置 PRIME_AGENT_CODING_AGENT_DIR 则以其为准。不会更改其他提供商或模型覆盖设置。对新会话生效。", "integrations.semantics.aside": "仅管理此配置文件的 ~/.aside/u//models.json 中的 providers.opencodex。其他提供商保持不变。应用后请完全退出并重新打开 Aside。", + "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中添加一个 OpenCodex 提供商条目,让所有已路由的模型出现在 Raycast AI 模型选择器中。需要 Raycast Pro。", + "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。文件会被写入,但在 Pro 订阅生效之前 Raycast 会忽略它。", + "integrations.raycast.planUnknown": "无法读取 Raycast 订阅状态;Custom Providers 需要 Raycast Pro。", + "integrations.raycast.revealConfig": "打开 Raycast → 设置 → AI,点击一次“Reveal Providers Config”,以便创建 providers 文件夹。", "codexAuth.mainAccount": "主账号", "codexAuth.logLabel": "日志标签", "codexAuth.codexApp": "Codex App", @@ -1600,6 +1605,7 @@ export const zh: Record = { "api.clientConfig.clientZcode": "ZCode", "api.clientConfig.clientPrime": "Prime Agent", "api.clientConfig.clientAside": "Aside", + "api.clientConfig.clientRaycast": "Raycast", "api.clientConfig.copy": "复制配置", "api.clientConfig.download": "下载", "api.clientConfig.loading": "正在生成客户端配置…", diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 51db75bd9f..2eef2c5cf0 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -8,6 +8,7 @@ import { markFor } from "../../components/integration-marks"; import IntegrationStateBadge from "./IntegrationStateBadge"; import ConsequenceDialog, { type ConsequenceCopy } from "./ConsequenceDialog"; import RestoreDialog from "./RestoreDialog"; +import RaycastPlanNotice from "./RaycastPlanNotice"; import { RollbackHistory } from "./RollbackHistory"; import { describeRefusal } from "./refusal-copy"; import { @@ -57,6 +58,7 @@ const SEMANTICS_KEY: Record = { zcode: "integrations.semantics.zcode", prime: "integrations.semantics.prime", aside: "integrations.semantics.aside", + raycast: "integrations.semantics.raycast", }; const TAB_LABEL_KEY: Record = { @@ -72,6 +74,7 @@ const TAB_LABEL_KEY: Record = { zcode: "integrations.tab.zcode", prime: "integrations.tab.prime", aside: "integrations.tab.aside", + raycast: "integrations.tab.raycast", }; export default function FileIntegrationPage({ @@ -261,6 +264,8 @@ export default function FileIntegrationPage({

{t(SEMANTICS_KEY[client])}

{status.configPath}

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

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

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

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

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

+ )} + + ); +} diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 7a9139f436..38b0e0fe89 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -14,6 +14,7 @@ export const FILE_INTEGRATION_CLIENTS = [ "zcode", "prime", "aside", + "raycast", ] as const; export type FileIntegrationClientId = (typeof FILE_INTEGRATION_CLIENTS)[number]; @@ -36,6 +37,19 @@ export type IntegrationRefusalReason = | "snapshot_expired" | "write_failed"; +export type RaycastPlan = "pro" | "free" | "unknown"; + +/** + * Raycast's app-side facts, sent only on `/api/client-integrations/raycast`. + * Custom Providers is a Pro feature, so a `current` file can still be one + * Raycast ignores — this is what lets the page say so instead of showing green. + */ +export interface RaycastInstall { + plan: RaycastPlan; + appPath: string | null; + aiDirPresent: boolean; +} + export interface IntegrationStatus { clientId: FileIntegrationClientId; state: IntegrationState; @@ -49,6 +63,7 @@ export interface IntegrationStatus { /** Aside's explicit account-backed profile scope and desired sync state. */ profileId?: number; enabled?: boolean; + raycast?: RaycastInstall; } export interface IntegrationStateListEnvelope { diff --git a/gui/src/pages/integrations/integration-tabs.ts b/gui/src/pages/integrations/integration-tabs.ts index 33c4f04358..99502bde87 100644 --- a/gui/src/pages/integrations/integration-tabs.ts +++ b/gui/src/pages/integrations/integration-tabs.ts @@ -46,6 +46,7 @@ export const TABS: readonly TabDefinition[] = [ { id: "zcode", hash: "integrations/zcode", labelKey: "integrations.tab.zcode" }, { id: "prime", hash: "integrations/prime", labelKey: "integrations.tab.prime" }, { id: "aside", hash: "integrations/aside", labelKey: "integrations.tab.aside" }, + { id: "raycast", hash: "integrations/raycast", labelKey: "integrations.tab.raycast" }, ] as const; export const FILE_CLIENTS = new Set([ @@ -61,4 +62,5 @@ export const FILE_CLIENTS = new Set([ "zcode", "prime", "aside", + "raycast", ]); diff --git a/gui/src/pages/integrations/overview-clients.ts b/gui/src/pages/integrations/overview-clients.ts index 4dd347b90c..7932cf5648 100644 --- a/gui/src/pages/integrations/overview-clients.ts +++ b/gui/src/pages/integrations/overview-clients.ts @@ -152,6 +152,7 @@ const FILE_LABEL_KEY: Record = { zcode: "integrations.tab.zcode", prime: "integrations.tab.prime", aside: "integrations.tab.aside", + raycast: "integrations.tab.raycast", }; /** A file client's block is in the file for both `current` and `stale`. */ diff --git a/gui/tests/client-config-panel.test.tsx b/gui/tests/client-config-panel.test.tsx index 8acc44e9ee..ea8210e7e4 100644 --- a/gui/tests/client-config-panel.test.tsx +++ b/gui/tests/client-config-panel.test.tsx @@ -170,8 +170,8 @@ function rowButton(container: HTMLElement, name: string, label: string): HTMLBut .find(el => el.textContent?.trim() === label)!; } -test("the API download surface includes DSH, MiniMax Code and Aside as clients", () => { - expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); +test("the API download surface includes DSH, MiniMax Code, Aside and Raycast as clients", () => { + expect(CLIENTS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); expect(CLIENT_LABEL_KEYS.dsh).toBe("api.clientConfig.clientDsh"); expect(CLIENT_LABEL_KEYS.mcode).toBe("api.clientConfig.clientMcode"); expect(CLIENT_LABEL_KEYS.zcode).toBe("api.clientConfig.clientZcode"); diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 221de55d00..8c1ca29ada 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -118,6 +118,8 @@ const INTENTIONAL_ENGLISH = new Set([ "api.clientConfig.clientPrime", "integrations.tab.aside", "api.clientConfig.clientAside", + "integrations.tab.raycast", + "api.clientConfig.clientRaycast", "models.reasoningEffort.minimal", "models.reasoningEffort.max", "pws.pacingRpmUnit", diff --git a/gui/tests/integration-marks.test.ts b/gui/tests/integration-marks.test.ts index b964bc4ce1..b13387d1b6 100644 --- a/gui/tests/integration-marks.test.ts +++ b/gui/tests/integration-marks.test.ts @@ -61,15 +61,16 @@ test("no multi-color asset is masked", () => { /* * The inverse rule, and the one that cannot be derived from the file: a mark may * be a single ink and still not be a masking candidate, because that ink is the - * brand. openai.svg is #10A37F and deepseek-harness.svg is #4d6bfe; masking - * either repaints a trademark in the theme's text color. Pinned with their inks - * so a vendor changing its asset shows up here rather than silently satisfying - * the assertion. + * brand. openai.svg is #10A37F, deepseek-harness.svg is #4d6bfe and raycast.svg + * is #FF6363; masking any of them repaints a trademark in the theme's text + * color. Pinned with their inks so a vendor changing its asset shows up here + * rather than silently satisfying the assertion. */ test("a single-ink asset whose ink is a brand color is not masked", () => { for (const [src, ink] of [ ["/provider-icons/openai.svg", "#10a37f"], ["/provider-icons/deepseek-harness.svg", "#4d6bfe"], + ["/provider-icons/raycast.svg", "#ff6363"], ] as const) { expect(MASKED_MARKS.has(src), `${src} must not be masked`).toBe(false); expect([...inksOf(bodyOf(src))], `${src} ink changed upstream`).toEqual([ink]); diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 4338d9ead8..eea7dcfa0c 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -16,9 +16,9 @@ import { const originalFetch = globalThis.fetch; -test("DSH and Aside are file integration clients", () => { +test("DSH, Aside and Raycast are file integration clients", () => { expect(FILE_INTEGRATION_CLIENTS).toEqual([ - "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", + "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", ]); }); diff --git a/gui/tests/integrations-overview-rows.test.ts b/gui/tests/integrations-overview-rows.test.ts index 5bd673f849..54809a4422 100644 --- a/gui/tests/integrations-overview-rows.test.ts +++ b/gui/tests/integrations-overview-rows.test.ts @@ -290,12 +290,17 @@ test("every client counts toward the summary, not just the file clients", () => test("an unsettled file list renders unknown rows instead of dropping them", () => { const built = buildOverviewRows(sources({ clients: [], clientsSettled: false })); - expect(built.rows).toHaveLength(17); + expect(built.rows).toHaveLength(18); expect(rowById(built, "omp").state).toBe("unknown"); expect(rowById(built, "mcode").state).toBe("unknown"); expect(rowById(built, "zcode").state).toBe("unknown"); expect(rowById(built, "prime").state).toBe("unknown"); expect(rowById(built, "aside").state).toBe("unknown"); + expect(rowById(built, "raycast")).toMatchObject({ + hash: "integrations/raycast", + labelKey: "integrations.tab.raycast", + state: "unknown", + }); expect(rowById(built, "kimi").state).toBe("unknown"); expect(rowById(built, "dsh")).toMatchObject({ hash: "integrations/dsh", diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 11976154c9..5ea6785493 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -130,6 +130,8 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "api.clientConfig.clientPrime", "integrations.tab.aside", "api.clientConfig.clientAside", + "integrations.tab.raycast", + "api.clientConfig.clientRaycast", "integrations.codex.title", // Provider proper nouns kept in English "provider.name.commandCodeAuth", diff --git a/gui/tests/raycast-plan-notice.test.tsx b/gui/tests/raycast-plan-notice.test.tsx new file mode 100644 index 0000000000..4a8d44a77d --- /dev/null +++ b/gui/tests/raycast-plan-notice.test.tsx @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { I18nContext, type TFn } from "../src/i18n/shared"; +import RaycastPlanNotice from "../src/pages/integrations/RaycastPlanNotice"; +import type { RaycastInstall } from "../src/pages/integrations/integration-api"; + +/* + * Raycast reads providers.yaml only on a Pro plan and only from a folder it + * creates itself, so a `current` badge can be a lie. The notice is the one place + * that lie is corrected, and each of its three lines answers a different + * question; a regression that drops one leaves the page green and silent. + */ + +const echoT: TFn = key => key; + +function render(install: RaycastInstall): string { + return renderToStaticMarkup( + createElement( + I18nContext.Provider, + { value: { locale: "en", setLocale: () => {}, t: echoT } }, + createElement(RaycastPlanNotice, { install }), + ), + ); +} + +test("a Pro install with the ai folder renders nothing", () => { + expect(render({ plan: "pro", appPath: "/Applications/Raycast.app", aiDirPresent: true })).toBe(""); +}); + +test("a free plan is a warning notice, never a refusal", () => { + const markup = render({ plan: "free", appPath: "/Applications/Raycast.app", aiDirPresent: true }); + expect(markup).toContain("notice-warn"); + expect(markup).toContain("integrations.raycast.proRequired"); + expect(markup).not.toContain("notice-err"); + expect(markup).not.toContain("integrations.raycast.planUnknown"); +}); + +test("an unreadable plan stays muted, because non-macOS hosts have no signal", () => { + const markup = render({ plan: "unknown", appPath: null, aiDirPresent: true }); + expect(markup).toContain('data-raycast-plan="unknown"'); + expect(markup).toContain("integrations.raycast.planUnknown"); + expect(markup).not.toContain("notice-warn"); +}); + +test("a missing ai folder adds the reveal hint independently of the plan", () => { + const markup = render({ plan: "free", appPath: "/Applications/Raycast.app", aiDirPresent: false }); + expect(markup).toContain("integrations.raycast.proRequired"); + expect(markup).toContain('data-raycast-ai-dir="absent"'); + expect(markup).toContain("integrations.raycast.revealConfig"); +}); diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d309073a3f..753b896416 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -690,6 +690,7 @@ "install-scripts.test.ts": "ci-workflows", "integrations-invariants.test.ts": "gui", "integrations-journal.test.ts": "clients", + "integrations-merge.test.ts": "clients", "integrations-serialize.test.ts": "clients", "integrations-state.test.ts": "clients", "integrations-writer.test.ts": "clients", @@ -998,6 +999,8 @@ "reserve-quota-scope.test.ts": "codex-integration", "rate-limit-reset-credits.test.ts": "gui", "rate-limit-retry.test.ts": "providers", + "raycast-client.test.ts": "clients", + "raycast-detect.test.ts": "clients", "reasoning-effort.test.ts": "codex-integration", "reasoning-replay-identity.test.ts": "adapters", "reasoning-replay-robustness.test.ts": "adapters", diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 6d018536c7..c884bb2e5d 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -403,7 +403,7 @@ const commandRunners: Record = { }, config, port: live.port, - }, ["mcode", "pi"])); + }, ["mcode", "pi", "raycast"])); } catch (error) { console.warn(`Client integrations were not refreshed: ${error instanceof Error ? error.message : String(error)}`); } diff --git a/src/cli/help.ts b/src/cli/help.ts index 0b3652ab59..0cd6bec4dc 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -77,7 +77,7 @@ Usage: ocx memory [--json] Alias of ocx observe memory ocx api-key Alias of ocx access key ocx access External API keys and endpoint information - ocx export --client Print a client config wired to the running proxy (12 clients) + ocx export --client Print a client config wired to the running proxy (13 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection diff --git a/src/cli/index.ts b/src/cli/index.ts index 7b863630b9..9d9f08951c 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -92,6 +92,15 @@ import { grokSyncFailureMessage, reconcileEnsureDesiredIntegrations, } from "./ensure-desired-integrations"; +import { refreshOwnedCatalogIntegrations } from "../integrations/catalog-refresh"; +import { loadExportModels } from "../server/management/model-rows"; + +import { removeOwnedConfigAfterDesktopCleanup } from "./uninstall-client-state"; +import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; +import { selfLaunchArgv } from "../lib/self-launch-argv"; +import { initializeNodeLauncherContext } from "./launcher-context"; +import { createLocalAttestationSecret } from "../lib/local-management-attestation"; +import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../lib/system-restart-contract"; /** * A failed shell-hook reconcile is not cosmetic: a stale hook keeps sourcing @@ -105,13 +114,25 @@ function reportShellHookFailure(result: { state: "installed" | "absent" | "faile console.warn(" Check ~/.zshrc for the '# opencodex claude-env hook' block."); } - -import { removeOwnedConfigAfterDesktopCleanup } from "./uninstall-client-state"; -import { withProcessRuntimeProvenance } from "../lib/bun-runtime"; -import { selfLaunchArgv } from "../lib/self-launch-argv"; -import { initializeNodeLauncherContext } from "./launcher-context"; -import { createLocalAttestationSecret } from "../lib/local-management-attestation"; -import { MEMORY_DRAIN_RESTART_MS, REPLACEMENT_READY_TIMEOUT_MS } from "../lib/system-restart-contract"; +async function refreshOwnedRaycastCatalog( + config: ReturnType, + port: number, +): Promise { + try { + const outcomes = await refreshOwnedCatalogIntegrations({ + models: () => loadExportModels(config), + config, + port, + }, ["raycast"]); + for (const outcome of outcomes) { + if (!outcome.ok) { + console.error(`⚠️ Raycast integration was not refreshed: ${outcome.reason}`); + } + } + } catch (error) { + console.error(`⚠️ Raycast integration was not refreshed: ${error instanceof Error ? error.message : String(error)}`); + } +} initializeNodeLauncherContext(); @@ -493,6 +514,7 @@ async function handleStart(options: { block?: boolean } = {}) { }, ); if (!startupSync.ran) console.log(" Codex integration OFF; startup left Codex native."); + await refreshOwnedRaycastCatalog(config, port); // #1046: one warning per startup, after BOTH writes. The server's cache // invalidation happens first and the catalog sync second, so the mtime is only // final here — and neither write site warns on its own, or a boot that hits @@ -558,6 +580,7 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom return null; }); if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); + await refreshOwnedRaycastCatalog(config, live.port); // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature). const systemEnv = await injectSystemEnv(live.port, config).catch(() => ({ injected: false })); reportShellHookFailure(reconcileShellHook(systemEnv.injected)); @@ -602,6 +625,7 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom return null; }); if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); + await refreshOwnedRaycastCatalog(config, port); // The child opens /healthz before its best-effort roster reconcile. Await the same idempotent // operation in the parent so `ocx ensure` cannot report success while stale ocx-*.md files are // still observable. Always use the live port, including fallback-port starts. diff --git a/src/cli/integrations.ts b/src/cli/integrations.ts index bcb87d5d18..89ab3ee046 100644 --- a/src/cli/integrations.ts +++ b/src/cli/integrations.ts @@ -161,6 +161,39 @@ export async function handleGrokCommand(argv: string[], deps: RuntimeApiDeps = { }); } +/** The Raycast-only block the single-client route adds; see IntegrationStateEnvelope. */ +interface RaycastStatusBlock { + plan: string; + aiDirPresent: boolean; +} + +function raycastBlock(result: unknown): RaycastStatusBlock | null { + if (!result || typeof result !== "object") return null; + const block = (result as { raycast?: unknown }).raycast; + if (!block || typeof block !== "object") return null; + const { plan, aiDirPresent } = block as Partial; + return typeof plan === "string" && typeof aiDirPresent === "boolean" ? { plan, aiDirPresent } : null; +} + +/** + * Text view of one client's status. + * + * Raycast carries an extra block, and the generic summary would print it as + * three dotted keys. A `current` file that Raycast ignores for want of a Pro + * subscription is the one fact this view must not bury, so `plan` gets its own + * line and a missing `ai` folder gets the instruction that creates it. + */ +function singleClientStatusLines(result: unknown): string[] { + const raycast = raycastBlock(result); + if (!raycast) return summaryLines(result); + const rest = Object.fromEntries(Object.entries(result as Record).filter(([key]) => key !== "raycast")); + const lines = [...summaryLines(rest), `plan: ${raycast.plan}`]; + if (!raycast.aiDirPresent) { + lines.push('Open Raycast → Settings → AI → "Reveal Providers Config" once so the ai folder exists.'); + } + return lines; +} + /** * The headless half of the client-integration toggle. * @@ -197,7 +230,7 @@ export async function handleClientIntegrationCommand( : [String((result as { error?: string }).error ?? "No Aside profiles found.")] : rows ? rows.map(row => `${String(row.clientId)}: ${String(row.state)}${row.installed ? "" : " (not installed)"}`) - : summaryLines(result)); + : singleClientStatusLines(result)); return; } diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 00ff25ed52..467a0f7971 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -287,8 +287,8 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ { name: "api-key", usage: "ocx api-key ...", summary: "Alias of ocx access key." }, { name: "export", - usage: "ocx export --client [--json] [--out ] [--force]", - summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside) wired to the running proxy.", + usage: "ocx export --client [--json] [--out ] [--force]", + summary: "Print a client config (OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside, Raycast) wired to the running proxy.", details: [ "--json prints the generated document as JSON on stdout; use --out for the client's native format.", "--out writes the native config there and refuses to replace an existing file without --force.", diff --git a/src/clients/config-export.ts b/src/clients/config-export.ts index 372abcc00e..8fb42f311d 100644 --- a/src/clients/config-export.ts +++ b/src/clients/config-export.ts @@ -37,6 +37,8 @@ export type { OmpModelEntry, OmpProviderBlock, OmpGeneratedConfig } from "./conf export type { ZcodeModelEntry, ZcodeProviderBlock, ZcodeGeneratedConfig } from "./config-export/zcode"; export type { DshReasoningEffort, DshWireReasoningEffort, DshModelEntry, DshProviderBlock, DshGeneratedConfig } from "./config-export/dsh"; export type { McodeProviderBlock, McodeModelEntry, McodeGeneratedConfig } from "./config-export/mcode"; +export type { RaycastAbility, RaycastAbilityName, RaycastModelEntry, RaycastProviderEntry, RaycastGeneratedConfig } from "./config-export/raycast"; +export { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast"; import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts"; import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, GAJAE_API_KEY_ENV, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants"; @@ -45,6 +47,7 @@ import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./conf import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh"; import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode"; import { buildZcodeClientConfig, summarizeZcode, buildZcodeContribution } from "./config-export/zcode"; +import { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast"; @@ -533,6 +536,22 @@ export function asideConfigPath(env: OpencodeLaunchEnv = process.env, home: stri return join(asideAccountDir(env, home), "models.json"); } +/** + * Raycast's Custom Providers directory. Raycast hard-codes + * `~/.config/raycast/ai` on macOS AND Windows: it neither honors + * `XDG_CONFIG_HOME` nor ships a variable of its own that relocates the file, so + * unlike `opencodeGlobalConfigPath` there is no override to mirror and the env + * parameter exists only to keep the resolver signature uniform with the rest. + */ +export function raycastAiDir(_env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(home, ".config", "raycast", "ai"); +} + +/** The providers file Raycast watches (manual.raycast.com/ai/custom-providers). */ +export function raycastConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { + return join(raycastAiDir(env, home), "providers.yaml"); +} + /** Endpoint plus admission, identical for the V1 `options` and V2 `settings` field. */ function opencodeProviderConnection(baseURL: string, config: OcxConfig): OpencodeProviderConnection { const options: OpencodeProviderConnection = { baseURL }; @@ -1259,6 +1278,23 @@ export const EXPORT_CLIENTS: Record = { // bind would generate a config that 401s. loopbackOnly: true, }, + raycast: { + id: "raycast", + // Not a bare `providers.yaml`: same Downloads-folder collision argument as + // `aside-models.json`. + filename: "raycast-providers.yaml", + destination: env => raycastConfigPath(env), + apiKeyEnv: "", + exportHint: "Raycast reads providers.yaml with no api_keys entry; loopback needs no key.", + build: buildRaycastClientConfig, + format: "yaml", + summarize: summarizeRaycast, + buildContribution: buildRaycastContribution, + // Raycast's provider entry has no header field, and its `api_keys` value + // is read literally (no env interpolation), so the only way to admit a + // remote bind would be a plaintext secret on disk. Refuse instead. + loopbackOnly: true, + }, }; export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[]; diff --git a/src/clients/config-export/contracts.ts b/src/clients/config-export/contracts.ts index 039d7eaaf0..c888a4c257 100644 --- a/src/clients/config-export/contracts.ts +++ b/src/clients/config-export/contracts.ts @@ -93,7 +93,8 @@ export type ExportClientId = | "mcode" | "zcode" | "prime" - | "aside"; + | "aside" + | "raycast"; export interface ExportClientSpec { id: ExportClientId; diff --git a/src/clients/config-export/raycast.ts b/src/clients/config-export/raycast.ts new file mode 100644 index 0000000000..2cf9396ed5 --- /dev/null +++ b/src/clients/config-export/raycast.ts @@ -0,0 +1,86 @@ +import { exportPresentationLabel } from "../model-presentation"; +import { OPENCODE_PROVIDER_ID } from "./constants"; +import type { ExportContext, ManagedContribution } from "./contracts"; +import { authoritativeContextWindow, normalizeExportModels, singleFragment } from "./model-metadata"; + +export interface RaycastAbility { + supported: boolean; +} + +export type RaycastAbilityName = + | "temperature" + | "vision" + | "system_message" + | "tools" + | "reasoning_effort"; + +export interface RaycastModelEntry { + id: string; + name: string; + context?: number; + abilities: Record; +} + +export interface RaycastProviderEntry { + id: string; + name: string; + base_url: string; + models: RaycastModelEntry[]; +} + +export interface RaycastGeneratedConfig { + providers: RaycastProviderEntry[]; +} + +/** + * Raycast appends `/chat/completions` to `base_url`, so the proxy's `/v1` + * root is passed through unchanged. The format has no safe credential + * interpolation, which is why the registry exposes it only on loopback. + */ +export function buildRaycastClientConfig(ctx: ExportContext): RaycastGeneratedConfig { + const models: RaycastModelEntry[] = normalizeExportModels(ctx.models).map(model => { + const hasLadder = (model.reasoningEfforts?.length ?? 0) > 0; + const context = authoritativeContextWindow(model.contextWindow); + return { + id: model.namespaced, + name: exportPresentationLabel(model), + ...(context !== undefined ? { context } : {}), + abilities: { + temperature: { supported: !hasLadder }, + vision: { supported: model.inputModalities?.includes("image") ?? false }, + system_message: { supported: true }, + tools: { supported: true }, + reasoning_effort: { supported: hasLadder }, + }, + }; + }); + return { + providers: [ + { id: OPENCODE_PROVIDER_ID, name: "OpenCodex", base_url: ctx.baseUrl, models }, + ], + }; +} + +export function summarizeRaycast( + document: unknown, +): { modelCount: number; modelsWithoutLimits: number } { + const providers = (document as RaycastGeneratedConfig | undefined)?.providers ?? []; + const models = providers.find(provider => provider.id === OPENCODE_PROVIDER_ID)?.models ?? []; + return { + modelCount: models.length, + modelsWithoutLimits: models.filter(model => model.context === undefined).length, + }; +} + +/** + * Raycast stores providers in a sequence. The stable id selector owns only + * OpenCodex's element, preserving user-defined providers around it. + */ +export function buildRaycastContribution(ctx: ExportContext): ManagedContribution { + const doc = buildRaycastClientConfig(ctx); + return singleFragment( + "raycast", + ["providers", `[id=${OPENCODE_PROVIDER_ID}]`], + doc.providers[0]!, + ); +} diff --git a/src/clients/model-presentation.ts b/src/clients/model-presentation.ts new file mode 100644 index 0000000000..9a5f9ae3c3 --- /dev/null +++ b/src/clients/model-presentation.ts @@ -0,0 +1,61 @@ +import { CURSOR_CAPABILITIES } from "../adapters/cursor/catalog"; +import { nativeOpenAiCapabilityDisplayName } from "../codex/catalog/metadata"; +import type { ExportModel } from "./config-export/contracts"; + +const KNOWN_ACRONYMS = new Set(["gpt", "glm", "grok"]); + +function titleWord(word: string): string { + const lower = word.toLowerCase(); + if (KNOWN_ACRONYMS.has(lower)) return lower.toUpperCase(); + if (/^\d+\.\d+$/.test(word)) return word; + return lower.charAt(0).toUpperCase() + lower.slice(1); +} + +/** + * Last-resort label when no catalog or operator name exists. Joins dotted version + * tails (`5-1` → `5.1`, `2-5` → `2.5`) so Raycast reads like a product name + * instead of a slug. + */ +function humanizeModelSlug(modelId: string): string { + const parts = modelId.split("-"); + const words: string[] = []; + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]!; + const next = parts[index + 1]; + if (/^\d+$/.test(part) && next !== undefined && /^\d+$/.test(next)) { + words.push(`${part}.${next}`); + index += 1; + continue; + } + words.push(part); + } + return words.map(titleWord).join(" "); +} + +function wireModelId(model: ExportModel): string { + if (model.id?.trim()) return model.id.trim(); + const slash = model.namespaced.lastIndexOf("/"); + return slash >= 0 ? model.namespaced.slice(slash + 1) : model.namespaced; +} + +/** + * Human-facing model label for clients whose picker shows `name` verbatim. + * + * Raycast has no second column for provider, so the shared `exportModelLabel` + * suffix `(anthropic)` would be noise — and its fallback is the raw wire id + * because management slugs are deliberately withheld from ExportModel. Resolve + * operator labels first, then the canonical capability tables, then a slug + * humanizer. + */ +export function exportPresentationLabel(model: ExportModel): string { + const configured = model.displayName?.trim(); + if (configured) return configured; + const wireId = wireModelId(model); + const fromCursor = CURSOR_CAPABILITIES[wireId]?.displayName; + if (fromCursor) return fromCursor; + if (model.native) { + const native = nativeOpenAiCapabilityDisplayName(wireId); + if (native) return native; + } + return humanizeModelSlug(wireId); +} diff --git a/src/integrations/catalog-refresh.ts b/src/integrations/catalog-refresh.ts index 8efb990002..8b89762f30 100644 --- a/src/integrations/catalog-refresh.ts +++ b/src/integrations/catalog-refresh.ts @@ -10,7 +10,7 @@ import { /** Refresh only previously connected clients; a refused file never blocks its peers. */ export async function refreshOwnedCatalogIntegrations( input: Omit, - clientIds: readonly IntegrationClientId[] = ["pi", "aside"], + clientIds: readonly IntegrationClientId[] = ["pi", "aside", "raycast"], ): Promise { let models: Promise | undefined; const loadModels = () => models ??= Promise.resolve().then(() => diff --git a/src/integrations/merge.ts b/src/integrations/merge.ts index 4dccd48e50..768ddc755f 100644 --- a/src/integrations/merge.ts +++ b/src/integrations/merge.ts @@ -20,18 +20,103 @@ function clone(value: T): T { return value === undefined ? value : (JSON.parse(JSON.stringify(value)) as T); } -/** Write `value` at `path`, creating intermediate objects. Returns a new document. */ +/** + * `[field=value]` addresses the ONE element of a sequence whose `field` equals + * `value`. Raycast keeps its providers as a YAML list, so the element is the + * smallest thing we can own there; an index would move under us the moment + * the user reordered their own entries. Any other segment is a plain key. + */ +const ARRAY_SELECTOR = /^\[([A-Za-z_][A-Za-z0-9_]*)=([^\]]+)\]$/u; + +export type PathSegment = + | { kind: "key"; key: string } + | { kind: "select"; field: string; value: string }; + +export function parseSegment(raw: string): PathSegment { + const match = ARRAY_SELECTOR.exec(raw); + if (!match) return { kind: "key", key: raw }; + return { kind: "select", field: match[1]!, value: match[2]! }; +} + +/** + * Thrown when a selector matches more than one element. Picking either one + * would silently rewrite an entry the user may have written; the writer maps + * this to an `unsafe` refusal instead. + */ +export class AmbiguousSelectorError extends Error { + constructor(field: string, value: string) { + super(`more than one entry has ${field}=${value}`); + this.name = "AmbiguousSelectorError"; + } +} + +/** The index of the element a selector names, -1 when none matches. */ +function selectIndex(items: readonly unknown[], field: string, value: string): number { + const matches: number[] = []; + items.forEach((item, index) => { + if (isPlainRecord(item) && item[field] === value) matches.push(index); + }); + if (matches.length > 1) throw new AmbiguousSelectorError(field, value); + return matches[0] ?? -1; +} + +function assertNever(segment: never): never { + throw new Error(`unknown path segment ${JSON.stringify(segment)}`); +} + +/** + * Write `value` at `path`, creating intermediate containers. Returns a new document. + * + * A `key` segment descends through a record, creating `{}` where the slot is + * absent or holds something else. A `select` segment descends through an + * array the same way, creating `[]`; a missing element is pushed, a matching + * one is replaced in place so the user's ordering survives. + */ export function setPath(doc: unknown, path: readonly string[], value: unknown): unknown { if (path.length === 0) throw new Error("setPath needs a non-empty path"); - const root: Record = isPlainRecord(doc) ? clone(doc) : {}; - let cursor = root; - for (const key of path.slice(0, -1)) { - const next = cursor[key]; - if (!isPlainRecord(next)) cursor[key] = {}; - cursor = cursor[key] as Record; + /* + * `parent[slot]` is the position the segment just consumed addresses. The + * root sits in a one-key holder so the first segment needs no special case: + * a non-record document is replaced by `{}` exactly as before. + */ + const holder: Record = { root: isPlainRecord(doc) ? clone(doc) : {} }; + let parent: Record | unknown[] = holder; + let slot: string | number = "root"; + const read = (): unknown => (Array.isArray(parent) ? parent[slot as number] : parent[slot as string]); + const write = (next: unknown): void => { + if (Array.isArray(parent)) parent[slot as number] = next; + else parent[slot as string] = next; + }; + for (const raw of path) { + const segment = parseSegment(raw); + switch (segment.kind) { + case "key": { + if (!isPlainRecord(read())) write({}); + parent = read() as Record; + slot = segment.key; + break; + } + case "select": { + if (!Array.isArray(read())) write([]); + const items = read() as unknown[]; + const found = selectIndex(items, segment.field, segment.value); + parent = items; + if (found >= 0) { + slot = found; + } else { + // Seed the element so the selector stays true for whatever a deeper + // segment writes into it; a last-position select replaces it whole. + slot = items.length; + items.push({ [segment.field]: segment.value }); + } + break; + } + default: + return assertNever(segment); + } } - cursor[path[path.length - 1]!] = clone(value); - return root; + write(clone(value)); + return holder.root; } /** @@ -54,27 +139,53 @@ export function deletePath( ): { doc: unknown; removed: boolean } { if (!isPlainRecord(doc) || path.length === 0) return { doc, removed: false }; const root = clone(doc) as Record; - const chain: Record[] = [root]; - let cursor: Record = root; - for (const key of path.slice(0, -1)) { - const next = cursor[key]; - if (!isPlainRecord(next)) return { doc: root, removed: false }; - cursor = next; - chain.push(cursor); + // `chain[i]` is the container segment `i` is resolved against; `slots[i]` is + // the key or index it resolved to, so the prune walk can delete by position. + const chain: (Record | unknown[])[] = [root]; + const slots: (string | number)[] = []; + for (let depth = 0; depth < path.length; depth += 1) { + const container = chain[depth]!; + const segment = parseSegment(path[depth]!); + switch (segment.kind) { + case "key": { + if (Array.isArray(container) || !(segment.key in container)) return { doc: root, removed: false }; + slots.push(segment.key); + chain.push(container[segment.key] as Record | unknown[]); + break; + } + case "select": { + if (!Array.isArray(container)) return { doc: root, removed: false }; + const found = selectIndex(container, segment.field, segment.value); + if (found < 0) return { doc: root, removed: false }; + slots.push(found); + chain.push(container[found] as Record | unknown[]); + break; + } + default: + return assertNever(segment); + } + // Only the leaf may be a scalar; walking into one means the path is absent. + if (depth < path.length - 1) { + const next = chain[depth + 1]; + if (!isPlainRecord(next) && !Array.isArray(next)) return { doc: root, removed: false }; + } } - const leaf = path[path.length - 1]!; - if (!(leaf in cursor)) return { doc: root, removed: false }; - delete cursor[leaf]; + const remove = (container: Record | unknown[], slot: string | number): void => { + if (Array.isArray(container)) container.splice(slot as number, 1); + else delete container[slot as string]; + }; + remove(chain[path.length - 1]!, slots[path.length - 1]!); /* * Walk back up, pruning only containers this deletion emptied AND that we * created. The root is never pruned. */ - for (let index = chain.length - 1; index >= 1; index -= 1) { + for (let index = path.length - 1; index >= 1; index -= 1) { const container = chain[index]!; - if (Object.keys(container).length > 0) break; + const empty = Array.isArray(container) ? container.length === 0 : Object.keys(container).length === 0; + if (!empty) break; const containerPath = path.slice(0, index).join("\u0000"); if (!createdContainers.has(containerPath)) break; - delete chain[index - 1]![path[index - 1]!]; + remove(chain[index - 1]!, slots[index - 1]!); } return { doc: root, removed: true }; } @@ -121,9 +232,31 @@ export function createdContainerPaths( for (const fragment of contribution.fragments) { let cursor: unknown = doc; for (let depth = 0; depth < fragment.path.length - 1; depth += 1) { - const key = fragment.path[depth]!; - const next = isPlainRecord(cursor) ? cursor[key] : undefined; - if (!isPlainRecord(next)) { + const segment = parseSegment(fragment.path[depth]!); + let next: unknown; + switch (segment.kind) { + case "key": { + /* + * The container this key must hold is whatever the NEXT segment + * descends into: an array when that is a selector, a record + * otherwise. Either one is ours to create when absent. + */ + const nextSegment = parseSegment(fragment.path[depth + 1]!); + next = isPlainRecord(cursor) ? cursor[segment.key] : undefined; + if (nextSegment.kind === "select" ? !Array.isArray(next) : !isPlainRecord(next)) next = undefined; + break; + } + case "select": { + // A selector that matches nothing means setPath will push the element. + next = Array.isArray(cursor) + ? cursor.find(item => isPlainRecord(item) && item[segment.field] === segment.value) + : undefined; + break; + } + default: + return assertNever(segment); + } + if (next === undefined) { created.add(fragment.path.slice(0, depth + 1).join("\u0000")); cursor = undefined; continue; diff --git a/src/integrations/raycast-detect.ts b/src/integrations/raycast-detect.ts new file mode 100644 index 0000000000..5e79578f46 --- /dev/null +++ b/src/integrations/raycast-detect.ts @@ -0,0 +1,110 @@ +/** + * Detect a Raycast install and whether Custom Providers can take effect. + * + * Custom Providers is a Raycast Pro feature: Raycast reads + * `~/.config/raycast/ai/providers.yaml` only while a subscription is active, and + * the `ai` directory itself only exists once the user has clicked "Reveal + * Providers Config" in Settings > AI. Neither fact stops the writer — the plan + * (devlog/_plan/260904_raycast_integration/000_plan.md) makes a free plan a + * WARNING, never a refusal — so this module only answers what status and the + * GUI need to explain a file that is written but ignored. + * + * Detection is read-only and injectable, like cursor-detect.ts: nothing here + * touches the Raycast install or its preferences, and the tests run against + * stubbed deps rather than the machine they execute on. + */ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { posix, win32 } from "node:path"; + +export type RaycastPlan = "pro" | "free" | "unknown"; + +export interface RaycastInstall { + /** The app bundle or install directory, or null when none of the well-known locations exist. */ + appPath: string | null; + /** `~/.config/raycast/ai` exists — the install signal the registry uses. */ + aiDirPresent: boolean; + plan: RaycastPlan; +} + +export interface RaycastDetectDeps { + platform: string; + homedir: string; + env: Record; + exists(path: string): boolean; + /** stdout of `defaults read ` trimmed, or null when the command fails / is unavailable. */ + readDefault(domain: string, key: string): string | null; +} + +/** + * The preference Raycast writes for its subscription state. Read through + * `defaults` rather than by parsing the plist: cfprefsd caches writes, so the + * file on disk can lag what the running app believes. + */ +const RAYCAST_DEFAULTS_DOMAIN = "com.raycast.macos.v1"; +const RAYCAST_SUBSCRIPTION_KEY = "subscriptions_active"; + +export function realRaycastDetectDeps(): RaycastDetectDeps { + return { + platform: process.platform, + homedir: homedir(), + env: process.env, + exists: path => { + try { + return existsSync(path); + } catch { + return false; + } + }, + readDefault: (domain, key) => { + // `defaults` is macOS-only; elsewhere the plan is simply unknown. + if (process.platform !== "darwin") return null; + try { + const result = Bun.spawnSync(["defaults", "read", domain, key], { stdout: "pipe", stderr: "pipe" }); + if (result.exitCode !== 0) return null; + return result.stdout.toString().trim(); + } catch { + return null; + } + }, + }; +} + +function appPathFor(deps: RaycastDetectDeps): string | null { + // Join with the target platform's separator so a test describing another OS + // gets that OS's paths, not the host's. + const { join } = deps.platform === "win32" ? win32 : posix; + if (deps.platform === "darwin") { + for (const candidate of ["/Applications/Raycast.app", join(deps.homedir, "Applications", "Raycast.app")]) { + if (deps.exists(candidate)) return candidate; + } + return null; + } + if (deps.platform === "win32") { + const local = deps.env.LOCALAPPDATA; + if (!local) return null; + const candidate = join(local, "Programs", "Raycast"); + return deps.exists(candidate) ? candidate : null; + } + return null; +} + +function planFor(deps: RaycastDetectDeps): RaycastPlan { + if (deps.platform !== "darwin") return "unknown"; + // Read once: `defaults` spawns a process, and the answer cannot change + // between two reads inside one detection. + const value = deps.readDefault(RAYCAST_DEFAULTS_DOMAIN, RAYCAST_SUBSCRIPTION_KEY); + if (value === "1") return "pro"; + if (value === "0") return "free"; + return "unknown"; +} + +export function detectRaycast(deps: RaycastDetectDeps = realRaycastDetectDeps()): RaycastInstall { + const { join } = deps.platform === "win32" ? win32 : posix; + return { + appPath: appPathFor(deps), + // Raycast ignores XDG and uses this path on every platform it ships on. + aiDirPresent: deps.exists(join(deps.homedir, ".config", "raycast", "ai")), + plan: planFor(deps), + }; +} diff --git a/src/integrations/registry.ts b/src/integrations/registry.ts index 13662d52d5..f5780f4f98 100644 --- a/src/integrations/registry.ts +++ b/src/integrations/registry.ts @@ -35,6 +35,8 @@ import { piConfigPath, primeAgentDir, primeConfigPath, + raycastAiDir, + raycastConfigPath, zcodeConfigPath, zcodeHomeDir, type ExportClientId, @@ -261,6 +263,22 @@ export const INTEGRATION_CLIENTS: Record join(asideHomeDir(env, home), "u"), }, + raycast: { + id: "raycast", + configPath: (env = process.env, home = homedir()) => raycastConfigPath(env, home), + /* + * The `ai` directory, not `Raycast.app`. Raycast creates it only when the + * user clicks "Reveal Providers Config" in Settings > AI, which is exactly + * the signal that Custom Providers is reachable on this install; an app + * bundle alone says nothing about the plan or the feature. + * + * No `sourcePreservingYaml`: that patcher handles block-map leaves only, + * and our entry is a SEQUENCE item, so the file is re-rendered through + * `renderYaml` (block style). The `[id=opencodex]` selector keeps the user's + * other providers in place across that re-render. + */ + detectDir: (env = process.env, home = homedir()) => raycastAiDir(env, home), + }, }; export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] = diff --git a/src/integrations/state.ts b/src/integrations/state.ts index 008f46fbf1..bb0e5b3567 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -12,6 +12,7 @@ import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel import type { OcxConfig } from "../types"; import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; import { SNAPSHOT_RETENTION } from "./journal"; +import { parseSegment, type PathSegment } from "./merge"; import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership"; import { protectedContributionFingerprint, @@ -52,11 +53,41 @@ export interface IntegrationStatus { retentionDegraded: boolean; } +function isPlainRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function assertNever(segment: never): never { + throw new Error(`unknown path segment ${JSON.stringify(segment)}`); +} + +/** The element a selector names, or `undefined` when none matches. */ +function selectElement(items: readonly unknown[], segment: PathSegment & { kind: "select" }): unknown { + return items.find(item => isPlainRecord(item) && item[segment.field] === segment.value); +} + +/** + * Same segment grammar as `setPath`: a plain key reads through a record, a + * `[field=value]` selector reads through an array. Because the classifier and + * the writer share this one function, status and mutation cannot disagree + * about which element is ours. + */ export function readPath(doc: unknown, path: readonly string[]): unknown { let cursor: unknown = doc; - for (const key of path) { - if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) return undefined; - cursor = (cursor as Record)[key]; + for (const raw of path) { + const segment = parseSegment(raw); + switch (segment.kind) { + case "key": + if (!isPlainRecord(cursor)) return undefined; + cursor = cursor[segment.key]; + break; + case "select": + if (!Array.isArray(cursor)) return undefined; + cursor = selectElement(cursor, segment); + break; + default: + return assertNever(segment); + } if (cursor === undefined) return undefined; } return cursor; @@ -82,10 +113,35 @@ export function blockedContainerPath( doc: unknown, contribution: ManagedContribution, ): readonly string[] | null { + /* + * What a segment needs the value it walks through to BE: a record for a key, + * an array for a selector. `typeof null === "object"`, so null is excluded + * by both checks rather than walking straight into the dereference below. + */ + const holds = (segment: PathSegment, value: unknown): boolean => { + switch (segment.kind) { + case "key": + return isPlainRecord(value); + case "select": + return Array.isArray(value); + default: + return assertNever(segment); + } + }; + const step = (segment: PathSegment, value: unknown): unknown => { + switch (segment.kind) { + case "key": + return (value as Record)[segment.key]; + case "select": + return selectElement(value as readonly unknown[], segment); + default: + return assertNever(segment); + } + }; for (const fragment of contribution.fragments) { let cursor: unknown = doc; for (let depth = 0; depth < fragment.path.length - 1; depth += 1) { - const key = fragment.path[depth]!; + const segment = parseSegment(fragment.path[depth]!); /* * ONLY `undefined` means absent. A missing file parses as `{}`, so an * absent prefix reads `undefined` — but a parsed `null` is a value the @@ -94,14 +150,10 @@ export function blockedContainerPath( * "successful" apply. */ if (cursor === undefined) break; - // `typeof null === "object"`, so null has to be named explicitly or it - // walks straight into the dereference below. - if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor)) { - return fragment.path.slice(0, depth); - } - const next = (cursor as Record)[key]; + if (!holds(segment, cursor)) return fragment.path.slice(0, depth); + const next = step(segment, cursor); if (next === undefined) break; - if (typeof next !== "object" || next === null || Array.isArray(next)) { + if (!holds(parseSegment(fragment.path[depth + 1]!), next)) { return fragment.path.slice(0, depth + 1); } cursor = next; diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 23b3eaaad4..7ec543715a 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -27,7 +27,7 @@ import { refreshablePathsOf, semanticProtectedContributionFingerprint, } from "./ownership-policy"; -import { createdContainerPaths, mergeContribution, removeFragments } from "./merge"; +import { AmbiguousSelectorError, createdContainerPaths, mergeContribution, removeFragments } from "./merge"; import { INTEGRATION_CLIENTS, isLoopbackOnly, resolveIntegrationPaths, type IntegrationClientId } from "./registry"; import { classifyIntegration, exportContextOf } from "./state"; import type { IntegrationState } from "./state"; @@ -352,36 +352,39 @@ function applyOrRefreshIntegration( * concludes the user owns it, and the replacement record forgets we made it * — so a later disable strands it forever. */ - const base = classified.state === "stale" && record - ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc - : classified.state === "conflict" && record - /* - * A forced overwrite of a `foreign-edit` conflict drops what the previous - * record owned for the same reason a stale refresh does: the replacement - * record covers the paths we are about to write, so a path the old record - * owned and the new one does not would be stranded forever, unremovable by - * any later disable. - * - * With NO record -- an `unowned-key` conflict -- there is nothing to drop and - * the merge runs against the user's document directly. That is correct: - * createdContainerPaths then attributes every container they already had to - * them, so a later disable removes our leaves and leaves their structure - * standing. - */ - ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc - : parsed; - // Computed against the document as it stands BEFORE the merge: afterwards - // every container exists and "did we create this?" is unanswerable. - const created = createdContainerPaths(base, contribution); /* * A document can hold a value its own format cannot round-trip through our * renderers. That used to throw straight out of the writer and reach the * user as a 500 with no path and no advice; it is a refusal like any other, - * and the file is untouched because this happens before any write. + * and the file is untouched because this happens before any write. The + * removal and merge sit inside the same guard: a sequence holding two + * entries our selector matches is equally unwritable, and equally untouched. */ - const nextDocument = mergeContribution(base, contribution); + let created: string[]; let text: string; try { + const base = classified.state === "stale" && record + ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc + : classified.state === "conflict" && record + /* + * A forced overwrite of a `foreign-edit` conflict drops what the previous + * record owned for the same reason a stale refresh does: the replacement + * record covers the paths we are about to write, so a path the old record + * owned and the new one does not would be stranded forever, unremovable by + * any later disable. + * + * With NO record -- an `unowned-key` conflict -- there is nothing to drop and + * the merge runs against the user's document directly. That is correct: + * createdContainerPaths then attributes every container they already had to + * them, so a later disable removes our leaves and leaves their structure + * standing. + */ + ? removeFragments(parsed, record.fragmentPaths, new Set(record.createdContainers ?? [])).doc + : parsed; + // Computed against the document as it stands BEFORE the merge: afterwards + // every container exists and "did we create this?" is unanswerable. + created = createdContainerPaths(base, contribution); + const nextDocument = mergeContribution(base, contribution); if (spec.sourcePreservingYaml && before !== null) { const value = sourcePreservingFragmentValue(contribution, spec.sourcePreservingYaml.path); const patched = value === undefined @@ -401,6 +404,10 @@ function applyOrRefreshIntegration( text = serializeDocument(nextDocument, exportSpec.format); } } catch (error) { + if (error instanceof AmbiguousSelectorError) { + return refuse(clientId, "unsafe", "unsafe", + `${configPath} holds more than one entry matching ours, so it was left alone`); + } if (!(error instanceof UnserializableValueError)) throw error; return refuse(clientId, "unsafe", "unsafe", `${configPath} contains something opencodex cannot rewrite safely (${error.message}), so it was left alone`); @@ -527,11 +534,15 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { return refuse(clientId, "unsafe", "unsafe", `${configPath} uses YAML source opencodex cannot patch without risking unrelated comments or formatting, so nothing was removed`); } - const { doc, removed } = removeFragments( - parsed, - record!.fragmentPaths, - new Set(prunableCreated), - ); + let doc: unknown; + let removed: boolean; + try { + ({ doc, removed } = removeFragments(parsed, record!.fragmentPaths, new Set(prunableCreated))); + } catch (error) { + if (!(error instanceof AmbiguousSelectorError)) throw error; + return refuse(clientId, "unsafe", "unsafe", + `${configPath} holds more than one entry matching ours, so nothing was removed`); + } if (!removed) { return { ok: true, changed: false, state: "absent", clientId, message: "nothing to remove" }; } diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index ac6929c968..4d551a886d 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -161,7 +161,7 @@ interface ClientIntegrationSyncOutcome { } /** - * Re-inject native clients that are switched ON and file integrations whose + * Re-inject native clients that are switched ON and every file integration whose * OpenCodex ownership record is the operator's durable opt-in. * * Only Codex used to run here, so a catalog change reached Codex and nothing else: a Grok @@ -169,6 +169,10 @@ interface ClientIntegrationSyncOutcome { * next `ocx start`. The startup path already gates each client on its own toggle * (`src/cli/index.ts`), and this is that same fan-out for the on-demand command. * + * File integrations use the catalog-refresh coordinator so owned blocks are + * updated without claiming unowned files. Aside remains on its multi-profile + * server-owned path inside that coordinator. + * * A client that is OFF or never connected is omitted from the result rather than reported as skipped — the * caller has to be able to tell "not touched" from "tried and failed". A client that fails * does not fail the sync: Codex is the one that matters for routing, and a broken Grok file @@ -233,7 +237,7 @@ export async function syncEnabledClientIntegrations( }, config, port, - }, ["mcode", "pi", "aside"])); + }, ["mcode", "pi", "aside", "raycast"])); return out; } diff --git a/src/server/management/integration-routes.ts b/src/server/management/integration-routes.ts index b332718e07..43c0e6a98c 100644 --- a/src/server/management/integration-routes.ts +++ b/src/server/management/integration-routes.ts @@ -22,6 +22,7 @@ import { isIntegrationClientId, type IntegrationClientId, } from "../../integrations/registry"; +import { detectRaycast, type RaycastInstall } from "../../integrations/raycast-detect"; import { readIntegrationState } from "../../integrations/state"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../integrations/store"; import { @@ -58,6 +59,13 @@ type RestoreResult = Awaited>; export type IntegrationStateEnvelope = { clientId: IntegrationClientId; + /** + * Raycast only, and only on the single-client read. Custom Providers is a + * Pro feature, so a file that is `current` can still be one Raycast ignores; + * this is the fact that lets status and the GUI say so. It is not part of + * the shared `IntegrationStatus`, which describes the file, not the app. + */ + raycast?: RaycastInstall; } & IntegrationStateRecord; export interface IntegrationStateListEnvelope { @@ -141,6 +149,17 @@ export function setIntegrationPathTestHooks(hooks: { env?: NodeJS.ProcessEnv; ho integrationPathTestHooks = hooks; } +/** + * Raycast detection override for tests. The real detector spawns `defaults` and + * reads the developer's own subscription state, which is exactly the kind of + * host fact a route test must not depend on. + */ +let raycastDetectTestHook: (() => RaycastInstall) | null = null; + +export function setRaycastDetectTestHook(hook: (() => RaycastInstall) | null): void { + raycastDetectTestHook = hook; +} + /** The `env`/`home` overrides, spread into every registry-resolving call. */ function pathOverrides(): { env?: NodeJS.ProcessEnv; home?: string } { return { @@ -177,7 +196,10 @@ export function setIntegrationMutationFlightTestHooks( setIntegrationMutationFlightTestHook(hooks?.run ?? null); // Path overrides are part of the same isolation contract: clearing flights // while leaving a temp home bound would let the next suite write real files. - if (hooks === null) integrationPathTestHooks = null; + if (hooks === null) { + integrationPathTestHooks = null; + raycastDetectTestHook = null; + } } /** @@ -633,7 +655,12 @@ export async function handleIntegrationRoutes(ctx: ManagementContext): Promise { + test("a selector splits into field and value; anything else is a key", () => { + expect(parseSegment("[id=opencodex]")).toEqual({ kind: "select", field: "id", value: "opencodex" }); + expect(parseSegment("[model_id=anthropic/claude-opus-5]")) + .toEqual({ kind: "select", field: "model_id", value: "anthropic/claude-opus-5" }); + expect(parseSegment("providers")).toEqual({ kind: "key", key: "providers" }); + // Near misses stay keys: a client whose map literally has such a key keeps working. + expect(parseSegment("[id=]")).toEqual({ kind: "key", key: "[id=]" }); + expect(parseSegment("[=x]")).toEqual({ kind: "key", key: "[=x]" }); + expect(parseSegment("[id=x")).toEqual({ kind: "key", key: "[id=x" }); + }); +}); + +describe("setPath with a selector", () => { + test("replaces the matching element in place and keeps siblings and order", () => { + const doc = { providers: [THEIRS, { id: "opencodex", name: "old" }, { id: "other" }], keep: true }; + const next = setPath(doc, SELECT, OURS) as typeof doc; + expect(next.providers).toEqual([THEIRS, OURS, { id: "other" }]); + expect(next.keep).toBe(true); + // The input is not mutated. + expect(doc.providers[1]).toEqual({ id: "opencodex", name: "old" }); + }); + + test("pushes when no element matches", () => { + const next = setPath({ providers: [THEIRS] }, SELECT, OURS) as { providers: unknown[] }; + expect(next.providers).toEqual([THEIRS, OURS]); + }); + + test("creates the array when absent, and createdContainerPaths reports it", () => { + expect(createdContainerPaths({}, contribution(SELECT))).toEqual(["providers"]); + expect(createdContainerPaths({ providers: {} }, contribution(SELECT))).toEqual(["providers"]); + expect(createdContainerPaths({ providers: [THEIRS] }, contribution(SELECT))).toEqual([]); + expect(setPath({}, SELECT, OURS)).toEqual({ providers: [OURS] }); + // A record where the array belongs is replaced, exactly as a scalar under a key is. + expect(setPath({ providers: {} }, SELECT, OURS)).toEqual({ providers: [OURS] }); + }); + + test("descends into a matched element, seeding one when absent", () => { + const path = ["providers", "[id=opencodex]", "name"]; + expect(setPath({ providers: [THEIRS] }, path, "X")) + .toEqual({ providers: [THEIRS, { id: "opencodex", name: "X" }] }); + expect(setPath({ providers: [OURS, THEIRS] }, path, "X")) + .toEqual({ providers: [{ id: "opencodex", name: "X" }, THEIRS] }); + // The element the selector would create is recorded, the existing array is not. + expect(createdContainerPaths({ providers: [THEIRS] }, contribution(path, "X"))) + .toEqual(["providers\u0000[id=opencodex]"]); + expect(createdContainerPaths({ providers: [OURS] }, contribution(path, "X"))).toEqual([]); + }); + + test("throws AmbiguousSelectorError when two elements match", () => { + const doc = { providers: [OURS, THEIRS, { id: "opencodex", name: "dupe" }] }; + expect(() => setPath(doc, SELECT, OURS)).toThrow(AmbiguousSelectorError); + expect(() => deletePath(doc, SELECT)).toThrow(AmbiguousSelectorError); + }); +}); + +describe("deletePath with a selector", () => { + test("removes only the matching element and leaves siblings", () => { + const { doc, removed } = deletePath({ providers: [THEIRS, OURS, { id: "other" }], keep: 1 }, SELECT); + expect(removed).toBe(true); + expect(doc).toEqual({ providers: [THEIRS, { id: "other" }], keep: 1 }); + }); + + test("reports nothing removed when no element matches or the slot is not an array", () => { + expect(deletePath({ providers: [THEIRS] }, SELECT)).toEqual({ doc: { providers: [THEIRS] }, removed: false }); + expect(deletePath({ providers: {} }, SELECT)).toEqual({ doc: { providers: {} }, removed: false }); + expect(deletePath({}, SELECT)).toEqual({ doc: {}, removed: false }); + }); + + test("prunes an emptied array we created and keeps one we did not", () => { + const created = new Set(["providers"]); + expect(deletePath({ providers: [OURS], keep: 1 }, SELECT, created).doc).toEqual({ keep: 1 }); + expect(deletePath({ providers: [OURS], keep: 1 }, SELECT).doc).toEqual({ providers: [], keep: 1 }); + // A sibling keeps the array alive even when we created it. + expect(deletePath({ providers: [OURS, THEIRS] }, SELECT, created).doc).toEqual({ providers: [THEIRS] }); + }); + + test("a leaf inside a selected element is removed without touching the element", () => { + const path = ["providers", "[id=opencodex]", "name"]; + const created = new Set(["providers", "providers\u0000[id=opencodex]"]); + // The seeded element keeps its selector field, so it is never empty and the prune walk + // stops at it. No client owns a leaf inside a selected element today; when one does, it + // decides whether a `{ id }` husk is residue worth a dedicated rule. + expect(deletePath({ providers: [{ id: "opencodex", name: "X" }] }, path, created).doc) + .toEqual({ providers: [{ id: "opencodex" }] }); + expect(deletePath({ providers: [{ id: "opencodex", name: "X", extra: 1 }] }, path, created).doc) + .toEqual({ providers: [{ id: "opencodex", extra: 1 }] }); + }); +}); + +describe("readPath and blockedContainerPath with a selector", () => { + test("readPath finds the element through a selector", () => { + const doc = { providers: [THEIRS, OURS] }; + expect(readPath(doc, SELECT)).toEqual(OURS); + expect(readPath(doc, ["providers", "[id=opencodex]", "name"])).toBe("OpenCodex"); + expect(readPath(doc, ["providers", "[id=missing]"])).toBeUndefined(); + expect(readPath({ providers: {} }, SELECT)).toBeUndefined(); + expect(readPath({ providers: "x" }, SELECT)).toBeUndefined(); + }); + + test("blockedContainerPath blocks a non-array where the selector expects one", () => { + expect(blockedContainerPath({ providers: {} }, contribution(SELECT))).toEqual(["providers"]); + expect(blockedContainerPath({ providers: "x" }, contribution(SELECT))).toEqual(["providers"]); + expect(blockedContainerPath({ providers: null }, contribution(SELECT))).toEqual(["providers"]); + expect(blockedContainerPath({ providers: [THEIRS] }, contribution(SELECT))).toBeNull(); + expect(blockedContainerPath({}, contribution(SELECT))).toBeNull(); + // Reading through a matched element continues the walk: a scalar element is blocked, + // a record one is fine, an absent one is simply not there yet. + const deep = ["providers", "[id=opencodex]", "name"]; + expect(blockedContainerPath({ providers: [OURS] }, contribution(deep, "X"))).toBeNull(); + expect(blockedContainerPath({ providers: [THEIRS] }, contribution(deep, "X"))).toBeNull(); + expect(blockedContainerPath({ providers: [{ id: "opencodex", name: 1 }] }, contribution(["providers", "[id=opencodex]", "name", "leaf"], "X"))) + .toEqual(["providers", "[id=opencodex]", "name"]); + }); +}); + +describe("plain-key paths are unchanged", () => { + test("setPath, deletePath, readPath, createdContainerPaths and blockedContainerPath behave as before", () => { + const path = ["providers", "opencodex", "api_key"]; + expect(setPath({}, path, "k")).toEqual({ providers: { opencodex: { api_key: "k" } } }); + expect(setPath({ providers: "x" }, path, "k")).toEqual({ providers: { opencodex: { api_key: "k" } } }); + expect(setPath({ providers: [1] }, path, "k")).toEqual({ providers: { opencodex: { api_key: "k" } } }); + expect(setPath({ providers: { other: 1 } }, path, "k")) + .toEqual({ providers: { other: 1, opencodex: { api_key: "k" } } }); + expect(createdContainerPaths({}, contribution(path, "k"))).toEqual(["providers", "providers\u0000opencodex"]); + expect(createdContainerPaths({ providers: { other: 1 } }, contribution(path, "k"))).toEqual(["providers\u0000opencodex"]); + + const created = new Set(["providers", "providers\u0000opencodex"]); + expect(deletePath({ providers: { opencodex: { api_key: "k" } } }, path, created)).toEqual({ doc: {}, removed: true }); + expect(deletePath({ providers: { opencodex: { api_key: "k" } } }, path)).toEqual({ doc: { providers: { opencodex: {} } }, removed: true }); + expect(deletePath({ providers: { opencodex: { api_key: "k", other: 1 } }, x: 1 }, path, created)) + .toEqual({ doc: { providers: { opencodex: { other: 1 } }, x: 1 }, removed: true }); + expect(deletePath({ providers: {} }, path)).toEqual({ doc: { providers: {} }, removed: false }); + expect(deletePath({ providers: [] }, path)).toEqual({ doc: { providers: [] }, removed: false }); + expect(deletePath({ providers: { opencodex: "x" } }, path)).toEqual({ doc: { providers: { opencodex: "x" } }, removed: false }); + expect(deletePath({ providers: { opencodex: { api_key: null } } }, path, created)).toEqual({ doc: {}, removed: true }); + + expect(readPath({ providers: { opencodex: { api_key: "k" } } }, path)).toBe("k"); + expect(readPath({ providers: [OURS] }, ["providers", "0"])).toBeUndefined(); + expect(readPath({ providers: null }, path)).toBeUndefined(); + + expect(blockedContainerPath({ providers: ["x"] }, contribution(path, "k"))).toEqual(["providers"]); + expect(blockedContainerPath({ providers: { opencodex: null } }, contribution(path, "k"))).toEqual(["providers", "opencodex"]); + expect(blockedContainerPath(null, contribution(path, "k"))).toEqual([]); + expect(blockedContainerPath({ providers: { opencodex: {} } }, contribution(path, "k"))).toBeNull(); + expect(blockedContainerPath(undefined, contribution(path, "k"))).toBeNull(); + }); +}); + +/** + * End to end through the real writer: Raycast is the first client whose + * fragment path carries a selector, so this is where status and mutation are + * shown agreeing on which sequence element is ours. + */ +describe("raycast writer round trip", () => { + const TEST_ENV = {} as NodeJS.ProcessEnv; + const MODELS: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-4-8", provider: "anthropic", id: "claude-opus-4-8", contextWindow: 200_000 }, + ]; + const CONFIG: OcxConfig = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, + } as unknown as OcxConfig; + let home: string; + let store: IntegrationStateStore; + + beforeEach(() => { + const base = mkdtempSync(join(tmpdir(), "ocx-integrations-merge-")); + home = join(base, "home"); + mkdirSync(home, { recursive: true }); + store = createIntegrationStateStore(join(base, "store", "integrations")); + }); + + afterEach(() => { + removeTreeWithRetry(dirname(home)); + }); + + function installRaycast(): string { + const spec = INTEGRATION_CLIENTS.raycast; + mkdirSync(spec.detectDir(TEST_ENV, home), { recursive: true }); + const configPath = spec.configPath(TEST_ENV, home); + mkdirSync(dirname(configPath), { recursive: true }); + return configPath; + } + + function input(): IntegrationWriteInput { + return { clientId: "raycast", models: MODELS, config: CONFIG, port: 10100, env: TEST_ENV, home, store }; + } + + test("apply appends beside the user's provider, disable removes only ours", () => { + const configPath = installRaycast(); + writeFileSync(configPath, Bun.YAML.stringify({ providers: [THEIRS] })); + + expect(readIntegrationState(input())).toMatchObject({ state: "absent" }); + expect(applyIntegration(input())).toMatchObject({ ok: true, changed: true }); + const applied = Bun.YAML.parse(readFileSync(configPath, "utf8")) as { providers: Array<{ id: string }> }; + expect(applied.providers.map(item => item.id)).toEqual(["lmstudio", "opencodex"]); + expect(readIntegrationState(input())).toMatchObject({ state: "current" }); + + expect(disableIntegration(input())).toMatchObject({ ok: true, changed: true }); + // The user's array was there before us, so it survives with their entry intact. + expect(Bun.YAML.parse(readFileSync(configPath, "utf8"))).toEqual({ providers: [THEIRS] }); + expect(readIntegrationState(input())).toMatchObject({ state: "absent" }); + }); + + test("a providers map instead of a sequence is unsafe for status and writer alike", () => { + const configPath = installRaycast(); + writeFileSync(configPath, Bun.YAML.stringify({ providers: { opencodex: {} } })); + expect(readIntegrationState(input())).toMatchObject({ state: "unsafe", reason: "blocked-container" }); + expect(applyIntegration(input())).toMatchObject({ ok: false, reason: "unsafe" }); + expect(Bun.YAML.parse(readFileSync(configPath, "utf8"))).toEqual({ providers: { opencodex: {} } }); + }); + + test("two entries with our id refuse as unsafe and leave the file alone", () => { + const configPath = installRaycast(); + const text = Bun.YAML.stringify({ providers: [{ id: "opencodex", name: "a" }, { id: "opencodex", name: "b" }] }); + writeFileSync(configPath, text); + // Neither entry is ours on record, so status reads conflict and a plain apply refuses + // there. The explicit overwrite reaches the merge, which is where the ambiguity is + // detected: it must surface as an `unsafe` refusal, never as a thrown error. + expect(readIntegrationState(input())).toMatchObject({ state: "conflict" }); + expect(applyIntegration(input())).toMatchObject({ ok: false, reason: "conflict" }); + const result = overwriteIntegration(input()); + expect(result).toMatchObject({ ok: false, reason: "unsafe", state: "unsafe" }); + if (!result.ok) expect(result.message).toContain("more than one entry"); + expect(readFileSync(configPath, "utf8")).toBe(text); + expect(store.listOperations("raycast")).toHaveLength(0); + }); +}); diff --git a/tests/clients/integrations-state.test.ts b/tests/clients/integrations-state.test.ts index 872e9b3824..56093b3dd6 100644 --- a/tests/clients/integrations-state.test.ts +++ b/tests/clients/integrations-state.test.ts @@ -775,9 +775,9 @@ describe("installation detection is independent of config state", () => { * from. Rationale and the per-client table: 020 §1 amendment. */ describe("the loopback-only set is one fact, read through one seam", () => { - test("omp, pi, kimi, gajae, dsh, mcode, zcode, prime and aside are loopback-only and nobody else is", () => { + test("omp, pi, kimi, gajae, dsh, mcode, zcode, prime, aside and raycast are loopback-only and nobody else is", () => { const loopbackOnly = INTEGRATION_CLIENT_IDS.filter(id => isLoopbackOnly(id)); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); }); test("the registry restates nothing — it reads the export spec", () => { diff --git a/tests/clients/raycast-client.test.ts b/tests/clients/raycast-client.test.ts new file mode 100644 index 0000000000..148f84babb --- /dev/null +++ b/tests/clients/raycast-client.test.ts @@ -0,0 +1,271 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + EXPORT_CLIENTS, + OPENCODE_PROVIDER_ID, + buildClientConfig, + buildClientConfigText, + buildClientContribution, + raycastAiDir, + raycastConfigPath, + type ExportContext, + type ExportModel, + type RaycastGeneratedConfig, +} from "../../src/clients/config-export"; +import { exportPresentationLabel } from "../../src/clients/model-presentation"; +import { refreshOwnedCatalogIntegrations } from "../../src/integrations/catalog-refresh"; +import { INTEGRATION_CLIENTS } from "../../src/integrations/registry"; +import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; +import { applyIntegration, disableIntegration, refreshIntegration } from "../../src/integrations/writer"; +import type { OcxConfig } from "../../src/types"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const CONFIG = { + port: 10100, + hostname: "127.0.0.1", + defaultProvider: "mock", + providers: { mock: { adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1" } }, +} as OcxConfig; + +// One model per cell of the vision x reasoning matrix, so every ability +// branch is exercised by a row that differs from its neighbours in one axis. +const MODELS: ExportModel[] = [ + { namespaced: "anthropic/claude-opus-5", provider: "anthropic", id: "claude-opus-5", contextWindow: 200_000, inputModalities: ["text", "image"] }, + { namespaced: "openai/gpt-5.6-sol", provider: "openai", id: "gpt-5.6-sol", contextWindow: 922_000, reasoningEfforts: ["low", "medium", "high"] }, + { namespaced: "mystery/model", provider: "mystery", id: "model" }, + { namespaced: "google/gemini-3-pro", provider: "google", id: "gemini-3-pro", contextWindow: 1_048_576, inputModalities: ["text", "image"], reasoningEfforts: ["low", "high"] }, +]; + +function context(models: readonly ExportModel[] = MODELS): ExportContext { + return { baseUrl: "http://127.0.0.1:10100/v1", config: CONFIG, models }; +} + +// A provider the user wrote by hand: the merge must carry it through every +// apply, refresh and disable untouched. +const LMSTUDIO = { id: "lmstudio", name: "LM Studio", base_url: "http://localhost:1234/v1", models: [] }; +const USER_SEED = [ + "providers:", + " - id: lmstudio", + " name: LM Studio", + " base_url: http://localhost:1234/v1", + " models: []", + "", +].join(String.fromCharCode(10)); + +function ourProvider(document: RaycastGeneratedConfig) { + return document.providers.find(provider => provider.id === OPENCODE_PROVIDER_ID)!; +} + +function abilitiesOf(document: RaycastGeneratedConfig, id: string): Record { + const model = ourProvider(document).models.find(entry => entry.id === id)!; + return Object.fromEntries(Object.entries(model.abilities).map(([name, ability]) => [name, ability.supported])); +} + +let home: string; +let store: IntegrationStateStore; + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-raycast-")); + store = createIntegrationStateStore(mkdtempSync(join(tmpdir(), "ocx-raycast-store-"))); +}); + +afterEach(() => { + removeTreeWithRetry(home); +}); + +/** Raycast "installed" for our purposes: the `ai` directory exists. */ +function installRaycast(seed?: string): string { + const spec = INTEGRATION_CLIENTS.raycast; + mkdirSync(spec.detectDir({}, home), { recursive: true }); + const configPath = spec.configPath({}, home); + if (seed !== undefined) writeFileSync(configPath, seed); + return configPath; +} + +function readProviders(configPath: string): RaycastGeneratedConfig { + return Bun.YAML.parse(readFileSync(configPath, "utf8")) as RaycastGeneratedConfig; +} + +function request(models: readonly ExportModel[] = MODELS) { + return { clientId: "raycast" as const, models, config: CONFIG, port: 10100, env: {}, home, store }; +} + +describe("Raycast client config", () => { + /* + * The shape is Raycast's, not ours: `providers` is a SEQUENCE, `base_url` + * ends in `/v1` without `/chat/completions`, and there is no `api_keys` at + * all because a loopback bind is unauthenticated. Every model carries all + * five abilities so Raycast never has to guess at a missing one. + */ + test("emits one provider element with the documented field vocabulary", () => { + const document = buildClientConfig("raycast", context()) as RaycastGeneratedConfig; + expect(Object.keys(document)).toEqual(["providers"]); + expect(document.providers.map(provider => provider.id)).toEqual([OPENCODE_PROVIDER_ID]); + + const provider = ourProvider(document); + expect(Object.keys(provider)).toEqual(["id", "name", "base_url", "models"]); + expect(provider.name).toBe("OpenCodex"); + expect(provider.base_url).toBe("http://127.0.0.1:10100/v1"); + expect(Object.keys(provider)).not.toContain("api_keys"); + + for (const model of provider.models) { + expect(Object.keys(model.abilities)).toEqual(["temperature", "vision", "system_message", "tools", "reasoning_effort"]); + } + const claude = provider.models.find(model => model.id === "anthropic/claude-opus-5")!; + // Raycast shows `name` verbatim with no provider suffix; capability tables + // supply the product label when ExportModel has no operator override. + expect(claude.name).toBe("Claude Opus 5"); + expect(claude.context).toBe(200_000); + // No authoritative window means the key is absent, not zero or null. + const unknown = provider.models.find(model => model.id === "mystery/model")!; + expect("context" in unknown).toBe(false); + }); + + test("uses product labels instead of raw slugs or provider suffixes", () => { + expect(exportPresentationLabel({ + namespaced: "anthropic/claude-fable-5-1", provider: "anthropic", id: "claude-fable-5-1", + })).toBe("Claude Fable 5.1"); + expect(exportPresentationLabel({ + namespaced: "cursor/composer-2.5", provider: "cursor", id: "composer-2.5", + })).toBe("Composer 2.5"); + expect(exportPresentationLabel({ + namespaced: "mystery/model", provider: "mystery", id: "model", displayName: "Custom Name", + })).toBe("Custom Name"); + }); + + /* + * Abilities follow the catalog row, not the vendor name. Temperature and + * reasoning_effort are the same bit inverted: Raycast's own template notes + * that reasoning models commonly reject temperature. system_message and + * tools are always on, the same stance as Hermes. + */ + test("maps vision and reasoning ladders onto abilities per model", () => { + const document = buildClientConfig("raycast", context()) as RaycastGeneratedConfig; + expect(abilitiesOf(document, "anthropic/claude-opus-5")).toEqual({ + temperature: true, vision: true, system_message: true, tools: true, reasoning_effort: false, + }); + expect(abilitiesOf(document, "openai/gpt-5.6-sol")).toEqual({ + temperature: false, vision: false, system_message: true, tools: true, reasoning_effort: true, + }); + expect(abilitiesOf(document, "mystery/model")).toEqual({ + temperature: true, vision: false, system_message: true, tools: true, reasoning_effort: false, + }); + expect(abilitiesOf(document, "google/gemini-3-pro")).toEqual({ + temperature: false, vision: true, system_message: true, tools: true, reasoning_effort: true, + }); + }); + + test("native YAML round-trips, leads with our element, and never carries a credential", () => { + const sentinel = ["sk", "live", "raycast", "sentinel"].join("-"); + const withKey = { ...CONFIG, apiKeys: [{ key: sentinel }] } as OcxConfig; + const built = buildClientConfigText("raycast", { ...context(), config: withKey }); + expect(built.format).toBe("yaml"); + expect(built.text.startsWith(["providers:", " - id: opencodex"].join(String.fromCharCode(10)))).toBe(true); + expect(Bun.YAML.parse(built.text)).toEqual(built.document as never); + expect(built.text).not.toContain(sentinel); + expect(built.text).not.toContain("api_keys"); + }); + + test("the contribution owns the providers element selected by our id", () => { + const contribution = buildClientContribution("raycast", context()); + expect(contribution.clientId).toBe("raycast"); + expect(contribution.fragments.map(fragment => fragment.path)).toEqual([["providers", `[id=${OPENCODE_PROVIDER_ID}]`]]); + expect((contribution.fragments[0]!.value as { id: string }).id).toBe(OPENCODE_PROVIDER_ID); + }); + + test("resolves under the home directory and ignores XDG_CONFIG_HOME", () => { + // Raycast hardcodes ~/.config/raycast on macOS and Windows alike; honoring + // XDG here would name a file Raycast never reads. + const env = { XDG_CONFIG_HOME: join(home, "elsewhere") }; + expect(raycastAiDir(env, home)).toBe(join(home, ".config", "raycast", "ai")); + expect(raycastConfigPath(env, home)).toBe(join(home, ".config", "raycast", "ai", "providers.yaml")); + expect(INTEGRATION_CLIENTS.raycast.configPath(env, home)).toBe(raycastConfigPath(env, home)); + expect(INTEGRATION_CLIENTS.raycast.detectDir(env, home)).toBe(raycastAiDir(env, home)); + }); + + test("ships as a loopback-only integration with no env var to export", () => { + const spec = EXPORT_CLIENTS.raycast; + // `api_keys` is read literally, so a remote bind would need a plaintext + // secret on disk; the spec refuses instead. + expect(spec.loopbackOnly).toBe(true); + expect(spec.apiKeyEnv).toBe(""); + expect(spec.format).toBe("yaml"); + // Not a bare providers.yaml: a download would collide with other clients'. + expect(spec.filename).toBe("raycast-providers.yaml"); + }); + + /* + * The whole point of the `[id=opencodex]` selector: the user's own element + * survives every operation, we replace only ours, and a disable leaves the + * sequence exactly as the user wrote it. + */ + test("apply, refresh and disable touch only our element of the sequence", () => { + const configPath = installRaycast(USER_SEED); + + const applied = applyIntegration(request()); + expect(applied.ok).toBe(true); + const afterApply = readProviders(configPath); + expect(new Set(afterApply.providers.map(provider => provider.id))).toEqual(new Set(["lmstudio", OPENCODE_PROVIDER_ID])); + expect(afterApply.providers.find(provider => provider.id === "lmstudio")).toEqual(LMSTUDIO); + expect(ourProvider(afterApply).models.map(model => model.id)).toEqual(MODELS.map(model => model.namespaced).sort()); + + // A smaller catalog rewrites our element in place and nothing else. + const fewer = MODELS.filter(model => model.namespaced !== "mystery/model"); + const refreshed = refreshIntegration(request(fewer)); + expect(refreshed.ok).toBe(true); + const afterRefresh = readProviders(configPath); + expect(afterRefresh.providers.map(provider => provider.id)).toEqual(afterApply.providers.map(provider => provider.id)); + expect(afterRefresh.providers.find(provider => provider.id === "lmstudio")).toEqual(LMSTUDIO); + expect(ourProvider(afterRefresh).models.map(model => model.id)).toEqual(fewer.map(model => model.namespaced).sort()); + + const disabled = disableIntegration(request(fewer)); + expect(disabled.ok).toBe(true); + const afterDisable = readProviders(configPath); + expect(afterDisable.providers).toEqual([LMSTUDIO]); + }); + + test("the default catalog refresh updates an owned Raycast provider", async () => { + const configPath = installRaycast(USER_SEED); + expect(applyIntegration(request()).ok).toBe(true); + const fewer = MODELS.filter(model => model.namespaced !== "mystery/model"); + let loads = 0; + + const outcomes = await refreshOwnedCatalogIntegrations({ + models: async () => { + loads += 1; + return fewer; + }, + config: CONFIG, + port: 10100, + env: {}, + home, + store, + }); + + expect(outcomes).toEqual([{ client: "raycast", ok: true, changed: true }]); + expect(loads).toBe(1); + expect(readProviders(configPath).providers.find(provider => provider.id === "lmstudio")).toEqual(LMSTUDIO); + expect(ourProvider(readProviders(configPath)).models.map(model => model.id)) + .toEqual(fewer.map(model => model.namespaced).sort()); + }); + + test("refuses a file whose providers is a map rather than a sequence", () => { + // `providers: {}` is a container we would have to REPLACE with `[]` to + // write our element, and replacing a user's container is never a success. + const configPath = installRaycast("providers: {}" + String.fromCharCode(10)); + const result = applyIntegration(request()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("unsafe"); + expect(readFileSync(configPath, "utf8")).toBe("providers: {}" + String.fromCharCode(10)); + }); + + test("refuses when the ai directory does not exist yet", () => { + // The directory appears only after "Reveal Providers Config" in Raycast's + // AI settings, which is the signal that Custom Providers is reachable. + const result = applyIntegration(request()); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("not_installed"); + }); +}); diff --git a/tests/clients/raycast-detect.test.ts b/tests/clients/raycast-detect.test.ts new file mode 100644 index 0000000000..4e4268b29b --- /dev/null +++ b/tests/clients/raycast-detect.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test"; +import { detectRaycast, type RaycastDetectDeps } from "../../src/integrations/raycast-detect"; + +/** + * Stubbed deps only. The real detector spawns `defaults` and reads the + * developer's subscription state, and this suite must pass identically on a + * machine with Raycast Pro, with the free tier, and with no Raycast at all. + */ +function fakeDeps( + platform: string, + existing: readonly string[], + options: { env?: Record; defaultValue?: string | null; homedir?: string } = {}, +): RaycastDetectDeps & { defaultsReads: number } { + const present = new Set(existing); + const deps = { + platform, + homedir: options.homedir ?? (platform === "win32" ? "C:\\Users\\u" : "/home/u"), + env: options.env ?? {}, + defaultsReads: 0, + exists: (path: string) => present.has(path), + readDefault: (domain: string, key: string) => { + deps.defaultsReads += 1; + expect(domain).toBe("com.raycast.macos.v1"); + expect(key).toBe("subscriptions_active"); + return options.defaultValue ?? null; + }, + }; + return deps; +} + +describe("detectRaycast", () => { + test("darwin: a Pro subscription, the app bundle and the revealed ai folder", () => { + const deps = fakeDeps("darwin", ["/Applications/Raycast.app", "/home/u/.config/raycast/ai"], { defaultValue: "1" }); + expect(detectRaycast(deps)).toEqual({ + appPath: "/Applications/Raycast.app", + aiDirPresent: true, + plan: "pro", + }); + // One process spawn per detection, not one per field. + expect(deps.defaultsReads).toBe(1); + }); + + test("darwin: the free tier is reported, not refused, and the user-local bundle is found", () => { + const deps = fakeDeps("darwin", ["/home/u/Applications/Raycast.app"], { defaultValue: "0" }); + expect(detectRaycast(deps)).toEqual({ + appPath: "/home/u/Applications/Raycast.app", + aiDirPresent: false, + plan: "free", + }); + }); + + test("darwin: a failed or unexpected defaults read is unknown, never free", () => { + expect(detectRaycast(fakeDeps("darwin", [], { defaultValue: null })).plan).toBe("unknown"); + expect(detectRaycast(fakeDeps("darwin", [], { defaultValue: "(null)" })).plan).toBe("unknown"); + expect(detectRaycast(fakeDeps("darwin", [], { defaultValue: "" })).plan).toBe("unknown"); + }); + + test("win32: LOCALAPPDATA\\Programs\\Raycast is the install path and the plan is unknown", () => { + const local = "C:\\Users\\u\\AppData\\Local"; + const deps = fakeDeps("win32", [`${local}\\Programs\\Raycast`, "C:\\Users\\u\\.config\\raycast\\ai"], { + env: { LOCALAPPDATA: local }, + defaultValue: "1", + }); + expect(detectRaycast(deps)).toEqual({ + appPath: `${local}\\Programs\\Raycast`, + aiDirPresent: true, + plan: "unknown", + }); + // `defaults` does not exist off macOS, so it is never asked. + expect(deps.defaultsReads).toBe(0); + }); + + test("win32: no LOCALAPPDATA means no app path rather than a guessed one", () => { + expect(detectRaycast(fakeDeps("win32", [])).appPath).toBeNull(); + }); + + test("linux: nothing is detected and nothing is spawned", () => { + const deps = fakeDeps("linux", [], { defaultValue: "1" }); + expect(detectRaycast(deps)).toEqual({ appPath: null, aiDirPresent: false, plan: "unknown" }); + expect(deps.defaultsReads).toBe(0); + }); +}); diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index 5661373642..9050eecd3b 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -65,7 +65,7 @@ describe("ocx sync fans out to enabled native clients and owned file integration expect(fn).toContain("grokIntegrationEnabled(config)"); expect(fn).toContain("claudeDesktopIntegrationEnabled(config)"); - expect(fn).toContain('["mcode", "pi", "aside"]'); + expect(fn).toContain('["mcode", "pi", "aside", "raycast"]'); expect(fn).toContain("refreshOwnedCatalogIntegrations"); // Native clients keep their catches; the owned catalog helper isolates file clients. expect(fn.match(/catch \(error\)/g)?.length).toBe(2); @@ -651,17 +651,29 @@ describe("owned Pi/Aside catalogs follow filtered model selections", () => { }); }); -test("the direct ocx sync command refreshes MCode, Pi and Aside instead of relying on /api/sync", async () => { +test("the direct ocx sync command refreshes MCode, Pi, Raycast and server-owned Aside", async () => { const src = await Bun.file(new URL("../../src/cli/dispatch.ts", import.meta.url)).text(); const start = src.indexOf("sync: async deps =>"); const command = src.slice(start, src.indexOf("v2: async deps =>", start)); expect(command).toContain("refreshOwnedCatalogIntegrations"); - expect(command).toContain('["mcode", "pi"]'); + expect(command).toContain('["mcode", "pi", "raycast"]'); expect(command).toContain("refreshAsideProfilesThroughServer"); expect(command.indexOf("syncModelsToCodex")).toBeLessThan(command.indexOf("refreshOwnedCatalogIntegrations")); expect(command).toContain('synced.status !== "refused"'); }); +test("startup and ensure refresh owned Raycast through the catalog coordinator", async () => { + const src = await Bun.file(new URL("../../src/cli/index.ts", import.meta.url)).text(); + const start = src.slice(src.indexOf("async function handleStart"), src.indexOf("function detachedStartEnvironment")); + const ensure = src.slice(src.indexOf("async function handleEnsure"), src.indexOf("async function handleTrayProxyStart")); + expect(src).toContain("refreshOwnedCatalogIntegrations"); + expect(src).toContain('}, ["raycast"]);'); + expect(start).toContain("await refreshOwnedRaycastCatalog(config, port)"); + expect(ensure).toContain("await refreshOwnedRaycastCatalog(config, live.port)"); + expect(ensure).toContain("await refreshOwnedRaycastCatalog(config, port)"); + expect(src).not.toContain("refreshAllOwnedIntegrations"); +}); + test("identical explicit mutation keys join but cannot swallow a different apply or disable", async () => { let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); diff --git a/tests/config/client-config-export-new-clients.test.ts b/tests/config/client-config-export-new-clients.test.ts index 6b6b4c4e80..5a381d6237 100644 --- a/tests/config/client-config-export-new-clients.test.ts +++ b/tests/config/client-config-export-new-clients.test.ts @@ -58,12 +58,14 @@ function ctx(config: OcxConfig = LOOPBACK): ExportContext { describe("no secret reaches a client config", () => { test("the generated client support policy identifies every loopback-only integration", () => { - // Pi, Kimi, Gajae and Aside cannot emit the dedicated admission header -- - // Aside's observed provider block has four keys and none is `headers`. OMP - // and Prime can carry provider headers, but remote credential wiring is - // deliberately deferred from those initial generated integrations. + // Pi, Kimi, Gajae, Aside and Raycast cannot emit the dedicated admission + // header -- Aside's observed provider block has four keys and none is + // `headers`; Raycast's `api_keys` is read literally with no env + // interpolation. OMP and Prime can carry provider headers, but remote + // credential wiring is deliberately deferred from those initial generated + // integrations. const loopbackOnly = EXPORT_CLIENT_IDS.filter(id => EXPORT_CLIENTS[id].loopbackOnly); - expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); + expect(loopbackOnly).toEqual(["pi", "omp", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); }); test("every client that is not loopback-only carries the header on a remote bind", () => { diff --git a/tests/config/client-config-export.test.ts b/tests/config/client-config-export.test.ts index 707d6dd62c..6a71813e9f 100644 --- a/tests/config/client-config-export.test.ts +++ b/tests/config/client-config-export.test.ts @@ -32,6 +32,7 @@ import { normalizeExportModels as leafNormalizeExportModels } from "../../src/cl import * as omp from "../../src/clients/config-export/omp"; import * as dsh from "../../src/clients/config-export/dsh"; import * as mcode from "../../src/clients/config-export/mcode"; +import * as raycast from "../../src/clients/config-export/raycast"; import * as zcode from "../../src/clients/config-export/zcode"; /** @@ -100,6 +101,7 @@ describe("split config-export public facade", () => { ["dsh", dsh.buildDshClientConfig, dsh.summarizeDsh, dsh.buildDshContribution], ["mcode", mcode.buildMcodeClientConfig, mcode.summarizeMcode, mcode.buildMcodeContribution], ["zcode", zcode.buildZcodeClientConfig, zcode.summarizeZcode, zcode.buildZcodeContribution], + ["raycast", raycast.buildRaycastClientConfig, raycast.summarizeRaycast, raycast.buildRaycastContribution], ] as const; for (const [id, build, summarize, contribute] of leaves) { expect(EXPORT_CLIENTS[id].build).toBe(build); @@ -803,8 +805,8 @@ describe("hub-resolved Fast exports", () => { }); describe("EXPORT_CLIENTS registry", () => { - test("covers exactly the twelve file-toggle clients", () => { - expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside"]); + test("covers exactly the thirteen file-toggle clients", () => { + expect(EXPORT_CLIENT_IDS).toEqual(["opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast"]); for (const id of EXPORT_CLIENT_IDS) expect(isExportClientId(id)).toBe(true); // The exception clients keep their own surfaces and are not export clients. expect(isExportClientId("claude-desktop")).toBe(false); diff --git a/tests/config/client-config-new-clients.test.ts b/tests/config/client-config-new-clients.test.ts index 65b52727c9..7deb7fdb36 100644 --- a/tests/config/client-config-new-clients.test.ts +++ b/tests/config/client-config-new-clients.test.ts @@ -17,6 +17,7 @@ import { type OpenclawGeneratedConfig, } from "../../src/clients/config-export"; import { serializeDocument } from "../../src/integrations/serialize"; +import { readPath } from "../../src/integrations/state"; import type { OcxConfig } from "../../src/types"; /** @@ -159,14 +160,11 @@ describe("contributions describe what a writer would own", () => { test("every client's fragments point at real entries in its own document", () => { for (const clientId of EXPORT_CLIENT_IDS) { - const document = buildClientConfig(clientId, ctx()) as Record; + const document = buildClientConfig(clientId, ctx()); for (const fragment of EXPORT_CLIENTS[clientId].buildContribution(ctx()).fragments) { - let cursor: unknown = document; - for (const key of fragment.path) { - expect(cursor && typeof cursor === "object").toBe(true); - cursor = (cursor as Record)[key]; - } - expect(cursor).toEqual(fragment.value); + // Read through the writer's own segment grammar: Raycast's path holds + // a `[id=opencodex]` selector into a sequence, not a map key. + expect(readPath(document, fragment.path)).toEqual(fragment.value); } } }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6565f12821..a00407e89a 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -525,6 +525,7 @@ "install-scripts.test.ts": "ci-workflows", "integrations-invariants.test.ts": "gui", "integrations-journal.test.ts": "clients", + "integrations-merge.test.ts": "clients", "integrations-serialize.test.ts": "clients", "integrations-state.test.ts": "clients", "integrations-writer.test.ts": "clients", @@ -833,6 +834,8 @@ "reserve-quota-scope.test.ts": "codex-integration", "rate-limit-reset-credits.test.ts": "gui", "rate-limit-retry.test.ts": "providers", + "raycast-client.test.ts": "clients", + "raycast-detect.test.ts": "clients", "reasoning-effort.test.ts": "codex-integration", "reasoning-replay-identity.test.ts": "adapters", "reasoning-replay-robustness.test.ts": "adapters", diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index 33e7480f86..2353104311 100644 --- a/tests/gui/integrations-invariants.test.ts +++ b/tests/gui/integrations-invariants.test.ts @@ -6,7 +6,7 @@ import { EXPORT_CLIENTS, EXPORT_CLIENT_IDS, type ExportModel } from "../../src/c import { parseConfig } from "../../src/integrations/config-io"; import { INTEGRATION_CLIENTS, INTEGRATION_CLIENT_IDS, type IntegrationClientId } from "../../src/integrations/registry"; import { createIntegrationStateStore, type IntegrationStateStore } from "../../src/integrations/store"; -import { readIntegrationState } from "../../src/integrations/state"; +import { readIntegrationState, readPath } from "../../src/integrations/state"; import { applyIntegration, disableIntegration, restoreIntegration } from "../../src/integrations/writer"; import { printSubcommandUsage, printUsage } from "../../src/cli/help"; import type { OcxConfig } from "../../src/types"; @@ -78,9 +78,9 @@ afterEach(() => { }); describe("the client registries cannot drift apart", () => { - test("every list of clients holds exactly the same twelve ids", async () => { + test("every list of clients holds exactly the same thirteen ids", async () => { /* - * Five lists name the same twelve clients, and two of them are maintained by + * Five lists name the same thirteen clients, and two of them are maintained by * hand: the GUI cannot import the backend registry, because that would * pull node:os and node:path into the browser bundle. A client added * server-side renders no row until someone remembers the tuple, and the @@ -91,7 +91,7 @@ describe("the client registries cannot drift apart", () => { const guiRouting = await import("../../gui/src/app-routing"); const expected = [...EXPORT_CLIENT_IDS].sort(); - expect(expected).toHaveLength(12); + expect(expected).toHaveLength(13); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); @@ -170,6 +170,13 @@ describe("every client survives a full lifecycle", () => { prime: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', // Aside reads the same models.json contract as Pi and Prime. aside: '{\n "providers": {\n "mine": { "api": "http://keep-me" }\n }\n}\n', + // Raycast's `providers` is a SEQUENCE keyed by `id`, so the user's entry is + // a sibling element rather than a sibling map key. + raycast: "providers:\n - id: lmstudio\n name: LM Studio\n base_url: http://localhost:1234/v1\n models: []\n", + }; + /** Where the seed's user-owned entry lives when the seed is a sequence. */ + const USER_ELEMENT: Partial> = { + raycast: ["providers", "[id=lmstudio]"], }; for (const clientId of INTEGRATION_CLIENT_IDS) { @@ -190,18 +197,22 @@ describe("every client survives a full lifecycle", () => { const afterApply = parseConfig(readFileSync(configPath, "utf8"), format); const record = store.readRecords()[clientId]!; expect(record.fragmentPaths.length).toBeGreaterThan(0); + // Read through the writer's own segment grammar: Raycast's path holds a + // `[id=opencodex]` selector into a sequence, not a map key. for (const path of record.fragmentPaths) { - let cursor: unknown = afterApply; - for (const segment of path) { - expect(cursor && typeof cursor === "object").toBe(true); - cursor = (cursor as Record)[segment]; - } - expect(cursor).toBeDefined(); + expect(readPath(afterApply, path)).toBeDefined(); + } + // …and the user's own entry is untouched. `toMatchObject` treats an + // array as exact-length, so a sequence-shaped seed is checked by the + // same selector the writer uses to find its own element. + const userElement = USER_ELEMENT[clientId]; + if (userElement) { + expect(readPath(afterApply, userElement)).toEqual(readPath(original, userElement)); + } else { + expect((afterApply as Record)).toMatchObject( + original as Record, + ); } - // …and the user's own entry is untouched. - expect((afterApply as Record)).toMatchObject( - original as Record, - ); const disabled = disableIntegration({ clientId, models: MODELS, config: CONFIG, port: 10100, diff --git a/tests/server/management-integration-routes.test.ts b/tests/server/management-integration-routes.test.ts index 1f8cba92a2..e0d6563aea 100644 --- a/tests/server/management-integration-routes.test.ts +++ b/tests/server/management-integration-routes.test.ts @@ -14,6 +14,7 @@ import { handleManagementAPI } from "../../src/server/management-api"; import { setIntegrationMutationFlightTestHooks, setIntegrationPathTestHooks, + setRaycastDetectTestHook, } from "../../src/server/management/integration-routes"; import type { OcxConfig } from "../../src/types"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; @@ -274,6 +275,31 @@ describe("GET /api/client-integrations", () => { // A read is a read: it appends nothing. expect(store.listOperations()).toHaveLength(before); }); + + test("the raycast envelope carries the plan block; every other client's does not", async () => { + // Stubbed: the real detector spawns `defaults` and would report the + // developer's own subscription. + setRaycastDetectTestHook(() => ({ appPath: "/Applications/Raycast.app", aiDirPresent: false, plan: "free" })); + try { + const raycast = await api("/api/client-integrations/raycast"); + expect(raycast.status).toBe(200); + const body = await raycast.json() as { clientId: string; raycast?: { plan: string; appPath: string | null; aiDirPresent: boolean } }; + expect(body.clientId).toBe("raycast"); + expect(body.raycast).toEqual({ appPath: "/Applications/Raycast.app", aiDirPresent: false, plan: "free" }); + + installHermes(); + const hermes = await api("/api/client-integrations/hermes"); + expect(hermes.status).toBe(200); + expect("raycast" in (await hermes.json() as Record)).toBe(false); + + // The collection read describes files, not apps: no client gets the block there. + const list = await api("/api/client-integrations"); + const { clients } = await list.json() as { clients: Array> }; + expect(clients.some(client => "raycast" in client)).toBe(false); + } finally { + setRaycastDetectTestHook(null); + } + }); }); /** The models the route itself derives, so expectations cannot drift from it. */ From ea3d03aeccd1cd9ebf37508f81ae7ff113e140b0 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:40:03 +0900 Subject: [PATCH 31/50] fix(integrations): repair Raycast #3733 ownership and plan guidance Reject duplicate selectors before ownership classification with or without a record. Validate unknown summary shapes; repair French, Turkish and Traditional Chinese client catalogs and optional api_keys guidance. Clarify unknown Pro activity in all GUI locales and advisory-only private detection. Retain export capability conventions and document their limits. Add selector cardinality, malformed summaries, unowned refresh, admission refusal and Windows notice regressions. Tests, typecheck, lint and builds NOT RUN per worker scope; combined remote CI and rendered preview belong to main. git diff --check passed. Co-authored-by: Chanhee Lee --- .../content/docs/fr/guides/integrations.md | 21 ++++--- .../src/content/docs/guides/integrations.md | 16 +++-- .../content/docs/tr/guides/integrations.md | 19 ++++-- .../content/docs/zh-tw/guides/integrations.md | 16 +++-- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- gui/src/i18n/zh.ts | 2 +- gui/src/pages/integrations/integration-api.ts | 1 + gui/tests/raycast-plan-notice.test.tsx | 18 ++++-- src/clients/config-export/raycast.ts | 26 +++++++- src/integrations/merge.ts | 4 +- src/integrations/raycast-detect.ts | 5 +- src/integrations/state.ts | 25 ++++++-- src/integrations/writer.ts | 8 ++- tests/clients/integrations-merge.test.ts | 63 ++++++++++++++----- tests/clients/raycast-client.test.ts | 49 ++++++++++++++- 22 files changed, 223 insertions(+), 66 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index c70d471f39..6a0a6d0dd3 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Intégrations -description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code et Raycast depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture. +description: Connectez opencodex à OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside et Raycast depuis le tableau de bord — un commutateur par client, avec une sauvegarde avant chaque écriture. --- L'onglet **Intégrations** écrit le bloc fournisseur d'opencodex dans le fichier de configuration du client, -puis peut le retirer. Dix clients fonctionnent ainsi, chacun avec son propre commutateur : +puis peut le retirer. Treize clients fonctionnent ainsi, chacun avec son propre commutateur : | Client | Fichier de configuration | Format | Prise d'effet de la modification | Identifiant | |---|---|---|---|---| @@ -17,6 +17,9 @@ puis peut le retirer. Dix clients fonctionnent ainsi, chacun avec son propre com | Gajae Code | `~/.gjc/agent/models.yml` | YAML | dans les nouvelles sessions ou à l'ouverture de `/model` |`OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (`~/.dsh/settings.yaml` par défaut) | YAML | rechargement à chaud | jeton porteur fictif et non secret pour le bouclage | | MiniMax Code | `~/.minimax/config.yaml` | YAML | dans les nouvelles sessions ou après l’ouverture du sélecteur de modèles | valeur fictive de bouclage | +| Prime Agent | `~/.prime/agent/models.json` | JSON | dans les nouvelles sessions | valeur fictive de bouclage | +| ZCode | `~/.zcode/v2/config.json` | JSON | au redémarrage | valeur fictive de bouclage | +| Aside | `~/.aside/u//models.json` | JSON | après avoir quitté complètement puis rouvert Aside | valeur fictive de bouclage | | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | immédiatement à l'enregistrement — Raycast surveille le fichier | aucun — bouclage uniquement | La prise en charge gérée de DSH exige au minimum **DSH 0.1.0-rc.6**. OpenCodex ne possède que le fragment @@ -45,14 +48,18 @@ d'installation et indique que le client n'est pas installé tant qu'il n'existe Le bloc géré est un seul élément, `id: opencodex`, dans la séquence `providers` du fichier : `name: OpenCodex`, `base_url: http://:/v1`, et chaque modèle routé avec ses `abilities` — -`tools` et `system_message` sont toujours pris en charge, `vision` suit les modalités d'entrée du +`tools` et `system_message` sont définis à `true` par convention d’export, `vision` suit les modalités d'entrée du catalogue, `reasoning_effort` est défini lorsque le modèle dispose d'une échelle d'effort, et `temperature` est désactivé pour les modèles de raisonnement. Les autres fournisseurs du fichier sont préservés, et la désactivation ne retire que l'élément OpenCodex. Raycast prend en compte la modification dès l'enregistrement du fichier, sans redémarrage ; les modèles apparaissent dans le -sélecteur de modèles de Raycast regroupés sous **OpenCodex**. Le fichier n'a aucun emplacement pour -un identifiant, ce client est donc limité au bouclage : aucune entrée `api_keys` n'est écrite et une -liaison hors bouclage est refusée. Le format est documenté sur +sélecteur de modèles de Raycast regroupés sous **OpenCodex**. Raycast accepte le champ facultatif +`api_keys`, mais OpenCodex l’omet volontairement et refuse les cibles hors bouclage ou exigeant +authentification : cette intégration ne fournit pas l’en-tête d’admission requis par OpenCodex. +Le signal Pro issu d’une préférence privée macOS est indicatif ; Windows ne la lit jamais et +renvoie un état inconnu. Il ne bloque pas l’écriture. Les métadonnées exportées ne prouvent pas +la prise en charge des outils pour chaque modèle. Les valeurs des autres fournisseurs sont +préservées, sans garantie pour les commentaires ou la mise en forme YAML. Le format est documenté sur [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). Les chemins respectent les variables de remplacement propres à chaque client, lorsqu'elles existent. Pour @@ -115,7 +122,7 @@ niveaux. Dans ces cas, le commutateur est verrouillé afin que rien ne soit modi **OMP** n'est pas affecté non plus par les modifications voisines, mais pour une autre raison : son outil d'écriture ne modifie, octet par octet, que sa propre plage `providers.opencodex` ; le reste du fichier n'est jamais réécrit. Pour les autres formats susceptibles de contenir des commentaires (Hermes, OpenClaw, -Kimi Code, Gajae Code, MiniMax Code et Raycast — documents YAML, JSON5 et TOML réécrits en entier), ou lorsque les propres entrées +Kimi Code, Gajae Code, MiniMax Code, ZCode, Prime Agent, Aside et Raycast — documents YAML, JSON5 et TOML réécrits en entier), ou lorsque les propres entrées d'opencodex ont été modifiées, le commutateur se verrouille et la désactivation est refusée plutôt que de deviner quelles modifications vous appartiennent. diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index bb8cea7001..e6230e4956 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -73,14 +73,22 @@ not honor `XDG_CONFIG_HOME`, so that path is not relocatable. The managed block is one element, `id: opencodex`, in the file's `providers` sequence: `name: OpenCodex`, `base_url: http://:/v1`, and every -routed model with its `abilities` — `tools` and `system_message` are always -supported, `vision` follows the catalog's input modalities, `reasoning_effort` +routed model with its `abilities` — the exporter sets `tools` and `system_message` to +`true` as a client-export convention, `vision` follows the catalog's input modalities, `reasoning_effort` is set when the model has an effort ladder, and `temperature` is turned off for reasoning models. Other providers in the file are preserved, and disable removes only the OpenCodex element. Raycast picks up the change as soon as the file is saved, no restart needed; the models appear in Raycast's model picker grouped -under **OpenCodex**. The file has no place for a credential, so this client is -loopback-only: no `api_keys` entry is written and a non-loopback bind is refused. +under **OpenCodex**. Raycast supports optional `api_keys`, but OpenCodex intentionally +omits them and refuses non-loopback or admission-authenticated targets; this integration +cannot supply OpenCodex's required admission header. + +The macOS private preference is only an advisory Pro hint; Windows never reads it and +reports the plan as unknown. Plan detection does not authorize or block a write. +The export metadata has no authoritative tool-support flag, so `tools: true` does not +prove every routed model supports tools. Vision and effort flags follow catalog metadata; +turning temperature off for an effort ladder is conservative export behavior. +Provider values are preserved; YAML formatting and comments are not guaranteed to survive. The format is documented at [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index ff0dcaa41e..145cd8c6bc 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -1,10 +1,10 @@ --- title: Entegrasyonlar -description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code ve Raycast'i opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. +description: Kontrol panelinden OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent, Aside ve Raycast'i opencodex'e bağlayın — istemci başına tek bir anahtar ve her yazmadan önce alınan bir yedek. --- **Entegrasyonlar** sekmesi, opencodex'in sağlayıcı bloğunu istemcinin kendi -yapılandırma dosyasına yazar ve tekrar kaldırır. On istemci bu şekilde +yapılandırma dosyasına yazar ve tekrar kaldırır. On üç istemci bu şekilde çalışır, her biri bir anahtarla: | İstemci | Yapılandırma dosyası | Format | Değişiklik ne zaman geçerli olur? | Kimlik bilgisi | @@ -18,6 +18,9 @@ yapılandırma dosyasına yazar ve tekrar kaldırır. On istemci bu şekilde | Gajae Code | `~/.gjc/agent/models.yml` | YAML | yeni oturumlarda veya `/model` açtığınızda | `OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (varsayılan `~/.dsh/settings.yaml`) | YAML | çalışırken yeniden yükleme | gizli olmayan geri döngü bearer yer tutucusu | | MiniMax Code | `~/.minimax/config.yaml` | YAML | yeni oturumlarda veya model seçici açıldıktan sonra | geri döngü (loopback) yer tutucusu | +| Prime Agent | `~/.prime/agent/models.json` | JSON | yeni oturumlarda | geri döngü yer tutucusu | +| ZCode | `~/.zcode/v2/config.json` | JSON | yeniden başlatmada | geri döngü yer tutucusu | +| Aside | `~/.aside/u//models.json` | JSON | Aside tamamen kapatılıp yeniden açıldıktan sonra | geri döngü yer tutucusu | | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | kaydedildiği anda — Raycast dosyayı izler | yok — yalnızca geri döngü | Yönetilen DSH desteğinin en düşük uyumlu sürümü **DSH 0.1.0-rc.6**'dır. OpenCodex yalnızca @@ -48,14 +51,18 @@ yol taşınamaz. Yönetilen blok, dosyanın `providers` dizisindeki tek bir öğedir: `id: opencodex`, `name: OpenCodex`, `base_url: http://:/v1` ve `abilities` alanıyla birlikte -yönlendirilen her model — `tools` ve `system_message` her zaman destekli, `vision` +yönlendirilen her model — dışa aktarma kuralı olarak `tools` ve `system_message` değeri `true` olur, `vision` kataloğun giriş modalitelerini izler, `reasoning_effort` modelin bir çaba merdiveni varsa ayarlanır ve `temperature` akıl yürütme modelleri için kapatılır. Dosyadaki diğer sağlayıcılar korunur ve devre dışı bırakma yalnızca OpenCodex öğesini kaldırır. Raycast değişikliği dosya kaydedilir kaydedilmez, yeniden başlatma gerekmeden alır; modeller -Raycast'in model seçicisinde **OpenCodex** altında gruplanmış olarak görünür. Dosyada -kimlik bilgisi için bir yer yoktur, bu yüzden bu istemci yalnızca geri döngü içindir: -hiçbir `api_keys` girdisi yazılmaz ve geri döngü dışı bir bağlama reddedilir. Format +Raycast'in model seçicisinde **OpenCodex** altında gruplanmış olarak görünür. Raycast şeması +isteğe bağlı `api_keys` alanını destekler; OpenCodex bu alanı bilerek yazmaz ve geri döngü +dışı veya kimlik doğrulaması gerektiren hedefleri reddeder. Bu entegrasyon OpenCodex'in +zorunlu kabul başlığını sağlayamaz. macOS'taki özel tercih yalnızca bir Pro ipucudur; +Windows bu tercihi hiç okumaz ve durumu bilinmiyor olarak bildirir. Bu bilgi yazmayı engellemez. +Dışa aktarılan meta veriler her modelin araç desteğini doğrulamaz. Diğer sağlayıcıların +değerleri korunur; YAML biçimlendirmesi ve yorumlarının korunması garanti edilmez. Format [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) adresinde belgelenmiştir. diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index ebf2e6f0de..6a42ea8571 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -1,9 +1,9 @@ --- title: 整合 -description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、Gajae Code、DeepSeek Harness、MiniMax Code 與 Raycast——每個客戶端一個開關,每次寫入前都會先備份。 +description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、OpenClaw、Kimi Code、Gajae Code、DeepSeek Harness、MiniMax Code、ZCode、Prime Agent、Aside 與 Raycast——每個客戶端一個開關,每次寫入前都會先備份。 --- -**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十個客戶端以這種方式運作,每個都有一個開關: +**整合(Integrations)** 分頁會把 opencodex 的 provider 區塊寫入客戶端自己的設定檔,也會把它移除。共有十三個客戶端以這種方式運作,每個都有一個開關: | 客戶端 | 設定檔 | 格式 | 變更生效時機 | 憑證 | |---|---|---|---|---| @@ -16,6 +16,9 @@ description: 從儀表板把 opencodex 連接到 OpenCode、Pi、OMP、Hermes、 | Gajae Code | `~/.gjc/agent/models.yml` | YAML | 新 sessions,或當你開啟 `/model` 時 | `OPENCODEX_GAJAE_API_KEY` | | DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml`(預設 `~/.dsh/settings.yaml`) | YAML | 熱重載 | 非秘密的 loopback bearer 佔位符 | | MiniMax Code | `~/.minimax/config.yaml` | YAML | 新 sessions,或開啟模型選擇器後 | loopback 佔位符 | +| Prime Agent | `~/.prime/agent/models.json` | JSON | 新 sessions | loopback 佔位符 | +| ZCode | `~/.zcode/v2/config.json` | JSON | 重新啟動時 | loopback 佔位符 | +| Aside | `~/.aside/u//models.json` | JSON | 完全結束並重新開啟 Aside 後 | loopback 佔位符 | | Raycast | `~/.config/raycast/ai/providers.yaml` | YAML | 儲存後立即生效——Raycast 會監看該檔案 | 無——僅限 loopback | 受管理 DSH 支援的相容性下限是 **DSH 0.1.0-rc.6**。OpenCodex 只擁有 @@ -40,11 +43,14 @@ macOS 與 Windows 上同樣讀取 `~/.config/raycast/ai/providers.yaml`,且不 受管理區塊是檔案 `providers` 序列中的單一元素 `id: opencodex`:`name: OpenCodex`、 `base_url: http://:/v1`,以及每個路由模型及其 `abilities`——`tools` 與 -`system_message` 一律支援,`vision` 依目錄的輸入模態而定,`reasoning_effort` 在模型有 effort +`system_message` 依匯出慣例設為 `true`,`vision` 依目錄的輸入模態而定,`reasoning_effort` 在模型有 effort 階梯時設定,`temperature` 對推理模型關閉。檔案中的其他 provider 會被保留,停用只移除 OpenCodex 元素。檔案一儲存 Raycast 就會套用變更,不需重新啟動;模型會在 Raycast 的模型選擇器中歸在 -**OpenCodex** 群組下。該檔案沒有存放憑證的位置,因此此客戶端僅限 loopback:不會寫入任何 -`api_keys` 項目,非 loopback 的 bind 會被拒絕。格式說明見 +**OpenCodex** 群組下。Raycast 支援選填的 `api_keys`,但 OpenCodex 刻意省略該欄位,並拒絕 +非 loopback 或需要准入驗證的目標,因為此整合無法提供 OpenCodex 要求的准入標頭。 +macOS 私有偏好設定僅提供 Pro 狀態提示;Windows 完全不讀取該設定,狀態會是未知。 +此提示不會阻擋寫入。匯出中繼資料並未證實每個模型的工具能力。其他 provider 的值會保留, +但不保證 YAML 格式與註解不變。格式說明見 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。 路徑遵循客戶端自己的環境覆寫(environment override)。對 OMP 而言,`OMP_PROFILE` 以存在與否優先於 `PI_PROFILE`,即使明確為空也一樣。具名 profile 會把 `PI_CONFIG_DIR` 當作相對於使用者家目錄的目錄名稱,並忽略 `PI_CODING_AGENT_DIR`;沒有具名 profile 時,`PI_CODING_AGENT_DIR` 勝出。OMP 支援 provider 層級的 headers,但這個最初的整合刻意只支援 loopback;遠端 `x-opencodex-api-key` 的連線設定被延後。搬移過的 `HERMES_HOME`、`KIMI_CODE_HOME` 與 `XDG_CONFIG_HOME` 路徑同樣會被遵循,而非猜測。表格列出每個客戶端的預設值。 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index af5546775a..85e5c90a25 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1255,7 +1255,7 @@ export const de: Record = { "integrations.semantics.aside": "Verwaltet nur providers.opencodex in der ~/.aside/u//models.json dieses Profils. Andere Provider bleiben unverändert. Beende Aside nach dem Anwenden vollständig und öffne es erneut.", "integrations.semantics.raycast": "Fügt einen OpenCodex-Provider-Eintrag in die providers.yaml von Raycast ein, damit jedes geroutete Modell in der Modellauswahl von Raycast AI erscheint. Raycast Pro erforderlich.", "integrations.raycast.proRequired": "Custom Providers ist eine Funktion von Raycast Pro. Die Datei wird geschrieben, aber Raycast ignoriert sie, bis ein Pro-Abonnement aktiv ist.", - "integrations.raycast.planUnknown": "Der Abonnementstatus von Raycast konnte nicht gelesen werden; Custom Providers erfordert Raycast Pro.", + "integrations.raycast.planUnknown": "Es konnte nicht festgestellt werden, ob Raycast Pro aktiv ist; Custom Providers erfordert Raycast Pro.", "integrations.raycast.revealConfig": "Öffnen Sie Raycast → Einstellungen → AI und klicken Sie einmal auf „Reveal Providers Config“, damit der Providers-Ordner existiert.", "codexAuth.mainAccount": "Hauptkonto", "codexAuth.logLabel": "Log-Kennung", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 71bfd5e2c3..4d9514e4de 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1802,7 +1802,7 @@ export const en = { "integrations.semantics.aside": "Manages only providers.opencodex in this profile’s ~/.aside/u//models.json. Your other providers stay unchanged. Fully quit and reopen Aside after applying.", "integrations.semantics.raycast": "Adds an OpenCodex provider entry to Raycast's providers.yaml so every routed model appears in the Raycast AI model picker. Raycast Pro required.", "integrations.raycast.proRequired": "Custom Providers is a Raycast Pro feature. The file will be written, but Raycast ignores it until a Pro subscription is active.", - "integrations.raycast.planUnknown": "Could not read the Raycast subscription state; Custom Providers requires Raycast Pro.", + "integrations.raycast.planUnknown": "Could not determine whether Raycast Pro is active; Custom Providers requires Raycast Pro.", "integrations.raycast.revealConfig": "Open Raycast → Settings → AI and click Reveal Providers Config once so the providers folder exists.", "codexAuth.mainAccount": "Main Account", "codexAuth.logLabel": "Log label", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 028e910bc3..3b370fa20d 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1734,7 +1734,7 @@ export const fr: Record = { "integrations.semantics.aside": "Gère uniquement providers.opencodex dans le fichier ~/.aside/u//models.json de ce profil. Vos autres fournisseurs restent inchangés. Quittez complètement Aside et relancez-le après application.", "integrations.semantics.raycast": "Ajoute une entrée de fournisseur OpenCodex dans le providers.yaml de Raycast afin que chaque modèle routé apparaisse dans le sélecteur de modèles de Raycast AI. Raycast Pro requis.", "integrations.raycast.proRequired": "Custom Providers est une fonctionnalité Raycast Pro. Le fichier sera écrit, mais Raycast l'ignore tant qu'un abonnement Pro n'est pas actif.", - "integrations.raycast.planUnknown": "Impossible de lire l'état de l'abonnement Raycast ; Custom Providers nécessite Raycast Pro.", + "integrations.raycast.planUnknown": "Impossible de déterminer si Raycast Pro est actif ; Custom Providers nécessite Raycast Pro.", "integrations.raycast.revealConfig": "Ouvrez Raycast → Réglages → AI et cliquez une fois sur « Reveal Providers Config » pour que le dossier des fournisseurs existe.", "codexAuth.mainAccount": "Compte principal", "codexAuth.logLabel": "Libellé du journal", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 864b498483..2112788943 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1675,7 +1675,7 @@ export const ja: Record = { "integrations.semantics.aside": "このプロファイルの ~/.aside/u//models.json 内の providers.opencodex のみを管理します。他のプロバイダーは変更しません。適用後は Aside を完全に終了してから開き直してください。", "integrations.semantics.raycast": "Raycast の providers.yaml に OpenCodex のプロバイダーエントリを追加し、ルーティングされたすべてのモデルを Raycast AI のモデル選択に表示します。Raycast Pro が必要です。", "integrations.raycast.proRequired": "Custom Providers は Raycast Pro の機能です。ファイルは書き込まれますが、Pro サブスクリプションが有効になるまで Raycast はこれを無視します。", - "integrations.raycast.planUnknown": "Raycast のサブスクリプション状態を読み取れませんでした。Custom Providers には Raycast Pro が必要です。", + "integrations.raycast.planUnknown": "Raycast Pro が有効かどうか確認できませんでした。Custom Providers には Raycast Pro が必要です。", "integrations.raycast.revealConfig": "Raycast → 設定 → AI を開き、「Reveal Providers Config」を一度クリックして providers フォルダを作成してください。", "codexAuth.mainAccount": "メインアカウント", "codexAuth.logLabel": "ログラベル", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 364805943e..2edc3de875 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1279,7 +1279,7 @@ export const ko: Record = { "integrations.semantics.aside": "이 프로필의 ~/.aside/u//models.json에서 providers.opencodex만 관리합니다. 다른 프로바이더는 그대로 유지됩니다. 적용 후 Aside를 완전히 종료하고 다시 여세요.", "integrations.semantics.raycast": "Raycast의 providers.yaml에 OpenCodex 프로바이더 항목을 추가해 라우팅된 모든 모델이 Raycast AI 모델 선택기에 표시되도록 합니다. Raycast Pro가 필요합니다.", "integrations.raycast.proRequired": "Custom Providers는 Raycast Pro 기능입니다. 파일은 기록되지만 Pro 구독이 활성화될 때까지 Raycast는 이를 무시합니다.", - "integrations.raycast.planUnknown": "Raycast 구독 상태를 읽을 수 없습니다. Custom Providers에는 Raycast Pro가 필요합니다.", + "integrations.raycast.planUnknown": "Raycast Pro 활성 여부를 확인할 수 없습니다. Custom Providers에는 Raycast Pro가 필요합니다.", "integrations.raycast.revealConfig": "Raycast → 설정 → AI를 열고 「Reveal Providers Config」를 한 번 클릭해 providers 폴더를 만드세요.", "codexAuth.mainAccount": "메인 계정", "codexAuth.logLabel": "로그 라벨", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index bfd6e0182e..a0675e554d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1745,7 +1745,7 @@ export const ru: Record = { "integrations.semantics.aside": "Управляет только providers.opencodex в файле ~/.aside/u//models.json этого профиля. Другие провайдеры остаются без изменений. После применения полностью закройте и снова откройте Aside.", "integrations.semantics.raycast": "Добавляет запись провайдера OpenCodex в providers.yaml Raycast, чтобы каждая маршрутизируемая модель появилась в выборе моделей Raycast AI. Требуется Raycast Pro.", "integrations.raycast.proRequired": "Custom Providers — функция Raycast Pro. Файл будет записан, но Raycast игнорирует его, пока не активна подписка Pro.", - "integrations.raycast.planUnknown": "Не удалось прочитать состояние подписки Raycast; для Custom Providers требуется Raycast Pro.", + "integrations.raycast.planUnknown": "Не удалось определить, активен ли Raycast Pro; для Custom Providers требуется Raycast Pro.", "integrations.raycast.revealConfig": "Откройте Raycast → Настройки → AI и один раз нажмите «Reveal Providers Config», чтобы папка провайдеров появилась.", "codexAuth.mainAccount": "Основной аккаунт", "codexAuth.logLabel": "Метка журнала", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 83ad09002b..8b8a9aa0c5 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1751,7 +1751,7 @@ export const tr: Record = { "integrations.semantics.aside": "Yalnızca bu profilin ~/.aside/u//models.json dosyasındaki providers.opencodex bölümünü yönetir. Diğer sağlayıcılarınız değişmez. Uyguladıktan sonra Aside’ı tamamen kapatıp yeniden açın.", "integrations.semantics.raycast": "Raycast'in providers.yaml dosyasına bir OpenCodex sağlayıcı girdisi ekler; böylece yönlendirilen her model Raycast AI model seçicisinde görünür. Raycast Pro gerekir.", "integrations.raycast.proRequired": "Custom Providers bir Raycast Pro özelliğidir. Dosya yazılır, ancak bir Pro aboneliği etkin olana kadar Raycast bunu yok sayar.", - "integrations.raycast.planUnknown": "Raycast abonelik durumu okunamadı; Custom Providers için Raycast Pro gerekir.", + "integrations.raycast.planUnknown": "Raycast Pro’nun etkin olup olmadığı belirlenemedi; Custom Providers için Raycast Pro gerekir.", "integrations.raycast.revealConfig": "Raycast → Ayarlar → AI bölümünü açıp sağlayıcı klasörünün oluşması için „Reveal Providers Config“ seçeneğine bir kez tıklayın.", "integrations.semantics.omp": "Kataloğu yüklemek için OMP'yi yeniden başlatın.", "codexAuth.mainAccount": "Ana Hesap", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 4e137ebdd6..51369b8bca 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2340,7 +2340,7 @@ export const zhTW: Record = { "integrations.semantics.aside": "僅管理此設定檔的 ~/.aside/u//models.json 中的 providers.opencodex。其他供應商維持不變。套用後請完全結束並重新開啟 Aside。", "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中新增一個 OpenCodex 供應商項目,讓所有已路由的模型出現在 Raycast AI 模型選擇器中。需要 Raycast Pro。", "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。檔案會被寫入,但在 Pro 訂閱生效之前 Raycast 會忽略它。", - "integrations.raycast.planUnknown": "無法讀取 Raycast 訂閱狀態;Custom Providers 需要 Raycast Pro。", + "integrations.raycast.planUnknown": "無法確認 Raycast Pro 是否已啟用;Custom Providers 需要 Raycast Pro。", "integrations.raycast.revealConfig": "開啟 Raycast → 設定 → AI,點一次「Reveal Providers Config」,以便建立 providers 資料夾。", "codexAuth.pinned": "已固定", "codexAuth.pinnedHint": "你手動選取了此帳號,因此較高的選擇順序不會越過它。此固定會持續到該帳號用盡、你改選其他帳號,或你變更任一選擇順序為止。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 67400eaaea..0ec38172c5 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1272,7 +1272,7 @@ export const zh: Record = { "integrations.semantics.aside": "仅管理此配置文件的 ~/.aside/u//models.json 中的 providers.opencodex。其他提供商保持不变。应用后请完全退出并重新打开 Aside。", "integrations.semantics.raycast": "在 Raycast 的 providers.yaml 中添加一个 OpenCodex 提供商条目,让所有已路由的模型出现在 Raycast AI 模型选择器中。需要 Raycast Pro。", "integrations.raycast.proRequired": "Custom Providers 是 Raycast Pro 功能。文件会被写入,但在 Pro 订阅生效之前 Raycast 会忽略它。", - "integrations.raycast.planUnknown": "无法读取 Raycast 订阅状态;Custom Providers 需要 Raycast Pro。", + "integrations.raycast.planUnknown": "无法确定 Raycast Pro 是否已激活;Custom Providers 需要 Raycast Pro。", "integrations.raycast.revealConfig": "打开 Raycast → 设置 → AI,点击一次“Reveal Providers Config”,以便创建 providers 文件夹。", "codexAuth.mainAccount": "主账号", "codexAuth.logLabel": "日志标签", diff --git a/gui/src/pages/integrations/integration-api.ts b/gui/src/pages/integrations/integration-api.ts index 38b0e0fe89..85ffdc7be4 100644 --- a/gui/src/pages/integrations/integration-api.ts +++ b/gui/src/pages/integrations/integration-api.ts @@ -26,6 +26,7 @@ export type IntegrationReason = | "foreign-edit" | "unowned-key" | "blocked-container" + | "ambiguous-selector" | "unresolvable-path"; export type IntegrationRefusalReason = diff --git a/gui/tests/raycast-plan-notice.test.tsx b/gui/tests/raycast-plan-notice.test.tsx index 4a8d44a77d..7f08550317 100644 --- a/gui/tests/raycast-plan-notice.test.tsx +++ b/gui/tests/raycast-plan-notice.test.tsx @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { I18nContext, type TFn } from "../src/i18n/shared"; +import { DICTS, I18nContext, type TFn } from "../src/i18n/shared"; import RaycastPlanNotice from "../src/pages/integrations/RaycastPlanNotice"; import type { RaycastInstall } from "../src/pages/integrations/integration-api"; @@ -14,11 +14,11 @@ import type { RaycastInstall } from "../src/pages/integrations/integration-api"; const echoT: TFn = key => key; -function render(install: RaycastInstall): string { +function render(install: RaycastInstall, t: TFn = echoT): string { return renderToStaticMarkup( createElement( I18nContext.Provider, - { value: { locale: "en", setLocale: () => {}, t: echoT } }, + { value: { locale: "en", setLocale: () => {}, t } }, createElement(RaycastPlanNotice, { install }), ), ); @@ -36,7 +36,7 @@ test("a free plan is a warning notice, never a refusal", () => { expect(markup).not.toContain("integrations.raycast.planUnknown"); }); -test("an unreadable plan stays muted, because non-macOS hosts have no signal", () => { +test("an unknown plan stays muted, because non-macOS hosts have no signal", () => { const markup = render({ plan: "unknown", appPath: null, aiDirPresent: true }); expect(markup).toContain('data-raycast-plan="unknown"'); expect(markup).toContain("integrations.raycast.planUnknown"); @@ -49,3 +49,13 @@ test("a missing ai folder adds the reveal hint independently of the plan", () => expect(markup).toContain('data-raycast-ai-dir="absent"'); expect(markup).toContain("integrations.raycast.revealConfig"); }); + +test("a Windows install reports unknown Pro activity without claiming a preference read failed", () => { + const markup = render({ + plan: "unknown", appPath: "C:\\Users\\u\\AppData\\Local\\Programs\\Raycast", aiDirPresent: true, + }, key => DICTS.en[key]); + expect(markup).toContain("Could not determine whether Raycast Pro is active"); + expect(markup).not.toContain("Could not read"); + expect(markup).not.toContain("notice-warn"); + expect(markup).not.toContain(" { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + export function summarizeRaycast( document: unknown, ): { modelCount: number; modelsWithoutLimits: number } { - const providers = (document as RaycastGeneratedConfig | undefined)?.providers ?? []; - const models = providers.find(provider => provider.id === OPENCODE_PROVIDER_ID)?.models ?? []; + const empty = { modelCount: 0, modelsWithoutLimits: 0 }; + if (!isRecord(document) || !Array.isArray(document.providers)) return empty; + const providers = document.providers.filter( + provider => isRecord(provider) && provider.id === OPENCODE_PROVIDER_ID, + ); + // An ambiguous managed provider has no meaningful summary either. + if (providers.length !== 1) return empty; + const provider: unknown = providers[0]; + if (!isRecord(provider) || !Array.isArray(provider.models)) return empty; + const models = provider.models.filter((model): model is Record => ( + isRecord(model) + && typeof model.id === "string" && model.id.trim().length > 0 + && typeof model.name === "string" && model.name.trim().length > 0 + )); return { modelCount: models.length, - modelsWithoutLimits: models.filter(model => model.context === undefined).length, + modelsWithoutLimits: models.filter(model => ( + typeof model.context !== "number" || authoritativeContextWindow(model.context) === undefined + )).length, }; } diff --git a/src/integrations/merge.ts b/src/integrations/merge.ts index 768ddc755f..ab2099b424 100644 --- a/src/integrations/merge.ts +++ b/src/integrations/merge.ts @@ -51,7 +51,7 @@ export class AmbiguousSelectorError extends Error { } /** The index of the element a selector names, -1 when none matches. */ -function selectIndex(items: readonly unknown[], field: string, value: string): number { +export function selectIndex(items: readonly unknown[], field: string, value: string): number { const matches: number[] = []; items.forEach((item, index) => { if (isPlainRecord(item) && item[field] === value) matches.push(index); @@ -249,7 +249,7 @@ export function createdContainerPaths( case "select": { // A selector that matches nothing means setPath will push the element. next = Array.isArray(cursor) - ? cursor.find(item => isPlainRecord(item) && item[segment.field] === segment.value) + ? cursor[selectIndex(cursor, segment.field, segment.value)] : undefined; break; } diff --git a/src/integrations/raycast-detect.ts b/src/integrations/raycast-detect.ts index 5e79578f46..7ae70edf46 100644 --- a/src/integrations/raycast-detect.ts +++ b/src/integrations/raycast-detect.ts @@ -10,7 +10,7 @@ * GUI need to explain a file that is written but ignored. * * Detection is read-only and injectable, like cursor-detect.ts: nothing here - * touches the Raycast install or its preferences, and the tests run against + * writes to the Raycast install or its preferences, and the tests run against * stubbed deps rather than the machine they execute on. */ import { existsSync } from "node:fs"; @@ -37,7 +37,8 @@ export interface RaycastDetectDeps { } /** - * The preference Raycast writes for its subscription state. Read through + * A private preference used only as an advisory subscription hint, not an + * entitlement API or a condition for writes. Read through * `defaults` rather than by parsing the plist: cfprefsd caches writes, so the * file on disk can lag what the running app believes. */ diff --git a/src/integrations/state.ts b/src/integrations/state.ts index bb0e5b3567..0249987027 100644 --- a/src/integrations/state.ts +++ b/src/integrations/state.ts @@ -12,7 +12,7 @@ import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel import type { OcxConfig } from "../types"; import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io"; import { SNAPSHOT_RETENTION } from "./journal"; -import { parseSegment, type PathSegment } from "./merge"; +import { AmbiguousSelectorError, parseSegment, selectIndex, type PathSegment } from "./merge"; import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership"; import { protectedContributionFingerprint, @@ -36,6 +36,7 @@ export type StateReason = | "unowned-key" /** A container we would have to write through holds a non-object value. */ | "blocked-container" + | "ambiguous-selector" /** A path selector we cannot resolve, e.g. a relative OPENCLAW_CONFIG_PATH. */ | "unresolvable-path"; @@ -63,7 +64,7 @@ function assertNever(segment: never): never { /** The element a selector names, or `undefined` when none matches. */ function selectElement(items: readonly unknown[], segment: PathSegment & { kind: "select" }): unknown { - return items.find(item => isPlainRecord(item) && item[segment.field] === segment.value); + return items[selectIndex(items, segment.field, segment.value)]; } /** @@ -291,8 +292,24 @@ export function classifyIntegration(input: { * Checked BEFORE `absent`: our leaf is missing in exactly this case, so the * absent branch would authorize an apply that replaces the user's value. */ - if (blockedContainerPath(input.parsed, input.contribution)) { - return { state: "unsafe", reason: "blocked-container" }; + try { + if (blockedContainerPath(input.parsed, input.contribution)) { + return { state: "unsafe", reason: "blocked-container" }; + } + // Check every selector before presence/fingerprint short-circuits, including + // paths an older ownership record may remove during refresh or disable. + const paths = [ + ...input.contribution.fragments.map(fragment => fragment.path), + ...(input.record?.fragmentPaths ?? []), + ]; + for (const path of paths) { + if (Array.isArray(path) && path.every(key => typeof key === "string")) { + readPath(input.parsed, path); + } + } + } catch (error) { + if (!(error instanceof AmbiguousSelectorError)) throw error; + return { state: "unsafe", reason: "ambiguous-selector" }; } if (!hasOurFragments(input.parsed, input.contribution)) return { state: "absent" }; diff --git a/src/integrations/writer.ts b/src/integrations/writer.ts index 7ec543715a..514fbc3220 100644 --- a/src/integrations/writer.ts +++ b/src/integrations/writer.ts @@ -321,7 +321,9 @@ function applyOrRefreshIntegration( return refuse(clientId, "unsafe", "unsafe", classified.reason === "blocked-container" ? `${configPath} holds a value where opencodex would have to write a section, so applying would replace it` - : `${configPath} cannot be changed safely`); + : classified.reason === "ambiguous-selector" + ? `${configPath} has more than one entry matching a managed selector` + : `${configPath} cannot be changed safely`); } /* * An implicit catalog sync is refresh-only. Keeping this decision inside the @@ -511,7 +513,9 @@ export function disableIntegration(input: IntegrationWriteInput): WriteOutcome { return refuse(clientId, "unsafe", "unsafe", classified.reason === "blocked-container" ? `${configPath} holds a value where opencodex would have to read a section, so nothing can be removed safely` - : `${configPath} cannot be changed safely`); + : classified.reason === "ambiguous-selector" + ? `${configPath} has more than one entry matching a managed selector` + : `${configPath} cannot be changed safely`); } /* diff --git a/tests/clients/integrations-merge.test.ts b/tests/clients/integrations-merge.test.ts index 6bea116319..6585d9f1e9 100644 --- a/tests/clients/integrations-merge.test.ts +++ b/tests/clients/integrations-merge.test.ts @@ -17,6 +17,7 @@ import { applyIntegration, disableIntegration, overwriteIntegration, + refreshIntegration, type IntegrationWriteInput, } from "../../src/integrations/writer"; import type { OcxConfig } from "../../src/types"; @@ -88,6 +89,9 @@ describe("setPath with a selector", () => { const doc = { providers: [OURS, THEIRS, { id: "opencodex", name: "dupe" }] }; expect(() => setPath(doc, SELECT, OURS)).toThrow(AmbiguousSelectorError); expect(() => deletePath(doc, SELECT)).toThrow(AmbiguousSelectorError); + expect(() => readPath(doc, SELECT)).toThrow(AmbiguousSelectorError); + expect(() => createdContainerPaths(doc, contribution([...SELECT, "name"]))) + .toThrow(AmbiguousSelectorError); }); }); @@ -250,19 +254,48 @@ describe("raycast writer round trip", () => { expect(Bun.YAML.parse(readFileSync(configPath, "utf8"))).toEqual({ providers: { opencodex: {} } }); }); - test("two entries with our id refuse as unsafe and leave the file alone", () => { - const configPath = installRaycast(); - const text = Bun.YAML.stringify({ providers: [{ id: "opencodex", name: "a" }, { id: "opencodex", name: "b" }] }); - writeFileSync(configPath, text); - // Neither entry is ours on record, so status reads conflict and a plain apply refuses - // there. The explicit overwrite reaches the merge, which is where the ambiguity is - // detected: it must surface as an `unsafe` refusal, never as a thrown error. - expect(readIntegrationState(input())).toMatchObject({ state: "conflict" }); - expect(applyIntegration(input())).toMatchObject({ ok: false, reason: "conflict" }); - const result = overwriteIntegration(input()); - expect(result).toMatchObject({ ok: false, reason: "unsafe", state: "unsafe" }); - if (!result.ok) expect(result.message).toContain("more than one entry"); - expect(readFileSync(configPath, "utf8")).toBe(text); - expect(store.listOperations("raycast")).toHaveLength(0); - }); + for (const recorded of [false, true]) { + for (const count of [0, 1, 2]) { + test(`${count} matching rows with record=${recorded} agree across status and mutation`, () => { + const configPath = installRaycast(); + writeFileSync(configPath, Bun.YAML.stringify({ providers: [THEIRS] })); + let managed: unknown = OURS; + if (recorded) { + expect(applyIntegration(input())).toMatchObject({ ok: true }); + const applied = Bun.YAML.parse(readFileSync(configPath, "utf8")) as { providers: unknown[] }; + managed = applied.providers[1]; + } + // For one owned row retain the writer's exact bytes, so this exercises + // current rather than an unrelated whole-file formatting conflict. + if (!recorded || count !== 1) { + writeFileSync(configPath, Bun.YAML.stringify({ + providers: [THEIRS, ...Array.from({ length: count }, () => managed)], + })); + } + const text = readFileSync(configPath, "utf8"); + const records = store.readRecords(); + const operations = store.listOperations("raycast"); + const expected = count === 0 ? "absent" : count === 2 ? "unsafe" : recorded ? "current" : "conflict"; + expect(readIntegrationState(input()).state).toBe(expected); + if (count === 2) { + expect(readIntegrationState(input()).reason).toBe("ambiguous-selector"); + for (const mutate of [applyIntegration, refreshIntegration, disableIntegration, overwriteIntegration]) { + expect(mutate(input())).toMatchObject({ ok: false, state: "unsafe", reason: "unsafe" }); + expect(readFileSync(configPath, "utf8")).toBe(text); + expect(store.readRecords()).toEqual(records); + expect(store.listOperations("raycast")).toEqual(operations); + } + } else if (count === 0) { + expect(refreshIntegration(input())).toMatchObject({ ok: true, changed: false, state: "absent" }); + expect(readFileSync(configPath, "utf8")).toBe(text); + } else if (recorded) { + expect(applyIntegration(input())).toMatchObject({ ok: true, changed: false, state: "current" }); + } else { + expect(applyIntegration(input())).toMatchObject({ ok: false, reason: "conflict" }); + expect(disableIntegration(input())).toMatchObject({ ok: false, reason: "conflict" }); + expect(readFileSync(configPath, "utf8")).toBe(text); + } + }); + } + } }); diff --git a/tests/clients/raycast-client.test.ts b/tests/clients/raycast-client.test.ts index 148f84babb..d84123f458 100644 --- a/tests/clients/raycast-client.test.ts +++ b/tests/clients/raycast-client.test.ts @@ -10,6 +10,7 @@ import { buildClientContribution, raycastAiDir, raycastConfigPath, + summarizeRaycast, type ExportContext, type ExportModel, type RaycastGeneratedConfig, @@ -73,6 +74,7 @@ beforeEach(() => { afterEach(() => { removeTreeWithRetry(home); + removeTreeWithRetry(store.root); }); /** Raycast "installed" for our purposes: the `ai` directory exists. */ @@ -123,6 +125,25 @@ describe("Raycast client config", () => { expect("context" in unknown).toBe(false); }); + test("summarizes unknown file shapes without trusting parsed YAML", () => { + const empty = { modelCount: 0, modelsWithoutLimits: 0 }; + for (const document of [undefined, null, false, 42, "providers", [], {}, + { providers: null }, { providers: {} }, { providers: "bad" }, + { providers: [null, false, "bad", [], {}] }, + ...[undefined, null, false, 42, "bad", {}].map(models => ({ providers: [{ id: "opencodex", models }] })), + { providers: [{ id: "opencodex", models: [] }, { id: "opencodex", models: [] }] }, + ]) expect(summarizeRaycast(document)).toEqual(empty); + expect(summarizeRaycast({ providers: [null, { id: "foreign", models: "bad" }, { + id: "opencodex", models: [null, false, 1, "bad", [], {}, { id: "x" }, + { id: "", name: "empty id" }, { id: "x", name: 1 }, + { id: "known", name: "Known", context: 1000 }, + { id: "unknown", name: "Unknown" }, + { id: "invalid", name: "Invalid", context: "1000" }, + { id: "negative", name: "Negative", context: -1 }, + ], + }] })).toEqual({ modelCount: 4, modelsWithoutLimits: 3 }); + }); + test("uses product labels instead of raw slugs or provider suffixes", () => { expect(exportPresentationLabel({ namespaced: "anthropic/claude-fable-5-1", provider: "anthropic", id: "claude-fable-5-1", @@ -137,9 +158,9 @@ describe("Raycast client config", () => { /* * Abilities follow the catalog row, not the vendor name. Temperature and - * reasoning_effort are the same bit inverted: Raycast's own template notes - * that reasoning models commonly reject temperature. system_message and - * tools are always on, the same stance as Hermes. + * reasoning_effort use opposite flags as a conservative export convention. + * This is not a complete per-model capability oracle. system_message and + * tools retain the client export convention, not verified per-model support. */ test("maps vision and reasoning ladders onto abilities per model", () => { const document = buildClientConfig("raycast", context()) as RaycastGeneratedConfig; @@ -251,6 +272,28 @@ describe("Raycast client config", () => { .toEqual(fewer.map(model => model.namespaced).sort()); }); + test("implicit catalog refresh neither loads models nor connects an unowned Raycast", async () => { + const configPath = installRaycast(USER_SEED); + const outcomes = await refreshOwnedCatalogIntegrations({ + ...request(), + models: async () => { throw new Error("unowned client must not load models"); }, + }, ["raycast"]); + expect(outcomes).toEqual([]); + expect(readFileSync(configPath, "utf8")).toBe(USER_SEED); + expect(store.readRecords().raycast).toBeUndefined(); + expect(store.listOperations("raycast")).toEqual([]); + }); + + for (const hostname of ["0.0.0.0", "192.0.2.1"]) { + test(`refuses admission-authenticated bind ${hostname} without changing the file`, () => { + const configPath = installRaycast(USER_SEED); + const result = applyIntegration({ ...request(), config: { ...CONFIG, hostname } }); + expect(result).toMatchObject({ ok: false, reason: "non_loopback" }); + expect(readFileSync(configPath, "utf8")).toBe(USER_SEED); + expect(store.listOperations("raycast")).toEqual([]); + }); + } + test("refuses a file whose providers is a map rather than a sequence", () => { // `providers: {}` is a container we would have to REPLACE with `[]` to // write our element, and replacing a user's container is never a success. From 387d787b43b9a8b3e1b048d9913c927e98531565 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:45:00 +0900 Subject: [PATCH 32/50] fix(raycast): honor live export admission and defer ensure refresh (#3733) Use the existing live client-config API for Raycast CLI export, refusing admission-authenticated destinations before serialization. Resolve declared unauthenticated loopback ports consistently in CLI/API URL composition. Preserve other exporter policy and add OpenCode compatibility coverage. Remove parent-side ensure catalog refresh from saved config; actual server startup and explicit sync remain responsible. Add reachable CLI/API admission/listener tests and an executed-command-body ensure divergence regression. Tests, typecheck, lint and builds NOT RUN per worker scope; git diff --check passed. Main owns final CI and rendered preview. Co-authored-by: Chanhee Lee --- .../content/docs/fr/guides/integrations.md | 6 ++ .../src/content/docs/guides/integrations.md | 6 ++ .../content/docs/tr/guides/integrations.md | 6 ++ .../content/docs/zh-tw/guides/integrations.md | 5 ++ src/cli/export-command.ts | 36 +++++++---- src/cli/index.ts | 7 ++- src/server/management/model-routes.ts | 10 +++- tests/cli/cli-export-command.test.ts | 59 +++++++++++++++++++ .../clients/sync-client-integrations.test.ts | 45 +++++++++++++- .../management-client-config-route.test.ts | 45 ++++++++++++++ 10 files changed, 207 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index 6a0a6d0dd3..c718801ddc 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -62,6 +62,12 @@ la prise en charge des outils pour chaque modèle. Les valeurs des autres fourni préservées, sans garantie pour les commentaires ou la mise en forme YAML. Le format est documenté sur [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). +Les exports Raycast en CLI et les téléchargements utilisent la destination et la politique +d’admission du serveur actif, y compris son listener de bouclage sans authentification. +`ocx ensure` ne réactualise pas Raycast depuis sa copie de configuration enregistrée, qui peut +différer du serveur actif. Le démarrage du serveur et la synchronisation explicite restent disponibles. + + Les chemins respectent les variables de remplacement propres à chaque client, lorsqu'elles existent. Pour OMP, la présence de `OMP_PROFILE` l'emporte sur `PI_PROFILE`, même si sa valeur est explicitement vide. Un profil nommé emploie `PI_CONFIG_DIR` comme nom de répertoire relatif au dossier personnel de l'utilisateur diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index e6230e4956..ea3c93f2dd 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -92,6 +92,12 @@ Provider values are preserved; YAML formatting and comments are not guaranteed t The format is documented at [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers). +Raycast CLI exports and dashboard downloads use the running server's destination and +admission policy, including a configured unauthenticated loopback listener. `ocx ensure` +does not refresh Raycast from its saved configuration snapshot: that can differ from the +running server. Server startup and explicit sync remain the catalog refresh paths. + + Cursor has a tab but is not one of these switches. Regular Cursor calls custom endpoints from its own backend, so a loopback proxy is unreachable without a public tunnel, and Cursor's separate Private Inference build is configured inside Cursor. The **Cursor** tab is read-only: diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index 145cd8c6bc..f068b0233f 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -66,6 +66,12 @@ değerleri korunur; YAML biçimlendirmesi ve yorumlarının korunması garanti e [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers) adresinde belgelenmiştir. +Raycast CLI dışa aktarmaları ve panel indirmeleri, yapılandırılmış kimlik doğrulamasız +geri döngü dinleyicisi dahil çalışan sunucunun adresini ve kabul politikasını kullanır. +`ocx ensure`, çalışan sunucudan farklı olabilecek kayıtlı yapılandırma kopyasıyla Raycast'i +yenilemez. Sunucu başlangıcı ve açık senkronizasyon katalog yenilemeye devam eder. + + Yollar, varsa her istemcinin kendi ortam geçersiz kılmalarını dikkate alır. OMP için `OMP_PROFILE`, açıkça boş olduğunda bile varlığıyla `PI_PROFILE`'a üstün gelir. Adlandırılmış bir profil, `PI_CONFIG_DIR`'i kullanıcının ev dizinine göre diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index 6a42ea8571..46b03df9a1 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -53,6 +53,11 @@ macOS 私有偏好設定僅提供 Pro 狀態提示;Windows 完全不讀取該 但不保證 YAML 格式與註解不變。格式說明見 [manual.raycast.com/ai/custom-providers](https://manual.raycast.com/ai/custom-providers)。 +Raycast CLI 匯出與儀表板下載會使用執行中伺服器的目標位址和准入規則,包含已設定的 +無驗證 loopback listener。`ocx ensure` 不會以可能與執行中伺服器不同的已儲存設定快照 +重新整理 Raycast;伺服器啟動與明確執行的同步仍會更新目錄。 + + 路徑遵循客戶端自己的環境覆寫(environment override)。對 OMP 而言,`OMP_PROFILE` 以存在與否優先於 `PI_PROFILE`,即使明確為空也一樣。具名 profile 會把 `PI_CONFIG_DIR` 當作相對於使用者家目錄的目錄名稱,並忽略 `PI_CODING_AGENT_DIR`;沒有具名 profile 時,`PI_CODING_AGENT_DIR` 勝出。OMP 支援 provider 層級的 headers,但這個最初的整合刻意只支援 loopback;遠端 `x-opencodex-api-key` 的連線設定被延後。搬移過的 `HERMES_HOME`、`KIMI_CODE_HOME` 與 `XDG_CONFIG_HOME` 路徑同樣會被遵循,而非猜測。表格列出每個客戶端的預設值。 對原生 OpenAI 模型,產生的 OMP 區塊會選用其模型層級的 Responses API,保留圖片輸入與 reasoning-effort 控制。路由模型則維持 provider 的 Chat Completions 方言,讓它們既有的 adapters 保持相容。 diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index c576435432..906065b2e8 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -122,10 +122,10 @@ export function exportModelsFromProxyRows( * Resolved ONCE and handed back to `runtimeRequest` as `baseUrl`, so the catalog and the * exported endpoint can never come from two different probes. */ -function proxyV1BaseUrl(root: string): string { +function proxyV1BaseUrl(root: string, config: OcxConfig): string { const url = new URL(root); const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80; - return opencodeProxyBaseUrl(port, url.hostname); + return opencodeProxyBaseUrl(port, url.hostname, config); } function parseClient(args: string[]): ExportClientId { @@ -172,17 +172,29 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep const spec = EXPORT_CLIENTS[client]; const root = await runtimeBaseUrl(deps); - const rows = await runtimeRequest("/api/models", {}, { ...deps, baseUrl: root }); - if (!Array.isArray(rows)) { - throw new RuntimeApiError("Management API returned an unexpected /api/models payload.", 502, rows); + let built: { document: unknown; text: string }; + if (client === "raycast") { + // The dial address alone cannot distinguish a wildcard authenticated bind + // from loopback. Let the live server resolve its admission/listener policy; + // saved config can differ from the process serving this request. + const exported = await runtimeRequest<{ + client: string; format: string; config: unknown; text: string; + }>("/api/client-config?client=raycast", {}, { ...deps, baseUrl: root }); + if (!exported || exported.client !== "raycast" || exported.format !== "yaml" + || typeof exported.text !== "string" || exported.config === undefined) { + throw new RuntimeApiError("Management API returned an unexpected Raycast export payload.", 502, null); + } + built = { document: exported.config, text: exported.text }; + } else { + const rows = await runtimeRequest("/api/models", {}, { ...deps, baseUrl: root }); + if (!Array.isArray(rows)) { + throw new RuntimeApiError("Management API returned an unexpected /api/models payload.", 502, rows); + } + // Discovery can persist selection; preserve the existing exporters' flow. + const config = (deps.configImpl ?? loadConfig)(); + const models = exportModelsFromProxyRows(rows, config); + built = buildClientConfigText(client, { baseUrl: proxyV1BaseUrl(root, config), models, config }); } - // Discovery can persist pending -> ready selection. Read from the caller's - // config source after the response, rather than filtering with a stale snapshot. - const config = (deps.configImpl ?? loadConfig)(); - const models = exportModelsFromProxyRows(rows, config); - // The text is the client's OWN format — YAML, TOML and JSON5 clients would - // otherwise receive a JSON rendering their parser reads differently. - const built = buildClientConfigText(client, { baseUrl: proxyV1BaseUrl(root), models, config }); const clientConfig = built.document; const text = built.text; diff --git a/src/cli/index.ts b/src/cli/index.ts index 9d9f08951c..663514a120 100755 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -580,7 +580,9 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom return null; }); if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); - await refreshOwnedRaycastCatalog(config, live.port); + // Do not refresh Raycast from saved config here: live bind/admission and + // secondary-listener settings may differ. Explicit sync or server startup + // owns catalog refresh; ensure must not overwrite a working destination. // Ensure env file exists for already-running proxy (may have been deleted or pre-dates this feature). const systemEnv = await injectSystemEnv(live.port, config).catch(() => ({ injected: false })); reportShellHookFailure(reconcileShellHook(systemEnv.injected)); @@ -625,7 +627,8 @@ async function handleEnsure(options: { existingIsSuccess?: boolean } = {}): Prom return null; }); if (synced?.status === "skipped") console.log(" Codex integration OFF; startup left Codex native."); - await refreshOwnedRaycastCatalog(config, port); + // The child performs Raycast refresh with its actual startup config. The + // parent's pre-spawn snapshot is not authoritative for a client-file write. // The child opens /healthz before its best-effort roster reconcile. Await the same idempotent // operation in the parent so `ocx ensure` cannot report success while stale ocx-*.md files are // still observable. Always use the live port, including fallback-port starts. diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index 28d1bef0ec..dd84d8af8c 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; +import { shouldInjectApiAuthHeader } from "../../codex/loopback-target"; /** * Codex parses a catalog entry's `input_modalities` as a closed enum, and one out-of-enum @@ -480,6 +481,13 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise { expect(parsed.provider.opencodex!.options.baseURL).not.toContain(":10100/"); }); + test("existing OpenCode export also resolves the declared unauthenticated listener", async () => { + const proxy = fakeProxy(); + const result = await run(["--client", "opencode", "--json"], { + baseUrl: proxy.baseUrl, + config: config({ hostname: "0.0.0.0", unauthenticatedLoopbackListener: { enabled: true, port: 10237 } }), + }); + expect(result.code).toBe(0); + const parsed = JSON.parse(result.stdout) as { provider: Record }; + expect(parsed.provider.opencodex!.options.baseURL).toBe("http://127.0.0.1:10237/v1"); + }); + test("disabled rows never reach the exported config", async () => { const proxy = fakeProxy(); const result = await run(["--client", "pi", "--json"], { baseUrl: proxy.baseUrl }); @@ -543,3 +554,51 @@ describe("export allowlist parity", () => { ], cfg).map(row => row.namespaced)).toEqual(["slash/org-model"]); }); }); + +describe("Raycast export uses the live management admission policy", () => { + for (const secondary of [false, true]) { + test(`live wildcard bind with secondary=${secondary} wins over saved loopback config`, async () => { + const oldHome = process.env.OPENCODEX_HOME; + const oldCodexHome = process.env.CODEX_HOME; + const root = tempDir(); + process.env.OPENCODEX_HOME = join(root, "ocx"); + process.env.CODEX_HOME = join(root, "codex"); + mkdirSync(process.env.CODEX_HOME, { recursive: true }); + try { + const liveConfig = config({ + hostname: "0.0.0.0", + providers: { mock: { + adapter: "openai-chat", baseUrl: "http://127.0.0.1/v1", + liveModels: false, models: ["fixture-model"], + } }, + ...(secondary ? { unauthenticatedLoopbackListener: { enabled: true, port: 10237 } } : {}), + }); + const proxy = managementProxy(liveConfig); + const out = join(root, "providers.yaml"); + writeFileSync(out, "keep existing export\n"); + const result = await run(["--client", "raycast", "--json", "--out", out, "--force"], { + baseUrl: proxy.baseUrl, + // Deliberately contradict both live bind and secondary port. + config: config({ unauthenticatedLoopbackListener: { enabled: true, port: 10999 } }), + }); + if (secondary) { + expect(result.code).toBe(0); + const document = JSON.parse(result.stdout) as { providers: Array<{ base_url: string }> }; + expect(document.providers[0]!.base_url).toBe("http://127.0.0.1:10237/v1"); + expect(readFileSync(out, "utf8")).toContain("10237/v1"); + expect(readFileSync(out, "utf8")).not.toContain("10999"); + } else { + expect(result.code).not.toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("non_loopback"); + expect(readFileSync(out, "utf8")).toBe("keep existing export\n"); + } + } finally { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = oldHome; + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = oldCodexHome; + } + }); + } +}); diff --git a/tests/clients/sync-client-integrations.test.ts b/tests/clients/sync-client-integrations.test.ts index 9050eecd3b..65dc41a2b3 100644 --- a/tests/clients/sync-client-integrations.test.ts +++ b/tests/clients/sync-client-integrations.test.ts @@ -662,18 +662,57 @@ test("the direct ocx sync command refreshes MCode, Pi, Raycast and server-owned expect(command).toContain('synced.status !== "refused"'); }); -test("startup and ensure refresh owned Raycast through the catalog coordinator", async () => { +test("server startup owns Raycast refresh; ensure does not reuse a saved-config snapshot", async () => { const src = await Bun.file(new URL("../../src/cli/index.ts", import.meta.url)).text(); const start = src.slice(src.indexOf("async function handleStart"), src.indexOf("function detachedStartEnvironment")); const ensure = src.slice(src.indexOf("async function handleEnsure"), src.indexOf("async function handleTrayProxyStart")); expect(src).toContain("refreshOwnedCatalogIntegrations"); expect(src).toContain('}, ["raycast"]);'); expect(start).toContain("await refreshOwnedRaycastCatalog(config, port)"); - expect(ensure).toContain("await refreshOwnedRaycastCatalog(config, live.port)"); - expect(ensure).toContain("await refreshOwnedRaycastCatalog(config, port)"); + expect(ensure).not.toContain("await refreshOwnedRaycastCatalog("); expect(src).not.toContain("refreshAllOwnedIntegrations"); }); +test("already-running ensure leaves Raycast untouched when saved host and listener policy diverge", async () => { + // Exercise the actual command body with external effects injected. Importing + // index.ts directly starts CLI dispatch, so isolate only handleEnsure here. + const src = await Bun.file(new URL("../../src/cli/index.ts", import.meta.url)).text(); + const command = src.slice(src.indexOf("async function handleEnsure"), src.indexOf("async function handleTrayProxyStart")); + const executable = new Bun.Transpiler({ loader: "ts" }).transformSync(command); + const root = mkdtempSync(join(tmpdir(), "ocx-ensure-raycast-divergence-")); + const configPath = join(root, "providers.yaml"); + const original = "providers:\n - id: opencodex\n base_url: http://127.0.0.1:10237/v1\n"; + writeFileSync(configPath, original); + const savedConfig = { + port: 10100, hostname: "192.0.2.40", providers: {}, defaultProvider: "mock", + unauthenticatedLoopbackListener: { enabled: true, port: 10999 }, + } as OcxConfig; + let refreshCalls = 0; + const deps = { + findProxyOwnerBeforeJournalRecovery: async () => ({ live: { hostname: "127.0.0.1", port: 10237 } }), + loadConfig: () => savedConfig, + codexAutoStartEnabled: () => true, + syncModelsToCodex: async () => ({ status: "skipped" }), + refreshOwnedRaycastCatalog: async () => { + refreshCalls += 1; + writeFileSync(configPath, "wrong saved destination"); + }, + injectSystemEnv: async () => ({ injected: true }), + reportShellHookFailure: () => {}, + reconcileShellHook: () => ({ state: "installed" }), + reconcileEnsureDesiredIntegrations: async () => {}, + console: { log: () => {}, error: () => {} }, + }; + try { + const ensure = new Function(...Object.keys(deps), `${executable}; return handleEnsure;`)(...Object.values(deps)) as () => Promise; + expect(await ensure()).toBe(true); + expect(refreshCalls).toBe(0); + expect(readFileSync(configPath, "utf8")).toBe(original); + } finally { + removeTreeWithRetry(root); + } +}); + test("identical explicit mutation keys join but cannot swallow a different apply or disable", async () => { let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); diff --git a/tests/server/management-client-config-route.test.ts b/tests/server/management-client-config-route.test.ts index d3d91aebdf..9fcb92e81d 100644 --- a/tests/server/management-client-config-route.test.ts +++ b/tests/server/management-client-config-route.test.ts @@ -23,6 +23,7 @@ import { type McodeGeneratedConfig, type OpencodeGeneratedConfig, type PiGeneratedConfig, + type RaycastGeneratedConfig, } from "../../src/clients/config-export"; import type { OcxConfig } from "../../src/types"; import { catalogConvergenceFactory } from "../helpers/catalog-convergence"; @@ -215,6 +216,50 @@ describe("native Anthropic effort ladder reaches the Aside document", () => { }); }); describe("GET /api/client-config", () => { + for (const hostname of ["0.0.0.0", "::", "192.0.2.40"]) { + test(`Raycast export refuses authenticated bind ${hostname} before generating a document`, async () => { + const response = await clientConfigApi(baseConfig({ hostname }), "?client=raycast"); + expect(response.status).toBe(400); + const body = await response.json() as Record; + expect(body.reason).toBe("non_loopback"); + expect(body.config).toBeUndefined(); + expect(body.text).toBeUndefined(); + }); + } + + test("Raycast export uses the declared unauthenticated listener instead of the management port", async () => { + const response = await clientConfigApi(baseConfig({ + hostname: "0.0.0.0", + unauthenticatedLoopbackListener: { enabled: true, port: 10237 }, + }), "?client=raycast"); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + const document = body.config as RaycastGeneratedConfig; + expect(document.providers[0]!.base_url).toBe("http://127.0.0.1:10237/v1"); + expect(document.providers[0]!.models.length).toBeGreaterThan(0); + expect(body.text).not.toContain(REAL_LOOKING_KEY); + expect(body.text).not.toContain("api_keys"); + }); + + test("OpenCode export keeps its envelope and uses the declared unauthenticated listener", async () => { + const response = await clientConfigApi(baseConfig({ + hostname: "0.0.0.0", unauthenticatedLoopbackListener: { enabled: true, port: 10237 }, + }), "?client=opencode"); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + expect(body.client).toBe("opencode"); + expect((body.config as OpencodeGeneratedConfig).provider.opencodex!.options.baseURL) + .toBe("http://127.0.0.1:10237/v1"); + }); + + test("Raycast export uses the main port for an ordinary loopback bind", async () => { + const response = await clientConfigApi(baseConfig(), "?client=raycast"); + expect(response.status).toBe(200); + const body = await response.json() as ClientConfigEnvelope; + expect((body.config as RaycastGeneratedConfig).providers[0]!.base_url) + .toBe("http://127.0.0.1:10100/v1"); + }); + test("opencode envelope carries the shared builder's exact bytes", async () => { const config = baseConfig(); const response = await clientConfigApi(config, "?client=opencode"); From 22f39ff105bea7675a82076b44b30aaf109826df Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 08:10:12 +0900 Subject: [PATCH 33/50] test(responses): exercise empty effort ladder through valid ingress CI 34065098625 and 34065112182 exposed a numeric-effort fixture rejected by parseRequest before it reaches the adapter. Use schema-valid high effort to exercise provider-wide omission and input immutability; preserve strict ingress validation. No production change and no local suites. Co-authored-by: jamespan --- tests/responses/openai-responses-passthrough.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index 7e97217dc2..24bc8e0e02 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -598,13 +598,13 @@ describe("DeepSeek Responses endpoint contract", () => { } }); - test("a provider-wide empty ladder removes even non-string raw effort", () => { + test("a provider-wide empty ladder removes schema-valid raw effort", () => { const keyed = { adapter: "openai-responses", baseUrl: "https://example.test/v1", authMode: "key" as const }; - const raw = { model: "model", input: "ping", reasoning: { effort: 123, summary: "auto" } }; + const raw = { model: "model", input: "ping", reasoning: { effort: "high", summary: "auto" } }; const wire = JSON.parse(createResponsesPassthroughAdapter({ ...keyed, reasoningEfforts: [] }) .buildRequest(parseRequest(raw)).body); expect(wire.reasoning).toEqual({ summary: "auto" }); - expect(raw.reasoning.effort).toBe(123); + expect(raw.reasoning.effort).toBe("high"); }); test("empty-ladder repair preserves unknown, non-rankable and native forward effort behavior", () => { From 95edd0aec05043d2e162c30edea98e174ad284e9 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:47:42 +0900 Subject: [PATCH 34/50] fix(export): preserve live CLI destination despite saved listener drift (#3733) Restore non-Raycast CLI URL composition from the live root only. Raycast remains delegated to the authoritative client-config API; API behavior is unchanged. Replace the OpenCode saved-listener substitution test with a live 10100 versus saved 10999 regression. Tests and suites NOT RUN; git diff --check passed. Co-authored-by: Chanhee Lee --- src/cli/export-command.ts | 6 +++--- tests/cli/cli-export-command.test.ts | 23 +++++++++++++++-------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/cli/export-command.ts b/src/cli/export-command.ts index 906065b2e8..739889a026 100644 --- a/src/cli/export-command.ts +++ b/src/cli/export-command.ts @@ -122,10 +122,10 @@ export function exportModelsFromProxyRows( * Resolved ONCE and handed back to `runtimeRequest` as `baseUrl`, so the catalog and the * exported endpoint can never come from two different probes. */ -function proxyV1BaseUrl(root: string, config: OcxConfig): string { +function proxyV1BaseUrl(root: string): string { const url = new URL(root); const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80; - return opencodeProxyBaseUrl(port, url.hostname, config); + return opencodeProxyBaseUrl(port, url.hostname); } function parseClient(args: string[]): ExportClientId { @@ -193,7 +193,7 @@ export async function handleExportCommand(argv: string[], deps: ExportCommandDep // Discovery can persist selection; preserve the existing exporters' flow. const config = (deps.configImpl ?? loadConfig)(); const models = exportModelsFromProxyRows(rows, config); - built = buildClientConfigText(client, { baseUrl: proxyV1BaseUrl(root, config), models, config }); + built = buildClientConfigText(client, { baseUrl: proxyV1BaseUrl(root), models, config }); } const clientConfig = built.document; const text = built.text; diff --git a/tests/cli/cli-export-command.test.ts b/tests/cli/cli-export-command.test.ts index 03bfd199a7..6d8a513558 100644 --- a/tests/cli/cli-export-command.test.ts +++ b/tests/cli/cli-export-command.test.ts @@ -204,15 +204,22 @@ describe("ocx export --json (accept criterion 1)", () => { expect(parsed.provider.opencodex!.options.baseURL).not.toContain(":10100/"); }); - test("existing OpenCode export also resolves the declared unauthenticated listener", async () => { - const proxy = fakeProxy(); - const result = await run(["--client", "opencode", "--json"], { - baseUrl: proxy.baseUrl, - config: config({ hostname: "0.0.0.0", unauthenticatedLoopbackListener: { enabled: true, port: 10237 } }), + test("OpenCode export keeps the live port when saved listener settings point at a future port", async () => { + const code = await handleExportCommand(["--client", "opencode", "--json"], { + baseUrl: "http://127.0.0.1:10100", + configImpl: () => config({ + hostname: "0.0.0.0", + unauthenticatedLoopbackListener: { enabled: true, port: 10999 }, + }), + fetchImpl: (async input => { + expect(String(input)).toBe("http://127.0.0.1:10100/api/models"); + return Response.json(ROWS); + }) as typeof fetch, }); - expect(result.code).toBe(0); - const parsed = JSON.parse(result.stdout) as { provider: Record }; - expect(parsed.provider.opencodex!.options.baseURL).toBe("http://127.0.0.1:10237/v1"); + expect(code).toBe(0); + const parsed = JSON.parse(stdout()) as { provider: Record }; + expect(parsed.provider.opencodex!.options.baseURL).toBe("http://127.0.0.1:10100/v1"); + expect(parsed.provider.opencodex!.options.baseURL).not.toContain(":10999/"); }); test("disabled rows never reach the exported config", async () => { From d175335943da2a2e64c5dac92a99a99295199335 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 08:12:44 +0900 Subject: [PATCH 35/50] test(container): verify first-start migrations before persistence baseline [skip ci] Remote source startup proved the expected tier/subagent migrations and schema-default persistence, followed by byte-stable second startup. Verify exact seed bytes before startup, six explicit migration/default additions at readiness, and an immutable post-start hash through bootstrap refusal and recreation. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- scripts/ci/docker-smoke.ts | 51 +++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/scripts/ci/docker-smoke.ts b/scripts/ci/docker-smoke.ts index eb488cf3b1..e26b11de34 100644 --- a/scripts/ci/docker-smoke.ts +++ b/scripts/ci/docker-smoke.ts @@ -127,6 +127,7 @@ const token = randomBytes(32).toString("hex"); const replacement = randomBytes(32).toString("hex"); const sha256 = (value: string) => createHash("sha256").update(value).digest("hex"); let seededConfigHash = ""; +let readyConfigHash = ""; // Check the loader, including its schema-repair/default-provider fallback, before server startup // and again in each running container. This isolates synthetic inference, not all process egress. @@ -184,6 +185,9 @@ async function inspect() { const stateProbe = ` import { readFileSync, statSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; + import { isDeepStrictEqual } from 'node:util'; + const phase = await Bun.stdin.text(); + if (!['seed', 'first-ready', 'steady'].includes(phase)) throw new Error('invalid state phase'); ${fixtureConfigCheck} const homes = ['/home/bun/.opencodex', '/home/bun/.codex']; const uid = process.getuid(); @@ -202,14 +206,51 @@ const stateProbe = ` if (s.uid !== uid || (s.mode & 0o777) !== 0o600 || s.size > 65536) throw new Error('file permissions/size'); return createHash('sha256').update(readFileSync(path)).digest('hex'); }); + // The immutable shipped config was byte-verified before fixture creation. Reconstruct only + // the deliberate fixture route edits, then compare every original key on disk (not loader defaults). + const seed = JSON.parse(readFileSync('docker/config.json', 'utf8')); + seed.providers = { smoke: { adapter: 'openai-responses', baseUrl: 'http://127.0.0.1:9/v1', authMode: 'local', allowPrivateNetwork: true } }; + seed.defaultProvider = 'smoke'; + const persisted = JSON.parse(readFileSync(paths[0], 'utf8')); + const loaded = JSON.parse(JSON.stringify(effective)); + for (const key of Object.keys(seed)) { + for (const config of [persisted, loaded]) { + if (!Object.hasOwn(config, key) || !isDeepStrictEqual(config[key], seed[key])) throw new Error('seed semantics changed'); + } + } + // Independent oracle measured by isolated startup; update only for an intentional contract change. + // Do not derive expected values from runtime migration/default helpers. + const additions = { + appOwnedMemoryBudgetMb: 256, fastRows: true, managementUsageMaxReadBytes: 67108864, + openaiProviderTierVersion: 2, + subagentModels: ['gpt-6-astra', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.5'], + subagentModelsVersion: 1, + }; + for (const config of [persisted, loaded]) { + if (Object.keys(config).some(key => !Object.hasOwn(seed, key) && !Object.hasOwn(additions, key))) throw new Error('unexpected startup config addition'); + for (const [key, expected] of Object.entries(additions)) { + if (phase !== 'seed' || Object.hasOwn(config, key)) { + if (!Object.hasOwn(config, key) || !isDeepStrictEqual(config[key], expected)) throw new Error('startup oracle mismatch'); + } + } + } + if (phase === 'seed' && Object.keys(persisted).some(key => !Object.hasOwn(seed, key))) throw new Error('premature seed addition'); console.log(JSON.stringify(hashes)); `; -async function state() { - const hashes = JSON.parse(await compose(["exec", "-T", "hub", "bun", "-e", stateProbe])) as string[]; +async function state(phase: "seed" | "first-ready" | "steady" = "steady") { + const invocation = phase === "seed" ? ["run", "--rm", "-T", "--no-deps"] : ["exec", "-T"]; + const hashes = JSON.parse(await compose([...invocation, "hub", "bun", "-e", stateProbe], phase)) as string[]; check(hashes.length === 3 && hashes.every(hash => /^[a-f0-9]{64}$/.test(hash)), "invalid state evidence"); - check(hashes[0] === seededConfigHash, "seeded config changed"); check(hashes[1] === sha256(`${token}\n`) && hashes[2] === sha256(fixture), "token/catalog changed"); + if (phase === "first-ready") { + check(!readyConfigHash, "post-start config baseline already established"); + // stateProbe has checked persisted/effective semantics and the independent startup oracle. + readyConfigHash = hashes[0]!; + } else { + check(hashes[0] === (phase === "seed" ? seededConfigHash : readyConfigHash), + phase === "seed" ? "seeded config changed before startup" : "post-start config changed"); + } return JSON.stringify(hashes); } @@ -353,11 +394,13 @@ async function main() { check(/^[a-f0-9]{64}$/.test(seededConfigHash), "invalid seeded config evidence"); progress("bootstrap throwaway token"); await compose(["run", "--rm", "-T", "--no-deps", "hub", "bun", "run", "docker/bootstrap-token.ts"], `${token}\n`); + progress("verify exact seed state before startup"); + await state("seed"); progress("start and check admission"); await compose(["up", "--no-build", "--wait", "--wait-timeout", "120", "hub"], undefined, 150_000); const first = await inspect(); await acceptance(first.url); - const before = await state(); + const before = await state("first-ready"); progress("refuse token replacement"); const refused = await run(["docker", ...composeArgs, "run", "--rm", "-T", "--no-deps", "hub", "bun", "run", "docker/bootstrap-token.ts"], `${replacement}\n`); From 68d90aa37d63e9c51a4d538d009633d0fd33d93b Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:43:01 +0900 Subject: [PATCH 36/50] docs(claude): describe redacted reasoning replay Carry the documentation-only tail of PR #3815 through 22135366225391494ca98301114486c85be466a4 across eight locales. Runtime and test trees remain identical to the verified protocol candidate. Co-authored-by: SB Yoon <44089734+yansigit@users.noreply.github.com> Co-authored-by: Yumi --- docs-site/src/content/docs/fr/guides/claude-code.md | 1 + docs-site/src/content/docs/guides/claude-code.md | 1 + docs-site/src/content/docs/ja/guides/claude-code.md | 1 + docs-site/src/content/docs/ko/guides/claude-code.md | 1 + docs-site/src/content/docs/ru/guides/claude-code.md | 1 + docs-site/src/content/docs/tr/guides/claude-code.md | 1 + docs-site/src/content/docs/zh-cn/guides/claude-code.md | 1 + docs-site/src/content/docs/zh-tw/guides/claude-code.md | 1 + 8 files changed, 8 insertions(+) diff --git a/docs-site/src/content/docs/fr/guides/claude-code.md b/docs-site/src/content/docs/fr/guides/claude-code.md index 7f1c0a54aa..2ae3940254 100644 --- a/docs-site/src/content/docs/fr/guides/claude-code.md +++ b/docs-site/src/content/docs/fr/guides/claude-code.md @@ -519,6 +519,7 @@ Sur l’adaptateur Anthropic prévu, les blocs signés non masqués (y compris t | Battement de coeur | `ping` | | Deltas de texte | `content_block_start` → `content_block_delta` (texte) → `content_block_stop` | | Résumé ou texte de raisonnement | Bloc `thinking` avec la signature relue, ou une enveloppe de secours `ocxr1` bornée | +| Raisonnement expurgé | Blocs `redacted_thinking` relus depuis l'enveloppe de raisonnement | | Trames d'appel de fonction | Bloc `tool_use` avec `input_json_delta` | | Événement terminal | `message_delta` → `message_stop` | | EOF avant la borne | style 502 `api_error` | diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index f1c1bfbfe1..14dbe92c81 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -533,6 +533,7 @@ name. | Heartbeat | `ping` | | Text deltas | `content_block_start` → `content_block_delta` (text) → `content_block_stop` | | Reasoning summary/text | `thinking` block with the replayed signature, or a bounded `ocxr1` fallback envelope | +| Redacted reasoning | `redacted_thinking` blocks replayed from the reasoning envelope | | Function-call frames | `tool_use` block with `input_json_delta` | | Terminal event | `message_delta` → `message_stop` | | EOF before terminal | 502-style `api_error` | diff --git a/docs-site/src/content/docs/ja/guides/claude-code.md b/docs-site/src/content/docs/ja/guides/claude-code.md index 2445e7fa3a..384c3f50df 100644 --- a/docs-site/src/content/docs/ja/guides/claude-code.md +++ b/docs-site/src/content/docs/ja/guides/claude-code.md @@ -387,6 +387,7 @@ role、`tool_use_id` のない `tool_result`、id/name のない `tool_use`、na | Heartbeat | `ping` | | テキスト delta | `content_block_start` → `content_block_delta`(text) → `content_block_stop` | | 推論要約/テキスト | 再生されたシグネチャ、または境界付き `ocxr1` フォールバックを持つ `thinking` ブロック | +| 秘匿化された推論 | 推論エンベロープから再生される `redacted_thinking` ブロック | | Function-call フレーム | `input_json_delta` を持つ `tool_use` ブロック | | 終了イベント | `message_delta` → `message_stop` | | 終了前に EOF | 502 形式 `api_error` | diff --git a/docs-site/src/content/docs/ko/guides/claude-code.md b/docs-site/src/content/docs/ko/guides/claude-code.md index 4e3535d821..676800d1e6 100644 --- a/docs-site/src/content/docs/ko/guides/claude-code.md +++ b/docs-site/src/content/docs/ko/guides/claude-code.md @@ -425,6 +425,7 @@ role, `tool_use_id` 없는 `tool_result`, id/name 없는 `tool_use`, name 없는 | Heartbeat | `ping` | | 텍스트 delta | `content_block_start` → `content_block_delta`(text) → `content_block_stop` | | 추론 요약/텍스트 | 재생된 서명 또는 제한된 `ocxr1` 폴백이 있는 `thinking` 블록 | +| 비공개 추론 | 추론 봉투에서 재생되는 `redacted_thinking` 블록 | | Function-call 프레임 | `input_json_delta`가 있는 `tool_use` 블록 | | 종료 이벤트 | `message_delta` → `message_stop` | | 종료 전에 EOF | 502 형식 `api_error` | diff --git a/docs-site/src/content/docs/ru/guides/claude-code.md b/docs-site/src/content/docs/ru/guides/claude-code.md index 847e79964a..60464bfb90 100644 --- a/docs-site/src/content/docs/ru/guides/claude-code.md +++ b/docs-site/src/content/docs/ru/guides/claude-code.md @@ -413,6 +413,7 @@ id/name; именованный `tool_choice` без имени. | Heartbeat | `ping` | | Текстовые дельты | `content_block_start` → `content_block_delta` (text) → `content_block_stop` | | Резюме/текст рассуждений | Блок `thinking` с повторно переданной подписью или ограниченным резервным конвертом `ocxr1` | +| Скрытое рассуждение | Блоки `redacted_thinking`, воспроизведённые из конверта рассуждений | | Кадры function-call | Блок `tool_use` с `input_json_delta` | | Завершающее событие | `message_delta` → `message_stop` | | EOF до завершающего события | `api_error` в стиле 502 | diff --git a/docs-site/src/content/docs/tr/guides/claude-code.md b/docs-site/src/content/docs/tr/guides/claude-code.md index f1c7fca438..4be81a4de8 100644 --- a/docs-site/src/content/docs/tr/guides/claude-code.md +++ b/docs-site/src/content/docs/tr/guides/claude-code.md @@ -602,6 +602,7 @@ kimlik/ad içermeyen `tool_use`; ad içermeyen adlandırılmış `tool_choice`. | Kalp atışı (Heartbeat) | `ping` | | Metin farkları | `content_block_start` → `content_block_delta` (metin) → `content_block_stop` | | Akıl yürütme özeti/metni | Tekrarlanan imzayı veya sınırlı bir `ocxr1` yedeğini taşıyan `thinking` bloğu | +| Gizli akıl yürütme | Akıl yürütme zarfından yeniden oynatılan `redacted_thinking` blokları | | Fonksiyon çağrısı çerçeveleri | `input_json_delta` ile `tool_use` bloğu | | Terminal olayı | `message_delta` → `message_stop` | | Terminalden önce EOF | 502 tarzı `api_error` | diff --git a/docs-site/src/content/docs/zh-cn/guides/claude-code.md b/docs-site/src/content/docs/zh-cn/guides/claude-code.md index 2b8c0bfa98..dd8bc740b8 100644 --- a/docs-site/src/content/docs/zh-cn/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-cn/guides/claude-code.md @@ -364,6 +364,7 @@ role;`tool_result` 缺少 `tool_use_id`;`tool_use` 缺少 id/name;指定 | 心跳 | `ping` | | 文本增量 | `content_block_start` → `content_block_delta`(文本)→ `content_block_stop` | | 推理摘要/文本 | 带重放签名或有界 `ocxr1` 回退信封的 `thinking` 块 | +| 脱敏推理 | 从推理信封重放的 `redacted_thinking` 块 | | Function-call 帧 | 带 `input_json_delta` 的 `tool_use` 块 | | 终止事件 | `message_delta` → `message_stop` | | 在终止事件前 EOF | 502 风格的 `api_error` | diff --git a/docs-site/src/content/docs/zh-tw/guides/claude-code.md b/docs-site/src/content/docs/zh-tw/guides/claude-code.md index db1c779145..86a3ea0782 100644 --- a/docs-site/src/content/docs/zh-tw/guides/claude-code.md +++ b/docs-site/src/content/docs/zh-tw/guides/claude-code.md @@ -440,6 +440,7 @@ role;`tool_result` 缺少 `tool_use_id`;`tool_use` 缺少 id/name;指定 | 心跳 | `ping` | | 文字增量 | `content_block_start` → `content_block_delta`(文字)→ `content_block_stop` | | 推理摘要/文字 | 帶重播簽名或有界 `ocxr1` 備援信封的 `thinking` 塊 | +| 遮蔽推理 | 從推理信封重播的 `redacted_thinking` 塊 | | Function-call 幀 | 帶 `input_json_delta` 的 `tool_use` 塊 | | 終止事件 | `message_delta` → `message_stop` | | 在終止事件前 EOF | 502 風格的 `api_error` | From e87300809ce67a946ccf2059e80b19d435032738 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 7 Sep 2026 08:49:52 +0900 Subject: [PATCH 37/50] docs(devlog): record verified protocol delivery and remainders --- .../260907_axis3_protocol/000_plan.md | 4 +++ .../260907_axis3_protocol/001_roadmap_lock.md | 0 .../010_prepare_and_verify.md | 0 .../260907_axis3_protocol/011_candidate.md | 0 .../260907_axis3_protocol/020_delivery.md | 15 +++++++++ .../021_delivery_record.md | 31 +++++++++++++++++++ .../260907_axis3_protocol/020_delivery.md | 7 ----- 7 files changed, 50 insertions(+), 7 deletions(-) rename devlog/{_plan => _fin}/260907_axis3_protocol/000_plan.md (90%) rename devlog/{_plan => _fin}/260907_axis3_protocol/001_roadmap_lock.md (100%) rename devlog/{_plan => _fin}/260907_axis3_protocol/010_prepare_and_verify.md (100%) rename devlog/{_plan => _fin}/260907_axis3_protocol/011_candidate.md (100%) create mode 100644 devlog/_fin/260907_axis3_protocol/020_delivery.md create mode 100644 devlog/_fin/260907_axis3_protocol/021_delivery_record.md delete mode 100644 devlog/_plan/260907_axis3_protocol/020_delivery.md diff --git a/devlog/_plan/260907_axis3_protocol/000_plan.md b/devlog/_fin/260907_axis3_protocol/000_plan.md similarity index 90% rename from devlog/_plan/260907_axis3_protocol/000_plan.md rename to devlog/_fin/260907_axis3_protocol/000_plan.md index 36cfe95772..d0511f46ec 100644 --- a/devlog/_plan/260907_axis3_protocol/000_plan.md +++ b/devlog/_fin/260907_axis3_protocol/000_plan.md @@ -11,3 +11,7 @@ Success: roadmap verified, accepted changes reviewed and remotely validated, com Acceptance: (1) thinking then text/tool then result retains order and genuine signatures; opaque blocks remain bounded and malformed/nested signatures fail closed. (2) Grok user agent receives ordinary Responses data without codex.rate_limits/codex.response.metadata, while proxy inspection and normal clients retain metadata. (3) valid external task seeds preserve text/order; absent metadata invalid tool outputs still reject. (4) no credential, admission, cache-retention default, provider/routing policy mutation. (5) final CI must really run relevant tests/typecheck, not skip/cancel or fabricate success. No local suite was run. Final failure permits lower-layer CI for localization; unrelated failures may defer delivery, never count as success. Sources: PRs https://github.com/lidge-jun/opencodex/pull/3815 and /pull/3816; issues /issues/3807 and /issues/3719. Current dev 137d6a727. Evidence snapshots under .tmp/axis3. Public notes contain no unreleased vulnerability detail; any new security investigation stays in scratch. + +## Terminal outcome + +Runtime scope delivered in3830–3832 with the evidence and explicit diagnostic remainders in021_delivery_record.md. Documentation-only completion retains the late source-author rows and archives this unit. Initial planning statements are historical; the delivery record is the outcome authority. diff --git a/devlog/_plan/260907_axis3_protocol/001_roadmap_lock.md b/devlog/_fin/260907_axis3_protocol/001_roadmap_lock.md similarity index 100% rename from devlog/_plan/260907_axis3_protocol/001_roadmap_lock.md rename to devlog/_fin/260907_axis3_protocol/001_roadmap_lock.md diff --git a/devlog/_plan/260907_axis3_protocol/010_prepare_and_verify.md b/devlog/_fin/260907_axis3_protocol/010_prepare_and_verify.md similarity index 100% rename from devlog/_plan/260907_axis3_protocol/010_prepare_and_verify.md rename to devlog/_fin/260907_axis3_protocol/010_prepare_and_verify.md diff --git a/devlog/_plan/260907_axis3_protocol/011_candidate.md b/devlog/_fin/260907_axis3_protocol/011_candidate.md similarity index 100% rename from devlog/_plan/260907_axis3_protocol/011_candidate.md rename to devlog/_fin/260907_axis3_protocol/011_candidate.md diff --git a/devlog/_fin/260907_axis3_protocol/020_delivery.md b/devlog/_fin/260907_axis3_protocol/020_delivery.md new file mode 100644 index 0000000000..714a9c0b8e --- /dev/null +++ b/devlog/_fin/260907_axis3_protocol/020_delivery.md @@ -0,0 +1,15 @@ +# Publish and deliver verified manual chain + +Prerequisite: wp1 accepted-source review and successful final-head remote validation, or source-grounded defer outcome. Publish ordinary PRs targeting dev then the parent branch, using every repository template section. Bodies name source PRs, own layer-only diff, exact final combined CI evidence and explicit lower-layer CI deferral per owner instruction. Do not attest local CI. Preserve original contributor trailers in commits; admin merge with merge commits preserves their identity. + +Read live native-stack membership and head/base identity before merge. Never register a native stack. Parent merges to dev first; retain its branch, retarget child to dev, verify current head and ancestry. If integration tree changes materially, refresh final combined CI before landing. Use --admin and --match-head-commit exact guard. Do not merge into the parent branch by mistake. Refresh origin/dev and prove each merge SHA ancestor. Close superseded source PRs only after equivalent fix is actually landed, with credit and replacement link. Keep #3807 and #3719 open if real reproduction/cache acceptance remains unmet. No release or deployment. + +Record final PR URLs, source-to-delivery mapping, commit authors/trailers, CI run and exact SHA, review verdicts, remaining limitations and preserved dirty-work evidence. No fabricated status checks. Completion: every candidate has an honest disposition, accepted work is landed, unresolved diagnostics explicitly deferred under user direction. + +## Delivery revalidation + +Previous D: all 24 real GitHub runtime producer jobs succeeded at final9b5b670db; same-head remote Bun1.4 full suite20897pass18skip0fail, focused405pass, docs build pass. Aggregate ci is still queued; do not claim the workflow complete or manufacture a status. Its only operation is combining those passed producer results. Maintainer explicitly authorized admin merge, and live dev rules expose no required_status_checks rule. Delivery may use the actual completed producer evidence with aggregation status explicitly disclosed; never waive an unrun or failed runtime producer. + +Carry late source docs #3815 through221353662: eight outbound redacted-reasoning table rows in the same eight locales. Prepared docs-only68d90aa37 has runtime/test trees identical to9b and remote docs build passed. After three runtime PRs land bottom-up, bring this docs-only tail and a final delivery record into a fourth ordinary PR. MOVE the completed owning unit from devlog/_plan/260907_axis3_protocol to devlog/_fin/260907_axis3_protocol and NEW021_delivery.md with actual merge/CI/source-credit evidence and deferred issues. No new runtime tests: exact code-tree equality plus docs build/hygiene are the applicable checks. + +Refresh dev and membership before each guarded admin merge; compare merged runtime tree with tested9b. Any unrelated concurrent dev change requires integration review and appropriate renewed evidence. Original #3815 and #3816 close only after their full carried changes (including docs tail) are landed. #3807 and #3719 remain open for the already recorded limits. diff --git a/devlog/_fin/260907_axis3_protocol/021_delivery_record.md b/devlog/_fin/260907_axis3_protocol/021_delivery_record.md new file mode 100644 index 0000000000..e27fd8b41b --- /dev/null +++ b/devlog/_fin/260907_axis3_protocol/021_delivery_record.md @@ -0,0 +1,31 @@ +# Axis 3 delivery record + +## Delivered runtime + +Ordinary PR chain, merged bottom-up with explicit owner admin authorization: + +| PR | Scope | Merge commit | +| --- | --- | --- | +| [3830](https://github.com/lidge-jun/opencodex/pull/3830) | Claude envelope foundation from #3815 | 2269e076d4222ada6ea3694fb8eed04f91a201d2 | +| [3831](https://github.com/lidge-jun/opencodex/pull/3831) | Grok strict-client projection from #3816 and SSE field correction | 07f8d70a75f088b19f4c9dd849e34034a88ab5f3 | +| [3832](https://github.com/lidge-jun/opencodex/pull/3832) | Replay boundaries, terminal overflow, established-history fixtures | 4349cf3cefdb5ed04575f49023ae34ffe6462e1c | + +Original contributors are retained in commits: SB Yoon and Yumi for #3815, Danh Thanh for #3816. Merge commits preserve the carried commits. The documentation-only tail of #3815 through221353662 is carried with both original contributor trailers in68d90aa37. + +## Verification + +- [Final candidate CI](https://github.com/lidge-jun/opencodex/actions/runs/34065721438) completed SUCCESS: all25 jobs at9b5b670db3e24ae5522c5d61e74c071c71257a26, including Linux, macOS shards/control and all six Windows shards. +- Same-head remote Linux Bun1.4.0 full suite:20897pass18skip0fail with `bun run test -- --parallel=1`; typecheck, privacy scan and documentation build passed. Focused protocol coverage:405pass1skip0fail. +- While CI ran, dev advanced to b65b9d8f2 with BigModel/Raycast changes. Conflict-free integration cc6afe2c97fb423363e99682b906bcb529478688 passed remote typecheck and633tests1skip0fail across15 relevant files, including shared passthrough/registry/layout guards. +- Actual runtime landing tree at4349cf3ce equals the integration tree ccaf0a0383cb3e8808e24576271c861625b506fb exactly. This is integration proof, not a claim that the earlier full CI ran on4349cf3ce. +- Independent Astra high source/security/integration reviews passed. The terminal-closure overflow finding was fixed before acceptance. New-test oracle mistakes found remotely were corrected without weakening exact assistant-array or pairing assertions. +- The earlier parallel remote run had21 catalog timeouts; isolated and final sequential runs passed, and final hosted CI passed. No separate root-cause fix is claimed. +- No local test suite or typecheck ran. All pushes used `--no-verify`. Native stacks and fabricated check statuses were not used. Automatic lower/intermediate CI was deferred or cancelled under the owner's combined-first direction. + +## Explicit remainders + +#3807 remains open: the demonstrated complete external-task envelope is already supported, and current-version raw reporter reproduction is unavailable. New ordinary, stored-ID continuation, v2-trigger and v1-compact fixtures preserve established history without relaxing missing-call-ID validation. + +#3719 remains open: live intended-Anthropic acceptance and controlled cache measurements are unverified. Locally hidden text through the Claude boundary and legacy combined-envelope streaming ordering remain outside the preservation claim. Existing compatibility enforcement, hidden display, authentication, routing and cache-retention defaults remain intact. + +The supplied dirty worktree and existing remote main checkout were preserved; execution used separate task worktrees. No release, deployment or account configuration change was made. diff --git a/devlog/_plan/260907_axis3_protocol/020_delivery.md b/devlog/_plan/260907_axis3_protocol/020_delivery.md deleted file mode 100644 index 050cc76d54..0000000000 --- a/devlog/_plan/260907_axis3_protocol/020_delivery.md +++ /dev/null @@ -1,7 +0,0 @@ -# Publish and deliver verified manual chain - -Prerequisite: wp1 accepted-source review and successful final-head remote validation, or source-grounded defer outcome. Publish ordinary PRs targeting dev then the parent branch, using every repository template section. Bodies name source PRs, own layer-only diff, exact final combined CI evidence and explicit lower-layer CI deferral per owner instruction. Do not attest local CI. Preserve original contributor trailers in commits; admin merge with merge commits preserves their identity. - -Read live native-stack membership and head/base identity before merge. Never register a native stack. Parent merges to dev first; retain its branch, retarget child to dev, verify current head and ancestry. If integration tree changes materially, refresh final combined CI before landing. Use --admin and --match-head-commit exact guard. Do not merge into the parent branch by mistake. Refresh origin/dev and prove each merge SHA ancestor. Close superseded source PRs only after equivalent fix is actually landed, with credit and replacement link. Keep #3807 and #3719 open if real reproduction/cache acceptance remains unmet. No release or deployment. - -Record final PR URLs, source-to-delivery mapping, commit authors/trailers, CI run and exact SHA, review verdicts, remaining limitations and preserved dirty-work evidence. No fabricated status checks. Completion: every candidate has an honest disposition, accepted work is landed, unresolved diagnostics explicitly deferred under user direction. From 91fba4b4cd3d31e79b7d404b3d201c7b9896f67d Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:41:25 +0900 Subject: [PATCH 38/50] ci: gate source-build Docker lifecycle verification [skip ci] Add Docker packaging paths to the existing CI scope, execute the isolated lifecycle smoke, and include it in the aggregate gate. Extend the workflow oracle and typecheck the new script. Final integration will be dispatched explicitly with lane=all; redundant lower-layer runs are intentionally skipped. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- .github/workflows/ci.yml | 31 ++++++++++++++++- .../src/content/docs/guides/remote-hub.md | 6 ++++ tests/ci-workflows/ci-workflows.test.ts | 34 ++++++++++++++++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c15cb696a..a6c0e7ab1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,10 @@ on: push: branches: [main, preview, dev] paths: + - "Dockerfile" + - "compose.yaml" + - ".dockerignore" + - "docker/**" - "src/**" - "bin/**" - "tests/**" @@ -180,6 +184,10 @@ jobs: # start the workflow so the aggregate check exists, while these # paths decide whether the expensive test jobs need to run. ci: + - 'Dockerfile' + - 'compose.yaml' + - '.dockerignore' + - 'docker/**' - 'src/**' - 'bin/**' - 'tests/**' @@ -423,6 +431,7 @@ jobs: run: | bun x tsc --noEmit bun x tsc --noEmit -p tests/tsconfig.doctor-service-memory-contract.json + bun x tsc --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts - name: GUI tests run: cd gui && bun test --isolate tests @@ -908,6 +917,26 @@ jobs: bun run scripts/keyring-smoke.ts ' + # Exercise the source-build Compose contract, including real volume reuse. + # Host fixtures cannot prove image construction or container recreation. + docker-smoke: + name: docker smoke + needs: changes + if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + + - name: Setup project Bun + uses: ./.github/actions/setup-project-bun + + - name: Build, start, and recreate the container + run: bun scripts/ci/docker-smoke.ts + npm-global-smoke: name: npm-global ${{ matrix.os }} needs: changes @@ -986,7 +1015,7 @@ jobs: # direct dependencies only, so a failing `select-windows-runner` would # otherwise reach this gate as nothing at all while its dependents report # `skipped` — which the gate is required to read as a deliberate skip. - needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, npm-global-smoke] + needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, npm-global-smoke] runs-on: ubuntu-latest timeout-minutes: 5 steps: diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 460fe4651d..6a2b2a33bd 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -291,6 +291,12 @@ unreadable, a non-loopback hub must not be accepted as ready. Never treat livene `docker compose down --volumes` as destructive: it deletes configuration, OAuth credentials, usage history, the data-plane token, and persisted Codex state together. +Cross-platform CI builds the source image and checks startup, data-plane token admission, and +container recreation using an isolated Compose project with throwaway credentials. It verifies that +both named volumes and a synthetic catalog survive replacement. This check does not validate a +real provider account, OAuth callback, custom mount migration, or every CPU architecture; perform +the authenticated routed-response check above for your deployment. + ## Rollback Inspect existing Serve mappings before changing them. `tailscale serve reset` removes every mapping diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index ec2c919c45..77192bbb48 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -506,17 +506,21 @@ describe("GitHub Actions hardening", () => { // allowlist. PRs always create the workflow and aggregate check; this list // decides whether the costly jobs run. Pin the entire list on both paths. const ciPaths = [ + ".dockerignore", ".gitattributes", ".github/workflows/ci.yml", ".github/workflows/enforce-pr-target.yml", ".github/workflows/release.yml", ".github/workflows/stale-needs-info.yml", ".npmignore", + "Dockerfile", "LICENSE", "README.md", "assets/**", "bin/**", "bun.lock", + "compose.yaml", + "docker/**", "gui/**", "package.json", "scripts/**", @@ -563,7 +567,7 @@ describe("GitHub Actions hardening", () => { expect(scopeIndex).toBeGreaterThan(filterIndex); const scopedCondition = "github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'"; - for (const jobName of ["test", "storage-policy", "gates", "platform-macos", "keyring-smoke"]) { + for (const jobName of ["test", "storage-policy", "gates", "platform-macos", "keyring-smoke", "docker-smoke"]) { const job = ci.jobs?.[jobName] as { needs?: string; if?: string } | undefined; expect(`${jobName}:${job?.needs}`).toBe(`${jobName}:changes`); expect(`${jobName}:${job?.if}`).toBe(`${jobName}:${scopedCondition}`); @@ -573,6 +577,34 @@ describe("GitHub Actions hardening", () => { expect(macosControlIf?.if).toBe("github.event_name == 'workflow_dispatch'"); }); + test("Docker smoke executes the source-build lifecycle and gates its result", async () => { + const ci = Bun.YAML.parse(await readText(".github/workflows/ci.yml")) as { + jobs?: Record; + steps?: Array<{ name?: string; run?: string; if?: string; "continue-on-error"?: boolean }>; + }>; + }; + const smoke = ci.jobs?.["docker-smoke"]; + expect(smoke?.["runs-on"]).toBe("ubuntu-latest"); + expect(smoke?.["timeout-minutes"]).toBe(20); + expect(smoke?.["continue-on-error"]).toBeUndefined(); + expect(smoke?.permissions).toBeUndefined(); // Inherits workflow contents:read. + const execution = smoke?.steps?.find(step => + hasExactShellCommand(step.run, "bun scripts/ci/docker-smoke.ts")); + expect(execution).toBeDefined(); + expect(execution?.if).toBeUndefined(); + expect(execution?.["continue-on-error"]).toBeUndefined(); + expect(ci.jobs?.ci?.needs).toContain("docker-smoke"); + const typecheck = ci.jobs?.gates?.steps?.find(step => step.name === "Typecheck"); + expect(hasExactShellCommand(typecheck?.run, + "bun x tsc --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts", + )).toBe(true); + }); + test("cross-platform CI keeps the GUI lint and build gates", async () => { // Review finding (PR #97): the GUI build gate was silently dropped once; assert the // enhanced gate (PR #99) stays wired so broken GUI builds cannot merge unnoticed. From 15fa571554d8eda59b91ca05a931dcb98c2c8a2c Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 08:54:51 +0900 Subject: [PATCH 39/50] fix(container): retain routed catalog across managed recreation [skip ci] Real Docker CI exposed foreground shutdown stripping routed catalog rows despite persistent volumes. Declare the existing service lifecycle mode in the runtime image so Docker-managed stop/recreate preserves routed state. Keep the routed fixture and exact-byte checks. Two independent lifecycle/security reviews accepted this bounded packaging change; isolated CLI before/after reproduced the defect and preservation with service mode. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- Dockerfile | 3 +++ .../025_container_lifecycle_mode.md | 10 ++++++++++ docs-site/src/content/docs/guides/remote-hub.md | 6 +++++- scripts/ci/docker-smoke.ts | 1 + tests/service/container-bootstrap.test.ts | 1 + 5 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 devlog/_plan/260907_platform_validation/025_container_lifecycle_mode.md diff --git a/Dockerfile b/Dockerfile index 1ed000743d..5987bfa991 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,10 @@ RUN cd gui && bun run build FROM ${BUN_IMAGE} AS runtime WORKDIR /home/bun/app +# Docker supervises this foreground process; retain routed state on stop/recreate. +# This uses the existing service lifecycle mode and does not install a service manager. ENV NODE_ENV=production \ + OCX_SERVICE=1 \ OPENCODEX_HOME=/home/bun/.opencodex \ CODEX_HOME=/home/bun/.codex \ OCX_API_TOKEN_FILE=/home/bun/.opencodex/service-api-token diff --git a/devlog/_plan/260907_platform_validation/025_container_lifecycle_mode.md b/devlog/_plan/260907_platform_validation/025_container_lifecycle_mode.md new file mode 100644 index 0000000000..c86f4fc765 --- /dev/null +++ b/devlog/_plan/260907_platform_validation/025_container_lifecycle_mode.md @@ -0,0 +1,10 @@ +# Container lifecycle mode + +Amendment after real Docker recreation verification. Docker supervises the foreground hub and must retain persisted routed state across replacement. + +MODIFY Dockerfile runtime ENV: set existing OCX_SERVICE=1, with no service manager installation or privilege change. Preserve image digest, foreground CMD, listener authentication, separate writable homes and read-only root. +MODIFY scripts/ci/docker-smoke.ts: assert the actual container process receives service lifecycle mode. Retain the routed synthetic slug and exact token/catalog/config hashes across graceful recreation. +MODIFY tests/service/container-bootstrap.test.ts: include the runtime ENV declaration in the existing packaging contract. +MODIFY docs-site/src/content/docs/guides/remote-hub.md: document service-mode foreground lifecycle, Compose restart/recreation, and the limit on other dashboard restart paths. + +Independent Astra high lifecycle/security review accepted the bounded packaging change. Actual remote CLI comparison confirmed preservation with service mode. Final image CI must prove the same real container lifecycle; no local tests or Docker execution. This does not change shared CLI cleanup, restart policy, or authentication code. diff --git a/docs-site/src/content/docs/guides/remote-hub.md b/docs-site/src/content/docs/guides/remote-hub.md index 2334303be7..460fe4651d 100644 --- a/docs-site/src/content/docs/guides/remote-hub.md +++ b/docs-site/src/content/docs/guides/remote-hub.md @@ -167,7 +167,11 @@ opencodex does not publish an official container image. The repository does main [`compose.yaml`](https://github.com/lidge-jun/opencodex/blob/main/compose.yaml), and a narrow `.dockerignore`. The build pins the multi-platform Bun 1.4.0 image index by digest, runs the proxy as the non-root `bun` user, keeps the root filesystem read-only, drops Linux capabilities, and publishes -only the data listener on the host's `127.0.0.1:10100` by default. +only the data listener on the host's `127.0.0.1:10100` by default. The foreground process uses +`OCX_SERVICE=1`, so stopping or recreating the container preserves routed Codex state instead +of restoring a native desktop configuration. Docker supplies supervision; no OS service manager +is installed in the image. Use Compose to restart/recreate the container; this does not extend +support to every dashboard restart path. The image seeds a first-run `hub` configuration that binds the container listener to `0.0.0.0`. Before the first normal start, stream a freshly generated data-plane token into the bootstrap helper. diff --git a/scripts/ci/docker-smoke.ts b/scripts/ci/docker-smoke.ts index e26b11de34..c2a7ec0ee2 100644 --- a/scripts/ci/docker-smoke.ts +++ b/scripts/ci/docker-smoke.ts @@ -190,6 +190,7 @@ const stateProbe = ` if (!['seed', 'first-ready', 'steady'].includes(phase)) throw new Error('invalid state phase'); ${fixtureConfigCheck} const homes = ['/home/bun/.opencodex', '/home/bun/.codex']; + if (process.env.OCX_SERVICE !== '1') throw new Error('image service lifecycle mode missing'); const uid = process.getuid(); if (uid === 0) throw new Error('root user'); const status = readFileSync('/proc/self/status', 'utf8'); diff --git a/tests/service/container-bootstrap.test.ts b/tests/service/container-bootstrap.test.ts index ada807d1d0..9eec8edd6b 100644 --- a/tests/service/container-bootstrap.test.ts +++ b/tests/service/container-bootstrap.test.ts @@ -63,6 +63,7 @@ describe("container deployment contract", () => { const runtime = readFileSync(repoPath("Dockerfile"), "utf8").split(" AS runtime")[1]!; expect(runtime).toContain("OPENCODEX_HOME=/home/bun/.opencodex"); expect(runtime).toContain("CODEX_HOME=/home/bun/.codex"); + expect(runtime).toContain("OCX_SERVICE=1"); expect(runtime).toContain("install -d -m 0700 -o bun -g bun /home/bun/.opencodex /home/bun/.codex"); expect(runtime).toContain('VOLUME ["/home/bun/.opencodex", "/home/bun/.codex"]'); expect(runtime).toContain("USER bun"); From 6f2ad1ef32c113ff5827c58959c85cefa923b15c Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:52:43 +0900 Subject: [PATCH 40/50] ci: select explicit-file typecheck mode for TypeScript 7 [skip ci] Final run 34064754947 passed root typechecks but rejected the standalone smoke-script command with TS5112. Explicit --ignoreConfig retains all strict script flags and preserves the existing root/project checks. Co-authored-by: Buseong Kim Co-authored-by: Ingwannu --- .github/workflows/ci.yml | 2 +- tests/ci-workflows/ci-workflows.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6c0e7ab1a..00abe5ec24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -431,7 +431,7 @@ jobs: run: | bun x tsc --noEmit bun x tsc --noEmit -p tests/tsconfig.doctor-service-memory-contract.json - bun x tsc --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts + bun x tsc --ignoreConfig --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts - name: GUI tests run: cd gui && bun test --isolate tests diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 77192bbb48..c967f9d4c0 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -601,7 +601,7 @@ describe("GitHub Actions hardening", () => { expect(ci.jobs?.ci?.needs).toContain("docker-smoke"); const typecheck = ci.jobs?.gates?.steps?.find(step => step.name === "Typecheck"); expect(hasExactShellCommand(typecheck?.run, - "bun x tsc --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts", + "bun x tsc --ignoreConfig --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts", )).toBe(true); }); From a3c2eb56216ac4f3331e7bc4b43b47e1d5d2ac6c Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 09:15:21 +0900 Subject: [PATCH 41/50] fix(anthropic): expire retained quota measurements at known resets [skip ci] Address #3825 review discussion_r3945728864. Retained standard and model-specific measurements become unknown after their known reset, including idle reads, hydration, persistence and joined failed probes. Reset-only headers cannot renew old usage. Keep unknown-reset behavior, probe clocks, unavailability and credential policy unchanged. Add real quota-evidence/manual-selection and persistence regressions; no local suites run per maintainer instruction. Original #3809 credit remains in ancestor f215f79b4. --- .../src/content/docs/guides/claude-code.md | 4 +- .../docs/reference/configuration/providers.md | 7 +- src/providers/quota.ts | 67 ++++- .../anthropic-ratelimit-headers.test.ts | 237 +++++++++++++++++- 4 files changed, 299 insertions(+), 16 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 2e841e205c..720a303b00 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -36,7 +36,9 @@ Operational contract when enabled: two the response carries is recorded for that account — each window independently, and a refusal counts as well as a success. Usage-aware selection works from ordinary traffic, without waiting for a dashboard poll. Headers preserve model-specific quota windows and do - not postpone usage probes or clear a failed usage probe's unavailable status. + not postpone usage probes or clear a failed usage probe's unavailable status. Measurements + whose known reset time has passed are discarded as unknown, including retained model-specific + windows. Values without a known reset are preserved; missing data is never reported as zero usage. - Affinity is **process-local** (lost on proxy restart). - **401/403** credential failures quarantine the account (`needsReauth`) so it is excluded from selection until re-authenticated. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c80176868e..710c069b24 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -446,7 +446,12 @@ of those two a given response carries is recorded against that account — each on refusals as well as successes. Usage-aware selection therefore works from the accounts you actually use, without waiting for the dashboard Providers page to poll them. These readings refresh the existing row rather than replacing it, so the model-scoped weekly bars that only the usage -endpoint reports are preserved. Header observations do not postpone usage probes or clear a failed +endpoint reports are preserved until their known reset time passes. Expired measurements become +unknown, including retained standard windows omitted by later headers. A reset-only header cannot +extend an older utilization measurement. Values with no known reset retain their existing behavior; +missing measurements are never replaced with zero usage. + +Header observations do not postpone usage probes or clear a failed probe's unavailable status. After restart, cached Anthropic observations remain available while the next quota read probes again, because the saved observations do not include the probe clock. diff --git a/src/providers/quota.ts b/src/providers/quota.ts index dbdb6699f7..5dcb69beb3 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1552,6 +1552,37 @@ type AccountQuotaCacheEntry = { identity?: string; isCurrent?: () => boolean; }; +/** Expired measurements become unknown; missing reset evidence never implies a fresh allowance. */ +function normalizeAnthropicQuota(quota: ProviderQuota | null | undefined, now: number): ProviderQuota | null { + if (!quota) return null; + const expired = (resetAt: number | undefined): boolean => typeof resetAt === "number" + && Number.isFinite(resetAt) && resetAt > 0 && resetAt <= now; + let result = quota; + for (const [percent, reset] of [ + ["fiveHourPercent", "fiveHourResetAt"], + ["weeklyPercent", "weeklyResetAt"], + ["monthlyPercent", "monthlyResetAt"], + ] as const) { + if (!expired(quota[reset])) continue; + if (result === quota) result = { ...quota }; + delete result[percent]; + delete result[reset]; + } + // Persisted rows validate only the outer quota object, so custom data may be malformed. + if (quota.customWindows !== undefined) { + const windows = Array.isArray(quota.customWindows) ? quota.customWindows : []; + const retained = windows.filter(window => window !== null && typeof window === "object" + && typeof window.label === "string" && typeof window.percent === "number" + && Number.isFinite(window.percent) && !expired(window.resetAt)); + if (!Array.isArray(quota.customWindows) || retained.length !== windows.length) { + if (result === quota) result = { ...quota }; + if (retained.length) result.customWindows = retained; + else delete result.customWindows; + } + } + return hasQuotaRows(result) ? result : null; +} + const accountQuotaCache = new Map(); let explicitAccountEpoch = 0; @@ -1570,15 +1601,21 @@ function hydrateAccountQuotaCache(): void { for (const [key, quota] of readPersistedAccountQuotas()) { // Disk stores observation time, not the Anthropic usage probe's clock. if (!accountQuotaCache.has(key)) { - accountQuotaCache.set(key, { ts: key.startsWith("anthropic\u0000") ? 0 : quota.updatedAt, quota }); + const anthropic = key.startsWith("anthropic\u0000"); + accountQuotaCache.set(key, { + ts: anthropic ? 0 : quota.updatedAt, + quota: anthropic ? normalizeAnthropicQuota(quota, Date.now()) : quota, + }); } } } function persistAccountQuotaCache(): void { schedulePersistAccountQuotas(function* () { + const now = Date.now(); for (const [key, entry] of accountQuotaCache) { - if (entry.quota) yield [key, entry.quota] as [string, ProviderQuota]; + const quota = key.startsWith("anthropic\u0000") ? normalizeAnthropicQuota(entry.quota, now) : entry.quota; + if (quota) yield [key, quota] as [string, ProviderQuota]; } }); } @@ -1628,7 +1665,7 @@ function accountCacheKey(provider: string, accountId: string): string { export function getCachedProviderAccountQuota(provider: string, accountId: string): ProviderQuota | null { const entry = accountQuotaCache.get(accountCacheKey(provider, accountId)); if (entry?.isCurrent && !entry.isCurrent()) return null; - return entry?.quota ?? null; + return provider === "anthropic" ? normalizeAnthropicQuota(entry?.quota, Date.now()) : entry?.quota ?? null; } /** Test-only: seed or clear the per-account quota cache without probing upstream. */ @@ -1661,9 +1698,9 @@ export function parseAnthropicRateLimitHeaders(headers: Headers): ProviderQuota const weeklyResetAt = anthropicHeaderResetAt(headers.get("anthropic-ratelimit-unified-7d-reset")); return { ...(fiveHourPercent !== undefined ? { fiveHourPercent } : {}), - ...(fiveHourResetAt !== undefined ? { fiveHourResetAt } : {}), + ...(fiveHourPercent !== undefined && fiveHourResetAt !== undefined ? { fiveHourResetAt } : {}), ...(weeklyPercent !== undefined ? { weeklyPercent } : {}), - ...(weeklyResetAt !== undefined ? { weeklyResetAt } : {}), + ...(weeklyPercent !== undefined && weeklyResetAt !== undefined ? { weeklyResetAt } : {}), updatedAt: Date.now(), }; } @@ -1700,7 +1737,9 @@ export function recordAnthropicAccountQuotaFromHeaders( ...previous, // Headers do not prove that the last usage probe succeeded. ts: previous?.ts ?? 0, - quota: { ...(previous?.quota ?? {}), ...observed }, + quota: normalizeAnthropicQuota({ + ...normalizeAnthropicQuota(previous?.quota, observed.updatedAt), ...observed, + }, observed.updatedAt), }); persistAccountQuotaCache(); } @@ -1978,7 +2017,9 @@ async function fetchAccountQuota( const key = accountCacheKey(provider, accountId); const writerGeneration = captureConfigGeneration(); const cached = accountQuotaCache.get(key); - if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) return cached; + if (!forceRefresh && cached && Date.now() - cached.ts < ACCOUNT_QUOTA_TTL_MS) { + return provider === "anthropic" ? { ...cached, quota: normalizeAnthropicQuota(cached.quota, Date.now()) } : cached; + } const joinable = accountQuotaInflight.get(key); if (joinable) return joinable; @@ -2016,7 +2057,8 @@ async function fetchAccountQuota( const entry: AccountQuotaCacheEntry = { ts: Date.now(), // Settle once for all joiners against observations committed during the probe. - quota: (provider === "anthropic" ? accountQuotaCache.get(key)?.quota : cached?.quota) ?? null, + quota: provider === "anthropic" + ? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null, unavailable: true, }; if (mayCommitAccountQuotaKey(key, writerGeneration)) { @@ -2026,7 +2068,9 @@ async function fetchAccountQuota( } return entry; } - const entry: AccountQuotaCacheEntry = { ts: Date.now(), quota }; + const entry: AccountQuotaCacheEntry = { + ts: Date.now(), quota: provider === "anthropic" ? normalizeAnthropicQuota(quota, Date.now()) : quota, + }; if (mayCommitAccountQuotaKey(key, writerGeneration)) { accountQuotaCache.set(key, entry); // Exhaustion state rides the SAME commit guard as the quota row: a probe from a @@ -2038,7 +2082,8 @@ async function fetchAccountQuota( } catch { const entry: AccountQuotaCacheEntry = { ts: Date.now(), - quota: (provider === "anthropic" ? accountQuotaCache.get(key)?.quota : cached?.quota) ?? null, + quota: provider === "anthropic" + ? normalizeAnthropicQuota(accountQuotaCache.get(key)?.quota, Date.now()) : cached?.quota ?? null, unavailable: true, }; if (mayCommitAccountQuotaKey(key, writerGeneration)) { @@ -2070,7 +2115,7 @@ export async function fetchProviderAccountQuotas( const entry = await fetchAccountQuota(provider, account.id, forceRefresh, providerConfig); const result: ProviderAccountQuota = { accountId: account.id, - quota: entry.quota, + quota: provider === "anthropic" ? normalizeAnthropicQuota(entry.quota, Date.now()) : entry.quota, ...(entry.unavailable ? { unavailable: true as const } : {}), }; if (!explicitAccountReader(provider)) return result; diff --git a/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts b/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts index 21ae2d8489..77989064ff 100644 --- a/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts +++ b/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts @@ -1,6 +1,6 @@ /** Anthropic response observations must preserve account usage and probe semantics. */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -9,8 +9,11 @@ import { forgetAnthropicFailoverQuorum, getAnthropicAccountHealthSnapshot, rotateAnthropicAccountOn429, + resetAnthropicRoutingForManualSelection, + resolveAnthropicAccountForSession, } from "../../../src/oauth/anthropic-routing"; import { projectStoredOAuthAccountHealth } from "../../../src/oauth/health"; +import { quotaEvidenceForCandidate } from "../../../src/routing/quota"; import { clearAccountQuotaCache, fetchProviderAccountQuotas, @@ -22,7 +25,7 @@ import { setCachedProviderAccountQuotaForTests, sweepExpiredProviderAccountQuotaRows, } from "../../../src/providers/quota"; -import { getAccountSet, saveCredential } from "../../../src/oauth/store"; +import { getAccountSet, saveCredential, setActiveAccount } from "../../../src/oauth/store"; import { clearPoolRotationState } from "../../../src/codex/pool-rotation"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; import type { OcxConfig } from "../../../src/types"; @@ -526,3 +529,231 @@ describe("Anthropic malformed deadlines and partial windows", () => { }); }); }); + +describe("Anthropic known-reset expiry", () => { + const start = 1_800_000_000_000; + let now: number; + + beforeEach(() => { + now = start; + Date.now = () => now; + }); + + function observe(id: string, headers: Record = { + "anthropic-ratelimit-unified-5h-utilization": "0.41", + }): void { + recordAnthropicAccountQuotaFromHeaders(id, new Headers(headers), 0); + } + + test("headers expire only known elapsed custom windows without mutating their source", async () => { + const [id] = await seed(1); + const saved = { + fiveHourPercent: 10, + customWindows: [ + { label: "Opus", percent: 100, resetAt: start + 60_000 }, + { label: "Sonnet", percent: 90, resetAt: start + 600_000 }, + { label: "Fable", percent: 70 }, + { label: "Unknown reset", percent: 60, resetAt: 0 }, + ], + updatedAt: start, + }; + setCachedProviderAccountQuotaForTests("anthropic", id!, saved); + now += 120_000; + observe(id!); + const quota = getCachedProviderAccountQuota("anthropic", id!); + expect(quota?.customWindows).toEqual(saved.customWindows.slice(1)); + expect(quota?.fiveHourPercent).toBe(41); + expect(quota?.updatedAt).toBe(now); + expect(saved.customWindows).toHaveLength(4); + expect(saved.updatedAt).toBe(start); + now += 30_000; + observe(id!); + expect(getCachedProviderAccountQuota("anthropic", id!)?.customWindows).toEqual(saved.customWindows.slice(1)); + }); + + for (const [percent, reset, observedWindow] of [ + ["fiveHourPercent", "fiveHourResetAt", "7d"], + ["weeklyPercent", "weeklyResetAt", "5h"], + ["monthlyPercent", "monthlyResetAt", "5h"], + ] as const) { + test(`partial headers remove the expired ${percent} pair without inventing zero`, async () => { + const [id] = await seed(1); + setCachedProviderAccountQuotaForTests("anthropic", id!, { + [percent]: 100, [reset]: start + 60_000, updatedAt: start, + }); + now += 60_000; + observe(id!, { [`anthropic-ratelimit-unified-${observedWindow}-utilization`]: "0.2" }); + const quota = getCachedProviderAccountQuota("anthropic", id!); + expect(quota).not.toBeNull(); + expect(quota?.[percent]).toBeUndefined(); + expect(quota?.[reset]).toBeUndefined(); + }); + } + + test("standard windows without reset evidence remain known", async () => { + const [id] = await seed(1); + setCachedProviderAccountQuotaForTests("anthropic", id!, { weeklyPercent: 100, updatedAt: start }); + now += 120_000; + observe(id!); + expect(getCachedProviderAccountQuota("anthropic", id!)?.weeklyPercent).toBe(100); + }); + + test("a reset-only header cannot extend retained usage even before the original reset", async () => { + const [id] = await seed(1); + setCachedProviderAccountQuotaForTests("anthropic", id!, { + fiveHourPercent: 10, weeklyPercent: 100, weeklyResetAt: start + 60_000, updatedAt: start, + }); + now += 30_000; + observe(id!, { + "anthropic-ratelimit-unified-5h-utilization": "0.2", + "anthropic-ratelimit-unified-7d-utilization": "invalid", + "anthropic-ratelimit-unified-7d-reset": String((start + 600_000) / 1000), + }); + expect(getCachedProviderAccountQuota("anthropic", id!)?.weeklyResetAt).toBe(start + 60_000); + now += 30_000; + expect(getCachedProviderAccountQuota("anthropic", id!)?.weeklyPercent).toBeUndefined(); + expect(getCachedProviderAccountQuota("anthropic", id!)?.weeklyResetAt).toBeUndefined(); + observe(id!, { + "anthropic-ratelimit-unified-7d-utilization": "0.3", + "anthropic-ratelimit-unified-7d-reset": String((start + 600_000) / 1000), + }); + expect(getCachedProviderAccountQuota("anthropic", id!)).toMatchObject({ + weeklyPercent: 30, weeklyResetAt: start + 600_000, + }); + }); + + test("idle cache reads cross a reset without another observation or probe", async () => { + const [id] = await seed(1); + const quota = { customWindows: [{ label: "Opus", percent: 100, resetAt: start + 60_000 }], updatedAt: start }; + setCachedProviderAccountQuotaForTests("anthropic", id!, quota); + setCachedProviderAccountQuotaForTests("kiro", "untouched", quota); + const candidate = { provider: "anthropic", model: "claude-opus-4-6", accountRef: id! }; + now += 59_999; + expect(getCachedProviderAccountQuota("anthropic", id!)).toEqual(quota); + expect(quotaEvidenceForCandidate(candidate)).toMatchObject({ known: true, exhausted: true, headroom: 0 }); + now++; + expect(getCachedProviderAccountQuota("anthropic", id!)).toBeNull(); + expect(quotaEvidenceForCandidate(candidate)).toEqual({ known: false }); + const [row] = await fetchProviderAccountQuotas("anthropic"); + expect(row?.quota).toBeNull(); + expect(row?.unavailable).toBeUndefined(); + expect(getCachedProviderAccountQuota("kiro", "untouched")).toBe(quota); + }); + + test("expired Opus evidence stops suppressing an otherwise healthy manual selection", async () => { + const [a, b] = await seed(2); + setCachedProviderAccountQuotaForTests("anthropic", a!, { + fiveHourPercent: 30, customWindows: [{ label: "Opus", percent: 100, resetAt: start + 60_000 }], updatedAt: start, + }); + setCachedProviderAccountQuotaForTests("anthropic", b!, { fiveHourPercent: 11, updatedAt: start }); + await setActiveAccount("anthropic", a!); + resetAnthropicRoutingForManualSelection(a!); + const config = poolEnabled(); + config.anthropicAccountPool = { enabled: true, strategy: "quota", autoSwitchThreshold: 20 }; + const candidate = { provider: "anthropic", model: "claude-opus-4-6", accountRef: a! }; + expect(resolveAnthropicAccountForSession(null, config, now).accountId).toBe(b); + expect(quotaEvidenceForCandidate(candidate)).toMatchObject({ known: true, exhausted: true, headroom: 0 }); + now += 60_000; + expect(resolveAnthropicAccountForSession(null, config, now)).toMatchObject({ accountId: a, reason: "manual" }); + expect(quotaEvidenceForCandidate(candidate)).toMatchObject({ known: true, exhausted: false, headroom: 0.7 }); + }); + + for (const failure of ["http", "network"] as const) { + test(`joined ${failure} failures remove windows expiring during the shared probe`, async () => { + const [id] = await seed(1); + setCachedProviderAccountQuotaForTests("anthropic", id!, { + fiveHourPercent: 10, weeklyPercent: 100, weeklyResetAt: start + 60_000, + customWindows: [{ label: "Opus", percent: 100, resetAt: start + 60_000 }, { label: "Fable", percent: 63 }], + updatedAt: start, + }); + let started!: () => void; + const dispatched = new Promise(resolve => { started = resolve; }); + let finish!: (response: Response) => void; + let fail!: (error: Error) => void; + const response = new Promise((resolve, reject) => { finish = resolve; fail = reject; }); + let calls = 0; + globalThis.fetch = (async () => { calls++; started(); return response; }) as typeof fetch; + const first = fetchProviderAccountQuotas("anthropic", true); + await dispatched; + const second = fetchProviderAccountQuotas("anthropic", true); + now += 30_000; + observe(id!); + now += 30_000; + if (failure === "http") finish(new Response("busy", { status: 429 })); + else fail(new Error("offline")); + const [a, b] = await Promise.all([first, second]); + expect(calls).toBe(1); + expect(a).toEqual(b); + expect(a[0]?.unavailable).toBe(true); + expect(a[0]?.quota).toEqual({ fiveHourPercent: 41, customWindows: [{ label: "Fable", percent: 63 }], updatedAt: start + 30_000 }); + expect(getCachedProviderAccountQuota("anthropic", id!)).toEqual(a[0]?.quota); + expect((await fetchProviderAccountQuotas("anthropic"))[0]).toEqual(a[0]); + expect(calls).toBe(1); + }); + } + + test("restart cannot revive expired bars from a recently updated disk row", async () => { + const [id] = await seed(1); + now += 120_000; + writeFileSync(join(home, "provider-account-quota-cache.json"), JSON.stringify({ version: 1, rows: { + [`anthropic\u0000${id}`]: { + fiveHourPercent: 41, weeklyPercent: 100, weeklyResetAt: start + 60_000, + customWindows: [{ label: "Opus", percent: 100, resetAt: start + 60_000 }, { label: "Fable", percent: 63 }], + updatedAt: now, + }, + } })); + clearAccountQuotaCache(); + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response("busy", { status: 429 }); }) as typeof fetch; + const [row] = await fetchProviderAccountQuotas("anthropic"); + expect(calls).toBe(1); + expect(row?.unavailable).toBe(true); + expect(row?.quota).toEqual({ fiveHourPercent: 41, customWindows: [{ label: "Fable", percent: 63 }], updatedAt: now }); + }); + + for (const malformed of [null, {}, [null, "bad", { label: "invalid", percent: "100" }]]) { + test(`malformed persisted custom windows stay unknown without breaking other rows: ${JSON.stringify(malformed)}`, async () => { + const [id] = await seed(1); + writeFileSync(join(home, "provider-account-quota-cache.json"), JSON.stringify({ version: 1, rows: { + [`anthropic\u0000${id}`]: { customWindows: malformed, updatedAt: now }, + "kiro\u0000untouched": { monthlyPercent: 17, updatedAt: now }, + } })); + clearAccountQuotaCache(); + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response("busy", { status: 429 }); }) as typeof fetch; + const [row] = await fetchProviderAccountQuotas("anthropic"); + expect(calls).toBe(1); + expect(row?.quota).toBeNull(); + expect(row?.unavailable).toBe(true); + expect(getCachedProviderAccountQuota("kiro", "untouched")).toEqual({ monthlyPercent: 17, updatedAt: now }); + }); + } + + test("fresh utilization without a reset does not inherit an expired reset", async () => { + const [id] = await seed(1); + setCachedProviderAccountQuotaForTests("anthropic", id!, { + fiveHourPercent: 100, fiveHourResetAt: start + 60_000, updatedAt: start, + }); + now += 60_000; + observe(id!); + expect(getCachedProviderAccountQuota("anthropic", id!)).toEqual({ fiveHourPercent: 41, updatedAt: now }); + }); + + test("deferred persistence evaluates expiry at write time and leaves other providers intact", async () => { + const [id] = await seed(1); + const saved = { weeklyPercent: 100, weeklyResetAt: start + 60_000, updatedAt: start }; + setCachedProviderAccountQuotaForTests("anthropic", id!, saved); + setCachedProviderAccountQuotaForTests("kiro", "untouched", saved); + let flush!: () => void; + const timer = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + flush = callback; + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + try { observe(id!); } finally { timer.mockRestore(); } + now += 60_000; + flush(); + const disk = JSON.parse(readFileSync(join(home, "provider-account-quota-cache.json"), "utf8")); + expect(disk.rows[`anthropic\u0000${id}`]).toEqual({ fiveHourPercent: 41, updatedAt: start }); + expect(disk.rows["kiro\u0000untouched"]).toEqual(saved); + }); +}); From 54fcc688d7b23677892a01803fc07663fbdd2074 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 09:22:08 +0900 Subject: [PATCH 42/50] test(anthropic): keep probe fixtures inside live reset windows [skip ci] The attribution/cache tests used July 2026 quota reset dates, which correctly expire under the known-reset fix. Generate future reset dates from one clock snapshot; preserve all existing assertions. Explicit expiry tests retain fixed simulated boundaries. No local suites run. --- tests/providers/provider-account-quota.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/providers/provider-account-quota.test.ts b/tests/providers/provider-account-quota.test.ts index 989f55a410..1b940d71d0 100644 --- a/tests/providers/provider-account-quota.test.ts +++ b/tests/providers/provider-account-quota.test.ts @@ -33,9 +33,11 @@ async function seedTwoAccounts(): Promise { } function usageBody(fiveHour: number, sevenDay: number): string { + // These tests exercise current account measurements, not expired historical windows. + const now = Date.now(); return JSON.stringify({ - five_hour: { utilization: fiveHour, resets_at: "2026-07-05T12:00:00Z" }, - seven_day: { utilization: sevenDay, resets_at: "2026-07-08T12:00:00Z" }, + five_hour: { utilization: fiveHour, resets_at: new Date(now + 5 * 60 * 60_000).toISOString() }, + seven_day: { utilization: sevenDay, resets_at: new Date(now + 7 * 24 * 60 * 60_000).toISOString() }, }); } From d3c70f9d8c8cc6fced7a93577b93e8b141473ea3 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 09:44:37 +0900 Subject: [PATCH 43/50] fix(anthropic): normalize retained quota metadata and guard test transport [skip ci] Reject unusable custom labels/percentages, omit invalid reset metadata while retaining valid usage, and state the existing 60-second fallback. Guard unexpected test network calls with teardown-safe restoration. No new TTL or synthetic quota values. Addresses #3825 review findings; no local suite was run. --- .../src/content/docs/guides/claude-code.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- src/providers/quota.ts | 38 ++++++++--- .../anthropic-quota-dispatch.test.ts | 64 +++++++++++++------ .../anthropic-ratelimit-headers.test.ts | 63 +++++++++++++++++- 5 files changed, 137 insertions(+), 32 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 720a303b00..4c5cc44b34 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -31,7 +31,7 @@ Operational contract when enabled: account within the same request (bounded). The cooldown uses a usable `Retry-After` when present, otherwise the latest valid reset time among windows Anthropic marks `rejected`, including weekly windows. Valid upstream deadlines are not shortened to a fixed cooldown ceiling. - A refusal with no usable deadline falls back to a default backoff. + A refusal with no usable deadline falls back to a 60-second default backoff. - Responses report the serving account's 5-hour and weekly utilization, and whichever of those two the response carries is recorded for that account — each window independently, and a refusal counts as well as a success. Usage-aware selection works from ordinary traffic, diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 710c069b24..b4d7cb2094 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -437,7 +437,7 @@ When enabled, 429 records a cooldown and may rotate within the request. The cool from a usable `Retry-After`, otherwise from the latest valid reset time among rate-limit windows Anthropic reports as `rejected`, including weekly windows. Valid upstream deadlines are not shortened to a fixed cooldown ceiling; non-finite or unrepresentable deadlines are ignored. -A refusal with no usable deadline falls back to a short default backoff. Affinity is process-local +A refusal with no usable deadline falls back to a 60-second default backoff. Affinity is process-local and size-bounded. Credential 401/403 marks the account as needing reauthentication. If all eligible accounts are cooling, clients receive 429 with `Retry-After` when known, not an authentication error. diff --git a/src/providers/quota.ts b/src/providers/quota.ts index 5dcb69beb3..71644a9eae 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1555,26 +1555,48 @@ type AccountQuotaCacheEntry = { /** Expired measurements become unknown; missing reset evidence never implies a fresh allowance. */ function normalizeAnthropicQuota(quota: ProviderQuota | null | undefined, now: number): ProviderQuota | null { if (!quota) return null; - const expired = (resetAt: number | undefined): boolean => typeof resetAt === "number" - && Number.isFinite(resetAt) && resetAt > 0 && resetAt <= now; + const validReset = (resetAt: unknown): resetAt is number => typeof resetAt === "number" + && Number.isFinite(resetAt) && resetAt > 0 && Number.isFinite(new Date(resetAt).getTime()); let result = quota; for (const [percent, reset] of [ ["fiveHourPercent", "fiveHourResetAt"], ["weeklyPercent", "weeklyResetAt"], ["monthlyPercent", "monthlyResetAt"], ] as const) { - if (!expired(quota[reset])) continue; + const resetAt = quota[reset]; + if (resetAt === undefined) continue; + const valid = validReset(resetAt); + if (valid && resetAt > now) continue; if (result === quota) result = { ...quota }; - delete result[percent]; + if (valid) delete result[percent]; delete result[reset]; } // Persisted rows validate only the outer quota object, so custom data may be malformed. if (quota.customWindows !== undefined) { const windows = Array.isArray(quota.customWindows) ? quota.customWindows : []; - const retained = windows.filter(window => window !== null && typeof window === "object" - && typeof window.label === "string" && typeof window.percent === "number" - && Number.isFinite(window.percent) && !expired(window.resetAt)); - if (!Array.isArray(quota.customWindows) || retained.length !== windows.length) { + const retained: ProviderQuotaWindow[] = []; + let changed = !Array.isArray(quota.customWindows); + for (const window of windows) { + if (!window || typeof window !== "object" || typeof window.label !== "string" || !window.label.trim() + || typeof window.percent !== "number" || !Number.isFinite(window.percent) + || window.percent < 0 || window.percent > 100) { + changed = true; + continue; + } + if (validReset(window.resetAt) && window.resetAt <= now) { + changed = true; + continue; + } + if (window.resetAt !== undefined && !validReset(window.resetAt)) { + const normalized = { ...window }; + delete normalized.resetAt; + retained.push(normalized); + changed = true; + } else { + retained.push(window); + } + } + if (changed) { if (result === quota) result = { ...quota }; if (retained.length) result.customWindows = retained; else delete result.customWindows; diff --git a/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts b/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts index 6442436fbb..09952ea5ab 100644 --- a/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts +++ b/tests/adapters/anthropic/anthropic-quota-dispatch.test.ts @@ -13,10 +13,19 @@ import type { OcxConfig, OcxProviderConfig } from "../../../src/types"; import { removeTreeWithRetry } from "../../helpers/remove-tree"; const originalHome = process.env.OPENCODEX_HOME; +let originalFetch: typeof globalThis.fetch; +let unexpectedGlobalFetches = 0; let home: string; let sent: { authorization: string | null; apiKey: string | null; body: Record }[]; beforeEach(() => { + home = ""; + originalFetch = globalThis.fetch; + unexpectedGlobalFetches = 0; + globalThis.fetch = (async () => { + unexpectedGlobalFetches += 1; + throw new Error("Unexpected global fetch in Anthropic quota dispatch test"); + }) as typeof fetch; home = mkdtempSync(join(tmpdir(), "ocx-anthropic-quota-dispatch-")); process.env.OPENCODEX_HOME = home; sent = []; @@ -29,16 +38,25 @@ beforeEach(() => { }); afterEach(() => { - clearAnthropicAccountPoolState(); - forgetAnthropicFailoverQuorum(); - clearGenericFailoverHealth(); - // Cancel the debounced persistence before restoring the real home. - clearAccountQuotaCache(); - resetProviderQuotaReconcileStateForTests(); - clearResponseStateForTests(); - if (originalHome === undefined) delete process.env.OPENCODEX_HOME; - else process.env.OPENCODEX_HOME = originalHome; - removeTreeWithRetry(home); + try { + // Provider code may catch the guard's rejection; the attempted network call still fails the test. + expect(unexpectedGlobalFetches).toBe(0); + } finally { + try { + // Cancel the debounced persistence before restoring the real home. + clearAccountQuotaCache(); + clearAnthropicAccountPoolState(); + forgetAnthropicFailoverQuorum(); + clearGenericFailoverHealth(); + resetProviderQuotaReconcileStateForTests(); + clearResponseStateForTests(); + } finally { + globalThis.fetch = originalFetch; + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + if (home) removeTreeWithRetry(home); + } + } }); function credential(index: number) { @@ -136,8 +154,9 @@ test("main A429 -> B200 records both physical responses against their sending ac return answer(body.stream === true); }); const response = await post(config); + const responseText = await response.text(); expect(response.status).toBe(200); - expect(await response.text()).toContain("The answer is complete."); + expect(responseText).toContain("The answer is complete."); expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); expectQuota(a!, 100, 61); expectQuota(b!, 23, 47); @@ -151,8 +170,8 @@ test("terminal 429 after both accounts are exhausted records both refused physic expectQuota(a!, 100, 61); return limited("0.89", "1"); })); - expect(response.status).toBe(429); await response.text(); + expect(response.status).toBe(429); expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); expectQuota(a!, 100, 61); expectQuota(b!, 89, 100); @@ -165,15 +184,16 @@ test("manual active switch while A is pending keeps A's measurement off B", asyn const config = configFor(() => { entered.resolve(); return returned.promise; }); const pending = post(config); await entered.promise; + let response!: Response; try { expect(sent[0]!.authorization).toBe(`Bearer ${credential(0).access}`); expect(await setActiveAccount("anthropic", b!)).toBe(true); } finally { returned.resolve(answer(false, "0.37", "0.53")); + response = await pending; + await response.text(); } - const response = await pending; expect(response.status).toBe(200); - await response.text(); expect(sent).toHaveLength(1); expect(getAccountSet("anthropic")!.activeAccountId).toBe(b!); expectQuota(a!, 37, 53); @@ -186,15 +206,16 @@ test("credential replacement while A is pending skips its old-generation respons const returned = deferred(); const pending = post(configFor(() => { entered.resolve(); return returned.promise; })); await entered.promise; + let response!: Response; try { expect(sent[0]!.authorization).toBe(`Bearer ${credential(0).access}`); await saveAccountCredential("anthropic", a!, { ...credential(0), access: "synthetic-replacement-access", refresh: "synthetic-replacement-refresh" }); } finally { returned.resolve(answer(false)); + response = await pending; + await response.text(); } - const response = await pending; expect(response.status).toBe(200); - await response.text(); expect(sent).toHaveLength(1); expect(getAccountSet("anthropic")!.accounts.find(row => row.id === a)!.credential.access).toBe("synthetic-replacement-access"); expect(getCachedProviderAccountQuota("anthropic", a!)).toBeNull(); @@ -208,8 +229,8 @@ const overriddenHeaders: { label: string; headers: Record; autho test.each(overriddenHeaders)("$label skips quota attribution even when a selected OAuth account exists", async ({ headers, authorization, apiKey }) => { const ids = await seed(); const response = await post(configFor(body => answer(body.stream === true), headers)); - expect(response.status).toBe(200); await response.text(); + expect(response.status).toBe(200); expect(sent).toHaveLength(1); expect(sent[0]).toMatchObject({ authorization, apiKey }); for (const id of ids) expect(getCachedProviderAccountQuota("anthropic", id)).toBeNull(); @@ -227,8 +248,9 @@ test("real web-search routed loop records A429 and B200 through fetchForRequest" }); config.webSearchSidecar = { backend: "anthropic", enabled: true }; const response = await post(config, { tools: [{ type: "web_search" }] }); + const responseText = await response.text(); expect(response.status).toBe(200); - expect(await response.text()).toContain("The answer is complete."); + expect(responseText).toContain("The answer is complete."); expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); expectQuota(a!, 100, 61); expectQuota(b!, 23, 47); @@ -252,8 +274,9 @@ test("real terminal continuation records A429 before retrying the continuation o input: "Please modify the file now", tools: [{ type: "function", name: "read_file", description: "read a file", parameters: { type: "object" } }], }); + const responseText = await response.text(); expect(response.status).toBe(200); - expect(await response.text()).toContain("The answer is complete."); + expect(responseText).toContain("The answer is complete."); expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); expectQuota(a!, 100, 61); expectQuota(b!, 23, 47); @@ -275,8 +298,9 @@ test("real image bridge routed loop records A429 and B200 through fetchForReques adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "key", apiKey: "synthetic-image-key", }; const response = await post(config, { stream: true, tools: [{ type: "image_generation" }] }); + const responseText = await response.text(); expect(response.status).toBe(200); - expect(await response.text()).toContain("The answer is complete."); + expect(responseText).toContain("The answer is complete."); expect(sent.map(row => row.authorization)).toEqual([`Bearer ${credential(0).access}`, `Bearer ${credential(1).access}`]); expectQuota(a!, 100, 61); expectQuota(b!, 23, 47); diff --git a/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts b/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts index 77989064ff..f1ad70542e 100644 --- a/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts +++ b/tests/adapters/anthropic/anthropic-ratelimit-headers.test.ts @@ -561,14 +561,56 @@ describe("Anthropic known-reset expiry", () => { now += 120_000; observe(id!); const quota = getCachedProviderAccountQuota("anthropic", id!); - expect(quota?.customWindows).toEqual(saved.customWindows.slice(1)); + const retained = [saved.customWindows[1], saved.customWindows[2], { label: "Unknown reset", percent: 60 }]; + expect(quota?.customWindows).toEqual(retained); expect(quota?.fiveHourPercent).toBe(41); expect(quota?.updatedAt).toBe(now); expect(saved.customWindows).toHaveLength(4); expect(saved.updatedAt).toBe(start); now += 30_000; observe(id!); - expect(getCachedProviderAccountQuota("anthropic", id!)?.customWindows).toEqual(saved.customWindows.slice(1)); + expect(getCachedProviderAccountQuota("anthropic", id!)?.customWindows).toEqual(retained); + }); + + test("custom windows reject empty labels and invalid percentages while preserving valid objects", async () => { + const [id] = await seed(1); + const valid = [{ label: "Opus", percent: 0 }, { label: "Sonnet", percent: 100, resetAt: start + 60_000 }]; + const saved = { customWindows: [ + ...valid, + { label: "", percent: 50 }, { label: " ", percent: 50 }, + { label: "negative", percent: -1 }, { label: "too high", percent: 101 }, + { label: "not finite", percent: Number.NaN }, { label: "infinite", percent: Infinity }, + ], updatedAt: start }; + setCachedProviderAccountQuotaForTests("anthropic", id!, saved); + const normalized = getCachedProviderAccountQuota("anthropic", id!); + expect(normalized?.customWindows).toEqual(valid); + expect(normalized?.customWindows?.[0]).toBe(valid[0]); + expect(saved.customWindows).toHaveLength(8); + setCachedProviderAccountQuotaForTests("anthropic", id!, normalized!); + expect(getCachedProviderAccountQuota("anthropic", id!)).toBe(normalized); + }); + + test("invalid reset metadata is removed without discarding valid usage", async () => { + const [id] = await seed(1); + const invalidResets = [0, -1, Number.NaN, Infinity, 8_640_000_000_000_001]; + const saved = { + fiveHourPercent: 40, fiveHourResetAt: 0, + weeklyPercent: 50, weeklyResetAt: Infinity, + monthlyPercent: 60, monthlyResetAt: 8_640_000_000_000_001, + customWindows: invalidResets.map((resetAt, index) => ({ label: `window-${index}`, percent: 70, resetAt })), + updatedAt: start, + }; + setCachedProviderAccountQuotaForTests("anthropic", id!, saved); + const normalized = getCachedProviderAccountQuota("anthropic", id!); + expect(normalized).toEqual({ + fiveHourPercent: 40, weeklyPercent: 50, monthlyPercent: 60, + customWindows: invalidResets.map((_, index) => ({ label: `window-${index}`, percent: 70 })), + updatedAt: start, + }); + expect(saved.customWindows[0]?.resetAt).toBe(0); + expect(saved.fiveHourResetAt).toBe(0); + setCachedProviderAccountQuotaForTests("anthropic", id!, normalized!); + expect(getCachedProviderAccountQuota("anthropic", id!)).toBe(normalized); }); for (const [percent, reset, observedWindow] of [ @@ -729,6 +771,23 @@ describe("Anthropic known-reset expiry", () => { }); } + test("persisted nonnumeric reset metadata does not erase otherwise valid windows", async () => { + const [id] = await seed(1); + writeFileSync(join(home, "provider-account-quota-cache.json"), JSON.stringify({ version: 1, rows: { + [`anthropic\u0000${id}`]: { + weeklyPercent: 80, weeklyResetAt: "unknown", + customWindows: [{ label: "Opus", percent: 70, resetAt: null }, { label: "Sonnet", percent: 60, resetAt: "later" }], + updatedAt: now, + }, + } })); + clearAccountQuotaCache(); + globalThis.fetch = (async () => new Response("busy", { status: 429 })) as typeof fetch; + const [row] = await fetchProviderAccountQuotas("anthropic"); + expect(row?.quota).toEqual({ weeklyPercent: 80, + customWindows: [{ label: "Opus", percent: 70 }, { label: "Sonnet", percent: 60 }], updatedAt: now }); + expect(row?.unavailable).toBe(true); + }); + test("fresh utilization without a reset does not inherit an expired reset", async () => { const [id] = await seed(1); setCachedProviderAccountQuotaForTests("anthropic", id!, { From 872f0e5aa714f6a2e757510195d1c038ac70e26d Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:41:57 +0900 Subject: [PATCH 44/50] fix(cli): explain which side of a version mismatch is older [skip ci] Address the diagnostic residual of #3464. Keep raw mismatch and placeholder behavior, compare valid SemVer precedence, and avoid false doctor match claims. No automatic service repair or request-policy change. Local suites omitted by owner instruction; final cumulative hosted CI pending. Reported-by: garysassano <10464497+garysassano@users.noreply.github.com> --- .../docs/ko/reference/cli/lifecycle.md | 13 +++ .../content/docs/reference/cli/lifecycle.md | 20 ++++- .../docs/ru/reference/cli/lifecycle.md | 13 +++ src/cli/doctor.ts | 4 +- src/cli/version-skew.ts | 39 ++++++++- tests/cli/cli-status-json.test.ts | 82 ++++++++++++++++++- tests/cli/cli-version-skew.test.ts | 70 +++++++++++++++- tests/codex-integration/doctor.test.ts | 63 +++++++++++++- 8 files changed, 291 insertions(+), 13 deletions(-) diff --git a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md index 4847614674..068807025b 100644 --- a/docs-site/src/content/docs/ko/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ko/reference/cli/lifecycle.md @@ -82,6 +82,19 @@ dedicated-provider history도 포함됩니다. 상태를 백업하고 이 전체 ### `ocx status [--json]` +status와 `ocx doctor`는 현재 CLI와 실행 중인 프록시의 버전을 비교합니다. CLI가 더 새로우면 +원하는 최신 설치로 프록시를 재시작하십시오. 백그라운드 서비스라면 `ocx service repair`를 +실행합니다(`ocx service restart`는 별칭). 프록시가 더 새로우면 CLI를 업그레이드하거나 +`PATH`가 원하는 설치를 가리키도록 수정하십시오. 이 진단은 서비스를 복구하거나 요청 허용 +여부를 바꾸지 않습니다. + +버전 문자열이 같거나 어느 쪽이 `unknown` / `0.0.0`이면 경고하지 않으며, 프록시 버전이 없어도 +경고하지 않습니다. doctor는 placeholder를 버전 일치로 확정하지 않습니다. 엄격한 SemVer로 +해석할 수 없는 서로 다른 문자열이나 build metadata만 다른 버전은 어느 쪽이 오래됐다고 +단정하지 않는 중립 경고를 표시합니다. 공백을 제거하거나 앞의 `v`를 정규화하지 않습니다. +JSON의 `versionSkew`에도 같은 안내가 들어가며 필드는 `cliVersion`, `proxyVersion`, `skewed`, +`warning` 그대로입니다. + 읽기 전용 진단 요약을 출력합니다. 프록시 PID, `/healthz` 도달 가능 여부, 대시보드 URL, 설정 경로, 기본 공급자, Codex 자동 시작 설정, 서비스 상태, shim 상태, 그리고 마스킹된 실제로 적용되는 Codex 홈이 포함됩니다. 명시적이고 높은 신뢰도의 Windows Orca 런타임 홈 시그니처만 diff --git a/docs-site/src/content/docs/reference/cli/lifecycle.md b/docs-site/src/content/docs/reference/cli/lifecycle.md index e75a2b6241..0dda487b3a 100644 --- a/docs-site/src/content/docs/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/reference/cli/lifecycle.md @@ -88,6 +88,19 @@ are left in place. ### `ocx status [--json]` +Status and `ocx doctor` compare this CLI's version with the running proxy. If the CLI is newer, +restart the proxy using the intended current installation; for a background service, run +`ocx service repair` (`ocx service restart` is an alias). If the proxy is newer, upgrade the CLI +or resolve `PATH` to the intended installation. These diagnostics do not repair the service or +change whether requests are allowed. + +Identical version strings and the `unknown` / `0.0.0` placeholders suppress the warning, as does +an absent proxy version. Doctor does not report placeholders as a confirmed match. Different +strings still produce a neutral warning when they cannot be strictly parsed as SemVer or differ +only in build metadata; neither side is called older. Versions are not trimmed and a leading `v` +is not normalized. JSON exposes the same advice in `versionSkew`, whose fields remain +`cliVersion`, `proxyVersion`, `skewed`, and `warning`. + Print a read-only diagnostic summary: proxy PID, `/healthz` reachability, dashboard URL, config path, default provider, Codex autostart setting, service state, shim state, and the redacted effective Codex home. Only the explicit, high-confidence Windows Orca runtime-home signature adds an actionable App-home @@ -261,9 +274,10 @@ bundled Bun paths are deliberately rediscovered after upgrades instead of being Definitions installed before this change still carry the old versioned paths and cannot migrate themselves — once the old executable is deleted, no opencodex code runs to fix it. Run `ocx service repair` once after upgrading; after that, each service start follows the launcher. -An already-running proxy is not replaced by an external upgrade: restart the service (or run -`ocx service repair`) so the new build serves, and treat a CLI/proxy version mismatch warning as -exactly that signal. +An already-running proxy is not replaced by an external upgrade: when the installed CLI is newer +than the running proxy, restart the service (or run `ocx service repair`) so the new build serves. +If the proxy is newer instead, check the CLI installation and `PATH` as described under +[`ocx status`](#ocx-status---json). | Subcommand | Action | | --- | --- | diff --git a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md index 1ace7cc10f..7be5d5ad77 100644 --- a/docs-site/src/content/docs/ru/reference/cli/lifecycle.md +++ b/docs-site/src/content/docs/ru/reference/cli/lifecycle.md @@ -89,6 +89,19 @@ ocx eject back ### `ocx status [--json]` +Status и `ocx doctor` сравнивают версии текущего CLI и работающего прокси. Если CLI новее, +перезапустите прокси из нужной актуальной установки. Для фоновой службы используйте +`ocx service repair` (`ocx service restart` — её псевдоним). Если новее прокси, обновите CLI +или исправьте `PATH`, чтобы он указывал на нужную установку. Диагностика не ремонтирует службу +и не меняет разрешение запросов. + +При одинаковых строках версий, значениях `unknown` / `0.0.0` или отсутствии версии прокси +предупреждение подавляется. Doctor не считает placeholder подтверждённым совпадением. +Разные строки, которые нельзя строго разобрать как SemVer, и версии, отличающиеся только +build metadata, вызывают нейтральное предупреждение без указания устаревшей стороны. +Пробелы не удаляются, префикс `v` не нормализуется. JSON содержит ту же рекомендацию в +`versionSkew` с прежними полями `cliVersion`, `proxyVersion`, `skewed` и `warning`. + Печатает read-only диагностическую сводку: PID прокси, достижимость `/healthz`, URL дашборда, путь к конфигу, провайдера по умолчанию, настройку автозапуска Codex, состояние службы, состояние shim'а и redacted effective Codex home. Только явная и высокоуверенная сигнатура mismatch diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 1ab4fe9f1b..d7148530a0 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -1157,11 +1157,11 @@ export async function runDoctor(args: string[] = []): Promise { // No extra probe -- findLiveProxy already carried the version back. { const { packageVersion } = await import("./help"); - const { computeVersionSkew } = await import("./version-skew"); + const { computeVersionSkew, isConfirmedVersionMatch } = await import("./version-skew"); const skew = computeVersionSkew(packageVersion(), live?.version); if (skew.skewed && skew.warning) { console.log(`!! ${skew.warning}`); - } else if (skew.proxyVersion !== null) { + } else if (isConfirmedVersionMatch(skew)) { console.log(`ok ocx ${skew.cliVersion} matches the running proxy`); } } diff --git a/src/cli/version-skew.ts b/src/cli/version-skew.ts index 588d29a307..48b71a51ee 100644 --- a/src/cli/version-skew.ts +++ b/src/cli/version-skew.ts @@ -1,5 +1,5 @@ /** - * CLI-versus-proxy version skew (#2701). + * CLI-versus-proxy version skew (#2701, #3464). * * The reported failure: `ocx` on PATH is an older install than the running proxy, so its * help describes commands the proxy does not have and its output describes a different @@ -9,6 +9,7 @@ * comparison instead of reimplementing it -- two diagnostics disagreeing about whether an * install is stale would be worse than neither reporting it. */ +import { parseStrictSemver, type StrictSemver } from "../lib/strict-semver"; /** Placeholder versions that mean "unknown", not "different". */ const PLACEHOLDERS = new Set(["unknown", "0.0.0"]); @@ -22,6 +23,30 @@ export interface VersionSkew { readonly warning: string | null; } +/** Suppressed comparisons are not confirmed matches, even when both placeholders agree. */ +export function isConfirmedVersionMatch(skew: VersionSkew): boolean { + return skew.proxyVersion === skew.cliVersion && !PLACEHOLDERS.has(skew.cliVersion); +} + +/** SemVer precedence ignores build metadata; raw equality is handled separately. */ +function compareVersions(cli: StrictSemver, proxy: StrictSemver): number { + for (let i = 0; i < cli.core.length; i++) { + if (cli.core[i]! !== proxy.core[i]!) return cli.core[i]! > proxy.core[i]! ? 1 : -1; + } + if (cli.prerelease.length === 0) return proxy.prerelease.length === 0 ? 0 : 1; + if (proxy.prerelease.length === 0) return -1; + for (let i = 0; i < Math.max(cli.prerelease.length, proxy.prerelease.length); i++) { + const left = cli.prerelease[i]; + const right = proxy.prerelease[i]; + if (left === right) continue; + if (left === undefined) return -1; + if (right === undefined) return 1; + if (typeof left !== typeof right) return typeof left === "bigint" ? -1 : 1; + return left > right ? 1 : -1; + } + return 0; +} + /** * Compare the running CLI against the live proxy. * @@ -36,11 +61,19 @@ export function computeVersionSkew(cliVersion: string, proxyVersion: string | un if (proxy === null || PLACEHOLDERS.has(proxy) || PLACEHOLDERS.has(cliVersion) || proxy === cliVersion) { return { cliVersion, proxyVersion: proxy, skewed: false, warning: null }; } + const cliSemver = parseStrictSemver(cliVersion); + const proxySemver = parseStrictSemver(proxy); + const order = cliSemver && proxySemver ? compareVersions(cliSemver, proxySemver) : 0; + const advice = order > 0 + ? "the running proxy is older than this CLI. Restart the proxy using the intended current installation. " + + "For a background service, run ocx service repair (ocx service restart is an alias)." + : order < 0 + ? "this ocx on PATH is older than the running proxy. Upgrade the CLI or resolve PATH to the intended installation." + : "the versions differ, but neither can be identified as older. Check which installations the CLI and proxy use."; return { cliVersion, proxyVersion: proxy, skewed: true, - warning: `CLI ${cliVersion} does not match the running proxy ${proxy} — this ocx on PATH is stale. ` - + "Its help and features describe a different build. Reinstall, or run the proxy's own binary.", + warning: `CLI ${cliVersion} does not match the running proxy ${proxy} — ${advice}`, }; } diff --git a/tests/cli/cli-status-json.test.ts b/tests/cli/cli-status-json.test.ts index 31371baa33..10ab4f110e 100644 --- a/tests/cli/cli-status-json.test.ts +++ b/tests/cli/cli-status-json.test.ts @@ -10,9 +10,11 @@ import { fileURLToPath } from "node:url"; import { isConnectionRefused, isUncleanExitEvidence, proxyHealthFailureReason, resolveStatusPid, selectListenTarget } from "../../src/cli/status"; import * as statusFacade from "../../src/cli/status"; import * as statusProbes from "../../src/cli/status-probes"; +import { packageVersion } from "../../src/cli/help"; +import { getDefaultConfig } from "../../src/config"; import { findDeadPid } from "../helpers/dead-pid"; import { removeTreeWithRetry } from "../helpers/remove-tree"; -import { STORE_BUDGET_MS } from "../helpers/test-budget"; +import { INTERNAL_DEADLINE_MS, SPAWN_BUDGET_MS, STORE_BUDGET_MS } from "../helpers/test-budget"; import { inspectClientRotationRecoveryGate, readClientConnectionState } from "../../src/client/state"; import * as lifecycleLock from "../../src/client/lifecycle-lock"; import { writeDesktopDisconnectReceipt } from "../../src/claude/desktop-remote-store"; @@ -28,6 +30,84 @@ function runStatusJson(opencodexHome: string) { }); } +describe("status version skew projection", () => { + test.each([ + ["0.0.1", "the running proxy is older"], + ["999999.0.0", "this ocx on PATH is older"], + [packageVersion(), null], + [`${packageVersion()}+skew-fixture`, "neither can be identified as older"], + ["not-a-version", "neither can be identified as older"], + ["unknown", null], + ["0.0.0", null], + [undefined, null], + ] as const)("projects proxy %s in JSON and human output", async (proxyVersion, expected) => { + const home = mkdtempSync(join(tmpdir(), "ocx-status-skew-")); + const codexHome = join(home, "codex"); + let server: ReturnType | undefined; + try { + // Explicit CODEX_HOME must exist before the CLI imports codex/paths.ts. + mkdirSync(codexHome, { recursive: true }); + server = Bun.serve({ + hostname: "127.0.0.1", port: 0, + fetch(request) { + return new URL(request.url).pathname === "/healthz" + ? Response.json({ service: "opencodex", status: "ok", version: proxyVersion, uptime: 1 }) + : new Response("not found", { status: 404 }); + }, + }); + writeFileSync(join(home, "config.json"), JSON.stringify({ + ...getDefaultConfig(), port: server.port, hostname: "127.0.0.1", codexAutoStart: false, + })); + for (const json of [true, false]) { + // Async child execution lets the fixture answer the real identity/health probes. + const child = Bun.spawn([process.execPath, cliPath, "status", ...(json ? ["--json"] : [])], { + cwd: repoRoot, + env: { ...process.env, OPENCODEX_HOME: home, CODEX_HOME: codexHome }, + stdout: "pipe", stderr: "pipe", + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + }, INTERNAL_DEADLINE_MS); + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited, + ]); + expect(timedOut).toBe(false); + // Preserve both gates while surfacing the child error when startup fails. + expect({ exitCode, stderr }).toEqual({ exitCode: 0, stderr: "" }); + if (json) { + const parsed = JSON.parse(stdout); + expect(parsed.schemaVersion).toBe(1); + expect(Object.keys(parsed.versionSkew).sort()).toEqual(["cliVersion", "proxyVersion", "skewed", "warning"]); + expect(parsed.versionSkew.cliVersion).toBe(packageVersion()); + expect(parsed.versionSkew.proxyVersion).toBe(proxyVersion ?? null); + expect(parsed.versionSkew.skewed).toBe(expected !== null); + if (expected === null) expect(parsed.versionSkew.warning).toBeNull(); + else expect(parsed.versionSkew.warning).toContain(expected); + } else if (expected === null) { + expect(stdout).not.toContain("does not match the running proxy"); + } else { + expect(stdout).toContain(expected); + } + } finally { + clearTimeout(timer); + if (child.exitCode === null) child.kill("SIGKILL"); + await child.exited; + } + } + expect(existsSync(join(home, "ocx.pid"))).toBe(false); + } finally { + try { + await server?.stop(true); + } finally { + removeTreeWithRetry(home); + } + } + }, SPAWN_BUDGET_MS); +}); + function withRecoveryStatusFixture(work: (fixture: { home: string; lockDeps: { lockPath: string }; diff --git a/tests/cli/cli-version-skew.test.ts b/tests/cli/cli-version-skew.test.ts index 6e45f83c28..36fb6845f9 100644 --- a/tests/cli/cli-version-skew.test.ts +++ b/tests/cli/cli-version-skew.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { computeVersionSkew } from "../../src/cli/version-skew"; +import { computeVersionSkew, isConfirmedVersionMatch } from "../../src/cli/version-skew"; import { packageVersion } from "../../src/cli/help"; /** @@ -7,20 +7,83 @@ import { packageVersion } from "../../src/cli/help"; * build, and nothing surfaced it because the CLI never compared the two versions. */ describe("version skew detection", () => { - test("reports skew when the proxy reports a different version", () => { + test("directs an older CLI to upgrade or resolve PATH", () => { const skew = computeVersionSkew("2.35.0", "2.36.1"); expect(skew.skewed).toBe(true); expect(skew.cliVersion).toBe("2.35.0"); expect(skew.proxyVersion).toBe("2.36.1"); expect(skew.warning).toContain("2.35.0"); expect(skew.warning).toContain("2.36.1"); - expect(skew.warning).toContain("stale"); + expect(skew.warning).toContain("this ocx on PATH is older"); + expect(skew.warning).toContain("Upgrade the CLI or resolve PATH"); + expect(skew.warning).not.toContain("ocx service repair"); + }); + + test("#3464 directs a newer CLI to restart the older proxy", () => { + const skew = computeVersionSkew("2.42.0", "2.10.1-preview.20260805"); + expect(skew).toEqual({ + cliVersion: "2.42.0", + proxyVersion: "2.10.1-preview.20260805", + skewed: true, + warning: "CLI 2.42.0 does not match the running proxy 2.10.1-preview.20260805 — " + + "the running proxy is older than this CLI. Restart the proxy using the intended current installation. " + + "For a background service, run ocx service repair (ocx service restart is an alias).", + }); + expect(skew.warning).not.toContain("this ocx on PATH is older"); + }); + + test.each([ + ["2.43.0", "2.43.0-preview.1"], + ["2.43.0-preview.10", "2.43.0-preview.2"], + ["2.43.0-preview.beta", "2.43.0-preview.10"], + ["2.43.0-preview.1", "2.43.0-preview"], + ["2.43.0-beta", "2.43.0-alpha"], + ["2.44.0-preview.1", "2.43.0"], + ["10.0.0", "9.99.99"], + ["2.43.1", "2.43.0"], + ["2.43.0-preview.9007199254740993", "2.43.0-preview.9007199254740992"], + ])("orders %s above %s in both directions", (newer, older) => { + expect(computeVersionSkew(newer, older).warning).toContain("the running proxy is older"); + expect(computeVersionSkew(older, newer).warning).toContain("this ocx on PATH is older"); + }); + + test.each([ + ["2.43.0+build.1", "2.43.0+build.2"], + ["2.43.0", "2.43.0+build.1"], + ["2.43.0-preview.1+a", "2.43.0-preview.1+b"], + ["invalid", "2.43.0"], + ["2.43", "2.43.0"], + ["v2.43.0", "2.43.0"], + [" 2.43.0", "2.43.0"], + ["2.43.0 ", "2.43.0"], + ["2.43.0-preview.01", "2.43.0-preview.1"], + ["", "2.43.0"], + ])("keeps raw unequal %s / %s neutral in both directions", (left, right) => { + for (const [cli, proxy] of [[left, right], [right, left]]) { + const skew = computeVersionSkew(cli!, proxy!); + expect(skew.cliVersion).toBe(cli); + expect(skew.proxyVersion).toBe(proxy); + expect(skew.skewed).toBe(true); + expect(skew.warning).toContain("neither can be identified as older"); + expect(skew.warning).not.toContain("ocx service repair"); + expect(isConfirmedVersionMatch(skew)).toBe(false); + } + }); + + test.each(["unknown", "0.0.0"])("suppresses %s on either side without confirming a match", placeholder => { + for (const [cli, proxy] of [[placeholder, "2.43.0"], ["2.43.0", placeholder], [placeholder, placeholder]]) { + const skew = computeVersionSkew(cli!, proxy!); + expect(skew.skewed).toBe(false); + expect(skew.warning).toBeNull(); + expect(isConfirmedVersionMatch(skew)).toBe(false); + } }); test("stays quiet when the versions match", () => { const skew = computeVersionSkew("2.35.0", "2.35.0"); expect(skew.skewed).toBe(false); expect(skew.warning).toBeNull(); + expect(isConfirmedVersionMatch(skew)).toBe(true); }); test("stays quiet when nothing is live", () => { @@ -28,6 +91,7 @@ describe("version skew detection", () => { expect(skew.skewed).toBe(false); expect(skew.proxyVersion).toBeNull(); expect(skew.warning).toBeNull(); + expect(isConfirmedVersionMatch(skew)).toBe(false); }); test("suppresses the warning when the proxy reports the 0.0.0 placeholder", () => { diff --git a/tests/codex-integration/doctor.test.ts b/tests/codex-integration/doctor.test.ts index 9fdb7ee30d..acb0f1b87e 100644 --- a/tests/codex-integration/doctor.test.ts +++ b/tests/codex-integration/doctor.test.ts @@ -1,4 +1,7 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import * as proxyLiveness from "../../src/server/proxy-liveness"; +import * as cliHelp from "../../src/cli/help"; +import { getDefaultConfig } from "../../src/config"; import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, utimesSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -32,6 +35,7 @@ import { } from "../../src/lib/local-management-capability"; import { findDeadPid } from "../helpers/dead-pid"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { STORE_BUDGET_MS } from "../helpers/test-budget"; const TEST_DIR = join(import.meta.dir, ".tmp-doctor-test"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); @@ -780,6 +784,63 @@ describe("doctor abandoned response-state temps", () => { }); }); +describe("doctor version skew projection", () => { + test.each([ + ["2.42.0", "2.10.1-preview.20260805", "the running proxy is older"], + ["2.35.0", "2.36.1", "this ocx on PATH is older"], + ["2.43.0", "2.43.0", "ok ocx 2.43.0 matches the running proxy"], + ["2.43.0+a", "2.43.0+b", "neither can be identified as older"], + ["v2.43.0", "2.43.0", "neither can be identified as older"], + ["2.43.0", "unknown", null], + ["unknown", "2.43.0", null], + ["2.43.0", "0.0.0", null], + ["0.0.0", "0.0.0", null], + ["unknown", "unknown", null], + ["2.43.0", undefined, null], + ] as const)("projects CLI %s / proxy %s without false matches", async (cli, proxy, expected) => { + const home = mkdtempSync(join(tmpdir(), "ocx-doctor-skew-")); + const codexHome = join(home, "codex"); + const previousHome = process.env.OPENCODEX_HOME; + const previousCodexHome = process.env.CODEX_HOME; + const previousExitCode = process.exitCode; + const restore: Array<() => void> = []; + try { + // Runtime history diagnostics resolve and stat an explicit CODEX_HOME. + mkdirSync(codexHome, { recursive: true }); + process.env.OPENCODEX_HOME = home; + process.env.CODEX_HOME = codexHome; + writeFileSync(join(home, "config.json"), JSON.stringify({ ...getDefaultConfig(), port: 9, codexAutoStart: false })); + const logged: string[] = []; + const log = spyOn(console, "log").mockImplementation((...args: unknown[]) => { logged.push(args.map(String).join(" ")); }); + restore.push(() => log.mockRestore()); + const version = spyOn(cliHelp, "packageVersion").mockReturnValue(cli); + restore.push(() => version.mockRestore()); + // Other doctor sections probe upstream health; this diagnostic fixture must stay offline. + const fetch = spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status: 503 })); + restore.push(() => fetch.mockRestore()); + const proxyInfo: proxyLiveness.LiveProxy = { + pid: null, port: 9, hostname: "127.0.0.1", source: "config", ...(proxy === undefined ? {} : { version: proxy }), + }; + const live = spyOn(proxyLiveness, "findLiveProxy").mockResolvedValue(proxyInfo); + restore.push(() => live.mockRestore()); + await runDoctor([]); + const output = logged.join("\n"); + if (expected !== null) expect(output).toContain(expected); + else expect(output).not.toContain("does not match the running proxy"); + if (cli !== "2.43.0" || proxy !== "2.43.0") expect(output).not.toContain("matches the running proxy"); + if (expected === "the running proxy is older") expect(output).toContain("ocx service repair"); + } finally { + for (const cleanup of restore.reverse()) cleanup(); + process.exitCode = previousExitCode; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + removeTreeWithRetry(home); + } + }, STORE_BUDGET_MS); +}); + describe("doctor reclaim wiring (end to end)", () => { // The formatter tests above cannot observe deletion. This covers the call site itself: // inverting the report/reclaim ternary in runDoctor must fail a test. From 2e8ef03428f8e619dc92b250fbbc5d5dd7ad53cb Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 07:42:20 +0900 Subject: [PATCH 45/50] fix(responses): classify encrypted task recovery failures Distinguish HTTP refusal, timeout, shared abort, transport failure and invalid output through the existing shared-flight cache. Preserve strict admission, success-only cache, wrapper APIs and caller-local cancellation. Addresses the diagnostic residual of #3661; multipart/retry policy stays deferred. Reported-by: Hu9956 <282876394+Hu9956@users.noreply.github.com> --- .../021_source_review.md | 7 ++ .../content/docs/reference/architecture.md | 14 ++- src/lib/bounded-body.ts | 29 +++-- .../responses/agent-task-recovery-cache.ts | 57 ++++++--- src/server/responses/agent-task-recovery.ts | 44 ++++--- structure/04_transports-and-sidecars.md | 14 ++- .../server/agent-task-recovery-cache.test.ts | 54 +++++++-- tests/server/agent-task-recovery.test.ts | 114 +++++++++++++++--- tests/server/bounded-body.test.ts | 62 +++++++++- 9 files changed, 327 insertions(+), 68 deletions(-) create mode 100644 devlog/_plan/260907_axis1_bugfixes/021_source_review.md diff --git a/devlog/_plan/260907_axis1_bugfixes/021_source_review.md b/devlog/_plan/260907_axis1_bugfixes/021_source_review.md new file mode 100644 index 0000000000..516a760813 --- /dev/null +++ b/devlog/_plan/260907_axis1_bugfixes/021_source_review.md @@ -0,0 +1,7 @@ +# wp1 source review + +Three bounded patches implemented with regression coverage. Hooke independently passed the physical-response quota observer wiring; Tesla independently passed quota/recovery security and source review with zero blockers. Version comparator and status/doctor projections inspected by main. All source workers report no local suite/typecheck/build execution. + +Quota source: #3809, Éverton Toffanetto; Co-authored-by included in f215f79b4. Version report: garysassano; Reported-by included in f91e3953a. Recovery report: Hu9956; Reported-by included in recovery commit. + +Source-only checks: git diff --check and documentation fence/whitespace inspection. These do not prove runtime correctness. wp2 final cumulative hosted CI is still mandatory. Final CI dispatch includes Windows because ordinary PR workflow omits it. No release/deploy workflow will be dispatched. diff --git a/docs-site/src/content/docs/reference/architecture.md b/docs-site/src/content/docs/reference/architecture.md index 8e1e361a81..e0fbc8bcba 100644 --- a/docs-site/src/content/docs/reference/architecture.md +++ b/docs-site/src/content/docs/reference/architecture.md @@ -233,7 +233,17 @@ response is not cacheable. Post-commit and 5xx errors keep the no-resend path. When encrypted agent-task recovery refuses a routed task, its existing 400 error can include a bounded `recovery_reason`: `unsupported_envelope`, -`admission_denied`, `recovery_unavailable`, `caller_cancelled`, or `input_changed`. -The field is omitted when no classified recovery result exists. +`admission_denied`, `recovery_unavailable`, `caller_cancelled`, `input_changed`, +`recovery_http_rejected`, `recovery_timeout`, `recovery_aborted`, +`recovery_transport_error`, or `recovery_invalid_output`. +HTTP rejection requires an observed non-success response. Invalid output includes +invalid UTF-8, oversized bodies, malformed or incomplete recovery streams, and +invalid or conflicting assignments. A caller's cancellation takes precedence over +an owned deadline, which takes precedence over decode/transport failures. +`recovery_aborted` describes a shared recovery cancelled independently of that caller. +Shared-flight waiters receive the same underlying failure unless individually cancelled; +only successful plaintext is cached. Diagnostics contain no upstream error or payload text. +The field is omitted when no classified recovery result exists, and existing combo +branches that return the original target failure keep that response. `recovery_unavailable` includes cache/singleflight capacity and does not prove an upstream request was attempted. No retry or broader envelope acceptance is enabled. diff --git a/src/lib/bounded-body.ts b/src/lib/bounded-body.ts index 4016a0a753..0975268560 100644 --- a/src/lib/bounded-body.ts +++ b/src/lib/bounded-body.ts @@ -212,13 +212,28 @@ export async function readBoundedResponseBytes( } } -function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean): string { +// Mark only exceptions thrown by our decoder, preserving their identity and TypeError contract. +// Timeout-path flushing may fail too; retain that origin so callers do not lose the deadline. +const decodeFailures = new WeakMap(); + +export function boundedBodyDecodeFailure(error: unknown): "invalid_utf8" | "timeout" | undefined { + return error !== null && typeof error === "object" ? decodeFailures.get(error) : undefined; +} + +function decodeUtf8(chunks: readonly Uint8Array[], fatal: boolean, timedOut = false): string { const decoder = new TextDecoder("utf-8", { fatal }); - let text = ""; - for (const chunk of chunks) text += decoder.decode(chunk, { stream: true }); - // Flush an incomplete trailing UTF-8 sequence deterministically. - text += decoder.decode(); - return text; + try { + let text = ""; + for (const chunk of chunks) text += decoder.decode(chunk, { stream: true }); + // Flush an incomplete trailing UTF-8 sequence deterministically. + text += decoder.decode(); + return text; + } catch (error) { + if (error !== null && typeof error === "object") { + decodeFailures.set(error, timedOut ? "timeout" : "invalid_utf8"); + } + throw error; + } } /** @@ -297,7 +312,7 @@ export async function readBoundedResponseBody( "TimeoutError", ); return { - text: decodeUtf8([retained.subarray(0, retainedBytes)], options.fatalUtf8 === true), + text: decodeUtf8([retained.subarray(0, retainedBytes)], options.fatalUtf8 === true, true), truncated: true, timedOut: true, totalTimedOut: outcome === TOTAL_TIMEOUT, diff --git a/src/server/responses/agent-task-recovery-cache.ts b/src/server/responses/agent-task-recovery-cache.ts index 93d0c1778b..398a0feba4 100644 --- a/src/server/responses/agent-task-recovery-cache.ts +++ b/src/server/responses/agent-task-recovery-cache.ts @@ -2,6 +2,20 @@ const MAX_CACHE_BYTES = 8 * 1024 * 1024; const MAX_CONCURRENT_RECOVERIES = 32; const CACHE_TTL_MS = 15 * 60 * 1000; +export type AgentTaskRecoveryResolutionFailureReason = + | "recovery_unavailable" + | "caller_cancelled" + | "recovery_http_rejected" + | "recovery_timeout" + | "recovery_aborted" + | "recovery_transport_error" + | "recovery_invalid_output"; + +/** Shared flights carry bounded failures; only successful plaintext enters the cache. */ +export type AgentTaskRecoveryResolution = + | { readonly recovered: true; readonly assignment: string } + | { readonly recovered: false; readonly reason: AgentTaskRecoveryResolutionFailureReason }; + interface RecoveryCacheEntry { assignment: string; bytes: number; @@ -11,7 +25,7 @@ interface RecoveryCacheEntry { interface RecoveryFlight { controller: AbortController; - promise: Promise; + promise: Promise; waiters: number; settled: boolean; } @@ -63,7 +77,7 @@ function insertRecoveryCacheEntry(key: string, assignment: string, maxEntries: n function startRecoveryFlight( key: string, maxEntries: number, - request: (signal: AbortSignal) => Promise, + request: (signal: AbortSignal) => Promise, ): RecoveryFlight | null { const active = RECOVERY_FLIGHTS.get(key); if (active) return active; @@ -72,15 +86,15 @@ function startRecoveryFlight( const controller = new AbortController(); const flight: RecoveryFlight = { controller, - promise: Promise.resolve(null), + promise: Promise.resolve({ recovered: false, reason: "recovery_unavailable" }), waiters: 0, settled: false, }; flight.promise = request(controller.signal) - .then((assignment) => { - if (!assignment || controller.signal.aborted) return null; - insertRecoveryCacheEntry(key, assignment, maxEntries); - return assignment; + .then((result): AgentTaskRecoveryResolution => { + if (controller.signal.aborted) return { recovered: false, reason: "recovery_aborted" }; + if (result.recovered) insertRecoveryCacheEntry(key, result.assignment, maxEntries); + return result; }) .finally(() => { flight.settled = true; @@ -93,14 +107,14 @@ function startRecoveryFlight( async function waitForRecoveryFlight( flight: RecoveryFlight, abortSignal?: AbortSignal, -): Promise { - if (abortSignal?.aborted) return null; +): Promise { + if (abortSignal?.aborted) return { recovered: false, reason: "caller_cancelled" }; flight.waiters += 1; let onAbort: (() => void) | undefined; try { if (!abortSignal) return await flight.promise; - const cancelled = new Promise((resolve) => { - onAbort = () => resolve(null); + const cancelled = new Promise((resolve) => { + onAbort = () => resolve({ recovered: false, reason: "caller_cancelled" }); abortSignal.addEventListener("abort", onAbort, { once: true }); if (abortSignal.aborted) onAbort(); }); @@ -120,12 +134,27 @@ export async function resolveCachedAgentTaskRecovery( request: (signal: AbortSignal) => Promise, abortSignal?: AbortSignal, ): Promise { - if (abortSignal?.aborted) return null; + const result = await resolveCachedAgentTaskRecoveryWithResult(key, maxEntries, async signal => { + const assignment = await request(signal); + return assignment + ? { recovered: true, assignment } + : { recovered: false, reason: "recovery_unavailable" }; + }, abortSignal); + return result.recovered ? result.assignment : null; +} + +export async function resolveCachedAgentTaskRecoveryWithResult( + key: string, + maxEntries: number, + request: (signal: AbortSignal) => Promise, + abortSignal?: AbortSignal, +): Promise { + if (abortSignal?.aborted) return { recovered: false, reason: "caller_cancelled" }; sweepRecoveryCache(Date.now(), maxEntries); const cached = RECOVERY_CACHE.get(key)?.assignment; - if (cached) return cached; + if (cached) return { recovered: true, assignment: cached }; const flight = startRecoveryFlight(key, maxEntries, request); - return flight ? waitForRecoveryFlight(flight, abortSignal) : null; + return flight ? waitForRecoveryFlight(flight, abortSignal) : { recovered: false, reason: "recovery_unavailable" }; } export function discardCachedAgentTaskRecovery(key: string): void { diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index 22b7a4e66b..a15a2563ca 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -1,14 +1,16 @@ import { createHash, createHmac, randomBytes } from "node:crypto"; import { decodeJwtPayload, extractAccountId } from "../../oauth/chatgpt"; import type { OcxConfig } from "../../types"; -import { readBoundedResponseBody } from "../../lib/bounded-body"; +import { boundedBodyDecodeFailure, readBoundedResponseBody } from "../../lib/bounded-body"; import { isApiAuthRequired, isProxyAdmissionSecret } from "../auth-cors"; import { structurallyValidFernetTokens } from "./encrypted-payload"; import { cachedAgentTaskRecovery, discardCachedAgentTaskRecovery, resetAgentTaskRecoveryCache, - resolveCachedAgentTaskRecovery, + resolveCachedAgentTaskRecoveryWithResult, + type AgentTaskRecoveryResolution, + type AgentTaskRecoveryResolutionFailureReason, } from "./agent-task-recovery-cache"; /** Experimental opt-in normalization through ChatGPT's fixed Codex endpoint. */ @@ -44,9 +46,8 @@ export interface AgentTaskRecoveryOptions { export type AgentTaskRecoveryFailureReason = | "unsupported_envelope" | "admission_denied" - // Includes cache capacity rejection; does not imply an upstream request was attempted. - | "recovery_unavailable" - | "caller_cancelled" + // recovery_unavailable includes capacity rejection, which does not imply an upstream attempt. + | AgentTaskRecoveryResolutionFailureReason | "input_changed"; export type AgentTaskRecoveryResult = @@ -436,7 +437,7 @@ async function requestRecovery( envelope: AgentEnvelope, options: AgentTaskRecoveryOptions, abortSignal?: AbortSignal, -): Promise { +): Promise { const controller = new AbortController(); const timeout = setTimeout( () => controller.abort(new DOMException("Agent task recovery timed out", "TimeoutError")), @@ -454,8 +455,11 @@ async function requestRecovery( redirect: "error", }); if (!response.ok) { - try { await response.body?.cancel(); } catch { /* already closed */ } - return null; + // A rejected or never-settling cancellation must not extend the recovery deadline. + try { void response.body?.cancel().catch(() => undefined); } catch { /* already closed */ } + if (abortSignal?.aborted) return { recovered: false, reason: "recovery_aborted" }; + if (controller.signal.aborted) return { recovered: false, reason: "recovery_timeout" }; + return { recovered: false, reason: "recovery_http_rejected" }; } const body = await readBoundedResponseBody(response, { signal, @@ -465,10 +469,18 @@ async function requestRecovery( inactivityTimeoutMs: options.timeoutMs ?? 45_000, firstByteTimeoutMs: options.timeoutMs ?? 45_000, }); - if (body.truncated || body.oversized || body.timedOut || !body.displaySafe) return null; - return assignmentFromRecoverySse(body.text, envelope); - } catch { - return null; + if (abortSignal?.aborted) return { recovered: false, reason: "recovery_aborted" }; + if (controller.signal.aborted || body.timedOut) return { recovered: false, reason: "recovery_timeout" }; + if (body.truncated || body.oversized || !body.displaySafe) return { recovered: false, reason: "recovery_invalid_output" }; + const assignment = assignmentFromRecoverySse(body.text, envelope); + return assignment === null + ? { recovered: false, reason: "recovery_invalid_output" } + : { recovered: true, assignment }; + } catch (error) { + if (abortSignal?.aborted) return { recovered: false, reason: "recovery_aborted" }; + const decodeFailure = boundedBodyDecodeFailure(error); + if (controller.signal.aborted || decodeFailure === "timeout") return { recovered: false, reason: "recovery_timeout" }; + return { recovered: false, reason: decodeFailure === "invalid_utf8" ? "recovery_invalid_output" : "recovery_transport_error" }; } finally { clearTimeout(timeout); } @@ -497,23 +509,23 @@ export async function recoverEncryptedAgentTaskWithResult( const admitted = admittedRecovery(req, input, config, context.parentThreadId); if (!admitted.admitted) return { recovered: false, reason: admitted.reason }; const { admission, cacheKey, envelope } = admitted.recovery; - const assignment = await resolveCachedAgentTaskRecovery( + const result = await resolveCachedAgentTaskRecoveryWithResult( cacheKey, options.cacheEntries ?? 200, signal => requestRecovery(admission, envelope, options, signal), context.abortSignal, ); - if (!assignment) { + if (!result.recovered) { return { recovered: false, - reason: context.abortSignal?.aborted ? "caller_cancelled" : "recovery_unavailable", + reason: context.abortSignal?.aborted ? "caller_cancelled" : result.reason, }; } if (context.abortSignal?.aborted) { discardCachedAgentTaskRecovery(cacheKey); return { recovered: false, reason: "caller_cancelled" }; } - if (!injectAssignment(input, envelope, assignment)) { + if (!injectAssignment(input, envelope, result.assignment)) { discardCachedAgentTaskRecovery(cacheKey); return { recovered: false, reason: "input_changed" }; } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 7e41dc2222..d27022c2ad 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -1719,7 +1719,17 @@ response is not cacheable. Post-commit and 5xx errors keep the no-resend path. When encrypted agent-task recovery refuses a routed task, its existing 400 error can include a bounded `recovery_reason`: `unsupported_envelope`, -`admission_denied`, `recovery_unavailable`, `caller_cancelled`, or `input_changed`. -The field is omitted when no classified recovery result exists. +`admission_denied`, `recovery_unavailable`, `caller_cancelled`, `input_changed`, +`recovery_http_rejected`, `recovery_timeout`, `recovery_aborted`, +`recovery_transport_error`, or `recovery_invalid_output`. +HTTP rejection requires an observed non-success response. Invalid output includes +invalid UTF-8, oversized bodies, malformed or incomplete recovery streams, and +invalid or conflicting assignments. A caller's cancellation takes precedence over +an owned deadline, which takes precedence over decode/transport failures. +`recovery_aborted` describes a shared recovery cancelled independently of that caller. +Shared-flight waiters receive the same underlying failure unless individually cancelled; +only successful plaintext is cached. Diagnostics contain no upstream error or payload text. +The field is omitted when no classified recovery result exists, and existing combo +branches that return the original target failure keep that response. `recovery_unavailable` includes cache/singleflight capacity and does not prove an upstream request was attempted. No retry or broader envelope acceptance is enabled. diff --git a/tests/server/agent-task-recovery-cache.test.ts b/tests/server/agent-task-recovery-cache.test.ts index 2ee994f8ce..35b5ba7050 100644 --- a/tests/server/agent-task-recovery-cache.test.ts +++ b/tests/server/agent-task-recovery-cache.test.ts @@ -29,16 +29,23 @@ describe("agent task recovery cache", () => { resetAgentTaskRecoveryCache(); }); - test("shared failure gives each waiter its own result without contaminating another key", async () => { + test.each([ + { kind: "http", reason: "recovery_http_rejected" }, + { kind: "reader", reason: "recovery_transport_error" }, + { kind: "decode", reason: "recovery_invalid_output" }, + ] as const)("shared $kind failure gives each waiter its own result without contaminating another key", async ({ kind, reason }) => { let release: (() => void) | undefined; const gate = new Promise(resolve => { release = resolve; }); let fetches = 0; globalThis.fetch = (async () => { const requestNumber = ++fetches; await gate; - return requestNumber === 1 - ? new Response("raw-failure-sentinel", { status: 503 }) - : new Response(recoverySse("Independent assignment.")); + if (requestNumber !== 1) return new Response(recoverySse("Independent assignment.")); + if (kind === "decode") return new Response(new Uint8Array([0xff])); + if (kind === "reader") return new Response(new ReadableStream({ + pull(controller) { controller.error(new TypeError("private-reader-failure")); }, + })); + return new Response("raw-failure-sentinel", { status: 503 }); }) as typeof fetch; const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); const config = routedConfig(); @@ -53,8 +60,8 @@ describe("agent task recovery cache", () => { expect(fetches).toBe(2); release?.(); const [firstResult, secondResult, otherResult] = await Promise.all([first, second, other]); - expect(firstResult).toEqual({ recovered: false, reason: "recovery_unavailable" }); - expect(secondResult).toEqual({ recovered: false, reason: "recovery_unavailable" }); + expect(firstResult).toEqual({ recovered: false, reason }); + expect(secondResult).toEqual({ recovered: false, reason }); expect(firstResult).not.toBe(secondResult); expect(otherResult).toEqual({ recovered: true }); expect(firstInput).toEqual(encryptedInput()); @@ -68,6 +75,39 @@ describe("agent task recovery cache", () => { } }); + test("shared flight reset reports abort to surviving callers and never caches late plaintext", async () => { + let release!: () => void; + const gate = new Promise(resolve => { release = resolve; }); + let fetches = 0; + globalThis.fetch = (async () => { + fetches++; + await gate; + return new Response(recoverySse("private-late-assignment")); + }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const firstInput = encryptedInput(); + const secondInput = encryptedInput(); + const first = recoverEncryptedAgentTaskWithResult(req, firstInput, {}, routedConfig()); + const second = recoverEncryptedAgentTaskWithResult(req, secondInput, {}, routedConfig()); + try { + expect(fetches).toBe(1); + resetAgentTaskRecoveryCache(); + release(); + const results = await Promise.all([first, second]); + expect(results).toEqual([ + { recovered: false, reason: "recovery_aborted" }, + { recovered: false, reason: "recovery_aborted" }, + ]); + expect(results[0]).not.toBe(results[1]); + expect(firstInput).toEqual(encryptedInput()); + expect(secondInput).toEqual(encryptedInput()); + expect(agentTaskRecoveryCacheSnapshotForTests()).toEqual({ entries: 0, bytes: 0 }); + } finally { + release(); + await Promise.all([first, second]); + } + }); + for (const succeeds of [true, false]) { test(`caller cancellation stays local when the remaining waiter ${succeeds ? "succeeds" : "fails"}`, async () => { let release: (() => void) | undefined; @@ -95,7 +135,7 @@ describe("agent task recovery cache", () => { release?.(); expect(await second).toEqual(succeeds ? { recovered: true } - : { recovered: false, reason: "recovery_unavailable" }); + : { recovered: false, reason: "recovery_http_rejected" }); expect(fetches).toBe(1); expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config)).toBe(succeeds ? 1 : 0); } finally { diff --git a/tests/server/agent-task-recovery.test.ts b/tests/server/agent-task-recovery.test.ts index ceb1c5b6b5..a168f2c364 100644 --- a/tests/server/agent-task-recovery.test.ts +++ b/tests/server/agent-task-recovery.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import { warnAgentTaskRecoveryStartup } from "../../src/server"; import { @@ -7,6 +7,7 @@ import { recoverEncryptedAgentTaskWithResult, resetAgentTaskRecoveryState, restoreCachedEncryptedAgentTasks, + type AgentTaskRecoveryFailureReason, } from "../../src/server/responses/agent-task-recovery"; import { agentTaskRecoveryWaiterCountForTests } from "../../src/server/responses/agent-task-recovery-cache"; import { @@ -78,24 +79,36 @@ describe("agent task recovery (opt-in, default off)", () => { }); } - const failedRecoveries: Array<[string, () => Response]> = [ - ["HTTP 503", () => new Response("raw-error-sentinel", { status: 503 })], - ["network exception", () => { throw new Error("raw-error-sentinel"); }], - ["malformed SSE", () => new Response("data: {not-json}\n\n")], - ["missing completion", () => new Response(recoverySse("payload-sentinel").split("data: {\"type\":\"response.completed\"")[0])], - ["conflicting assignment", () => new Response(recoverySse("payload-sentinel") + recoveryCompletedSse("other-payload-sentinel"))], - ["failed terminal", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"response.failed","response":{"error":{"message":"raw-error-sentinel"}}}\n\n')], - ["incomplete terminal", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"response.incomplete"}\n\n')], - ["bare error", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"error","error":{"message":"raw-error-sentinel"}}\n\n')], + const failedRecoveries: Array<[string, () => Response, AgentTaskRecoveryFailureReason]> = [ + ["HTTP 401", () => new Response("private-error", { status: 401 }), "recovery_http_rejected"], + ["HTTP 403", () => new Response("private-error", { status: 403 }), "recovery_http_rejected"], + ["HTTP 429", () => new Response("private-error", { status: 429 }), "recovery_http_rejected"], + ["fetch TypeError", () => { throw new TypeError("private-error"); }, "recovery_transport_error"], + ["unowned TimeoutError", () => { throw new DOMException("private-error", "TimeoutError"); }, "recovery_transport_error"], + ["reader TypeError", () => new Response(new ReadableStream({ + pull(controller) { controller.error(new TypeError("private-reader-error")); }, + })), "recovery_transport_error"], + ["invalid UTF-8", () => new Response(new Uint8Array([0xff])), "recovery_invalid_output"], + ["trailing UTF-8", () => new Response(new Uint8Array([0xe2, 0x82])), "recovery_invalid_output"], + ["oversized body", () => new Response(new Uint8Array(4 * 1024 * 1024 + 1)), "recovery_invalid_output"], + ["invalid arguments", () => new Response(recoverySse("task").replace('{\\"assignment\\":\\"task\\"}', '{broken')), "recovery_invalid_output"], + ["HTTP 503", () => new Response("raw-error-sentinel", { status: 503 }), "recovery_http_rejected"], + ["network exception", () => { throw new Error("raw-error-sentinel"); }, "recovery_transport_error"], + ["malformed SSE", () => new Response("data: {not-json}\n\n"), "recovery_invalid_output"], + ["missing completion", () => new Response(recoverySse("payload-sentinel").split("data: {\"type\":\"response.completed\"")[0]), "recovery_invalid_output"], + ["conflicting assignment", () => new Response(recoverySse("payload-sentinel") + recoveryCompletedSse("other-payload-sentinel")), "recovery_invalid_output"], + ["failed terminal", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"response.failed","response":{"error":{"message":"raw-error-sentinel"}}}\n\n'), "recovery_invalid_output"], + ["incomplete terminal", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"response.incomplete"}\n\n'), "recovery_invalid_output"], + ["bare error", () => new Response(recoverySse("payload-sentinel") + 'data: {"type":"error","error":{"message":"raw-error-sentinel"}}\n\n'), "recovery_invalid_output"], // Exact-case events are also used by the pinned official Codex source. Recovery's // additional completed-status requirement remains deliberately stricter. - ["mixed-case completion", () => new Response(recoverySse("payload-sentinel").replace("response.completed", "Response.Completed"))], - ["mixed-case status", () => new Response(recoverySse("payload-sentinel").replace('"status":"completed"', '"status":"Completed"'))], - ["missing status", () => new Response(recoverySse("payload-sentinel").replace('"status":"completed",', ""))], - ["ciphertext assignment", () => new Response(recoverySse(FERNET_TASK))], + ["mixed-case completion", () => new Response(recoverySse("payload-sentinel").replace("response.completed", "Response.Completed")), "recovery_invalid_output"], + ["mixed-case status", () => new Response(recoverySse("payload-sentinel").replace('"status":"completed"', '"status":"Completed"')), "recovery_invalid_output"], + ["missing status", () => new Response(recoverySse("payload-sentinel").replace('"status":"completed",', "")), "recovery_invalid_output"], + ["ciphertext assignment", () => new Response(recoverySse(FERNET_TASK)), "recovery_invalid_output"], ]; - for (const [name, response] of failedRecoveries) { - test(`typed recovery keeps ${name} coarse and preserves false without retrying`, async () => { + for (const [name, response, reason] of failedRecoveries) { + test(`typed recovery classifies ${name} and preserves false without retrying`, async () => { const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); const config = routedConfig(); let fetches = 0; @@ -103,7 +116,7 @@ describe("agent task recovery (opt-in, default off)", () => { const input = encryptedInput(); const original = structuredClone(input); expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, config)) - .toEqual({ recovered: false, reason: "recovery_unavailable" }); + .toEqual({ recovered: false, reason }); expect(input).toEqual(original); expect(fetches).toBe(1); expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config)).toBe(0); @@ -113,6 +126,69 @@ describe("agent task recovery (opt-in, default off)", () => { }); } + test.each(["pending", "rejecting"] as const)("HTTP refusal does not await %s body cancellation", async mode => { + let cancels = 0; + let reads = 0; + let releaseCancel: (() => void) | undefined; + const cancellation = new Promise(resolve => { releaseCancel = resolve; }); + globalThis.fetch = (async () => new Response(new ReadableStream({ + pull() { reads++; }, + cancel() { + cancels++; + return mode === "pending" ? cancellation : Promise.reject(new Error("private-cancel-error")); + }, + }, { highWaterMark: 0 }), { status: 503 })) as typeof fetch; + try { + const result = await recoverEncryptedAgentTaskWithResult( + new Request("http://localhost/v1/responses", { headers: codexHeaders() }), encryptedInput(), {}, routedConfig(), + ); + expect(result).toEqual({ recovered: false, reason: "recovery_http_rejected" }); + expect(cancels).toBe(1); + expect(reads).toBe(0); + } finally { + releaseCancel?.(); + } + }); + + test.each(["headers", "body", "caller"] as const)("owned deadline classification at %s preserves cancellation precedence", async site => { + const callbacks: Array<() => void> = []; + const timers = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + callbacks.push(callback); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + const caller = new AbortController(); + let started!: () => void; + const ready = new Promise(resolve => { started = resolve; }); + let fetches = 0; + globalThis.fetch = ((_, init) => { + fetches++; + if (site === "body") return Promise.resolve(new Response(new ReadableStream({ + pull(controller) { + controller.enqueue(new Uint8Array([0xe2, 0x82])); + started(); + return new Promise(() => {}); + }, + }, { highWaterMark: 0 }))); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }); + started(); + }); + }) as typeof fetch; + try { + const pending = recoverEncryptedAgentTaskWithResult( + new Request("http://localhost/v1/responses", { headers: codexHeaders() }), encryptedInput(), {}, routedConfig(), + { abortSignal: caller.signal }, + ); + await ready; + callbacks[0]!(); // Fire the owned deadline without wall-clock sleeps. + if (site === "caller") caller.abort(new TypeError("private-caller-error")); + expect(await pending).toEqual({ recovered: false, reason: site === "caller" ? "caller_cancelled" : "recovery_timeout" }); + expect(fetches).toBe(1); + } finally { + timers.mockRestore(); + } + }); + test("keeps the disabled fail-fast response byte-identical to the absent feature", async () => { const snapshot = async (config: ReturnType) => { let fetchCalls = 0; @@ -226,7 +302,7 @@ describe("agent task recovery (opt-in, default off)", () => { expect(response.status).toBe(400); expect(json.error?.code).toBe("unreadable_encrypted_agent_task"); - expect(json.error?.recovery_reason).toBe("recovery_unavailable"); + expect(json.error?.recovery_reason).toBe("recovery_invalid_output"); expect(fetchedUrls.length).toBeGreaterThan(0); expect(fetchedUrls[0]).toContain("chatgpt.com/backend-api/codex"); }); @@ -778,7 +854,7 @@ describe("agent task recovery (opt-in, default off)", () => { expect(fetchedUrls).toHaveLength(1); expect(fetchedUrls[0]).toContain("chatgpt.com/backend-api/codex/responses"); expect(await response.json()).toMatchObject({ - error: { code: "unreadable_encrypted_agent_task", recovery_reason: "recovery_unavailable" }, + error: { code: "unreadable_encrypted_agent_task", recovery_reason: "recovery_transport_error" }, }); }); }); diff --git a/tests/server/bounded-body.test.ts b/tests/server/bounded-body.test.ts index f5223d34a4..0bf5e0ae1b 100644 --- a/tests/server/bounded-body.test.ts +++ b/tests/server/bounded-body.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { BOUNDED_BODY_MAX_BYTES, boundedBodyBufferGrowthsForTests, + boundedBodyDecodeFailure, readBoundedResponseBytes, readBoundedResponseBody, } from "../../src/lib/bounded-body"; @@ -21,6 +22,65 @@ function responseFromChunks(...chunks: Uint8Array[]): Response { } describe("readBoundedResponseBody", () => { + test("only actual decoder exceptions carry the decode discriminator", async () => { + for (const bytes of [new Uint8Array([0xff]), new Uint8Array([0xe2, 0x82])]) { + let caught: unknown; + try { await readBoundedResponseBody(responseFromChunks(bytes), { fatalUtf8: true }); } + catch (error) { caught = error; } + expect(caught).toBeInstanceOf(TypeError); + expect(boundedBodyDecodeFailure(caught)).toBe("invalid_utf8"); + } + const readerError = new TypeError("private-reader-error"); + const response = new Response(new ReadableStream({ pull(controller) { controller.error(readerError); } })); + let caught: unknown; + try { await readBoundedResponseBody(response, { fatalUtf8: true }); } + catch (error) { caught = error; } + expect(caught).toBe(readerError); + expect(boundedBodyDecodeFailure(caught)).toBeUndefined(); + }); + + test("fatal UTF-8 abort retains the exact caller reason without a decode mark", async () => { + const caller = new AbortController(); + const reason = new TypeError("private-caller-error"); + const pending = readBoundedResponseBody(new Response(new ReadableStream({})), { signal: caller.signal, fatalUtf8: true }); + caller.abort(reason); + let caught: unknown; + try { await pending; } catch (error) { caught = error; } + expect(caught).toBe(reason); + expect(boundedBodyDecodeFailure(caught)).toBeUndefined(); + }); + + test.each([0, 1])("fatal timeout flush retains deadline origin %s and cancels without waiting", async deadline => { + const callbacks: Array<() => void> = []; + const timers = spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void) => { + callbacks.push(callback); + return 0 as unknown as ReturnType; + }) as typeof setTimeout); + let stalled!: () => void; + const ready = new Promise(resolve => { stalled = resolve; }); + let pulls = 0; + let cancelled = false; + const response = new Response(new ReadableStream({ + pull(controller) { + if (pulls++ === 0) controller.enqueue(new Uint8Array([0xe2, 0x82])); + else { stalled(); return new Promise(() => {}); } + }, + cancel() { cancelled = true; return new Promise(() => {}); }, + }, { highWaterMark: 0 })); + try { + const pending = readBoundedResponseBody(response, { fatalUtf8: true }); + await ready; + callbacks[deadline === 0 ? 0 : callbacks.length - 1]!(); + let caught: unknown; + try { await pending; } catch (error) { caught = error; } + expect(caught).toBeInstanceOf(TypeError); + expect(boundedBodyDecodeFailure(caught)).toBe("timeout"); + expect(cancelled).toBe(true); + } finally { + timers.mockRestore(); + } + }); + test("the bounded JSON caller allows a full total deadline for its first byte", () => { expect(UPSTREAM_JSON_BODY_READ_OPTIONS.firstByteTimeoutMs) .toBe(UPSTREAM_JSON_BODY_READ_OPTIONS.totalTimeoutMs); From 2c8ec0bc3684d9079d0c2a50cb0ed5284c56d226 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 10:05:03 +0900 Subject: [PATCH 46/50] docs(devlog): close axis five display and CLI delivery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record credited admin landings, successful feature CI, exact merged-tree focused checks, and the unresolved Mac timing/stall diagnosis. Co-authored-by: Éverton Toffanetto Co-authored-by: 투린 Co-authored-by: Zig Zag --- .../260907_axis5_display_cli/000_plan.md | 4 ++ .../001_roadmap_audit.md | 0 .../260907_axis5_display_cli/010_delivery.md | 0 .../260907_axis5_display_cli/020_delivery.md | 40 +++++++++++++++++++ 4 files changed, 44 insertions(+) rename devlog/{_plan => _fin}/260907_axis5_display_cli/000_plan.md (95%) rename devlog/{_plan => _fin}/260907_axis5_display_cli/001_roadmap_audit.md (100%) rename devlog/{_plan => _fin}/260907_axis5_display_cli/010_delivery.md (100%) create mode 100644 devlog/_fin/260907_axis5_display_cli/020_delivery.md diff --git a/devlog/_plan/260907_axis5_display_cli/000_plan.md b/devlog/_fin/260907_axis5_display_cli/000_plan.md similarity index 95% rename from devlog/_plan/260907_axis5_display_cli/000_plan.md rename to devlog/_fin/260907_axis5_display_cli/000_plan.md index 222ef89899..eb6ed46d27 100644 --- a/devlog/_plan/260907_axis5_display_cli/000_plan.md +++ b/devlog/_fin/260907_axis5_display_cli/000_plan.md @@ -31,3 +31,7 @@ Review-ready requirements remain visible; local suite prohibition is explicitly CI scope refinement: the discovered editor is the final layer so the final commit and PR diff include gui/**, activating GUI lint/build/artifact jobs. ci.yml gates always run GUI tests; docs deployment is NOT dispatched because it publishes. Public docs receive static source consistency inspection here, with docs build explicitly unverified unless an existing build-only remote path is available. CI scheduling refinement: lower-layer head commits may use GitHub documented [skip ci] to avoid push/pull_request suite launches; this yields missing/pending evidence, NOT green. Final head has no skip marker and receives lane=all workflow_dispatch. Source: https://docs.github.com/en/actions/how-tos/manage-workflow-runs/skip-workflow-runs (opened 2026-09-07). Admin merge records this explicit owner-requested lower-layer waiver. Do not propagate skip markers into integration merge messages. + +## Terminal status + +DONE: all three feature layers landed; see 020_delivery.md for exact commits, verification boundaries and deferred Mac test-runner investigation. diff --git a/devlog/_plan/260907_axis5_display_cli/001_roadmap_audit.md b/devlog/_fin/260907_axis5_display_cli/001_roadmap_audit.md similarity index 100% rename from devlog/_plan/260907_axis5_display_cli/001_roadmap_audit.md rename to devlog/_fin/260907_axis5_display_cli/001_roadmap_audit.md diff --git a/devlog/_plan/260907_axis5_display_cli/010_delivery.md b/devlog/_fin/260907_axis5_display_cli/010_delivery.md similarity index 100% rename from devlog/_plan/260907_axis5_display_cli/010_delivery.md rename to devlog/_fin/260907_axis5_display_cli/010_delivery.md diff --git a/devlog/_fin/260907_axis5_display_cli/020_delivery.md b/devlog/_fin/260907_axis5_display_cli/020_delivery.md new file mode 100644 index 0000000000..0b06325172 --- /dev/null +++ b/devlog/_fin/260907_axis5_display_cli/020_delivery.md @@ -0,0 +1,40 @@ +# Axis five delivery record + +Outcome: DONE on 2026-09-07. The three feature layers landed in dev through owner-authorized admin integration. Original contribution credit is present in both carried commits and merge commits. + +| Source | Delivery | Merge commit | +| --- | --- | --- | +| #3627 native OpenAI display names | #3820 | 1e16fe4c077ecf353d79c46873d8039d9176704d | +| #3780 provider list JSONL | #3821 | be24986e5ff8474ca6699895855f0ad9352e9d86 | +| #2716 discovered-model name editor | #3824 | 44c69fdd619b272066113388edd80f6c59b0682a | + +The source pull requests were closed after landing. The late #3627 head 81f150e4 added metadata wording already covered by the delivery; its runtime files were checked byte-for-byte against dev before closure. + +## Delivered behavior + +Native labels are reversible overlays on supported bare native rows. IDs, capabilities and routing remain intact; restoring a label still respects existing pinned Astra normalization. Both retained synchronization and convergence pass the same configuration map. + +JSONL emits one configured-provider object per line using the existing JSON fields. Both conflicting flag orders fail without stdout. Multi-provider parity and escaping are covered, and all translated CLI tables and generated capability documentation were updated. + +The editor preserves exact selectors, validates labels, supports reset, and recovers confirmed saves separately from failed refreshes and unknown transport outcomes. Stalled operations use the existing bounded-fetch mechanism. Draft reconciliation preserves the mounted dialog and focus behavior. Desktop/mobile Korean rendering and save/reset/validation/focus were driven against the compiled CI artifact with disposable fixtures. + +## Verification boundaries + +- Feature head f51ec2421c49df0fd4eac8a9a56a6283b426387d: [Cross-platform CI attempt 2](https://github.com/lidge-jun/opencodex/actions/runs/34068041704/attempts/2), 25 successful jobs. Dashboard tests: 1,737 passed, zero failed. Typecheck, lint, scans and build passed. +- Late platform base changes had zero overlap with the 39-file feature delta and passed [their 26-job CI](https://github.com/lidge-jun/opencodex/actions/runs/34068218011). Independent compatibility review checked the decompression diagnostics and container lifecycle interaction. +- Prospective merge tree 85c9b25818a93859a6d6fc824e2ed0678da46c8f passed 370 focused tests on isolated Linux with project Bun 1.4.0: 344 catalog/CLI tests and 26 editor tests, zero failures. The transmitted source archive SHA-256 was ae191e1f0a809e75c9c198bc92233964880541f6747e4603a47b2c180f773d49. +- The actual final runtime merge 44c69fdd619b272066113388edd80f6c59b0682a has exactly that tested tree. This is focused merged-tree evidence plus full feature-head CI, not a claim of full CI on the final merge commit. +- No local test suite, typecheck or build ran. Pushes used --no-verify. Lower-layer CI was deferred until a final-head failure, and no cancelled or missing check was presented as passing. +- Public documentation was source-reviewed. This axis did not run a documentation build. + +## Diagnostic disposition + +One Mac shard reached its 20-minute limit after an unchanged history-lock test. The full Mac control had two Cursor decoded-frame-silence assertion failures; the separately annotated server-auth stream reset was intentional and its test passed. Only the unsuccessful Mac jobs were replayed, with unchanged source and limits, and they passed. The [baseline control comparison](https://github.com/lidge-jun/opencodex/actions/runs/34069848260) also passed. These observations do not establish the stall or timing root cause. No threshold increase, assertion suppression, or unrelated harness fix was included; deeper investigation remains deferred. + +## Attribution + +- Éverton Toffanetto +- 투린 +- Zig Zag + +The preceding numbered files are the historical roadmap and audits; their original _plan locations refer to the planning phase before this closeout. From 0ccc6bdd5d4014f365451eb540bf89d44860f154 Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 10:14:55 +0900 Subject: [PATCH 47/50] docs: keep delivery attribution without contact addresses [skip ci] --- devlog/_fin/260907_axis5_display_cli/020_delivery.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/devlog/_fin/260907_axis5_display_cli/020_delivery.md b/devlog/_fin/260907_axis5_display_cli/020_delivery.md index 0b06325172..7a2b76d0f8 100644 --- a/devlog/_fin/260907_axis5_display_cli/020_delivery.md +++ b/devlog/_fin/260907_axis5_display_cli/020_delivery.md @@ -33,8 +33,10 @@ One Mac shard reached its 20-minute limit after an unchanged history-lock test. ## Attribution -- Éverton Toffanetto -- 투린 -- Zig Zag +- Éverton Toffanetto +- 투린 +- Zig Zag + +Original author identities remain in the landed commits; this note lists names without contact addresses. The preceding numbered files are the historical roadmap and audits; their original _plan locations refer to the planning phase before this closeout. From b29bbb440aaf70b283445a9c37e194a4a4e6859a Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 10:51:00 +0900 Subject: [PATCH 48/50] fix(clients): preserve exact Aside file identities [skip ci] Use private BigInt stat identities so distinct legal 64-bit file IDs are not collapsed by Number conversion. Preserve all path/link/hardlink and directory replacement checks, manifest bounds, public IO types and serialization. Add semantic high-ID and native link controls with fixture-reachability checks. No local suite was run; the earlier Windows incident IDs remain unmeasured. --- scripts/test-layout/layout.json | 1 + src/clients/aside-profiles.ts | 15 ++- tests/clients/aside-profile-identity.test.ts | 132 +++++++++++++++++++ tests/fixtures/test-layout-expected.json | 1 + 4 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 tests/clients/aside-profile-identity.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f560dffeb5..7547d467c7 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -239,6 +239,7 @@ "aside-profiles-routes.test.ts": "server", "aside-profiles.test.ts": "clients", "aside-profile-paths.test.ts": "clients", + "aside-profile-identity.test.ts": "clients", "aside-profile-sync-owner.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", diff --git a/src/clients/aside-profiles.ts b/src/clients/aside-profiles.ts index 31f13d9b76..770857611a 100644 --- a/src/clients/aside-profiles.ts +++ b/src/clients/aside-profiles.ts @@ -1,4 +1,4 @@ -import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync, type Stats } from "node:fs"; +import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync, type BigIntStats } from "node:fs"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, resolve } from "node:path"; import type { IntegrationIO } from "../integrations/config-io"; @@ -14,7 +14,7 @@ export interface AsideProfile { } const MAX_PROFILES = 128; -const MAX_MANIFEST_BYTES = 4 * 1024 * 1024; +const MAX_MANIFEST_BYTES = 4n * 1024n * 1024n; const MAX_LEAF_LINKS = 40; function refuse(message: string): never { @@ -30,9 +30,10 @@ function object(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } -function inspect(path: string, follow = false): Stats | null { +function inspect(path: string, follow = false): BigIntStats | null { try { - return follow ? statSync(path) : lstatSync(path); + // File IDs can exceed Number's exact integer range; never round identities. + return follow ? statSync(path, { bigint: true }) : lstatSync(path, { bigint: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; return refuse("a filesystem boundary could not be inspected."); @@ -110,10 +111,10 @@ export function listAsideProfiles(env: NodeJS.ProcessEnv = process.env, home: st return readProfiles(root); } -type DirectoryIdentity = { path: string; dev: number; ino: number }; +type DirectoryIdentity = { path: string; dev: bigint; ino: bigint }; type Boundary = Array; -function sameIdentity(a: Pick, b: Pick): boolean { +function sameIdentity(a: Pick, b: Pick): boolean { return a.dev === b.dev && a.ino === b.ino; } @@ -162,7 +163,7 @@ function boundary(profile: AsideProfile, profiles: AsideProfile[], mutation: boo } if (absent) return identities; const leaf = inspect(profile.configPath); - if (leaf && (leaf.isSymbolicLink() || !leaf.isFile() || leaf.nlink > 1)) { + if (leaf && (leaf.isSymbolicLink() || !leaf.isFile() || leaf.nlink > 1n)) { refuse("the model catalog is a link, shared file or non-regular file."); } if (leaf && canonical(profile.configPath) !== join(parent!, "models.json")) { diff --git a/tests/clients/aside-profile-identity.test.ts b/tests/clients/aside-profile-identity.test.ts new file mode 100644 index 0000000000..771966a1c4 --- /dev/null +++ b/tests/clients/aside-profile-identity.test.ts @@ -0,0 +1,132 @@ +import { expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { IntegrationIO } from "../../src/integrations/config-io"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// Capture real delegates before spying. Only fixture inode values are controlled; +// existence, file type, link count, realpath and link resolution remain native. +const nativeLstat = fs.lstatSync; +const nativeStat = fs.statSync; +const FIRST_INODE = 2n ** 53n; +const SECOND_INODE = FIRST_INODE + 1n; + +test("Aside preserves high file identities without admitting shared targets or directory replacement", async () => { + const home = fs.mkdtempSync(join(tmpdir(), "ocx-aside-identity-")); + const root = join(home, ".aside"); + const paths = [0, 1].map(id => join(root, "u", String(id), "models.json")); + const identities = new Map(); + const reads = new Set(); + let observingBoundary = false; + const restoreSpies: Array<() => void> = []; + + function controlledStat(delegate: typeof fs.statSync, kind: "stat" | "lstat"): typeof fs.statSync { + // Preserve fs's overload contract: the native delegate determines the result + // type, including undefined for throwIfNoEntry:false and number vs bigint. + return ((path: fs.PathLike, options?: fs.StatOptions) => { + const stats = delegate(path, options); + const inode = typeof path === "string" ? identities.get(path) : undefined; + if (stats && inode !== undefined) { + if (observingBoundary) reads.add(`${kind}:${path}`); + // Mutate this fresh native result, retaining its prototype and method + // receiver. Spreading Stats would lose native isFile/isDirectory methods. + stats.ino = options?.bigint ? inode : Number(inode); + } + return stats; + }) as typeof fs.statSync; + } + + function observe(run: () => T): T { + reads.clear(); + observingBoundary = true; + try { return run(); } finally { observingBoundary = false; } + } + + try { + for (const id of [0, 1]) fs.mkdirSync(join(root, "u", String(id)), { recursive: true }); + fs.writeFileSync(join(root, "accounts.json"), JSON.stringify({ + currentAccountId: 0, accounts: [{ id: 0 }, { id: 1 }], + })); + for (const path of paths) fs.writeFileSync(path, "{}"); + // Controlled IDs must not hide a runtime lacking native BigInt stat support. + expect(typeof nativeStat(paths[0]!, { bigint: true }).ino).toBe("bigint"); + expect(typeof nativeLstat(paths[0]!, { bigint: true }).ino).toBe("bigint"); + const lstatSpy = spyOn(fs, "lstatSync"); + restoreSpies.push(() => lstatSpy.mockRestore()); + lstatSpy.mockImplementation(controlledStat(nativeLstat, "lstat")); + const statSpy = spyOn(fs, "statSync"); + restoreSpies.push(() => statSpy.mockRestore()); + statSpy.mockImplementation(controlledStat(nativeStat, "stat")); + + // Load after spies so the regression also covers the native named-import seam. + const { assertAsideProfileBoundary, guardAsideProfileIO, listAsideProfiles } = + await import("../../src/clients/aside-profiles"); + const [selected, peer] = listAsideProfiles({}, home); + if (!selected || !peer) throw new Error("fixture requires two profiles"); + const profiles = [selected, peer]; + expect(Number(FIRST_INODE)).toBe(Number(SECOND_INODE)); + expect(FIRST_INODE).not.toBe(SECOND_INODE); + expect(nativeStat(selected.configPath, { bigint: true }).dev) + .toBe(nativeStat(peer.configPath, { bigint: true }).dev); + // Distinct catalogs and directories are allowed even though their Number + // representations collide. + // Reads are recorded only DURING boundary calls, so a missed spy binding + // cannot silently turn this into a passing ordinary-filesystem test. + for (const target of ["configPath", "detectDir"] as const) { + identities.clear(); + identities.set(selected[target], FIRST_INODE); + identities.set(peer[target], SECOND_INODE); + for (const profile of profiles) { + const sibling = profile === selected ? peer : selected; + observe(() => expect(() => assertAsideProfileBoundary(profile, profiles, true)).not.toThrow()); + expect(reads.has(`lstat:${profile[target]}`)).toBe(true); + expect(reads.has(`stat:${sibling[target]}`)).toBe(true); + } + } + + identities.clear(); + identities.set(selected.detectDir, FIRST_INODE); + let delegatedReads = 0; + const io: IntegrationIO = { + readText: () => { delegatedReads++; return { kind: "text", text: "{}" }; }, + statKind: () => "file", + writeText: () => {}, removeFile: () => {}, mkdirp: () => {}, + now: () => 0, appendJournal: () => {}, putRecord: () => {}, dropRecord: () => {}, + }; + const guarded = observe(() => guardAsideProfileIO(selected, io, profiles)); + expect(reads.has(`lstat:${selected.detectDir}`)).toBe(true); + observe(() => expect(guarded.readText(selected.configPath)).toEqual({ kind: "text", text: "{}" })); + expect(reads.has(`lstat:${selected.detectDir}`)).toBe(true); + expect(delegatedReads).toBe(1); + identities.set(selected.detectDir, SECOND_INODE); + observe(() => expect(() => guarded.readText(selected.configPath)) + .toThrow("the account directory changed after the operation began.")); + expect(reads.has(`lstat:${selected.detectDir}`)).toBe(true); + expect(delegatedReads).toBe(1); + + // No synthetic IDs for these controls: real hardlinks and symlinks must + // continue to be refused by the same boundary, with native stat delegates. + identities.clear(); + fs.unlinkSync(peer.configPath); + fs.linkSync(selected.configPath, peer.configPath); + expect(nativeLstat(selected.configPath, { bigint: true }).nlink).toBe(2n); + for (const profile of profiles) { + expect(() => assertAsideProfileBoundary(profile, profiles, true)) + .toThrow("the model catalog is a link, shared file or non-regular file."); + } + fs.unlinkSync(peer.configPath); + fs.symlinkSync(selected.configPath, peer.configPath, "file"); + expect(nativeLstat(peer.configPath, { bigint: true }).isSymbolicLink()).toBe(true); + expect(() => assertAsideProfileBoundary(selected, profiles, true)) + .toThrow("account catalogs share a target."); + expect(() => assertAsideProfileBoundary(peer, profiles, true)) + .toThrow("the model catalog is a link, shared file or non-regular file."); + } finally { + observingBoundary = false; + identities.clear(); + reads.clear(); + for (const restore of restoreSpies.reverse()) restore(); + removeTreeWithRetry(home); + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 2a1cac7ec3..cac40976e1 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -74,6 +74,7 @@ "aside-profiles-routes.test.ts": "server", "aside-profiles.test.ts": "clients", "aside-profile-paths.test.ts": "clients", + "aside-profile-identity.test.ts": "clients", "aside-profile-sync-owner.test.ts": "clients", "assert-mergeable-review.test.ts": "ci-workflows", "auto-compact-budget.test.ts": "providers", From be112e4fae9596950f81fa1b039d5f1f5c365a5d Mon Sep 17 00:00:00 2001 From: t Date: Mon, 7 Sep 2026 11:24:54 +0900 Subject: [PATCH 49/50] docs: close axis1 bug-fix delivery record [skip ci] --- .../260907_axis1_bugfixes/000_plan.md | 2 + .../260907_axis1_bugfixes/010_roadmap.md | 0 .../260907_axis1_bugfixes/011_audit.md | 0 .../260907_axis1_bugfixes/012_roadmap_lock.md | 0 .../020_bounded_fixes.md | 0 .../021_source_review.md | 0 .../260907_axis1_bugfixes/030_delivery.md | 0 .../031_delivery_record.md | 53 +++++++++++++++++++ 8 files changed, 55 insertions(+) rename devlog/{_plan => _fin}/260907_axis1_bugfixes/000_plan.md (96%) rename devlog/{_plan => _fin}/260907_axis1_bugfixes/010_roadmap.md (100%) rename devlog/{_plan => _fin}/260907_axis1_bugfixes/011_audit.md (100%) rename devlog/{_plan => _fin}/260907_axis1_bugfixes/012_roadmap_lock.md (100%) rename devlog/{_plan => _fin}/260907_axis1_bugfixes/020_bounded_fixes.md (100%) rename devlog/{_plan => _fin}/260907_axis1_bugfixes/021_source_review.md (100%) rename devlog/{_plan => _fin}/260907_axis1_bugfixes/030_delivery.md (100%) create mode 100644 devlog/_fin/260907_axis1_bugfixes/031_delivery_record.md diff --git a/devlog/_plan/260907_axis1_bugfixes/000_plan.md b/devlog/_fin/260907_axis1_bugfixes/000_plan.md similarity index 96% rename from devlog/_plan/260907_axis1_bugfixes/000_plan.md rename to devlog/_fin/260907_axis1_bugfixes/000_plan.md index 1fa7537164..ad0da69270 100644 --- a/devlog/_plan/260907_axis1_bugfixes/000_plan.md +++ b/devlog/_fin/260907_axis1_bugfixes/000_plan.md @@ -1,5 +1,7 @@ # Axis 1: measured bug fixes and failure diagnostics +Completed: see [031_delivery_record.md](031_delivery_record.md) for merged commits, final CI, attribution and deferrals. + Archetype: satisfy existing contracts. Trigger: owner assigned axis 1 (#3809, #3464, #3661). Goal: deliver reviewable fixes through a manual PR chain and merge the verified scope. Non-goals: new account/retry policy, auth defaults, multipart recovery, releases, native stacks, sibling edits. Stop: merged feasible scope plus explicit unresolved dispositions. Escalation: defer a policy-dependent or unreproducible slice; reclaim a worker slice after two failed packets. Evidence: this unit plus ignored `.tmp/axis1/` and `.codexclaw` receipts. Resources: task-owned worktree/branches and GitHub repository access; Astra high leaves within host capacity; no caller-specified token or wall-clock budget. Baseline: origin/dev 137d6a727; source PR #3809 at 4a1012359a522ddd6d7ff77203c9e5f3632d605c. Assigned 5cc8 checkout has pre-existing changes and remains untouched. Code lives in /tmp/ocx-axis1-20260907. diff --git a/devlog/_plan/260907_axis1_bugfixes/010_roadmap.md b/devlog/_fin/260907_axis1_bugfixes/010_roadmap.md similarity index 100% rename from devlog/_plan/260907_axis1_bugfixes/010_roadmap.md rename to devlog/_fin/260907_axis1_bugfixes/010_roadmap.md diff --git a/devlog/_plan/260907_axis1_bugfixes/011_audit.md b/devlog/_fin/260907_axis1_bugfixes/011_audit.md similarity index 100% rename from devlog/_plan/260907_axis1_bugfixes/011_audit.md rename to devlog/_fin/260907_axis1_bugfixes/011_audit.md diff --git a/devlog/_plan/260907_axis1_bugfixes/012_roadmap_lock.md b/devlog/_fin/260907_axis1_bugfixes/012_roadmap_lock.md similarity index 100% rename from devlog/_plan/260907_axis1_bugfixes/012_roadmap_lock.md rename to devlog/_fin/260907_axis1_bugfixes/012_roadmap_lock.md diff --git a/devlog/_plan/260907_axis1_bugfixes/020_bounded_fixes.md b/devlog/_fin/260907_axis1_bugfixes/020_bounded_fixes.md similarity index 100% rename from devlog/_plan/260907_axis1_bugfixes/020_bounded_fixes.md rename to devlog/_fin/260907_axis1_bugfixes/020_bounded_fixes.md diff --git a/devlog/_plan/260907_axis1_bugfixes/021_source_review.md b/devlog/_fin/260907_axis1_bugfixes/021_source_review.md similarity index 100% rename from devlog/_plan/260907_axis1_bugfixes/021_source_review.md rename to devlog/_fin/260907_axis1_bugfixes/021_source_review.md diff --git a/devlog/_plan/260907_axis1_bugfixes/030_delivery.md b/devlog/_fin/260907_axis1_bugfixes/030_delivery.md similarity index 100% rename from devlog/_plan/260907_axis1_bugfixes/030_delivery.md rename to devlog/_fin/260907_axis1_bugfixes/030_delivery.md diff --git a/devlog/_fin/260907_axis1_bugfixes/031_delivery_record.md b/devlog/_fin/260907_axis1_bugfixes/031_delivery_record.md new file mode 100644 index 0000000000..439c395f8b --- /dev/null +++ b/devlog/_fin/260907_axis1_bugfixes/031_delivery_record.md @@ -0,0 +1,53 @@ +# Axis 1 delivery record + +Terminal outcome: DONE for the authorized bounded bug/diagnostic scope, with the explicitly listed broader work deferred. Completed 2026-09-07. + +## Delivered + +- #3825 carries #3809 with serving-credential quota attribution, upstream deadline handling, probe-clock preservation and known-reset expiration. Invalid reset metadata does not erase otherwise valid usage; no new unknown-window TTL or synthetic zero was introduced. +- #3826 corrects CLI-versus-proxy version guidance in both directions and prevents false doctor match claims. +- #3827 exposes bounded recovery refusal/timeout/transport/invalid-output reasons through shared flights while preserving admission, success-only caching and caller-local cancellation. +- #3842 is supporting validation work: exact private BigInt file identities preserve existing Aside profile boundaries, including high-ID distinction and directory replacement detection. Public IO/serialization and link refusals remain unchanged. + +## Landing proof + +All four ordinary PRs were merged bottom-up with owner-authorized admin authority. No native stack was registered. Children were retargeted to dev before their parent branches could be automatically deleted. + +| PR | Reviewed layer head | Merge commit | +| --- | --- | --- | +| [#3825](https://github.com/lidge-jun/opencodex/pull/3825) | `d3c70f9d8c8cc6fced7a93577b93e8b141473ea3` | `85fbdb59621046da3db1839a5cce4c7260f99385` | +| [#3826](https://github.com/lidge-jun/opencodex/pull/3826) | `872f0e5aa714f6a2e757510195d1c038ac70e26d` | `860baaf9032fa7ea3030c78ab555608e3325a338` | +| [#3827](https://github.com/lidge-jun/opencodex/pull/3827) | `2e8ef03428f8e619dc92b250fbbc5d5dd7ad53cb` | `5a97db9b20f03a65e714ddc88d2523bea9aeacae` | +| [#3842](https://github.com/lidge-jun/opencodex/pull/3842) | `b29bbb440aaf70b283445a9c37e194a4a4e6859a` | `5fdf9bbdd9ff7657f0b6d7101697317d708af0e7` | + +The runtime integration commit is `5fdf9bbdd9ff7657f0b6d7101697317d708af0e7`. Its full tree `90a75118402d2f310393bef9ac3e4668cfcbdcfa` exactly matches the final combined validation candidate `9470fdb1bc9a02715a3760c36301d3d030a4e4fa`. A fresh fetch and ancestor check confirmed every merge on dev. The candidate included dev `bf85e675484a2391b94b2135bbebe739813a9621` plus all four layers. + +## Verification + +- [Cross-platform CI 34074350604](https://github.com/lidge-jun/opencodex/actions/runs/34074350604): all 26 jobs succeeded at the combined candidate, including Linux, macOS, Windows, Docker smoke, typecheck, privacy, build and operational checks. +- [Service lifecycle 34074351720](https://github.com/lidge-jun/opencodex/actions/runs/34074351720): Linux, macOS and Windows succeeded at the same candidate. +- Independent Astra high source/security audits covered the scoped implementations, merge interactions and exact-identity support. +- All current review threads on the four delivered PRs were resolved after runtime evidence was available. +- No local application test suite or local typecheck ran. Pushes used --no-verify; per-layer CI was deferred by explicit owner instruction. Cancelled and skipped checks were never represented as passing tests. +- Privacy scanning passed. Documentation static build produced 425 pages in 8.23 seconds at 2522264d5; its documentation subtree remained unchanged by the supporting identity fix. Dependencies were installed from the frozen lockfile with install scripts disabled. The build changed no tracked files. +- The assigned pre-existing working-tree changes were preserved; delivery used an isolated worktree. + +## Corrections and remaining limits + +Initial verification exposed incomplete test homes/default configuration and old calendar reset dates in current-measurement fixtures. Those fixtures were corrected without removing behavioral assertions. Known-expiry tests use explicit simulated time. Later review added expired-window handling, field normalization and a global test network guard. + +Imported axis-five closeout contact addresses blocked privacy scanning. [#3836](https://github.com/lidge-jun/opencodex/pull/3836) removed the addresses while retaining author names and all commit attribution; no scanner rule or allowlist was weakened. + +Earlier Windows Aside incidents reported an apparent shared catalog target. Their actual file IDs were not captured. The independently demonstrable Number-precision defect was corrected by #3842, and semantic/native regressions plus the previously failing route case passed in final CI. This does not retroactively prove every earlier incident's raw IDs or cause. + +An earlier Windows outbound-proxy test timed out at its existing 15-second bound. Its scoped test/transport files were unchanged and the stalled phase was not measured. No timeout increase or unrelated proxy repair was made; later passing execution is not a claim that the timing root cause was fixed. + +## Attribution and issue disposition + +Éverton Toffanetto's Co-authored-by trailer is retained in reachable commit `f215f79b4562735029ad5672a68bc6104e534b98`. The issue reporters garysassano and Hu9956 are acknowledged in the corresponding diagnostic commits. Merge commits preserve those commits and trailers. + +The original #3809 was confirmed closed with a landed-via-#3825 marker at final recheck. The initial carry source was 4a1012359; the original author subsequently updated the source PR, so this record does not claim a verbatim merge of its later head. + +#3464 remains open for its broader automatic-repair/request-policy requests. #3661 remains open for multipart reconstruction and recovery retry policy. Those choices were outside this delivery. No release, deployment, new account-selection strategy or authentication-default change was performed. + +The preceding numbered documents are historical plans and audits; their original _plan paths refer to the planning stage. From f99728a2830a456e3f45445c005cf65d2c7b13db Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 7 Sep 2026 16:10:21 +0900 Subject: [PATCH 50/50] fix(combos): preserve scoped quota eligibility --- src/combos/resolve.ts | 35 +++----------------------- tests/codex-integration/combos.test.ts | 31 +++++++++++++++-------- 2 files changed, 25 insertions(+), 41 deletions(-) diff --git a/src/combos/resolve.ts b/src/combos/resolve.ts index bd88e82244..cc0c46990b 100644 --- a/src/combos/resolve.ts +++ b/src/combos/resolve.ts @@ -1,7 +1,5 @@ import type { OcxComboTarget, OcxConfig } from "../types"; import { getCachedProviderQuota } from "../providers/quota-routing-cache"; -import type { ProviderQuota } from "../providers/quota-types"; -import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; import { sleepWithAbort } from "../lib/upstream-retry"; import { coolComboTarget, @@ -61,35 +59,10 @@ export class NoAvailableComboTargetsError extends Error { } } -function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget, now: number): boolean { +function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget): boolean { if (!Object.hasOwn(config.providers, target.provider)) return false; const provider = config.providers[target.provider]; - if (!provider || provider.disabled === true) return false; - // Native account selection owns model-scoped quota; a provider summary cannot veto it. - return isCanonicalOpenAiForwardProvider(provider) - || !cachedProviderQuotaIsExhausted(getCachedProviderQuota(target.provider, now), now); -} - -function quotaWindowExhausted(percent: number | undefined, resetAt: number | undefined, now: number): boolean { - if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 100) return false; - return typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt > now; -} - -export function cachedProviderQuotaIsExhausted( - quota: ProviderQuota | null, - now = Date.now(), -): boolean { - if (!quota) return false; - if (quotaWindowExhausted(quota.fiveHourPercent, quota.fiveHourResetAt, now)) return true; - if (quotaWindowExhausted(quota.weeklyPercent, quota.weeklyResetAt, now)) return true; - if (quotaWindowExhausted(quota.monthlyPercent, quota.monthlyResetAt, now)) return true; - if (quota.customWindows?.some(window => quotaWindowExhausted(window.percent, window.resetAt, now))) return true; - if (quota.creditsUsd?.unlimited !== true - && typeof quota.creditsUsd?.percent === "number" - && Number.isFinite(quota.creditsUsd.percent) - && quota.creditsUsd.percent >= 100 - && quota.creditsUsd.remaining <= 0) return true; - return false; + return Boolean(provider && provider.disabled !== true); } function smoothWeightedIndex( @@ -164,7 +137,7 @@ export function pickComboTarget( const excluded = new Set(options.exclude ?? []); const now = options.now ?? Date.now(); const eligible = (target: Required): boolean => - targetProviderIsUsable(config, target, now) + targetProviderIsUsable(config, target) && !isComboTargetInCooldown(comboId, target, now) && !excluded.has(targetKey(target)) && (options.eligible?.(target) ?? true); @@ -342,7 +315,7 @@ export async function pickComboTargetWithWait( const combo = getCombo(config, comboId); if (!combo) throw new UnknownComboError(comboId); const waitingTargets = combo.targets.filter(target => - targetProviderIsUsable(config, target, now) + targetProviderIsUsable(config, target) && !excluded.has(targetKey(target)) && isComboTargetInCooldown(comboId, target, now) && (customEligible?.(target) ?? true), diff --git a/tests/codex-integration/combos.test.ts b/tests/codex-integration/combos.test.ts index 98174c3848..9969258d1f 100644 --- a/tests/codex-integration/combos.test.ts +++ b/tests/codex-integration/combos.test.ts @@ -722,7 +722,7 @@ describe("combo target cooldowns", () => { expect(slept).toBe(true); }); - test("does not wait for cooling targets with exhausted provider quota", async () => { + test("provider quota summaries do not suppress a bounded cooldown wait", async () => { const now = 50_000; const config = baseConfig({ combos: { @@ -739,12 +739,12 @@ describe("combo target cooldowns", () => { }); coolComboTarget("free", target, { now, cooldownMs: 1_000 }); const sleeps: number[] = []; - expect(await pickComboTargetWithWait(config, "free", { + expect((await pickComboTargetWithWait(config, "free", { now, waitForCooldownMs: 5_000, sleep: async ms => { sleeps.push(ms); }, - })).toBeNull(); - expect(sleeps).toEqual([]); + }))?.target.provider).toBe("a"); + expect(sleeps).toEqual([1_000]); }); test("fails closed without sleeping when cooldown expiry exceeds the wait budget", async () => { @@ -846,7 +846,7 @@ describe("combo failure policy and advancement", () => { })).toBe(true); }); - test("failover skips providers with fresh exhausted quota evidence before dispatch", () => { + test("account-scoped provider quota summaries do not suppress combo targets", () => { const now = 50_000; const config = baseConfig(); setCachedProviderQuotaForTests("a", { @@ -855,7 +855,7 @@ describe("combo failure policy and advancement", () => { updatedAt: now, }); const pick = pickComboTarget(config, "free", { now }); - expect(pick?.target.provider).toBe("b"); + expect(pick?.target.provider).toBe("a"); }); test.each(["pool", "direct"] as const)("defers native %s quota decisions to account and model scoped authentication", mode => { @@ -910,7 +910,7 @@ describe("combo failure policy and advancement", () => { expect(sleeps).toEqual([1_000]); }); - test("still filters exhausted quota on a noncanonical forward destination", () => { + test("does not treat a noncanonical forward destination's summary as target-scoped", () => { const now = 50_000; const config = baseConfig({ providers: { @@ -927,7 +927,7 @@ describe("combo failure policy and advancement", () => { const pick = pickComboTarget(config, "free", { now }); - expect(pick?.target.provider).toBe("b"); + expect(pick?.target.provider).toBe("a"); }); test("retains caller eligibility restrictions for native targets", () => { @@ -965,14 +965,25 @@ describe("combo failure policy and advancement", () => { expect(pick?.target.provider).toBe("a"); }); - test("exhausted credits without an unlimited flag skip the provider", () => { + test("account-scoped exhausted credits do not skip the provider", () => { const now = 50_000; const config = baseConfig(); setCachedProviderQuotaForTests("a", { creditsUsd: { used: 10, limit: 10, remaining: 0, percent: 100 }, updatedAt: now, }); - expect(pickComboTarget(config, "free", { now })?.target.provider).toBe("b"); + expect(pickComboTarget(config, "free", { now })?.target.provider).toBe("a"); + }); + + test("model-scoped custom quota windows do not skip unrelated combo models", () => { + const now = 50_000; + const config = baseConfig(); + setCachedProviderQuotaForTests("a", { + customWindows: [{ label: "Search hourly", percent: 100, resetAt: now + 60_000 }], + updatedAt: now, + }); + + expect(pickComboTarget(config, "free", { now })?.target.provider).toBe("a"); }); test("provider-scoped cooldown skips sibling models but leaves other providers eligible", () => {