[WRONG BRANCH] feat(shadow-call): per-source-model replacement mapping (Plan B) - #1
Closed
yorkane wants to merge 298 commits into
Closed
[WRONG BRANCH] feat(shadow-call): per-source-model replacement mapping (Plan B)#1yorkane wants to merge 298 commits into
yorkane wants to merge 298 commits into
Conversation
Connecting after `ocx start` is the ordinary path: routing is already injected and the Codex journal is owned by the proxy process. Ownership never transfers during connect, because writeJournal() refuses to overwrite a journal whose config is already injected — so the process owner survives into the connected state. disconnectClient() read any non-matching owner as a conflict and refused. That stranded the connection: the operator could not disconnect, and no action available to them would make the check pass. The artifacts were preserved, so nothing was lost, but the connected state had no exit. A process-owned journal records the pre-injection baseline this same tool wrote, so restoring it is exactly the right unwind. The genuine conflict is a journal owned by a DIFFERENT client key, where restoring would tear down another key's routing; that case still refuses, and its existing test still passes. Injected routing with no journal at all now gets its own message. Previously it fell into the ownership error, which named the wrong cause: there is no recorded baseline to restore, so unwinding would be guessing at the original config rather than reading it. The regression test drives the real shape — injected config plus a process-owned journal — and fails against the previous refusal.
… exchange body Two defects the review raised on this phase, both on the unauthenticated pairing path. Plaintext pairing is removed rather than gated. consumeGuiPairingGrant issued an "insecure-http-pairing" session over non-loopback HTTP whenever remoteGui.allowInsecureHttp was true. A reusable grant on plaintext is readable by anything on the path and the session it mints is reusable, so the flag recorded a risk the operator could not bound rather than controlling one. A grant now crosses loopback or authenticated HTTPS only, and no configuration re-opens it. The scheme check also moved ahead of the grant lookup. It previously ran after the grant was found and validated, so a refused exchange still consumed a single-use code — an attacker who strips TLS termination could spend every code the operator prints without ever authenticating. Refusing first leaves the grant intact, which the regression test asserts by replaying the same unspent grant successfully over HTTPS. allowInsecureHttp stays in the schema, marked retired. The config schema is strict, so deleting the key would make an existing config file fail to load entirely; accepting and ignoring it is the smaller harm. The exchange body bound now holds against caller-chosen framing. The endpoint is reachable without a credential, and the pre-check read Content-Length, which the caller controls: omit the header and Number(null ?? "0") is 0, or send chunked and there is no header at all. Both passed the check and reached req.text(), which buffers to completion — an unauthenticated caller decided how much memory the process spent, and the post-check only measured a string it had already been forced to hold. The read now stops at limit+1 bytes and cancels the body rather than draining it. The regression test streams 512 KiB against the 4 KiB limit with no Content-Length and asserts the server pulled fewer chunks than were offered; it fails against the previous implementation. Also repairs the rebase of tests/cli-dispatch.test.ts and tests/cli-registry.test.ts, where dev and this phase appended different tests at the same place. Both sides are kept.
…anch tests/cli-transport-honesty.test.ts flags any runner that awaits a handler and then returns a literal 0, because that erases a failure the handler recorded in process.exitCode. The exemption list requires a verified reason rather than a name, and the connected sync branch has none: handleConnectedSyncCatalogWrite drives app-server restarts, so a failure there must survive. Returns process.exitCode like every other runner. Node types it as number | string; only a numeric code is meaningful to the dispatcher.
Tests that clear a rejected session reach the admin-token fallback, which calls window.prompt. happy-dom does not implement it, so those tests died on a TypeError instead of asserting the behavior they were written for. Most tests in the file never reach the fallback, which is why it stayed hidden. A null-returning stub is the honest stand-in: it means "the operator dismissed the prompt", which is the path these tests want. Installed only when prompt is genuinely missing, so a real implementation is never shadowed.
…uck profile a clean restore Two rollback defects. Both let disconnect report that native Codex state was restored while leaving the user worse off than before they connected. Connect overwrites whatever catalog is already at DEFAULT_CATALOG_PATH. The pre-connect bytes were snapshotted only into an in-memory `priorCatalog`, which covers a connect that fails and rolls back in the same run — not a disconnect, which is a different process on a different day. Durable state recorded only the remote catalog's fingerprint, so disconnect deleted the remote catalog and left the user with none. That is the one artifact a rollback cannot reconstruct from anywhere else: the token can be reissued and the config is journaled, but a catalog the user brought with them is simply gone. The snapshot is now persisted on the connection as `priorCatalog` (base64, or "" for "there genuinely was none") and disconnect writes it back. An older connection with the field absent keeps the previous removal behavior, since nothing recorded what to restore. Ownership is still checked first — a catalog edited since connect belongs to the user and `changed` refuses rather than overwriting it. The result gains `catalogRestored` so the two outcomes are distinguishable instead of both reading as `catalogRemoved`. restoreJournalState set profileRestored = true after a swallowed unlink. When the original profile was absent, "delete the one we generated" failing meant the function still reported complete, which deletes the journal — the only record that the leftover profile is ours. The user is told native state was restored while our profile stays on disk with nothing left pointing at it. Now only a verified removal counts, with ENOENT treated as success because the file being already gone is the outcome the removal wanted. The catalog fix carries a runtime regression driven red against the previous behavior. The profile fix is asserted source-level, and the test says why: making unlink fail requires denying writes on the Codex home, which denies the atomic config write earlier in the same function, so the branch is unreachable from a test process. Asserting a fabricated runtime failure would prove less than asserting the shape.
…ed (first oracle dogfood boot)
…t (oracle dogfood)
…he D1/D2 client side Five defects on this phase, plus the client half of two contract changes the earlier phases made on the server side. /api/machine/* is declared. The seven routes this phase adds were absent from the headless parity table, so tests/cli-headless-parity.test.ts failed on undeclared endpoints. They are not undocumented: status/clients mirror `ocx connect status`, sync mirrors `ocx sync`, shim mirrors the client integration commands, and disconnect mirrors `ocx disconnect`. hub-relay is the transport those commands select with --management-transport relay rather than a verb of its own. Declared as one prefix with that mapping written down. Relay actually works now. connectClient() threw "relay management transport is not available before Remote Hub Phase 4" — but this IS phase 4, and the machine listener plus hub-relay both land here. A documented option that always threw was worse than an undocumented one. A supervised client comes back after disconnect. scheduleStandaloneRecycle() skipped its own respawn when OCX_SERVICE=1, correctly leaving the restart to the supervisor, then exited 0. The real supervisor configs are failure-only (systemd Restart=on-failure, WinSW onfailure, the Task Scheduler ERRORLEVEL loop), so a clean exit reads as "finished" and nothing restarts — the client stayed down until someone noticed. Now exits 1 under supervision, the same policy the dashboard recycle already uses. launchd KeepAlive was fine either way. D1 client side: --allow-insecure-http is removed from the CLI, the connect options, and the hub client. The hub refuses plaintext pairing outright now, so keeping the flag would only spend a single-use grant against a certain rejection. The client checks the same rule locally and refuses before sending. D2 client side: the catalog fetch is unconditional. /v1/catalog emits no validator, so If-None-Match had nothing to match and connect's "initial hub catalog did not include a fresh ETag" check would have failed every connection. The stored catalogEtag becomes catalogFingerprint — our own hash of the bytes we wrote. That value was never a cache concern: it answers "is the file on disk still ours" before disconnect removes it, which needs no server participation. usage/summary.ts keeps dev's per-attribution filtering and this phase's per-key entry slice; the comment now says which filter operates at which level, because they are deliberately different.
…ing the commit (dogfood)
… its test The relayed pairing request went out unauthenticated. submitConnectPairing took `fetchImpl: typeof fetch = fetch`, and a default parameter binds the global as it was when the module was evaluated — the unwrapped original, not the wrapper installApiAuthFetch puts on window.fetch. The relay needs the machine-session headers that wrapper attaches, so the hub refused the exchange. Resolved at call time now. The transport also moves to its own module. One file exported both a transport function and a component, which react-refresh/only-export-components flags for good reason; the previous shape carried an eslint-disable instead. The two have no reason to share a file: the transport is testable without React and the form has no logic beyond calling it. tests/connect-pairing.test.ts passed alone and failed in the full GUI run. App calls installApiAuthFetch() at module scope, so it runs on first import only; a later test importing App gets the cached module and no install, leaving the wrapper bound to whichever window imported it first. The test now binds the wrapper to its own window before mounting, and claude-toggle-race.test.tsx clears the install latch in afterEach alongside the window it closes. Both are test isolation rather than product behavior.
… machine plane Three things a user who never enabled remote hub was paying for. Every dashboard load fired GET /api/machine/status. Discovery ran unconditionally and inferred standalone FROM the resulting 404, so the browser announced the feature's existence on every paint of a plain install. The server already injects session meta into the served document, so it now states the runtime role there too and the client reads it instead of asking. A missing tag reads as standalone, which covers an older server, a separately hosted GUI, and the Vite dev server — all of which should make no remote-hub request. The role meta is emitted independently of the session block. A standalone install never issues a GUI session, so tying the role to session issuance would have left exactly the case that needs it with nothing to read. The page body was gated behind targetsSettled, so a standalone user saw "Discovering local and shared targets…" before their own dashboard. Standalone now starts settled: there is nothing to discover, so there is nothing to wait for. A failed discovery replaced the entire body with a machine-plane error. A slow or restarting proxy cost a standalone user their dashboard over a plane they never turned on. It is a banner now; the requests that actually need the machine plane still report their own failures. Regressions are driven red against the previous behavior: standalone discovery makes zero fetches across null/standalone/hub roles, and a client role still discovers, so the tag narrows who asks rather than removing discovery.
…lly reachable tests/loopback-listener-admission.test.ts asserted that every non-hub role is refused with "requires runtimeRole hub", looping over undefined, standalone, and client. The client case is refused earlier, by the separate rule that a client role needs a complete client connection block, so the assertion was testing an ordering that two independent validation rules never promised. Split rather than loosened. undefined and standalone still assert the exact ingress message. client asserts only that it is refused, because that is the guarantee the config actually makes for a role that is incomplete on its own. A second case closes the gap this would otherwise open: with a COMPLETE client connection, so the earlier rule no longer fires, the ingress rule is what refuses. Without it, the weaker client assertion could pass even if the ingress rule stopped applying to that role entirely. Also keeps both sides of two rebase conflicts. This phase inserts a management ingress pairing-exchange test ahead of the plaintext-pairing test that phase 2 rewrote, and structure/01_runtime.md gained a codex-cli-update sentence on dev and a hub-management-listener clause here; both rows carry both.
The dashboard 1M toggle writes providerContextCaps.openai = 922000 for the whole native group, but narrowToLimits only RAISED a window for members of NATIVE_GPT56_FAMILY. Astra ships its own 272k/872k pair and was removed from that family so it would stop inheriting the measured 922k clamp, which silently took the opt-in path with it: the toggle moved every other native and left Astra pinned at 272k. Read the opt-in ceiling per slug instead. The family keeps its measured 922k; a self-described native uses its own maxContextWindow, so the shared 922k lever raises Astra to 872k rather than advertising a ceiling the model does not have. Verified live: with the toggle on, /v1/models reports 922000 for gpt-5.6-sol and 872000 for gpt-6-astra.
…ptin-window fix(codex): let the 1M opt-in raise gpt-6-astra to its own ceiling
…psed The 260904 dashboard-minimal roadmap traded working controls for visual quiet, and the result cost real function. Reverts lidge-jun#3382 (sidebar footer), lidge-jun#3387 (dashboard home), lidge-jun#3390 (models catalog), lidge-jun#3395 (usage) and lidge-jun#3399 (the i18n prune that removed those surfaces keys). What comes back: the labelled sidebar footer rows instead of two rows of unlabelled 28px orbs; the v1/base/v2 subagent surface switch inline on Models, which is a primary control and not an advanced disclosure; the ultra-mode effort controls; the sidecar and memory cards without their closed disclosure; and the Usage active-days card with its heatmap inline rather than behind a 일별 활동 details. The v1/base/v2 switch is kept in BOTH homes: the revert restores Models, and UltraModeState/UltraModePatch keep multiAgentMode so the Subagents copy added by lidge-jun#3390 still reads and writes /api/v2. Three imports the revert left dangling (Tooltip, IconInfo, TKey) are restored alongside it. dashboard-tabs.test.ts anchored its .page-tabs CSS lookup on a bare substring, which now matches an earlier descendant rule added after it was written; it reads the base rule at line start instead.
…-dashboard-affordances revert(gui): restore the dashboard affordances the minimal pass collapsed
Reverts lidge-jun#3391. That change hid the tabs for uninstalled file clients behind a 다른 클라이언트 (N) button outside the tablist, and folded their overview cards under a closed 설치되지 않음 (N) details. Both are back inline: the full tab strip wraps to two rows and every client card is visible, along with the page subtitle and the summary last-change cell it also removed.
…-integrations revert(gui): show every Integrations client without a disclosure
…t card (lidge-jun#3423) * fix(codex,gui): restore the plan and ticket badges on the main account card The main account card showed neither its plan badge nor its reset-credit ticket badge, while every pool card showed both. Two independent causes: The plan badge was simply absent from the main card's badge row. codex-account-pool-cards.tsx renders it for pool accounts; the main card never did, even though the server has always sent `plan`. The ticket badge had a data cause. `poolAccountDto` serializes the merged quota store, because `commitPoolQuotaResponse` re-reads `getAccountQuota()` after committing. The main DTO instead serialized the raw WHAM parse result and reached into the store for `updatedAt` alone, so a `resetCredits` the store had carried forward never reached the response. `/wham/usage` includes `rate_limit_reset_credits` only intermittently, so the badge vanished on every response that omitted it and `CodexTicketBadge` returned null. The fix carries only `resetCredits`, and not from the store. `__main__` is an alias: `auth.json` can be swapped for another account while the proxy is down, and `reconcileMainCodexAccountRuntimeState` cannot purge alias-keyed state on its first observation after a restart, so a disk-hydrated entry may belong to the previous login. The carried count is therefore an in-process observation tagged with the account id it was read from, released only while that identity still matches. Window fields are untouched, so the monthly-only clearing behaviour from lidge-jun#382 is unaffected. Verification: bun test tests/codex-auth-api.test.ts 199 pass / 0 fail; both new tests were driven red first (removing the DTO fix fails the carry test, removing the identity guard fails the leak test). typecheck, lint:gui and privacy:scan exit 0. * docs(devlog): record the main-card badge parity audit outcome and render evidence --------- Co-authored-by: jun <jun@lidge.dev>
…420-badges [WRONG BRANCH] release: promote dev onto main for the main-account badge fix
Add a managementAuthDisabled config flag (loopback binds only) that bypasses admin-token auth on /api/*, so the dashboard and management API are directly accessible without credentials in a local single-user deployment. Changes: - src/types/config.ts + src/config.ts: new optional boolean managementAuthDisabled (defaults false). - src/server/management-auth.ts: requireManagementAuth returns null immediately when the flag is on and the bind is loopback. - src/server/management/config-routes.ts: GET/PUT /api/settings expose the flag alongside the other settings. - GUI: SettingsData carries the field; use-dashboard-data adds a toggleManagementAuth; dashboard-overview renders a switch panel; i18n keys added (en + zh canonical, others as English placeholders). The flag is guarded to loopback hostname so it can never take effect on a public listener. 10100 instance remains token-gated.
When managementAuthDisabled is on (loopback only), the management API origin gate still required an exact match against the process-derived origin (http://127.0.0.1:PORT). A browser visiting http://localhost:PORT sends Origin: http://localhost:PORT, which did not match, so every async dashboard request returned 403 "cross-origin request blocked" even though auth was disabled. Relax the origin check under managementAuthDisabled to accept any loopback origin (localhost/127.0.0.1/::1), not just the exact process origin. Non-loopback binds are unaffected by the guard.
Add a disableOriginCheck config flag that bypasses all origin/CORS gates on both the data plane and management API, so an external reverse proxy (e.g. https://10101-235.ai-t.wtvdev.com) can reach the dashboard and API without 403 cross-origin blocks. When enabled, isAllowedRequestOrigin and isAllowedManagementOrigin return true immediately, accepting any Origin header. Also adds corsAllowOrigins to the zod schema (it existed only in the type before) so it persists. Exposes the flag on GET/PUT /api/settings and adds a dashboard toggle alongside the management-auth switch. i18n keys added for all locales. This is a security trade-off: only enable when you understand the proxy is reachable by arbitrary origins. Pair with managementAuthDisabled for fully open local access, or keep auth on and use corsAllowOrigins for a narrower allowlist.
The per-source mapping UI was hard to read: the header row listed every source model in a warning badge followed by one dropdown, which read as "all models map to this target", and the per-source rows dimmed their labels at 0.6 opacity so the source-to-replacement association was not clear. Restructure the section: - header row keeps only the title, tooltip, and enable switch - the shared fallback dropdown moves to its own row explicitly labeled "Fallback for unlisted source models" (new models.shadowCallFallback i18n key, all locales) - each per-source row renders as a clear "<source> ->" label at full weight (models-shadow-source-name class) next to its replacement select Also drops the now-unused shadowSourceModelBadge import.
Replace the shared fallback dropdown with user-defined custom mappings: an operator can type any source model id and map it to a replacement, not just the five built-in ChatGPT-native slugs. Backend: - shadowCallReplacementFor now matches against the configured sourceModels instead of the baked-in defaults, so custom source ids actually resolve their modelMap entry (previously a custom id fell through and was never intercepted). - PUT /api/shadow-call-settings accepts sourceModels (array of non-empty strings) alongside modelMap, persisting custom source registration. GUI: - The five built-in source rows are fixed; the shared fallback row is removed. A new custom-mapping row (text input for the source id + replacement select + Add) appends entries rendered with a delete button. Adding also registers the source in sourceModels; deleting removes it from both.
# Conflicts: # src/server/management-auth.ts # src/server/management/config-routes.ts # src/server/responses/core.ts
yorkane
force-pushed
the
codex/shadow-call-per-source-modelmap
branch
from
September 4, 2026 09:58
fb0cb27 to
9a18fbb
Compare
|
✅ Deterministic PR hygiene checks passed. |
⏳ DRAFT
What to do
Its title has been prefixed with |
…calls Routed providers that hallucinate client tools they were never told about (e.g. update_plan / collaboration__update_plan behind a shadow route) previously failed the whole turn with a fail-closed 502 from the lidge-jun#1700 undeclared-tool guard. Providers may now declare undeclaredToolAllowlist: matching phantom calls are silently dropped — streaming announcements, deltas, completions, and the output_item entries inside response.completed snapshots — so the turn finishes with only the legitimate tool calls. Names are matched both bare and namespace-flattened (ns__name). Default off; everything else keeps the fail-closed behavior. The field is registered in the provider editor field policy, so it is editable from the dashboard JSON editor without any GUI change. Regression coverage: tests/undeclared-tool-phantom-allowlist.test.ts
A routed model that serializes a call to another tool (a JSON object with quoted keys, or an XML parameter tag) into the freeform exec input hands the client JavaScript that is illegal at program position: an object literal with quoted keys opens a block statement with a string label, and a program-leading parameter tag never parses. The client VM answers with an opaque Unexpected-token error that weak routed models usually cannot act on, so the turn silently ends mid-task. Both bridges (streaming and batch) now replace dead-on-arrival exec bodies with a directive throw that names the actual mistake and shows the correct emission shape, so the model sees an actionable error and retries properly instead of stalling the turn. Detection is deliberately narrow - only a program-leading quoted-key object or parameter tag - and only for the top-level functions-namespace exec tool. Every other freeform body, including all genuine JavaScript, remains byte-identical; the pass-through wire was checked and is left alone because its bytes go to non-JS clients. Regression coverage: tests/exec-envelope-repair.test.ts (detector shapes, streaming repair + byte-exact twin, batch repair).
…back A routed model that calls the client tool namespace itself (tools, collaboration, ...) instead of a concrete tool is a container leak: the intent is real, but the name never maps to a callable. Silently dropping it loses both the intent and the learning signal, so the model keeps repeating the mistake. When such a name hits the phantom allowlist AND a declared freeform exec channel exists, the phantom is now rewritten into a synthetic exec call whose body throws a directive error naming the namespace and showing the correct flattened namespace__toolname form. The client runs the call as an ordinary tool execution and reports the thrown message back to the model, which then retries correctly. Non-namespace phantoms still drop silently; without a declared exec channel the fallback is the previous silent drop. Regression coverage: tests/exec-envelope-repair.test.ts gains a namespace-leak describe block (sandbox namespace, collapsed prefix, non-namespace twin, no-exec fallback, batch path).
…tom guard Routed models frequently emit a Codex tool in a different naming form than the request declared: the bare sub-agent name (spawn_agent for collaboration__spawn_agent), the dotted namespace form (collaboration.spawn_agent), the historical functions__ prefix, or a fully namespaced name when only the bare form is declared. Each currently dies on the undeclared-client-tool guard, failing a turn whose intent was valid. repairEmittedToolName runs between normalization and the phantom guard in both bridges: a declared name passes through; otherwise the candidates are the functions-prefix strip, the dotted-to-flattened form, the unique namespace__name suffix match for a bare name, and the bare tail of a namespaced name. Exactly one match rewrites the call; zero or several leave it for the existing guard. Declared names, ambiguous names, and unknown names are byte-identical. Regression coverage: tests/emitted-call-shape-repair.test.ts (unit matrix plus streaming and batch bridge paths, including the fail-closed twin).
Q38-class routed models compose the exec sandbox namespace onto real tool names (tools__web_run for the declared web__run). repairEmittedToolName now strips the tools__/tools. prefix and retries against the declared set, with a separator-insensitive fallback for the common __ -> _ collapse. When the remainder is declared, the intent survives; when it is not, the call falls through to the existing phantom rules unchanged. Regression coverage: tests/emitted-call-shape-repair.test.ts gains a sandbox-namespace describe block (prefix strip, dotted form, remainder-undeclared twin).
Four separately-patched symptoms of routed models emitting a Codex tool by the wrong name (shape repair, namespace leak, phantom drop, fail-closed) each lived where it was first observed, spreading one decision across three files and making the precedence between them implicit. resolveEmittedCall() states that order once: 1. shape repair unique mapping back to a declared tool -> rewrite 2. leak feedback namespace container called as a tool -> directive error 3. phantom drop known hallucination for this provider -> remove 4. fail closed anything else -> 502, never relay an unknown call Repair only fires on a UNIQUE match; zero or ambiguous matches fall through untouched, since guessing between two real tools beats interrupting the turn only in theory. Pure refactor: the streaming and batch paths in bridge.ts now resolve one verdict instead of re-deriving the decision, with no behaviour change. Covered by the four pre-existing suites plus tests/emitted-call-guard.test.ts, which pins the layer order (repair outranks drop; ambiguity never guesses). An onDecision hook reports declared/repaired/namespace-leak/phantom-drop/ undeclared per call, turning the "add a name to the allowlist when someone notices" loop into something measurable.
Owner
Author
|
关闭:误建的分支 PR([WRONG BRANCH]),源分支是日常开发分支,每次 push 都触发整套 CI 且必然红(目标分支不对)。有效提交走上游 PR lidge-jun#3451 / lidge-jun#3452。 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Expands the shadow-call intercept from a single replacement target to per-source-model replacement mapping (Plan B), so each ChatGPT-native helper model (luna / sol / terra / 5.5 / 5.4-mini) can route to a different third-party model.
Background
The previous design had one shared
shadowCallIntercept.model— every intercepted source model was rewritten to the same target. Default source coverage was justgpt-5.6-luna.Changes
Defaults expanded — the default
sourceModelslist now covers the full ChatGPT-native helper lineup:Per-source mapping — new
shadowCallIntercept.modelMap: Record<string, string>:"gpt-5.6-luna"), each value is the replacement model id.modelMaptakes precedence over the sharedmodelfallback.modelMapandmodelis left native (not intercepted).This lets an operator intercept luna → deepseek, sol → xai/grok, while leaving terra native — all in one config.
Routing (
src/server/responses/core.ts) — the route point resolves the replacement viashadowCallReplacementFor(); when no replacement is configured for a source, the request passes through unmodified.Management API (
/api/shadow-call-settings) — GET/PUT now read/writemodelMap; every replacement target is validated (resolves to a provider, does not self-intersect).Migrations — legacy OpenAI-multi rewrite and provider-id rewrite both cover
modelMapvalues.GUI — the Models page renders a per-source replacement dropdown row for each source model when shadow-call is enabled, alongside the existing shared fallback dropdown.
Files changed
src/lib/shadow-call.tssrc/types/config.tssrc/server/responses/core.ts,src/server/responses.tssrc/server/management/config-routes.ts,src/server/management/shadow-call-validation.tssrc/providers/openai-tiers.ts,src/providers/provider-id-rewrite.tsgui/src/pages/Models.tsx,gui/src/pages/dashboard-shared.ts,gui/src/pages/models-shared.ts,gui/src/pages/shadow-call-source.ts,gui/src/styles-models-workspace.csstests/responses-shadow-intercept.test.tsBackward compatibility
shadowCallIntercept.model(nomodelMap) keep working as the shared fallback.modelMapis optional and additive.Test plan
bun run typecheck— 0 errorstsc -b+vite build— passesbun test ./tests/responses-shadow-intercept.test.ts— 23 pass / 0 failbun test ./tests/provider-id-rewrite.test.ts— 14 pass / 0 failbun test ./tests/openai-provider-option-migration.test.ts— 31 pass / 0 failgpt-5.6-luna→tencent/glm-5.2end-to-end verified on a live 10101 instance