diff --git a/devlog/_plan/260905_grok_responses_default/000_plan.md b/devlog/_plan/260905_grok_responses_default/000_plan.md new file mode 100644 index 0000000000..20b75f4b59 --- /dev/null +++ b/devlog/_plan/260905_grok_responses_default/000_plan.md @@ -0,0 +1,41 @@ +# Grok Responses default and Chat opt-in + +- Loop archetype: spec-satisfaction, one product slice / one PABCD cycle. +- Trigger: owner request to restore Responses, expose Chat through GUI and CLI, open a PR and admin-merge it. +- Goal: Grok 4.5/4.6 OAuth Responses callers use native Responses; existing Chat overrides are removed once on upgrade per owner steering, and subsequent operator choices remain authoritative. +- Non-goals: API-key default changes, other inbound defaults, other Grok models, tier policy, new endpoints, credential changes, release/deploy or restarting the live dogfood service. +- Class: C4 because owner steering requires a one-time persisted configuration migration. +- Verifier: exact-head GitHub CI (typecheck, runtime and GUI tests, GUI build, privacy and docs checks), isolated GUI interaction and CLI invocation. No local test suites or typecheck; pushes use `git push --no-verify`. +- Stop condition: acceptance below plus PR merged into dev with fetched ancestry proof. +- Memory artifact: this unit and numbered implementation/check record, all in the bound worktree. +- Expected terminal outcomes: DONE, or NEEDS_HUMAN if authority/external access prevents completion. CI failure is work to diagnose, not permission to weaken the gate. +- Escalation: no unrelated changes or live account/service mutations. Main owns implementation; independent read-only reviewer checks plan and diff. Reclaim failed review after two distinct failed dispatches. + +## Current evidence and reuse + +Baseline `c4701938c`: clean detached app worktree, equal to fetched origin/dev; adopted in place as `codex/grok-responses-default-chat-optin`. +`src/providers/registry.ts:1263` owns exact-model, OAuth/Responses-scoped defaults. `src/server/adapter-resolve.ts:23` already gives explicit modelAdapters precedence. `src/providers/xai-responses-opt-in.ts:8` currently reports stored entries rather than effective wire. `src/server/management/provider-routes.ts:392` owns the atomic switch patch. `src/cli/provider-runtime.ts:55` already sends provider edits to that endpoint. `gui/src/components/provider-workspace/ProviderAuthPanel.tsx:40` already owns a pending/error/mixed-state switch. + +No-code alternatives: doing nothing does not flip the shipped default; per-user configuration would not implement the product request; deleting Chat support removes the required rollback. Reuse existing registry defaults, modelAdapters, provider edit, and GUI switch. Do not introduce a second persisted preference or a second route. + +## Acceptance + +1. Unconfigured OAuth Grok 4.5/4.6 Responses requests resolve to Responses. Startup removes old Chat overrides once for this same built-in xai OAuth scope. A persisted per-provider version prevents reapplying the upgrade over subsequent Chat opt-in. Custom provider IDs, key auth, translated Chat/Anthropic defaults and other Grok models remain unchanged; removal of an old explicit per-model override also restores those inbounds to their own defaults. The reserved xai OAuth provider is name-pinned to the Grok CLI destination regardless of saved baseUrl (`src/providers/xai-transport.ts:170`); it is not a custom transport. +2. API/DTO state follows effective Responses-inbound routing: no entries means true on the canonical OAuth preset; explicit Chat for one means mixed; both Chat means false. Explicit Responses on just one is not mixed when the other already defaults to Responses. +3. Existing `xaiResponsesOptIn` boolean API remains compatible in visible intent: true selects Responses, false selects Chat explicitly. Both update only the two owned entries; malformed/non-xAI writes still fail. +4. GUI shows Chat Completions selection. Off means both Responses; on means both Chat; mixed click selects Chat for both. Pending/error and authoritative server echo remain intact. All locale copy agrees. +5. `ocx provider edit xai --xai-chat on|off [--json]` uses that same live API, validates input, and preserves unrelated configuration. Help and docs expose the option. +6. Existing sanitizer/replay, tier-isolation, transport, API, CLI, and component regressions run on CI; no tests are disabled. Screenshot is inspected and included in the templated PR. Admin bypass is disclosed, never self-approved. +7. Migration is idempotent and rebased on fresh disk state under the existing mutation lock. Read-only config APIs do not migrate. Failed persistence preserves disk bytes and reports an in-memory-only upgrade. Switch writes mark the migration complete so an intentional Chat selection survives restart even after startup persistence failed. + +## Steering at A + +Owner explicitly rejected preserving pre-upgrade Chat settings. Amend the single slice with a one-time migration; keep the previously declared canonical OAuth/Responses default scope. No changes to live user settings in this development task. A new provider marker is upgrade bookkeeping, not a parallel wire preference. + +## Audit fold-back + +Round 1 FAIL: (1) custom URL promise was imprecise, (2) POST overwrite could lose opt-in/marker, (3) switch could lower future version. Resolve (1) by documenting actual name-pinned OAuth transport, not inventing a new endpoint guard inconsistent with Fast authority; test custom provider IDs. Fold (2) into existing POST retention using the latest live row after DNS awaits; preserve omitted modelAdapters plus marker. Fold (3) by preserving the maximum of the existing version and 1. Runtime marker classification and real startup/restart coverage are also required. + +## Verification execution + +The requested no-local-suite restriction supersedes local preflight requirements. Read the repository workflow and scripts to confirm test target coverage; execute them on CI, not locally. `git diff --check` and help/isolated UI invocations are local non-suite checks. If remote CI does not cover an acceptance row, use an isolated remote checkout for a focused command. diff --git a/devlog/_plan/260905_grok_responses_default/002_first_output_rca.md b/devlog/_plan/260905_grok_responses_default/002_first_output_rca.md new file mode 100644 index 0000000000..991c60cddc --- /dev/null +++ b/devlog/_plan/260905_grok_responses_default/002_first_output_rca.md @@ -0,0 +1,9 @@ +# Native Responses first exec output + +The shell succeeds, but a bare awaited helper call is not an output operation in the code-mode host. Three observed first-round scripts discarded their returned values; each host result contained only an empty completion wrapper. The retry emitted the result with `text(...)` and was usable. Request-level HTTP 200 and one upstream send do not prove that tool code emitted output. + +Competing explanations: shell failure was contradicted by populated nested execution records; proxy truncation was contradicted by already-empty original host results; missing explicit emission matched the failing scripts and the successful retry. + +The translated adapters already share a first-call echo instruction and empty-result explanation. Native Responses custom-tool lowering instead advertised a bare `await tools.exec_command(...)` example and omitted the shared first-call guidance. Restore that guidance and the paired empty-result explanation on the native routed path. Keep valid JavaScript unchanged: the proxy cannot safely infer arbitrary program intent or reconstruct a result the host never emitted. + +Regression evidence must include outbound guidance on the first native call, an executable echo example that emits exactly once, untouched populated/multimodal results and native OpenAI traffic, and a synthetic live Grok first-result roundtrip. Never commit the private task payload or user command output. diff --git a/devlog/_plan/260905_grok_responses_default/010_default_and_controls.md b/devlog/_plan/260905_grok_responses_default/010_default_and_controls.md new file mode 100644 index 0000000000..11717765cd --- /dev/null +++ b/devlog/_plan/260905_grok_responses_default/010_default_and_controls.md @@ -0,0 +1,76 @@ +# Implementation slice + +Dependencies: existing modelWireDefaults, modelAdapters, provider PATCH and startup migration pattern. One new persisted version marker; no new wire enum. + +| Action | Path | Before -> after | +| --- | --- | --- | +| MODIFY | `src/providers/registry.ts` | Grok 4.5/4.6 `wire: openai-chat` -> `openai-responses`; keep inbound/auth/tier fences | +| MODIFY | `src/providers/xai-responses-opt-in.ts` | Stored Responses equality -> explicit allowed override, registry default, provider adapter; derive true/mixed/false for Responses inbound | +| MODIFY | `src/providers/xai-responses-opt-in.ts` | Add pure idempotent `migrateXaiResponsesDefault(config)`; if version < 1 and canonical OAuth defaults select Responses, copy provider/map, remove only old Chat entries for 4.5/4.6, mark version 1 | +| NEW | `src/server/xai-responses-startup.ts` | Follow `subagent-models-startup.ts`: project, mutate fresh disk under lock, return whole rebased config; warn/fall back to projection when unavailable | +| MODIFY | `src/server/index.ts` | Wrap the existing startup migration result before live consumers initialize; no async changes | +| MODIFY | `src/types/provider.ts`, `src/config.ts` | Declare `xaiResponsesDefaultVersion?: number`, positive integer optional with degraded invalid load; preserve future versions | +| MODIFY | `src/server/auth-cors.ts` | Classify the marker as runtime-owned in PROVIDER_CONFIG_FIELD_POLICY; raw editor must not remove/replace it | +| MODIFY | `src/server/management/provider-routes.ts` | false deletes entries -> false writes explicit `openai-chat`; true remains explicit Responses; mark version 1 on either operator choice | +| MODIFY | `src/server/management/provider-routes.ts` | Existing xai POST replacement retains omitted modelAdapters and migration version from the latest live row after DNS; switch version uses max(existing, 1), never downgrades future version | +| MODIFY | `src/cli/provider-runtime.ts` | Add `--xai-chat on|off`, parsed with takeBooleanOption; xAI-only guard; map to `xaiResponsesOptIn: !xaiChat` | +| MODIFY | `src/cli/provider.ts` | Add a provider-edit example documenting both directions | +| MODIFY | `gui/src/components/provider-workspace/ProviderAuthPanel.tsx` | Rename private control to Chat; checked when Responses state is false; next Responses value is `state === false`; fallback initial state true | +| MODIFY | `gui/src/i18n/*.ts` | Replace three old Responses opt-in keys with Chat selection keys and translated descriptions; same layout/styles | +| MODIFY | `tests/server/adapter-resolve.test.ts` | Native default expectation; add explicit Chat, omitted auth, custom destination and effective-state cases | +| MODIFY | `tests/server/config.test.ts` | One-time migration, post-upgrade opt-in retention, schema persistence/future version, no-change custom/key/other provider, fresh-disk rebase, read-only load, persistence failures | +| MODIFY | `tests/server/server-startup-reconcile-resilience.test.ts` | Real startServer upgrades both legacy Chat overrides, persists marker, and subsequent restart preserves new Chat choice | +| MODIFY | `tests/routing/fastwire-policy.test.ts` | Native OAuth default expectation, no caller tier promotion | +| MODIFY | `tests/server/management-provider-validation.test.ts` | Make mixed fixture truly mixed; assert both explicit Chat entries after false, persisted parity and effective routing | +| MODIFY | `tests/server/management-provider-validation.test.ts` | POST overwrite retains later Chat selection and future migration marker; malformed/non-xAI writes still rejected | +| MODIFY | `tests/cli/cli-headless-parity.test.ts` | on/off PATCH parity, invalid value/wrong provider make no request | +| MODIFY | `gui/tests/provider-xai-responses-optin.test.tsx` | Inverted checked state and payload; mixed normalization, pending/failure/server-echo behavior | +| MODIFY | `docs-site/src/content/docs/reference/configuration/providers.md` | Default scope and GUI/CLI Chat selection instructions, legacy API behavior | +| MODIFY | `structure/04_transports-and-sidecars.md` | Replace obsolete Chat-default rationale with current bounded Responses default and explicit rollback | + +Use existing files, so no test-layout registry additions. Capability surface currently records selected commands, not the provider-edit flag list; only add a capability entry if the generator requires it. + +## Specific edits + +```diff +- wire: "openai-chat", ++ wire: "openai-responses", +``` + +Only the two entries preceding the multi-agent entry change. Preserve `authModes: ["oauth"]`, `inbound: ["responses"]`, `forwardCallerServiceTier: false`. + +```diff +- else delete modelAdapters[model]; ++ else modelAdapters[model] = "openai-chat"; +``` + +```diff +- const next = state !== true; ++ const next = state === false; +- on={state === true} ++ on={state === false} +``` + +Existing response field names and derived DTO filters remain unchanged for compatibility. The new CLI flag is transient argv -> boolean parser -> legacy PATCH boolean -> modelAdapters -> persisted config -> resolver / DTO / GUI. Marker chain: startup migration or explicit switch write -> provider config save -> provider schema read -> migration guard; unknown future positive integers suppress migration, invalid markers degrade to absent on load. Never seed the marker from registry defaults over existing configs. The existing model-adapter enum is unchanged. + +Startup wrapper uses the same exact algorithm and failure handling as `src/server/subagent-models-startup.ts`, substituting `migrateXaiResponsesDefault` and a non-sensitive `[xai-responses-migration]` warning. Migration copies the provider and modelAdapters before editing so the input projection does not mutate the stale config. Only model entries equal to `openai-chat` are deleted; other entries stay byte-equivalent. + +Default scope is the reserved `xai` provider ID: its OAuth transport always resolves to the official subscription URL irrespective of saved baseUrl. A custom provider ID keeps its own transport and defaults; no new URL filtering is added to either resolver or Fast authority. + +## Check and closure + +Capture fresh CI URLs and exact SHA, inspected isolated GUI screenshot, CLI receipts, independent review and merge proof in `011_verification.md`. Archive the unit after completion. No production proxy restart or default changes to the running account. + +## Owner-requested first-output prerequisite + +Before opening the PR, close the empty first exec result regression on native Responses. Synthetic reproduction: the model emits `await tools.exec_command({cmd: "printf marker"})`, whose result is discarded by the code-mode host; `text(await tools.exec_command(...))` emits it. The actual task rollout establishes that execution succeeded and the original host output was already empty, so this is not lost transport data. + +Reuse `CODE_MODE_RESULT_ECHO_SENTENCE` (already used in translated tool-catalog guidance) in the native routed exec input description. Add a small native Responses code-mode compatibility module to place that same guidance in instructions only for a genuinely code-mode catalog on non-OpenAI destinations; empty paired exec history gets existing empty/failed-output guidance, without changing populated or multimodal outputs. Do not rewrite valid user/model JavaScript or invent missing command output. Preserve OpenAI-native and shell-only calls. Add native outbound-body and executable-example regression tests in existing custom-tool/passthrough test files. Probe real Grok first-call code with synthetic commands and execute only a bounded known-safe fixture helper to confirm first result reaches the next turn. + +MODIFY `src/adapters/openai-responses.ts`; NEW `src/adapters/responses-code-mode.ts`; MODIFY `tests/responses/openai-responses-passthrough.test.ts`, `structure/04_transports-and-sidecars.md`. No new test file or dependency. This remains the prerequisite for the one default-rollout slice, not a second implementation phase. Run the new helper after native custom-tool/namespace lowering: the same non-OpenAI-operated + genuine code-mode gate protects all three changes (input description, instructions, paired empty-result annotation). Existing generic custom-tool lowering stays unchanged, including on official OpenAI API traffic. Only a verified exec declaration gets the stronger parameter description; shell-only/other namespaces are no-ops. Prior instructions remain intact and repeated builds do not duplicate the shared sentence. + +Implementation review correction: run OAuth preset reconciliation BEFORE the one-time Grok migration and before initializing live config consumers. A transient migration persistence failure must not be overwritten by a later disk-derived preset reconciliation. Add a real startup test with injected migration-write failure, asserting final live config still uses Responses. + +C review fold-back: MODIFY `src/oauth/index.ts` `upsertOAuthProvider` to preserve xAI modelAdapters and xaiResponsesDefaultVersion through reauth/add-account; MODIFY existing OAuth upsert tests to verify login plus subsequent migration keeps later Chat choices and still upgrades unmarked legacy Chat. Correct multimodal table cases to pass arrays as one argument rather than Bun test.each spreading their parts. These close the same opt-in persistence and result-preservation contracts; no auth/credential algorithm changes. + +Full-CI prerequisite: `oauth-account-attribution.test.ts` proves native Responses bypasses the generic OAuth HTTP-429 rotation loop. MODIFY `src/server/responses/core.ts` inside the existing pre-stream passthroughRecovery loop: reuse quorum/rotation limit, rotate the actually failed account, resolve the full new snapshot, apply/stamp it, update OAuth refresh/replay provenance and transport, rebuildAndRefetch with `oauth-account-429`, and re-enter the bounded recovery loop. Never retry after streaming begins or rotate single-account/key/Codex-owned pools. MODIFY existing attribution tests for buffered+streaming success, one-account refusal and the request rotation cap. The current registry default requires the same account-rotation contract on either wire. diff --git a/devlog/_plan/260905_grok_responses_default/011_verification.md b/devlog/_plan/260905_grok_responses_default/011_verification.md new file mode 100644 index 0000000000..2211af6555 --- /dev/null +++ b/devlog/_plan/260905_grok_responses_default/011_verification.md @@ -0,0 +1,57 @@ +# Verification and review + +No local test suite or typecheck was run. Dependencies and an isolated Vite/runtime preview were used for manual UI/CLI checks. Production port 10100 was not restarted or reconfigured by this task. + +## Remote checks + +On macmini-cf, clean temporary clone at `601725c87`, project Bun 1.4.0: + +- GUI build: exit 0 (existing large-chunk warning). +- GUI focused tests: 9 pass, 0 fail. +- Typecheck: exit 0. +- Runtime focused tests: 803 pass, 0 fail across adapter resolution, configuration migration, management validation, startup, xAI transport, Fast policy, headless CLI and Responses passthrough. +- Privacy scan: passed. + +Subsequent `64e3e079a` adds OAuth re-login retention and corrects multimodal test parameterization; these require fresh verification. Latest-head PR CI is the final gate, not the earlier remote run. + +## Live first-result experiment + +The new adapter generated the upstream synthetic request without changing live user config. Initial example quoting caused one model response to overescape JavaScript string delimiters; switching the shared example to a single-quoted JavaScript literal fixed that observed output. + +Successful first generated source, executed unchanged with the host code-mode tool: + +```js +text(JSON.stringify(await tools.exec_command({cmd: 'printf OCX_FIRST_RESULT_7391'}))) +``` + +The helper returned exit 0 and stdout `OCX_FIRST_RESULT_7391`. Replaying that actual result produced HTTP 200, one final message containing exactly the marker, and zero additional function calls. This verifies one synthetic live roundtrip, not a guarantee that a probabilistic model can never omit output again. The fallback explains an empty result; it does not fabricate discarded output or rewrite JavaScript. + +## UI and CLI + +Isolated home with no credentials, backend port 10239 and Vite port 15239; production user settings are not the fixture. Seeded old Chat overrides were removed at startup and version 1 was persisted. `provider edit xai --xai-chat on --json` returned success and effective Responses state false. The real Accounts screen then showed Chat checked. Clicking it off returned unchecked; the same persisted setting is shared by both surfaces. + +Screenshot: `assets/001_chat_optin.png`, inspected after capture. The app-level screenshot path clipped the right side; direct tab compositor capture produced the complete 1600x900 page, including the switch. No page content or styles were modified for capture. + +## Independent reviews + +- A: PASS after clarifying name-pinned xAI OAuth scope, preserving latest POST choices and future migration versions. +- B/C: fixed reconciliation ordering so transient persistence failure cannot undo the projected default. +- C: fixed OAuth re-login retention and array-row test parameterization. Fresh interdiff review and latest-head CI pending. + +## Remaining delivery gate + +Templated PR, current-head CI, admin-bypass disclosure, fetched dev merge ancestry, and temporary resource teardown must be recorded before completion. + +## First full-CI finding + +PR #3670 at `4f827844b`: Linux shard 1 failed in `anthropic-thinking-signature.test.ts` because its hand-built `as never` request omitted the required `OcxParsedRequest.context` and used obsolete provider `passthrough` metadata. The new guidance path exposed this malformed fixture. Fix the fixture with the real `parseRequest(body)` and `authMode: forward`, keeping both original envelope-stripping assertions unchanged. Do not add a production fallback for a state the parser cannot produce. + +Remote current-head follow-up before this fixture change: typecheck passed and 178 tests across OAuth upsert/native Responses passed. Independent review resolved all findings at `64e3e079a`; latest-head full CI remains required. + +Linux shard 4 found a real native-wire parity gap: HTTP 429 bypassed generic OAuth account rotation. Remote red proof at `4f827844b` with expanded attribution tests: 14 pass / 3 fail (both buffered/streaming rotation returned 429; five-account bound sent once rather than four times). The fix reuses the existing account/quorum/cooldown budget in the native pre-stream loop and keeps selected-account refresh/replay identity synchronized. Green proof and new exact-head CI are required. + +At `3ebc3abba`, remote typecheck and 67 tests across attribution, generic/event failover and the signature fixture passed. This includes failed alternate-snapshot preservation and `429 -> 401` refreshing the newly selected account. Independent review: PASS, zero blockers. CI then flagged a long synthetic bearer literal in the new test; use a short unmistakable fixture value instead, with identical authentication assertions and no scanner exception. + +Temporary UI tab closed; preview processes stopped and ports 10239/15239 verified unbound. The screenshot and private synthetic probe receipts remain as evidence. The pending gate is latest-head full CI and authorized admin landing of PR #3670. + +The Chat reasoning-stream regression also relied on the previous default. Its fixture now explicitly chooses Chat with the completed migration marker and static model discovery; unexpected native-wire calls fail locally instead of escaping its mock. Original reasoning ordering, tier stripping and header assertions are unchanged. diff --git a/devlog/_plan/260905_grok_responses_default/assets/001_chat_optin.png b/devlog/_plan/260905_grok_responses_default/assets/001_chat_optin.png new file mode 100644 index 0000000000..3540aad3e8 Binary files /dev/null and b/devlog/_plan/260905_grok_responses_default/assets/001_chat_optin.png differ diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index ab8a154ecb..d6adbdac9c 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -162,7 +162,7 @@ predictions. Explicit provider/model price overrides still take precedence. | `modelSupportsReasoningSummaries?` | `Record` | Set a model to `false` to stop advertising summaries and strip summary-delivery fields. | | `modelReasoningSummaryDelivery?` | `Record` | Per-model Responses delivery enum; rewrites an existing delivery field. | | `modelAdapters?` | `Record` | Per-model `openai-chat` or `openai-responses` wire override for mixed-wire gateways. Explicit entries beat registry defaults. The OpenCode Go preset selects Responses for `gpt-5.6-luna` while leaving sibling models on their documented wires; DeepSeek can select native Responses for `deepseek-v4-flash`; and GitHub Copilot declares Responses-only defaults for its GPT-5 family (`gpt-5.3-codex`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.5`, `gpt-5.6-luna`, `gpt-5.6-sol`, `gpt-5.6-terra`) because those models reject `/chat/completions` for agent traffic. Models without a built-in default (for example `gpt-5.4-nano`) can be opted in here. Single-wire upstream pins and canonical ChatGPT forward reject overrides. | -| xAI Responses opt-in (dashboard) | switch | For `xai` only, atomically sets or clears the `grok-4.5` and `grok-4.6` `modelAdapters` entries. A hand-edited single entry appears as mixed until the next switch write normalizes both. Other overrides and tier behavior are unchanged. | +| xAI Chat Completions (dashboard / CLI) | switch | Grok 4.5/4.6 OAuth Responses requests default to Responses. Existing Chat overrides are migrated once on upgrade; later Chat choices are preserved. Turn on to select Chat for both models, off to select Responses. CLI: `ocx provider edit xai --xai-chat on` or `--xai-chat off` (running proxy required). Mixed means only one model currently uses Chat. Other overrides and tier policy stay unchanged. API-key and translated Chat/Anthropic defaults are unchanged. | | `xaiResponsesXSearch?` | `boolean` | Disabled by default. On an xAI Responses destination, append the provider-hosted `x_search` declaration only when a live `web_search` tool survives final request normalization. Existing declarations are not duplicated, caller `tool_choice`/`allowed_tools` selectors are never widened, and this is separate from the web-search sidecar's `search.xSearch` options. | | `modelPreferHostedTools?` | `Record` | Exact-model opt-in for non-forward Responses gateways that reserve a hosted-tool namespace. Currently accepts only `["image_generation"]`; a matching model must use the `openai-responses` wire and support that hosted tool. It removes colliding client `image_gen` declarations and rewrites their selectors to preserve caller tool choice. For OpenAI API virtual `-pro` models, the selected public ID is matched first and the resolved base wire-model ID is a fallback. `modelAdapters` resolves the public ID first, then the base ID; the second resolution determines the final wire. Other models retain normal alias behavior. | | `annotateEmptyToolOutputs?` | `boolean` | Replace a present-but-empty tool result with a short marker before it reaches the model, so a blank result is not read as a missing one. Applies to blank strings and text-only part arrays; image, file, and encrypted parts are never touched. Defaults to `true` for DeepSeek from the built-in registry and is otherwise unset. Set `false` to opt a provider out — an explicit `false` is preserved across later edits that omit the field. `PATCH /api/providers?name=` accepts `true`, `false`, or `null` to clear the override and return to registry-default behavior. | diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index d475262263..fecf40ac2b 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -37,7 +37,7 @@ const COCKPIT_IMPORT_MAX_BYTES = 256 * 1024; const EMPTY_OAUTH_ACCOUNTS: OAuthAccountRow[] = []; const EMPTY_API_KEYS: ApiKeyRow[] = []; -function XaiResponsesOptInControl({ +function XaiChatOptInControl({ initialState, onUpdateProvider, }: { @@ -57,7 +57,7 @@ function XaiResponsesOptInControl({ const toggle = async () => { if (!onUpdateProvider || saving) return; - const next = state !== true; + const next = state === false; setSaving(true); setError(""); try { @@ -77,19 +77,19 @@ function XaiResponsesOptInControl({ return (
- {t("pws.xaiResponsesOptIn")} + {t("pws.xaiChatOptIn")} - {t("pws.xaiResponsesOptInDesc")} - {mixed && {t("pws.xaiResponsesOptInMixed")}} + {t("pws.xaiChatOptInDesc")} + {mixed && {t("pws.xaiChatOptInMixed")}} {error && {error}}
{ void toggle(); }} disabled={!onUpdateProvider || saving} - label={t("pws.xaiResponsesOptIn")} + label={t("pws.xaiChatOptIn")} />
); @@ -377,8 +377,8 @@ export default function ProviderAuthPanel({
{item.name === "xai" && ( - )} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 4d4b79e5d5..cf04d02f67 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1978,9 +1978,9 @@ export const de: Record = { "pws.allowPrivateNetwork": "Lokales/privates Netzwerk erlauben", "pws.liveModels": "Modelle beim Anbieter erkennen", "pws.liveModelsDesc": "Lädt den Live-Modellkatalog des Anbieters. Ausschalten, um nur konfigurierte statische Modelle zu verwenden.", - "pws.xaiResponsesOptIn": "Responses API für Grok 4.5 und 4.6 verwenden", - "pws.xaiResponsesOptInDesc": "Leitet beide Modelle über openai-responses. Andere Grok-Modelle und das Tier-Verhalten bleiben unverändert.", - "pws.xaiResponsesOptInMixed": "Teilweise aktiviert.", + "pws.xaiChatOptIn": "Chat Completions für Grok 4.5 und 4.6 verwenden", + "pws.xaiChatOptInDesc": "Aus wählt Responses, den Standard für OAuth-Responses-Anfragen. Andere Grok-Modelle und das Tier-Verhalten bleiben unverändert.", + "pws.xaiChatOptInMixed": "Nur ein Modell verwendet Chat.", "pws.cursorTransport": "Cursor-Transport", "pws.cursorTransportHttp2": "HTTP/2 (Standard)", "pws.cursorTransportHttp1": "HTTP/1.1 (Proxy-Kompatibilität)", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index cf9eb253dd..5d0c0b0983 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1243,9 +1243,9 @@ export const en = { "pws.allowPrivateNetwork": "Allow local/private network", "pws.liveModels": "Discover models from provider", "pws.liveModelsDesc": "Fetch the provider's live model catalog. Turn this off to use only configured/static models.", - "pws.xaiResponsesOptIn": "Use Responses API for Grok 4.5 and 4.6", - "pws.xaiResponsesOptInDesc": "Routes both models through openai-responses. Other Grok models and tier behavior are unchanged.", - "pws.xaiResponsesOptInMixed": "Partially enabled.", + "pws.xaiChatOptIn": "Use Chat Completions for Grok 4.5 and 4.6", + "pws.xaiChatOptInDesc": "Off selects Responses. OAuth Responses requests use it by default. Other Grok models and tier behavior are unchanged.", + "pws.xaiChatOptInMixed": "Only one model uses Chat.", "pws.cursorTransport": "Cursor transport", "pws.cursorTransportHttp2": "HTTP/2 (default)", "pws.cursorTransportHttp1": "HTTP/1.1 (proxy compatibility)", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 2d90382f1a..ac0d9f17c3 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1216,9 +1216,9 @@ export const fr: Record = { "pws.allowPrivateNetwork": "Autoriser le réseau local/privé", "pws.liveModels": "Détecter les modèles auprès du fournisseur", "pws.liveModelsDesc": "Récupérez le catalogue de modèles en direct du fournisseur. Désactivez cette option pour utiliser uniquement les modèles configurés/statiques.", - "pws.xaiResponsesOptIn": "Utiliser l’API Responses pour Grok 4.5 et 4.6", - "pws.xaiResponsesOptInDesc": "Achemine les deux modèles via openai-responses. Les autres modèles Grok et le comportement des tiers restent inchangés.", - "pws.xaiResponsesOptInMixed": "Activation partielle.", + "pws.xaiChatOptIn": "Utiliser Chat Completions pour Grok 4.5 et 4.6", + "pws.xaiChatOptInDesc": "Désactivé : Responses, le choix par défaut pour les requêtes Responses OAuth. Les autres modèles Grok et les niveaux de service restent inchangés.", + "pws.xaiChatOptInMixed": "Un seul modèle utilise Chat.", "pws.cursorTransport": "Transport Cursor", "pws.cursorTransportHttp2": "HTTP/2 (par défaut)", "pws.cursorTransportHttp1": "HTTP/1.1 (compatibilité proxy)", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c9d2e9ea4a..7c6eab5675 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1171,9 +1171,9 @@ export const ja: Record = { "pws.allowPrivateNetwork": "ローカル/プライベートネットワークを許可", "pws.liveModels": "プロバイダーからモデルを検出", "pws.liveModelsDesc": "プロバイダーのライブモデルカタログを取得します。オフにすると設定済みの静的モデルのみを使用します。", - "pws.xaiResponsesOptIn": "Grok 4.5 と 4.6 で Responses API を使用", - "pws.xaiResponsesOptInDesc": "両モデルを openai-responses 経由でルーティングします。他の Grok モデルと tier 動作は変わりません。", - "pws.xaiResponsesOptInMixed": "一部のみ有効です。", + "pws.xaiChatOptIn": "Grok 4.5 と 4.6 で Chat Completions を使用", + "pws.xaiChatOptInDesc": "オフにすると Responses を使用します。OAuth Responses リクエストの既定値です。他の Grok モデルと tier 動作は変わりません。", + "pws.xaiChatOptInMixed": "片方のモデルのみ Chat を使用しています。", "pws.cursorTransport": "Cursor トランスポート", "pws.cursorTransportHttp2": "HTTP/2(デフォルト)", "pws.cursorTransportHttp1": "HTTP/1.1(プロキシ互換)", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index d9c983a5fb..21d9ab45eb 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2005,9 +2005,9 @@ export const ko: Record = { "pws.allowPrivateNetwork": "로컬/사설 네트워크 허용", "pws.liveModels": "프로바이더에서 모델 검색", "pws.liveModelsDesc": "프로바이더의 실시간 모델 카탈로그를 가져옵니다. 끄면 설정된 정적 모델만 사용합니다.", - "pws.xaiResponsesOptIn": "Grok 4.5와 4.6에 Responses API 사용", - "pws.xaiResponsesOptInDesc": "두 모델을 openai-responses로 라우팅합니다. 다른 Grok 모델과 티어 동작은 바뀌지 않습니다.", - "pws.xaiResponsesOptInMixed": "일부만 활성화됨.", + "pws.xaiChatOptIn": "Grok 4.5와 4.6에 Chat Completions 사용", + "pws.xaiChatOptInDesc": "끄면 Responses를 사용합니다. OAuth Responses 요청의 기본값입니다. 다른 Grok 모델과 티어 동작은 바뀌지 않습니다.", + "pws.xaiChatOptInMixed": "한 모델만 Chat을 사용합니다.", "pws.cursorTransport": "Cursor 전송", "pws.cursorTransportHttp2": "HTTP/2 (기본값)", "pws.cursorTransportHttp1": "HTTP/1.1 (프록시 호환)", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 950cea7a81..c97b62e9ac 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1227,9 +1227,9 @@ export const ru: Record = { "pws.allowPrivateNetwork": "Разрешить локальную/частную сеть", "pws.liveModels": "Обнаруживать модели провайдера", "pws.liveModelsDesc": "Загружать актуальный каталог моделей провайдера. Выключите, чтобы использовать только настроенные статические модели.", - "pws.xaiResponsesOptIn": "Использовать Responses API для Grok 4.5 и 4.6", - "pws.xaiResponsesOptInDesc": "Направляет обе модели через openai-responses. Другие модели Grok и поведение tier не меняются.", - "pws.xaiResponsesOptInMixed": "Включено частично.", + "pws.xaiChatOptIn": "Использовать Chat Completions для Grok 4.5 и 4.6", + "pws.xaiChatOptInDesc": "В выключенном состоянии используется Responses — протокол по умолчанию для запросов Responses через OAuth. Другие модели Grok и уровни обслуживания не меняются.", + "pws.xaiChatOptInMixed": "Только одна модель использует Chat.", "pws.cursorTransport": "Транспорт Cursor", "pws.cursorTransportHttp2": "HTTP/2 (по умолчанию)", "pws.cursorTransportHttp1": "HTTP/1.1 (совместимость с прокси)", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 5db3ca23b5..7a6f5107c0 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1234,9 +1234,9 @@ export const tr: Record = { "pws.allowPrivateNetwork": "Yerel/özel ağa izin ver", "pws.liveModels": "Sağlayıcıdan canlı model keşfet", "pws.liveModelsDesc": "Sağlayıcının canlı model kataloğunu çekin.", - "pws.xaiResponsesOptIn": "Grok 4.5 ve 4.6 için Responses API kullan", - "pws.xaiResponsesOptInDesc": "İki modeli de openai-responses üzerinden yönlendirir. Diğer Grok modelleri ve katman davranışı değişmez.", - "pws.xaiResponsesOptInMixed": "Kısmen etkin.", + "pws.xaiChatOptIn": "Grok 4.5 ve 4.6 için Chat Completions kullan", + "pws.xaiChatOptInDesc": "Kapalıyken OAuth Responses isteklerinin varsayılanı olan Responses kullanılır. Diğer Grok modelleri ve hizmet katmanı davranışı değişmez.", + "pws.xaiChatOptInMixed": "Yalnızca bir model Chat kullanıyor.", "pws.cursorTransport": "Cursor aktarımı", "pws.cursorTransportHttp2": "HTTP/2 (varsayılan)", "pws.cursorTransportHttp1": "HTTP/1.1 (proxy uyumluluğu)", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 06f8e6fa4b..ed0aeba2d4 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1020,9 +1020,9 @@ export const zhTW: Record = { "pws.allowPrivateNetwork": "允許本地/私有網路", "pws.liveModels": "從供應商發現模型", "pws.liveModelsDesc": "取得供應商的即時模型目錄。關閉後僅使用已配置的靜態模型。", - "pws.xaiResponsesOptIn": "讓 Grok 4.5 與 4.6 使用 Responses API", - "pws.xaiResponsesOptInDesc": "透過 openai-responses 路由這兩個模型。其他 Grok 模型與層級行為不變。", - "pws.xaiResponsesOptInMixed": "已部分啟用。", + "pws.xaiChatOptIn": "讓 Grok 4.5 與 4.6 使用 Chat Completions", + "pws.xaiChatOptInDesc": "關閉時使用 Responses,即 OAuth Responses 請求的預設協定。其他 Grok 模型與服務層級行為不變。", + "pws.xaiChatOptInMixed": "只有一個模型使用 Chat。", "pws.cursorTransport": "Cursor 傳輸協定", "pws.cursorTransportHttp2": "HTTP/2(預設)", "pws.cursorTransportHttp1": "HTTP/1.1(代理相容)", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 315869d88d..44450d8785 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1998,9 +1998,9 @@ export const zh: Record = { "pws.allowPrivateNetwork": "允许本地/私有网络", "pws.liveModels": "从提供方发现模型", "pws.liveModelsDesc": "获取提供方的实时模型目录。关闭后仅使用已配置的静态模型。", - "pws.xaiResponsesOptIn": "为 Grok 4.5 和 4.6 使用 Responses API", - "pws.xaiResponsesOptInDesc": "通过 openai-responses 路由这两个模型。其他 Grok 模型和层级行为不变。", - "pws.xaiResponsesOptInMixed": "已部分启用。", + "pws.xaiChatOptIn": "为 Grok 4.5 和 4.6 使用 Chat Completions", + "pws.xaiChatOptInDesc": "关闭时使用 Responses,即 OAuth Responses 请求的默认协议。其他 Grok 模型和服务层级行为不变。", + "pws.xaiChatOptInMixed": "只有一个模型使用 Chat。", "pws.cursorTransport": "Cursor 传输协议", "pws.cursorTransportHttp2": "HTTP/2(默认)", "pws.cursorTransportHttp1": "HTTP/1.1(代理兼容)", diff --git a/gui/tests/provider-xai-responses-optin.test.tsx b/gui/tests/provider-xai-responses-optin.test.tsx index bdece63dc8..c8f870bdab 100644 --- a/gui/tests/provider-xai-responses-optin.test.tsx +++ b/gui/tests/provider-xai-responses-optin.test.tsx @@ -102,29 +102,67 @@ test("OAuth xAI renders one mixed switch and applies the PATCH echoed effective const patches: Array<{ name: string; patch: ProviderUpdatePatch }> = []; await mount(xaiItem("oauth", "mixed"), async (name, patch) => { patches.push({ name, patch }); - return { ok: true, xaiResponsesOptInState: true }; + return { ok: true, xaiResponsesOptInState: false }; }); expect(container.textContent).toContain("Available accounts"); - expect(container.textContent).toContain("Use Responses API for Grok 4.5 and 4.6"); - expect(container.textContent).toContain("Partially enabled."); + expect(container.textContent).toContain("Use Chat Completions for Grok 4.5 and 4.6"); + expect(container.textContent).toContain("Only one model uses Chat."); expect(optInSwitch().getAttribute("aria-pressed")).toBe("mixed"); expect(optInSwitch().classList.contains("mixed")).toBe(true); await act(async () => { optInSwitch().click(); }); - expect(patches).toEqual([{ name: "xai", patch: { xaiResponsesOptIn: true } }]); + expect(patches).toEqual([{ name: "xai", patch: { xaiResponsesOptIn: false } }]); expect(optInSwitch().getAttribute("aria-pressed")).toBe("true"); expect(optInSwitch().classList.contains("mixed")).toBe(false); }); -test("API-key xAI renders the same single Responses opt-in switch", async () => { +test("API-key xAI shows the effective Chat default as checked", async () => { await mount(xaiItem("key", false), async () => ({ ok: true, xaiResponsesOptInState: true, })); expect(container.textContent).toContain("API Keys"); - expect(container.textContent).toContain("Use Responses API for Grok 4.5 and 4.6"); + expect(container.textContent).toContain("Use Chat Completions for Grok 4.5 and 4.6"); + expect(optInSwitch().getAttribute("aria-pressed")).toBe("true"); +}); + +test("OAuth default is unchecked and Chat can be enabled and disabled", async () => { + const patches: ProviderUpdatePatch[] = []; + await mount(xaiItem("oauth", true), async (_name, patch) => { + patches.push(patch); + return { ok: true, xaiResponsesOptInState: patch.xaiResponsesOptIn }; + }); + expect(optInSwitch().getAttribute("aria-pressed")).toBe("false"); + await act(async () => { optInSwitch().click(); }); + expect(optInSwitch().getAttribute("aria-pressed")).toBe("true"); + await act(async () => { optInSwitch().click(); }); + expect(optInSwitch().getAttribute("aria-pressed")).toBe("false"); + expect(patches).toEqual([{ xaiResponsesOptIn: false }, { xaiResponsesOptIn: true }]); +}); + +test("failed Chat selection keeps the previous wire and displays the error", async () => { + await mount(xaiItem("oauth", true), async () => ({ ok: false, error: "Save rejected" })); + await act(async () => { optInSwitch().click(); }); expect(optInSwitch().getAttribute("aria-pressed")).toBe("false"); + expect(container.querySelector('[role="alert"]')?.textContent).toBe("Save rejected"); + expect(optInSwitch().disabled).toBe(false); +}); + +test("pending selection disables repeat writes and uses the server echo", async () => { + let settle!: (value: ProviderUpdateResult) => void; + let calls = 0; + await mount(xaiItem("oauth", true), () => { + calls++; + return new Promise(resolve => { settle = resolve; }); + }); + await act(async () => { optInSwitch().click(); }); + expect(optInSwitch().disabled).toBe(true); + await act(async () => { optInSwitch().click(); }); + expect(calls).toBe(1); + await act(async () => { settle({ ok: true, xaiResponsesOptInState: "mixed" }); }); + expect(optInSwitch().getAttribute("aria-pressed")).toBe("mixed"); + expect(optInSwitch().disabled).toBe(false); }); diff --git a/src/adapters/exec-tool-result-normalize.ts b/src/adapters/exec-tool-result-normalize.ts index 56a0386c4d..c103808420 100644 --- a/src/adapters/exec-tool-result-normalize.ts +++ b/src/adapters/exec-tool-result-normalize.ts @@ -113,7 +113,7 @@ export const EMPTY_EXEC_OUTPUT_MESSAGE = * that drifts apart is how a model gets told two different things about the same isolate. */ export const CODE_MODE_RESULT_ECHO_SENTENCE = - "Nothing in the isolate is echoed automatically: a bare trailing `await tools.(...)` or final expression value is DISCARDED, and the cell reports empty output. Pass anything you need to read to `text(...)` (or `notify(...)`) in the same cell — for example `text(JSON.stringify(await tools.exec_command({cmd: \"ls\"})))` — and treat an empty result as your own missing `text(...)` call rather than a failed command or lost context."; + "Nothing in the isolate is echoed automatically: a bare trailing `await tools.(...)` or final expression value is DISCARDED, and the cell reports empty output. Pass anything you need to read to `text(...)` (or `notify(...)`) in the same cell — for example `text(JSON.stringify(await tools.exec_command({cmd: 'ls'})))` — and treat an empty result as your own missing `text(...)` call rather than a failed command or lost context."; /** * Codex exec / shell-bridge tool names (flat and MCP-prefixed display aliases). An empty result diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index e10fa7d20e..a007e21a4a 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -21,6 +21,7 @@ import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-co import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat"; import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat"; import { openaiResponsesUrl } from "./openai-responses-url"; +import { normalizeResponsesCodeMode } from "./responses-code-mode"; import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "./xai-web-search"; import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation"; import { @@ -2447,6 +2448,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): // it as a summarizer turn (#422). The compaction body build removes the tool surface and must // therefore be the last routed transform that may depend on those declarations. Structural // sanitizers below can still run after it. + outBody = normalizeResponsesCodeMode(outBody, parsed, provider); if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) { outBody = buildRoutedCompactionBody(outBody); } diff --git a/src/adapters/responses-code-mode.ts b/src/adapters/responses-code-mode.ts new file mode 100644 index 0000000000..25e51f204e --- /dev/null +++ b/src/adapters/responses-code-mode.ts @@ -0,0 +1,59 @@ +import { toolChoiceToolPredicate, type OcxParsedRequest, type OcxProviderConfig } from "../types"; +import { isOpenAiOperatedResponsesDestination } from "../providers/openai-tiers"; +import { CODE_MODE_RESULT_ECHO_SENTENCE, normalizeEmptyExecToolResultText } from "./exec-tool-result-normalize"; +import { isBareShellBridgeTool, isCodexCodeModeExecTool } from "./tool-catalog-nudge"; + +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** Inspect the whole result, not just its empty header: later text or media is real output. */ +function textOnlyOutput(output: unknown): string | undefined { + if (typeof output === "string") return output; + if (!Array.isArray(output)) return undefined; + if (!output.every(part => record(part) + && ["text", "input_text", "output_text"].includes(String(part.type)) + && typeof part.text === "string")) return undefined; + return output.map(part => part.text).join("\n"); +} + +function withExecInputGuidance(tool: unknown): unknown { + if (!record(tool) || tool.type !== "function" || tool.name !== "exec" || tool.namespace !== undefined) return tool; + if (!record(tool.parameters) || !record(tool.parameters.properties) || !record(tool.parameters.properties.input)) return tool; + return { ...tool, parameters: { ...tool.parameters, properties: { + ...tool.parameters.properties, + input: { + ...tool.parameters.properties.input, + description: `JavaScript source for unified exec; do not provide a bare shell command. ${CODE_MODE_RESULT_ECHO_SENTENCE}`, + }, + } } }; +} + +/** Native routed Responses needs the same first-call/output contract as translated adapters. */ +export function normalizeResponsesCodeMode(body: unknown, parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown { + if (!record(body) || parsed._compactionRequest || isOpenAiOperatedResponsesDestination(provider)) return body; + const visible = parsed.context.tools?.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools)); + if (!visible?.some(isCodexCodeModeExecTool) || visible.some(isBareShellBridgeTool)) return body; + const instructions = typeof body.instructions === "string" ? body.instructions : ""; + const input = Array.isArray(body.input) ? body.input : undefined; + const execCalls = new Set(input?.filter(item => record(item) + && (item.type === "function_call" || item.type === "custom_tool_call") + && item.name === "exec" && item.namespace === undefined && typeof item.call_id === "string") + .map(item => item.call_id)); + return { + ...body, + instructions: instructions.includes(CODE_MODE_RESULT_ECHO_SENTENCE) + ? instructions : [instructions, CODE_MODE_RESULT_ECHO_SENTENCE].filter(Boolean).join("\n\n"), + ...(Array.isArray(body.tools) ? { tools: body.tools.map(withExecInputGuidance) } : {}), + ...(input ? { input: input.map(item => { + if (!record(item)) return item; + if (item.type === "additional_tools" && Array.isArray(item.tools)) { + return { ...item, tools: item.tools.map(withExecInputGuidance) }; + } + if ((item.type !== "function_call_output" && item.type !== "custom_tool_call_output") || !execCalls.has(item.call_id)) return item; + const text = textOnlyOutput(item.output); + const normalized = text === undefined ? undefined : normalizeEmptyExecToolResultText(text, { toolName: "exec" }); + return normalized === undefined ? item : { ...item, output: normalized }; + }) } : {}), + }; +} diff --git a/src/cli/provider-runtime.ts b/src/cli/provider-runtime.ts index 416d5ddb1b..d2f24d0b8d 100644 --- a/src/cli/provider-runtime.ts +++ b/src/cli/provider-runtime.ts @@ -40,6 +40,7 @@ const USAGE = `Usage: [--api-key-transport ] [--headers ] [--enabled ] [--live-models ] [--retain-models ] + [--xai-chat ] [--allow-private-network ] [--json] ocx provider test [--json] ocx provider quota [--refresh] [--json] @@ -70,7 +71,12 @@ async function edit(argv: string[], deps: RuntimeApiDeps): Promise { const enabled = takeBooleanOption(args, "--enabled"); const liveModels = takeBooleanOption(args, "--live-models"); const allowPrivateNetwork = takeBooleanOption(args, "--allow-private-network"); + const xaiChat = takeBooleanOption(args, "--xai-chat"); rejectArgs(args, USAGE); + if (xaiChat !== undefined) { + if (name !== "xai") throw new CliUsageError("--xai-chat is valid only for provider xai", USAGE); + patch.xaiResponsesOptIn = !xaiChat; + } if (adapter !== undefined) patch.adapter = adapter; if (baseUrl !== undefined) patch.baseUrl = baseUrl; if (defaultModel !== undefined) patch.defaultModel = defaultModel; diff --git a/src/cli/provider.ts b/src/cli/provider.ts index 6795b3db52..55c654d8d7 100644 --- a/src/cli/provider.ts +++ b/src/cli/provider.ts @@ -448,6 +448,8 @@ Examples: ocx provider add my-ollama --adapter openai-chat --base-url http://localhost:11434/v1 ocx provider show anthropic --json ocx provider set-default anthropic + ocx provider edit xai --xai-chat on # opt Grok 4.5/4.6 into Chat Completions + ocx provider edit xai --xai-chat off # use Responses again ocx provider remove my-ollama`; export async function handleProviderCommand(args: string[]): Promise { diff --git a/src/config.ts b/src/config.ts index fdcda9547c..54f320e43a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -583,6 +583,7 @@ const providerConfigSchema = z.object({ }).strict().optional(), responsesSnapshotRepair: z.boolean().optional(), xaiResponsesXSearch: z.boolean().optional(), + xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined), }).passthrough(); export { isValidProviderName, hasOwnProvider } from "./config/provider-name"; diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 1a8bd07157..3a2ea7922e 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -1446,6 +1446,13 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void { if (existing?.commandCodeVersion !== undefined) { next.commandCodeVersion = existing.commandCodeVersion; } + // Reauth/add-account refreshes credentials, not the operator's post-upgrade wire choice. + if (provider === "xai") { + if (existing?.modelAdapters !== undefined) next.modelAdapters = { ...existing.modelAdapters }; + if (existing?.xaiResponsesDefaultVersion !== undefined) { + next.xaiResponsesDefaultVersion = existing.xaiResponsesDefaultVersion; + } + } // User-configured price overlays are operator data, not preset state; a // re-login, add-account, or reauth must not silently drop them from the // Logs/Usage estimates. diff --git a/src/providers/registry.ts b/src/providers/registry.ts index f78e82b965..64c01dbb10 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1255,20 +1255,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // than the seeded ones do. supportsVerbosity: false, defaultModel: "grok-4.5", - // Keep 4.6/4.5 Responses callers on the compatibility Chat wire until xAI can replay - // opaque reasoning continuation and compaction state across later turns. Multi-agent has - // no Chat wire, so Responses callers use its only working wire under both auth modes. + // Grok 4.6/4.5 subscription Responses callers use the native wire with the existing + // namespace/web-search/replay normalization. Chat remains an explicit modelAdapters + // opt-in. Multi-agent has no Chat wire and uses Responses under both auth modes. // Caller-owned service tiers stay off the unclassified OAuth subscription route; key-auth // Fast remains proxy-owned and is still selected through keyAuthServiceTier above. modelWireDefaults: { "grok-4.6": { - wire: "openai-chat", + wire: "openai-responses", inbound: ["responses"], authModes: ["oauth"], forwardCallerServiceTier: false, }, "grok-4.5": { - wire: "openai-chat", + wire: "openai-responses", inbound: ["responses"], authModes: ["oauth"], forwardCallerServiceTier: false, diff --git a/src/providers/xai-responses-opt-in.ts b/src/providers/xai-responses-opt-in.ts index 34cbc5dccc..6e7225fe2a 100644 --- a/src/providers/xai-responses-opt-in.ts +++ b/src/providers/xai-responses-opt-in.ts @@ -1,15 +1,41 @@ -import type { OcxProviderConfig } from "../types"; +import { MODEL_ADAPTER_OVERRIDE_ALLOWED, type OcxConfig, type OcxProviderConfig } from "../types"; +import { providerModelWireDefault } from "./registry"; export const XAI_RESPONSES_OPT_IN_MODELS = ["grok-4.6", "grok-4.5"] as const; +export const XAI_RESPONSES_DEFAULT_VERSION = 1; export type XaiResponsesOptInState = boolean | "mixed"; -/** Derived dashboard/API state for the two modelAdapters entries owned by the xAI opt-in. */ +/** Effective Responses-inbound wire; the legacy API field name remains compatible. */ export function xaiResponsesOptInState(provider: OcxProviderConfig): XaiResponsesOptInState { - const enabled = XAI_RESPONSES_OPT_IN_MODELS.map( - model => provider.modelAdapters?.[model] === "openai-responses", - ); + const enabled = XAI_RESPONSES_OPT_IN_MODELS.map(model => { + const configured = provider.modelAdapters?.[model]; + const wire = configured && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured) + ? configured + : providerModelWireDefault("xai", provider, model, MODEL_ADAPTER_OVERRIDE_ALLOWED, "responses") + ?? provider.adapter; + return wire === "openai-responses"; + }); if (enabled.every(Boolean)) return true; if (enabled.some(Boolean)) return "mixed"; return false; } + +/** Upgrade old Chat choices once; a later explicit Chat opt-in must survive restart. */ +export function migrateXaiResponsesDefault(config: OcxConfig): boolean { + const provider = config.providers.xai; + if (!provider || (provider.xaiResponsesDefaultVersion ?? 0) >= XAI_RESPONSES_DEFAULT_VERSION) return false; + if (!XAI_RESPONSES_OPT_IN_MODELS.every(model => + providerModelWireDefault("xai", provider, model, MODEL_ADAPTER_OVERRIDE_ALLOWED, "responses") === "openai-responses")) { + return false; + } + const modelAdapters = { ...provider.modelAdapters }; + for (const model of XAI_RESPONSES_OPT_IN_MODELS) { + if (modelAdapters[model] === "openai-chat") delete modelAdapters[model]; + } + const next = { ...provider, xaiResponsesDefaultVersion: XAI_RESPONSES_DEFAULT_VERSION }; + if (Object.keys(modelAdapters).length) next.modelAdapters = modelAdapters; + else delete next.modelAdapters; + config.providers = { ...config.providers, xai: next }; + return true; +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index ccc23c5ef5..0677722979 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -833,6 +833,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = { modelPreferHostedTools: "editor", supportsOpenAiWebSearchToolFields: "editor", xaiResponsesXSearch: "editor", + xaiResponsesDefaultVersion: "runtime", supportsResponsesCustomTools: "editor", responsesSnapshotRepair: "editor", reasoningEffortMap: "editor", diff --git a/src/server/index.ts b/src/server/index.ts index 82f36d7b6a..834813b3a2 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -22,6 +22,7 @@ import { import { grokDefaultReasoningEffort } from "../grok/effort"; import { flushConfigDirHardening } from "../config/paths"; import { migrateStartupSubagentModels } from "./subagent-models-startup"; +import { migrateStartupXaiResponses } from "./xai-responses-startup"; import { reconcileOAuthProviders } from "../oauth"; import { withCatalogWriteSerialization } from "../codex/catalog-write-serialization"; import { invalidateCodexModelsCacheWithPermit } from "../codex/catalog/sync"; @@ -654,9 +655,13 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server 0) next.modelAdapters = modelAdapters; else delete next.modelAdapters; + next.xaiResponsesDefaultVersion = Math.max(next.xaiResponsesDefaultVersion ?? 0, XAI_RESPONSES_DEFAULT_VERSION); touched = true; } if (Object.hasOwn(rawBody, "requestPacing")) { @@ -1006,6 +1008,17 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise {}); } catch { /* already closed */ } + const result = await rebuildAndRefetch("oauth-account-429"); + if ("failed" in result) return result.failed; + upstreamResponse = result; + continue passthroughRecovery; + } + } + // Same-target 429 wait-and-retry (opt-in `retryOn429`) for key-auth providers on the // passthrough wire. This branch returns before the recovery loop below, so Responses-shaped // key-auth gateways (e.g. the built-in DeepSeek preset) would otherwise surface 429 diff --git a/src/server/xai-responses-startup.ts b/src/server/xai-responses-startup.ts new file mode 100644 index 0000000000..b9309150ab --- /dev/null +++ b/src/server/xai-responses-startup.ts @@ -0,0 +1,21 @@ +import { mutatePersistedConfig } from "../config"; +import { migrateXaiResponsesDefault } from "../providers/xai-responses-opt-in"; +import type { OcxConfig } from "../types"; + +/** Rebase the one-time wire upgrade before initializing any live config consumers. */ +export function migrateStartupXaiResponses(config: OcxConfig): OcxConfig { + const projection = { ...config }; + if (!migrateXaiResponsesDefault(projection)) return config; + try { + const outcome = mutatePersistedConfig(fresh => ({ + changed: migrateXaiResponsesDefault(fresh), + value: fresh, + })); + if (outcome.status !== "unavailable") return outcome.value; + console.warn(`[xai-responses-migration] Persistence unavailable (${outcome.reason}); using Responses in memory only.`); + } catch { + // Filesystem errors can carry private paths. Startup must still remain available. + console.warn("[xai-responses-migration] Persistence failed; using Responses in memory only."); + } + return projection; +} diff --git a/src/types/provider.ts b/src/types/provider.ts index b1304f454c..cb2abc1f0c 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -521,6 +521,8 @@ export interface OcxProviderConfig { * from the web-search sidecar's `search.xSearch` options and never widens caller tool selectors. */ xaiResponsesXSearch?: boolean; + /** One-time Grok subscription wire upgrade; later explicit Chat choices remain authoritative. */ + xaiResponsesDefaultVersion?: number; /** * Whether the Responses upstream accepts native custom tools and custom_tool_call items. * Set false only for a provider whose native contract rejects them; absence preserves diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index c545d41d8c..4ee22c114c 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -297,19 +297,38 @@ different custom destination does not inherit its upstream assumptions. Object-f also narrow the decision by inbound protocol and authentication mode; an auth-scoped default must not leak from a subscription transport into an API-key or forwarded-credential route. -xAI keeps `openai-chat` as both its provider-wide compatibility wire and the default for Grok 4.5 -and 4.6 subscription traffic. The official Grok CLI catalog declares those models as Responses -backends, but the current gateway rejects opaque reasoning continuation and compaction state on -later turns. Operators may still select `openai-responses` with an explicit model adapter override -while that compatibility work continues. The OAuth route drops caller-owned `service_tier` even -when an override selects Responses, and native Responses OAuth 401 replay remains available to -explicit opt-ins. API-key requests, translated Chat/Anthropic callers, and other Grok models retain -their existing wire and tier policy. - -The dashboard's xAI Responses opt-in switch is the GUI surface of this same `modelAdapters` lane, -not a separate tier policy. One write sets or clears the Grok 4.5 and 4.6 entries together while -preserving unrelated overrides; a pre-existing one-entry state is reported as mixed until the next -switch write normalizes both. +xAI keeps `openai-chat` as its provider-wide compatibility wire, but Grok 4.5/4.6 subscription +Responses requests default to native `openai-responses`. Existing namespace, hosted-search and +reasoning-replay normalization remains in force. The reserved `xai` OAuth transport is name-pinned +to the Grok CLI gateway even if its saved base URL differs; custom provider IDs do not inherit this +default. API-key requests, translated Chat/Anthropic defaults and other Grok models retain their +existing wire and tier policy. OAuth still drops caller-owned `service_tier` on either wire. + +Native Responses participates in the same pre-stream OAuth HTTP-429 account rotation as the Chat +bridge. It uses the existing account quorum, cooldown and three-rotation request cap, refreshes +the complete credential/transport/replay identity, and attributes usage to the serving account. +Single-account installs do not retry; a missing alternate credential preserves the original error. + +Startup removes legacy Grok 4.5/4.6 Chat overrides once and persists the provider-owned +`xaiResponsesDefaultVersion` marker. Later explicit Chat choices survive restarts. The migration +rebases under the config mutation lock; unavailable persistence warns and uses an isolated in-memory +projection without overwriting invalid disk state. Read-only config loading does not migrate. + +The dashboard's Chat Completions switch and `ocx provider edit xai --xai-chat on|off` share the +existing `modelAdapters` lane. On writes Chat for both models; off writes Responses. Unrelated +overrides remain intact. The legacy PATCH field `xaiResponsesOptIn` retains its direction: +true selects Responses, false now writes explicit Chat rather than deleting entries. Its derived +`xaiResponsesOptInState` reflects effective Responses-inbound routing, including registry defaults; +only genuinely different effective wires report mixed. A switch write also records the migration +version (without lowering a future version), and provider-form overwrites retain omitted choices. + +Native routed Responses code-mode turns also receive the shared result-emission contract in both +instructions and the lowered exec input description: a bare awaited helper return is discarded by +the host, so visible results need `text(...)` or `notify(...)` in that first call. Paired exec outputs +containing only an empty completion/failure wrapper use the shared explanatory annotation. The +whole result is examined; populated text, image/file parts, unpaired results, shell-only catalogs, +compaction and OpenAI-operated destinations are untouched. This does not rewrite valid JavaScript +or reconstruct output that the code-mode host never emitted. [Decision Log] - 목적과 의도: Keep Codex hosted web search usable on xAI's public Responses endpoint without forwarding private OpenAI-only fields that xAI rejects. diff --git a/tests/adapters/anthropic/anthropic-thinking-signature.test.ts b/tests/adapters/anthropic/anthropic-thinking-signature.test.ts index efb9338b41..68c972a742 100644 --- a/tests/adapters/anthropic/anthropic-thinking-signature.test.ts +++ b/tests/adapters/anthropic/anthropic-thinking-signature.test.ts @@ -311,8 +311,8 @@ describe("passthrough scrub of ocxr1 envelopes", () => { test("sanitize strips ocxr1 encrypted_content even with empty content", async () => { const { createResponsesPassthroughAdapter } = await import("../../../src/adapters/openai-responses"); const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter({ - adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", passthrough: true, - } as OcxProviderConfig)); + adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", + })); expect(adapter.passthrough).toBe(true); const body = { model: "gpt-5.5", @@ -321,7 +321,7 @@ describe("passthrough scrub of ocxr1 envelopes", () => { ], }; // Build the outgoing request the adapter would send; the ocxr1 envelope must be stripped. - const req = await adapter.buildRequest({ _rawBody: body, model: "gpt-5.5", messages: [], options: {} } as never) as { body?: string }; + const req = await adapter.buildRequest(parseRequest(body)); expect(req.body ?? "").not.toContain(OCX_REASONING_PREFIX); expect(req.body ?? "").toContain('"rs_1"'); // reasoning item itself survives }); diff --git a/tests/cli/cli-headless-parity.test.ts b/tests/cli/cli-headless-parity.test.ts index 42d66d3f04..a494c8b2e1 100644 --- a/tests/cli/cli-headless-parity.test.ts +++ b/tests/cli/cli-headless-parity.test.ts @@ -313,6 +313,22 @@ describe("headless GUI parity CLI", () => { }]); }); + test.each(["on", "off"])("provider edit --xai-chat %s shares the GUI wire selector", async value => { + const runtime = fakeRuntime(); + expect(await handleProviderRuntimeCommand("edit", ["xai", "--xai-chat", value, "--json"], runtime.deps)).toBe(0); + expect(runtime.requests).toEqual([{ + path: "/api/providers?name=xai", method: "PATCH", body: { xaiResponsesOptIn: value === "off" }, + }]); + }); + + test.each([ + ["xai", "--xai-chat", "maybe"], ["xai", "--xai-chat"], ["other", "--xai-chat", "on"], + ])("invalid xAI wire option %j makes no request", async (...args) => { + const runtime = fakeRuntime(); + expect(await handleProviderRuntimeCommand("edit", args, runtime.deps)).toBe(2); + expect(runtime.requests).toHaveLength(0); + }); + test("provider edit --headers sends the parsed block and - clears it", async () => { const runtime = fakeRuntime(); const code = await handleProviderRuntimeCommand("edit", [ diff --git a/tests/oauth/generic-oauth-failover.test.ts b/tests/oauth/generic-oauth-failover.test.ts index dc8fec9714..3761d65ce3 100644 --- a/tests/oauth/generic-oauth-failover.test.ts +++ b/tests/oauth/generic-oauth-failover.test.ts @@ -304,11 +304,9 @@ describe("sidecar on429 wiring", () => { // bearer by hand would reintroduce the mixed-identity bug this helper exists to prevent. const snapshotUses = coreSource.match(/failoverAccountSnapshot\(/g) ?? []; const helperUses = coreSource.match(/applyFailoverSnapshot\(snapshot(?:, nextParsed)?\)/g) ?? []; - // Four since the continuation loop gained its own generic-OAuth arm: the streaming loop grew - // one with #2568 and the continuation loop did not, so an xAI/Cursor continuation 429 stayed - // terminal. Bumping this count is the deliberate act of admitting a fourth rotation site -- - // which is exactly why the guard is a count and not a floor. - expect(snapshotUses.length).toBe(4); + // Five includes native Responses passthrough, which returns before the Chat bridge loop. + // The explicit count keeps a newly added rotation site from skipping identity pairing. + expect(snapshotUses.length).toBe(5); expect(helperUses.length).toBe(snapshotUses.length); // The bearer is written in exactly one place — inside the helper. Any other occurrence is a // rotation site that skipped the pairing rules. @@ -339,7 +337,9 @@ describe("sidecar on429 wiring", () => { // The counts differ by rotator because the recovery sites differ, and each number is a // statement about which providers can recover where: // - // generic = 4: streaming loop, continuation loop, sidecar hook, runTurn preflight. + // generic = 5: streaming loop, continuation loop, sidecar hook, runTurn preflight, + // native Responses passthrough. The new default only moves OAuth traffic; + // key-auth defaults and Anthropic's own wire/pool remain unchanged. // anthropic = 3: the same, MINUS runTurn -- that path is Cursor-only (cursor.ts is the // sole adapter implementing runTurn), so Anthropic cannot reach it. // key = 3: hasKeyPoolFailover guards the two 429 response loops plus the @@ -349,7 +349,7 @@ describe("sidecar on429 wiring", () => { // // Adding a fifth recovery site means deciding, deliberately, which rotators it needs and // updating the matching number. That decision is the thing this test exists to force. - expect(counts.generic).toBe(4); + expect(counts.generic).toBe(5); expect(counts.anthropic).toBe(3); expect(counts.key).toBe(3); }); diff --git a/tests/oauth/oauth-account-attribution.test.ts b/tests/oauth/oauth-account-attribution.test.ts index 317108fd86..ff6a838462 100644 --- a/tests/oauth/oauth-account-attribution.test.ts +++ b/tests/oauth/oauth-account-attribution.test.ts @@ -1,10 +1,12 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { mkdtempSync, readFileSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { oauthAccountLogLabel, ACCOUNT_LOG_LABEL_RE } from "../../src/codex/account-label"; import { getAccountSet, saveCredential } from "../../src/oauth/store"; import { clearGenericFailoverHealth } from "../../src/oauth/generic-account-failover"; +import * as accountFailover from "../../src/oauth/generic-account-failover"; +import * as oauth from "../../src/oauth"; import { stampOAuthAccountLabel } from "../../src/providers/label"; import { isCodexUsageAccountLogLabel, isCodexPoolAccountLogLabel } from "../../src/usage/log"; import type { PersistedUsageEntry } from "../../src/usage/log"; @@ -37,21 +39,25 @@ function oauthConfig(): OcxConfig { } as OcxConfig; } -function request(): Request { +function request(stream = false): Request { return new Request("http://localhost/v1/responses", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "grok-4.6", input: "hello", stream: false }), + body: JSON.stringify({ model: "grok-4.6", input: "hello", stream }), }); } -function completed(): Response { - return Response.json({ +function completed(stream = false): Response { + const response = { id: "resp_attrib", status: "completed", output: [], usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, - }); + }; + return stream + ? new Response(`event: response.completed\ndata: ${JSON.stringify({ type: "response.completed", response })}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }) : Response.json(response); } async function withHome(run: (home: string) => Promise): Promise { @@ -74,6 +80,7 @@ async function withHome(run: (home: string) => Promise): Promise { afterEach(() => { globalThis.fetch = originalFetch; + clearGenericFailoverHealth(); }); describe("the label families", () => { @@ -201,7 +208,7 @@ describe("Responses per-account attribution for non-Codex OAuth", () => { * one that hit the 429. All three rotation sites in `core.ts` funnel through * `applyFailoverSnapshot`, so the re-stamp lives there -- one edit covering all three. */ - test("a rotated request is attributed to the account that actually served it", async () => { + test.each([false, true])("a rotated request is attributed to the account that actually served it (stream=%s)", async stream => { await withHome(async () => { clearGenericFailoverHealth(); for (const i of [1, 2]) { @@ -222,15 +229,16 @@ describe("Responses per-account attribution for non-Codex OAuth", () => { if (bearers.length === 1) { return Response.json({ error: { message: "rate limited" } }, { status: 429, headers: { "retry-after": "42" } }); } - return completed(); + return completed(stream); }) as typeof fetch; const logCtx: RequestLogContext = { model: "", provider: "" }; - const response = await handleResponses(request(), oauthConfig(), logCtx, {}); + const response = await handleResponses(request(stream), oauthConfig(), logCtx, {}); // Two accounts present and no explicit knob is the presence-consent case (#2568d), so the // rotation happens without configuration. expect(response.status).toBe(200); + await response.text(); expect(bearers).toHaveLength(2); expect(bearers[0]).not.toBe(bearers[1]); @@ -249,6 +257,80 @@ describe("Responses per-account attribution for non-Codex OAuth", () => { clearGenericFailoverHealth(); }); }); + + test.each([[1, 1], [5, 4]])("native Responses with %i accounts stays within %i sends on repeated 429", async (accounts, expectedSends) => { + await withHome(async () => { + clearGenericFailoverHealth(); + for (let index = 0; index < accounts; index++) { + await saveCredential("xai", { + access: `bounded-access-${index}`, refresh: `bounded-refresh-${index}`, + expires: Date.now() + 3_600_000, accountId: `bounded-${index}`, source: "local-cli", + }, { addAccount: true } as never); + } + let sends = 0; + globalThis.fetch = (async () => { + sends++; + return Response.json({ error: { message: "rate limited" } }, { status: 429, headers: { "retry-after": "42" } }); + }) as typeof fetch; + const response = await handleResponses(request(), oauthConfig(), { model: "", provider: "" }, {}); + expect(response.status).toBe(429); + expect(await response.text()).toContain("rate limited"); + expect(sends).toBe(expectedSends); + clearGenericFailoverHealth(); + }); + }); + + test("native rotation keeps the original 429 readable when the alternate snapshot fails", async () => { + await withHome(async () => { + for (const i of [1, 2]) await saveCredential("xai", { + access: `snapshot-access-${i}`, refresh: `snapshot-refresh-${i}`, + expires: Date.now() + 3_600_000, accountId: `snapshot-${i}`, source: "local-cli", + }, { addAccount: true } as never); + const snapshot = spyOn(accountFailover, "failoverAccountSnapshot").mockRejectedValue(new Error("snapshot unavailable")); + let sends = 0; + globalThis.fetch = (async () => { + sends++; + return Response.json({ error: { message: "original rate limit" } }, { status: 429 }); + }) as typeof fetch; + try { + const response = await handleResponses(request(), oauthConfig(), { model: "", provider: "" }, {}); + expect(response.status).toBe(429); + expect(await response.text()).toContain("original rate limit"); + expect(sends).toBe(1); + expect(snapshot).toHaveBeenCalledTimes(1); + } finally { snapshot.mockRestore(); } + }); + }); + + test("a 429 then 401 refreshes the newly selected OAuth account, not the failed one", async () => { + await withHome(async () => { + for (const i of [1, 2]) await saveCredential("xai", { + access: `refresh-access-${i}`, refresh: `refresh-token-${i}`, + expires: Date.now() + 3_600_000, accountId: `refresh-${i}`, source: "local-cli", + }, { addAccount: true } as never); + let refreshedId: string | undefined; + const refresh = spyOn(oauth, "forceRefreshOAuthAccessSnapshot").mockImplementation(async snapshot => { + refreshedId = snapshot.accountId; + return { ...snapshot, accessToken: "fresh-b" }; + }); + const bearers: string[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + bearers.push(new Headers(init?.headers).get("authorization") ?? ""); + const status = bearers.length === 1 ? 429 : bearers.length === 2 ? 401 : 200; + return status === 200 ? completed() : Response.json({ error: "retry" }, { status }); + }) as typeof fetch; + try { + const logCtx: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(request(), oauthConfig(), logCtx, {}); + expect(response.status).toBe(200); + expect(bearers).toHaveLength(3); + expect(bearers[2]).toBe("Bearer fresh-b"); + const selected = getAccountSet("xai")!.accounts.find(account => bearers[1]!.includes(account.credential.access)); + expect(refreshedId).toBe(selected?.id); + expect(logCtx.accountLogLabel).toBe(oauthAccountLogLabel(selected!.id, "xai")); + } finally { refresh.mockRestore(); } + }); + }); }); /** diff --git a/tests/oauth/oauth-upsert-preserves-api-key.test.ts b/tests/oauth/oauth-upsert-preserves-api-key.test.ts index dc5b55e1c6..e8cc0fced9 100644 --- a/tests/oauth/oauth-upsert-preserves-api-key.test.ts +++ b/tests/oauth/oauth-upsert-preserves-api-key.test.ts @@ -3,6 +3,8 @@ import { mkdtempSync} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { upsertOAuthProvider } from "../../src/oauth"; +import { migrateXaiResponsesDefault } from "../../src/providers/xai-responses-opt-in"; +import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; import { apiKeyPoolEntryId, listProviderApiKeys, @@ -36,6 +38,26 @@ function configWithKey(provider: string, adapter: string, baseUrl: string): OcxC } describe("upsertOAuthProvider credential preservation", () => { + test.each([undefined, 1, 2])("Grok login preserves wire choice and migration version %j", version => { + const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); + const before = config.providers.xai!; + before.authMode = "oauth"; + before.xaiResponsesDefaultVersion = version; + before.modelAdapters = { "grok-4.6": "openai-chat", "grok-4.5": "openai-chat", other: "openai-responses" }; + upsertOAuthProvider(config, "xai"); + expect(config.providers.xai!.modelAdapters).toEqual(before.modelAdapters); + expect(config.providers.xai!.modelAdapters).not.toBe(before.modelAdapters); + expect(config.providers.xai!.xaiResponsesDefaultVersion).toBe(version); + expect(migrateXaiResponsesDefault(config)).toBe(version === undefined); + const expected = version === undefined ? "openai-responses" : "openai-chat"; + for (const model of ["grok-4.5", "grok-4.6"]) { + expect(resolveWireProtocolOverride("xai", model, config.providers.xai!).adapter).toBe(expected); + } + expect(config.providers.xai!.modelAdapters!.other).toBe("openai-responses"); + upsertOAuthProvider(config, "xai"); + expect(migrateXaiResponsesDefault(config)).toBe(false); + }); + test("keeps a stored API key and the explicit key billing mode for xai", () => { const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); upsertOAuthProvider(config, "xai"); diff --git a/tests/providers/xai/xai-transport.test.ts b/tests/providers/xai/xai-transport.test.ts index 5b87d9bad6..9eb21fcacd 100644 --- a/tests/providers/xai/xai-transport.test.ts +++ b/tests/providers/xai/xai-transport.test.ts @@ -11,7 +11,7 @@ import { XAI_GROK_CLIENT_VERSION, } from "../../../src/providers/xai-transport"; import { getProviderRegistryEntry } from "../../../src/providers/registry"; -import { XAI_RESPONSES_OPT_IN_MODELS } from "../../../src/providers/xai-responses-opt-in"; +import { XAI_RESPONSES_OPT_IN_MODELS, xaiResponsesOptInState } from "../../../src/providers/xai-responses-opt-in"; import { resolveWireProtocolOverride } from "../../../src/server/adapter-resolve"; import type { OcxAssistantMessage, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; @@ -75,6 +75,17 @@ describe("xAI Responses destination detection", () => { }); }); +describe("xAI effective wire control state", () => { + test("defaults and overrides agree with Responses-inbound routing", () => { + expect(xaiResponsesOptInState(provider("oauth"))).toBe(true); + expect(xaiResponsesOptInState(provider("key"))).toBe(false); + expect(xaiResponsesOptInState({ ...provider("oauth"), modelAdapters: { "grok-4.6": "openai-responses" } })).toBe(true); + expect(xaiResponsesOptInState({ ...provider("oauth"), modelAdapters: { "grok-4.6": "openai-chat" } })).toBe("mixed"); + expect(xaiResponsesOptInState({ ...provider("oauth"), modelAdapters: { "grok-4.6": "invalid" } })).toBe(true); + expect(xaiResponsesOptInState({ ...provider("oauth"), modelAdapters: { "grok-4.6": "openai-chat", "grok-4.5": "openai-chat" } })).toBe(false); + }); +}); + describe("xAI auth-mode transport selection", () => { test("OAuth selects the Grok CLI subscription transport and required headers", () => { const effective = resolveProviderTransport("xai", provider("oauth")); diff --git a/tests/responses/openai-responses-passthrough.test.ts b/tests/responses/openai-responses-passthrough.test.ts index 578a0f1906..7fa408ded4 100644 --- a/tests/responses/openai-responses-passthrough.test.ts +++ b/tests/responses/openai-responses-passthrough.test.ts @@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test"; import { createOpenAIChatAdapter } from "../../src/adapters/openai-chat"; import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../../src/adapters/openai-responses"; import { openaiResponsesUrl } from "../../src/adapters/openai-responses-url"; +import { normalizeResponsesCodeMode } from "../../src/adapters/responses-code-mode"; +import { CODE_MODE_RESULT_ECHO_SENTENCE, EMPTY_EXEC_OUTPUT_MESSAGE, FAILED_EXEC_OUTPUT_MESSAGE } from "../../src/adapters/exec-tool-result-normalize"; import { chatCompletionsToResponsesBody } from "../../src/chat/inbound"; import { anthropicToResponsesBody } from "../../src/claude/inbound"; import { parseRequest } from "../../src/responses/parser"; @@ -29,6 +31,100 @@ const provider = { authMode: "forward" as const, }; +describe("native routed code-mode result visibility", () => { + const routed = { adapter: "openai-responses", baseUrl: "https://api.x.ai/v1", authMode: "key" as const }; + const exec = { type: "custom", name: "exec", description: "Run JavaScript in a V8 isolate." }; + const empty = "Script completed\nWall time 0.2 seconds\nOutput:\n"; + const raw = (output: unknown = empty) => ({ + model: "grok-4.6", instructions: "Keep this instruction.", + tools: [{ type: "namespace", name: "functions", tools: [exec] }], + input: [ + { type: "custom_tool_call", name: "exec", call_id: "call_probe", input: 'await tools.exec_command({cmd: "printf marker"})' }, + { type: "custom_tool_call_output", call_id: "call_probe", output }, + ], + }); + + test("first native request carries the echo rule in instructions and the exact input schema", () => { + const body = { ...raw(), input: [{ role: "user", content: "Read a marker with the shell helper." }] }; + const before = JSON.stringify(body); + const request = createResponsesPassthroughAdapter(routed).buildRequest(parseRequest(body)); + const wire = JSON.parse(request.body); + expect(wire.instructions).toBe(`Keep this instruction.\n\n${CODE_MODE_RESULT_ECHO_SENTENCE}`); + expect(wire.tools.find((tool: { name: string }) => tool.name === "exec").parameters.properties.input.description) + .toContain(CODE_MODE_RESULT_ECHO_SENTENCE); + expect(JSON.stringify(body)).toBe(before); + }); + + test("the advertised first-call example emits a helper result exactly once", async () => { + const example = CODE_MODE_RESULT_ECHO_SENTENCE.match(/`(text\(JSON\.stringify\(await tools\.exec_command[^`]+)`/)?.[1]; + if (!example) throw new Error("Missing executable result-emission example"); + const output: unknown[] = []; + let calls = 0; + const tools = { exec_command: async () => { calls++; return { output: "marker", exit_code: 0 }; } }; + const run = new Function("tools", "text", `return (async () => { ${example}; })();`); + await run(tools, (value: unknown) => output.push(value)); + expect(calls).toBe(1); + expect(output).toEqual(['{"output":"marker","exit_code":0}']); + }); + + test.each([ + [empty, EMPTY_EXEC_OUTPUT_MESSAGE], + [[{ type: "input_text", text: empty }], EMPTY_EXEC_OUTPUT_MESSAGE], + ["Script failed\nWall time 0.1 seconds\nOutput:\n", FAILED_EXEC_OUTPUT_MESSAGE], + ])("explains a wholly empty paired exec result %# without rewriting its program", (output, expected) => { + const body = raw(output); + const wire = JSON.parse(createResponsesPassthroughAdapter(routed).buildRequest(parseRequest(body)).body); + expect(wire.input[1].output).toBe(expected); + expect(JSON.parse(wire.input[0].arguments).input).toBe(body.input[0].input); + }); + + test.each([ + "actual result", + [{ type: "input_text", text: empty }, { type: "input_text", text: "actual result" }], + [{ type: "input_text", text: empty }, { type: "input_image", image_url: "https://example.test/image.png" }], + [{ type: "input_file", file_id: "file_probe" }], + null, + ].map(output => ({ output })))("preserves populated, multimodal and incomplete results %#", ({ output }) => { + const body = raw(output); + const normalized = normalizeResponsesCodeMode(body, parseRequest(body), routed) as typeof body; + expect(normalized.input[1]).toBe(body.input[1]); + expect(normalized.input[0]).toBe(body.input[0]); + }); + + test("does not duplicate instructions or explain an unpaired or unrelated result", () => { + const body = raw(); + body.input[0].name = "other"; + const parsed = parseRequest(body); + const first = normalizeResponsesCodeMode(body, parsed, routed) as typeof body; + const second = normalizeResponsesCodeMode(first, parsed, routed) as typeof body; + expect(first.input[1]).toBe(body.input[1]); + expect(second.instructions).toBe(first.instructions); + }); + + test("official OpenAI and non-code-mode catalogs remain untouched", () => { + const body = raw(); + for (const native of [provider, { ...routed, baseUrl: "https://api.openai.com/v1" }]) { + expect(normalizeResponsesCodeMode(body, parseRequest(body), native)).toBe(body); + const wire = JSON.parse(createResponsesPassthroughAdapter(native).buildRequest(parseRequest(body)).body); + expect(wire.instructions).toBe(body.instructions); + expect(JSON.stringify(wire.tools)).not.toContain(CODE_MODE_RESULT_ECHO_SENTENCE); + } + for (const tools of [ + [{ type: "function", name: "exec", parameters: { type: "object" } }], + [exec, { type: "function", name: "exec_command", parameters: { type: "object" } }], + [{ type: "namespace", name: "remote", tools: [exec] }], + ]) { + const alternate = { ...body, tools }; + expect(normalizeResponsesCodeMode(alternate, parseRequest(alternate), routed)).toBe(alternate); + } + const excluded = { ...body, tool_choice: "none" }; + expect(normalizeResponsesCodeMode(excluded, parseRequest(excluded), routed)).toBe(excluded); + const compact = parseRequest(body); + compact._compactionRequest = true; + expect(normalizeResponsesCodeMode(body, compact, routed)).toBe(body); + }); +}); + describe("external image wire matrix", () => { // Same decodable 1x1 PNG as anthropic-image-normalize.test.ts; no fetch is needed. const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; diff --git a/tests/routing/fastwire-policy.test.ts b/tests/routing/fastwire-policy.test.ts index 2f85fb8fb9..e9918dc6ce 100644 --- a/tests/routing/fastwire-policy.test.ts +++ b/tests/routing/fastwire-policy.test.ts @@ -267,9 +267,9 @@ describe("resolveFastPolicy matrix", () => { baseUrl: "https://api.x.ai/v1", authMode: "oauth" as const, }, - adapter: "openai-chat", + adapter: "openai-responses", forwardCallerTier: false, - callerTier: undefined, + callerTier: "flex", settledCallerTier: undefined, }, { diff --git a/tests/server/adapter-resolve.test.ts b/tests/server/adapter-resolve.test.ts index 26ed9fb141..1805ad7022 100644 --- a/tests/server/adapter-resolve.test.ts +++ b/tests/server/adapter-resolve.test.ts @@ -100,10 +100,10 @@ describe("registry per-model wire defaults", () => { }); } - test("keeps current xAI subscription models on Chat by default", () => { + test("routes current xAI subscription Responses callers through Responses by default", () => { for (const model of ["grok-4.6", "grok-4.5"]) { expect(resolveWireProtocolOverride("xai", model, xai("oauth"), "responses").adapter) - .toBe("openai-chat"); + .toBe("openai-responses"); } }); @@ -126,6 +126,23 @@ describe("registry per-model wire defaults", () => { } }); + test("explicit Chat opts out of the xAI Responses default without changing other models", () => { + for (const model of ["grok-4.6", "grok-4.5"]) { + const configured = xai("oauth", { modelAdapters: { [model]: "openai-chat" } }); + expect(resolveWireProtocolOverride("xai", model, configured).adapter).toBe("openai-chat"); + expect(resolveWireProtocolOverride("xai", "grok-4.20-multi-agent-0309", configured).adapter) + .toBe("openai-responses"); + } + }); + + test("xAI wire defaults are name-pinned, not inherited by custom provider IDs", () => { + const configured = xai("oauth", { baseUrl: "https://gateway.example.test/v1" }); + expect(resolveWireProtocolOverride("custom-xai", "grok-4.6", configured).adapter).toBe("openai-chat"); + expect(resolveWireProtocolOverride("xai", "grok-4.6", configured).adapter).toBe("openai-responses"); + expect(resolveWireProtocolOverride("xai", "grok-4.6", xai("oauth", { authMode: undefined })).adapter) + .toBe("openai-responses"); + }); + function deepseek(overrides: Partial = {}): OcxProviderConfig { return gateway({ baseUrl: "https://api.deepseek.com", diff --git a/tests/server/config.test.ts b/tests/server/config.test.ts index 024ca4e65a..00c096c02b 100644 --- a/tests/server/config.test.ts +++ b/tests/server/config.test.ts @@ -39,6 +39,8 @@ import { nextAtomicTempSequence } from "../../src/config/atomic-write"; import { flushConfigDirHardeningForTests } from "../../src/config/paths"; import { DEFAULT_SUBAGENT_MODELS, migrateSubagentModels } from "../../src/config/subagent-models"; import { migrateStartupSubagentModels } from "../../src/server/subagent-models-startup"; +import { migrateXaiResponsesDefault } from "../../src/providers/xai-responses-opt-in"; +import { migrateStartupXaiResponses } from "../../src/server/xai-responses-startup"; import * as configStore from "../../src/config"; import { runClaudeAuthModeMigration } from "../../src/claude/auth-mode-migration"; import { providerManagementConfigError } from "../../src/server/auth-cors"; @@ -202,6 +204,89 @@ describe("Astra-first subagent upgrade", () => { }); }); +describe("one-time Grok Responses upgrade", () => { + function legacy() { + return { + ...getDefaultConfig(), + providers: { + xai: { + adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "oauth" as const, + xaiResponsesDefaultVersion: undefined as number | undefined, + modelAdapters: { "grok-4.6": "openai-chat", "grok-4.5": "openai-chat", "other": "openai-chat" }, + }, + }, + defaultProvider: "xai", + }; + } + + test("read-only load preserves legacy choices; startup flips both once and saves the marker", () => { + saveConfig(legacy()); + const before = readFileSync(getConfigPath(), "utf8"); + const config = loadConfig(); + expect(config.providers.xai!.modelAdapters!["grok-4.6"]).toBe("openai-chat"); + expect(readConfigDiagnostics().config.providers.xai!.xaiResponsesDefaultVersion).toBeUndefined(); + expect(readFileSync(getConfigPath(), "utf8")).toBe(before); + const upgraded = migrateStartupXaiResponses(config); + expect(upgraded.providers.xai!.modelAdapters).toEqual({ other: "openai-chat" }); + expect(upgraded.providers.xai!.xaiResponsesDefaultVersion).toBe(1); + expect(loadConfig().providers.xai).toEqual(upgraded.providers.xai); + expect(config.providers.xai!.modelAdapters).toEqual(legacy().providers.xai.modelAdapters); + expect(migrateXaiResponsesDefault(upgraded)).toBe(false); + }); + + test.each([1, 2])("later Chat choices and future version %i survive startup", version => { + const config = legacy(); + config.providers.xai.xaiResponsesDefaultVersion = version; + saveConfig(config); + expect(migrateStartupXaiResponses(loadConfig()).providers.xai).toEqual(config.providers.xai); + expect(loadConfig().providers.xai!.xaiResponsesDefaultVersion).toBe(version); + }); + + test("migration does not touch key auth, other adapters or custom provider IDs", () => { + for (const change of [{ authMode: "key" }, { adapter: "anthropic" }]) { + const config = legacy(); + Object.assign(config.providers.xai, change); + const before = structuredClone(config); + expect(migrateXaiResponsesDefault(config)).toBe(false); + expect(config).toEqual(before); + } + const source = legacy(); + const custom = { ...source, defaultProvider: "custom-xai", providers: { "custom-xai": source.providers.xai } }; + expect(migrateXaiResponsesDefault(custom)).toBe(false); + }); + + test("fresh disk state wins over stale startup and preserves a concurrent completed opt-in", () => { + saveConfig(legacy()); + const stale = loadConfig(); + const fresh = loadConfig(); + fresh.port = 23456; + fresh.providers.xai!.xaiResponsesDefaultVersion = 2; + saveConfig(fresh); + const upgraded = migrateStartupXaiResponses(stale); + expect(upgraded.port).toBe(23456); + expect(upgraded.providers.xai).toEqual(fresh.providers.xai); + expect(loadConfig().providers.xai).toEqual(fresh.providers.xai); + }); + + test("unavailable or throwing persistence preserves disk and returns an isolated projection", () => { + const config = legacy(); + writeConfig("{ invalid"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(migrateStartupXaiResponses(config).providers.xai!.xaiResponsesDefaultVersion).toBe(1); + expect(readFileSync(getConfigPath(), "utf8")).toBe("{ invalid"); + expect(config.providers.xai.modelAdapters).toEqual(legacy().providers.xai.modelAdapters); + const mutation = spyOn(configStore, "mutatePersistedConfig").mockImplementation(() => { + throw new Error("private path must not be logged"); + }); + try { + expect(migrateStartupXaiResponses(config).providers.xai!.xaiResponsesDefaultVersion).toBe(1); + expect(warn).toHaveBeenLastCalledWith("[xai-responses-migration] Persistence failed; using Responses in memory only."); + } finally { mutation.mockRestore(); } + } finally { warn.mockRestore(); } + }); +}); + function writeConfig(content: unknown): void { writeFileSync( getConfigPath(), diff --git a/tests/server/management-provider-validation.test.ts b/tests/server/management-provider-validation.test.ts index 35a7924ebe..386cc10931 100644 --- a/tests/server/management-provider-validation.test.ts +++ b/tests/server/management-provider-validation.test.ts @@ -3515,7 +3515,7 @@ describe("provider management validation", () => { }); }); - test("xAI Responses opt-in reports mixed state and atomically normalizes both model adapters", async () => { + test("xAI wire selection reports effective state and persists later Chat opt-in across provider overwrite", async () => { if (existsSync(TEST_DIR)) removeTreeWithRetry(TEST_DIR); mkdirSync(TEST_DIR, { recursive: true }); process.env.OPENCODEX_HOME = TEST_DIR; @@ -3528,8 +3528,9 @@ describe("provider management validation", () => { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "oauth", + xaiResponsesDefaultVersion: 2, modelAdapters: { - "grok-4.6": "openai-responses", + "grok-4.6": "openai-chat", "other-model": "openai-chat", }, }, @@ -3604,8 +3605,25 @@ describe("provider management validation", () => { name: "xai", xaiResponsesOptInState: false, }); - expect(liveConfig.providers.xai?.modelAdapters).toEqual({ "other-model": "openai-chat" }); - expect(loadConfig().providers.xai?.modelAdapters).toEqual({ "other-model": "openai-chat" }); + const chatAdapters = { "grok-4.6": "openai-chat", "grok-4.5": "openai-chat", "other-model": "openai-chat" }; + expect(liveConfig.providers.xai?.modelAdapters).toEqual(chatAdapters); + expect(loadConfig().providers.xai?.modelAdapters).toEqual(chatAdapters); + expect(loadConfig().providers.xai?.xaiResponsesDefaultVersion).toBe(2); + for (const model of ["grok-4.6", "grok-4.5"]) { + expect(resolveWireProtocolOverride("xai", model, liveConfig.providers.xai!).adapter).toBe("openai-chat"); + } + const overwrite = new Request("http://127.0.0.1/api/providers", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "xai", provider: { + adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "oauth", note: "edited", + } }), + }); + const overwritten = await handleManagementAPI(overwrite, new URL(overwrite.url), liveConfig, { + createManagementConvergeCodex: catalogConvergenceFactory(), + }); + expect(overwritten?.status).toBe(200); + expect(loadConfig().providers.xai?.modelAdapters).toEqual(chatAdapters); + expect(loadConfig().providers.xai?.xaiResponsesDefaultVersion).toBe(2); } finally { destinationProbe.mockRestore(); } diff --git a/tests/server/server-startup-reconcile-resilience.test.ts b/tests/server/server-startup-reconcile-resilience.test.ts index 2bc31b65ef..0969c63d5c 100644 --- a/tests/server/server-startup-reconcile-resilience.test.ts +++ b/tests/server/server-startup-reconcile-resilience.test.ts @@ -18,9 +18,12 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigPath, loadConfig, saveConfig } from "../../src/config"; +import * as configStore from "../../src/config"; +import * as stateStores from "../../src/lib/state-store-registrations"; import { OAUTH_PROVIDERS, reconcileOAuthProviders } from "../../src/oauth"; import { runModelRenameStartupMigration } from "../../src/providers/model-rename-startup"; import { startServer } from "../../src/server"; +import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; import { CURSOR_STATIC_MODELS, cursorModelIds } from "../../src/adapters/cursor/discovery"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -63,6 +66,68 @@ test.skipIf(!CAN_BIND)("startServer persists the Astra-first legacy roster upgra } }); +test.skipIf(!CAN_BIND)("startServer migrates old Grok Chat choices once and preserves later opt-in", async () => { + saveConfig({ + ...staleConfig(), defaultProvider: "xai", + providers: { xai: { + adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "oauth", + modelAdapters: { "grok-4.6": "openai-chat", "grok-4.5": "openai-chat" }, + } }, + }); + const server = startServer(0); + try { + const upgraded = loadConfig(); + expect(upgraded.providers.xai!.xaiResponsesDefaultVersion).toBe(1); + for (const model of ["grok-4.6", "grok-4.5"]) { + expect(resolveWireProtocolOverride("xai", model, upgraded.providers.xai!).adapter).toBe("openai-responses"); + } + upgraded.providers.xai!.modelAdapters = { "grok-4.6": "openai-chat", "grok-4.5": "openai-chat" }; + saveConfig(upgraded); + } finally { await server.stop(true); } + const restarted = startServer(0); + try { + const optedIn = loadConfig(); + for (const model of ["grok-4.6", "grok-4.5"]) { + expect(resolveWireProtocolOverride("xai", model, optedIn.providers.xai!).adapter).toBe("openai-chat"); + } + } finally { await restarted.stop(true); } +}); + +test.skipIf(!CAN_BIND)("preset reconciliation cannot undo an in-memory Grok migration after its write fails", async () => { + saveConfig({ + ...staleConfig(), defaultProvider: "xai", + providers: { xai: { + ...structuredClone(OAUTH_PROVIDERS.xai.providerConfig), authMode: "oauth", + noVisionModels: ["stale-model"], + modelAdapters: { "grok-4.6": "openai-chat", "grok-4.5": "openai-chat" }, + } }, + }); + const originalMutation = configStore.mutatePersistedConfig; + let rejectedMigration = false; + const mutation = spyOn(configStore, "mutatePersistedConfig").mockImplementation((mutate, ...rest) => + originalMutation(fresh => { + const result = mutate(fresh); + if (!rejectedMigration && fresh.providers.xai?.xaiResponsesDefaultVersion === 1) { + rejectedMigration = true; + throw new Error("injected migration write failure"); + } + return result; + }, ...rest)); + const live = spyOn(stateStores, "setLiveStateStoreConfig"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + let server: ReturnType | undefined; + try { + server = startServer(0); + expect(rejectedMigration).toBe(true); + const liveConfig = live.mock.calls[0]![0]; + expect(liveConfig.providers.xai!.xaiResponsesDefaultVersion).toBe(1); + expect(resolveWireProtocolOverride("xai", "grok-4.6", liveConfig.providers.xai!).adapter).toBe("openai-responses"); + } finally { + mutation.mockRestore(); live.mockRestore(); warn.mockRestore(); + await server?.stop(true); + } +}); + let testDir = ""; let previousHome: string | undefined; let isolatedCodexHome: IsolatedCodexHome | null = null; diff --git a/tests/server/server-xai-chat-reasoning-streaming.test.ts b/tests/server/server-xai-chat-reasoning-streaming.test.ts index 861a40098b..5019147189 100644 --- a/tests/server/server-xai-chat-reasoning-streaming.test.ts +++ b/tests/server/server-xai-chat-reasoning-streaming.test.ts @@ -56,6 +56,10 @@ function config(): OcxConfig { baseUrl: "https://api.x.ai/v1", authMode: "oauth", models: ["grok-4.6"], + liveModels: false, + // This regression owns the optional Chat wire, not the migrated default. + modelAdapters: { "grok-4.6": "openai-chat" }, + xaiResponsesDefaultVersion: 1, }, }, } as OcxConfig; @@ -76,6 +80,7 @@ describe("xAI OAuth Chat reasoning streaming", () => { globalThis.fetch = (async (input, init) => { const url = input instanceof Request ? input.url : String(input); + if (url === `${XAI_GROK_CLI_BASE_URL}/responses`) throw new Error("Chat regression selected the native wire"); if (url !== CHAT_ENDPOINT) return originalFetch(input, init); upstreamCalls += 1; outboundHeaders = new Headers(init?.headers);