diff --git a/devlog/_fin/260913_xai_oauth_fast/010_plan.md b/devlog/_fin/260913_xai_oauth_fast/010_plan.md new file mode 100644 index 0000000000..e9b9af2b5e --- /dev/null +++ b/devlog/_fin/260913_xai_oauth_fast/010_plan.md @@ -0,0 +1,61 @@ +# xAI Grok OAuth fast tier (service_tier: "priority") — plan + +## Loop spec (HOTL wp1) + +- Tool/credential scope: local proxy management (ocx CLI, /api/providers/reload probe only, already applied), GitHub via gh/MCP for this PR only. No other accounts, no release/deploy, no service restart. +- Write scope: branch codex/xai-oauth-fast-tier in this worktree; the live ~/.opencodex config keeps the probe override (modelSupportsServiceTier for the 7 honored models) — user asked to keep working models enabled; PR + merge to dev per MAINTAINERS.md self-integration policy. +- Budget: unlimited read-only subagents on xai/grok-4.6 (user-granted); probe calls already completed. +- Wall-clock: bounded by hosted CI; if CI cannot reach a terminal state within ~90 minutes of the final push, report BUDGET_EXHAUSTED rather than DONE. +- Certification: hosted exact-head CI only. Full local suite and local typecheck NOT RUN (standing rule); focused tests below are development feedback, not certification. + +## Context + +xAI documents Priority Processing: `service_tier: "priority"` on /v1/responses and /v1/chat/completions, echoed in the response, billed 2x on API keys. hermes-agent#89440 verified it live on SuperGrok Heavy OAuth; there is no grok-*-fast slug. ocx's xai registry entry declares keyAuthServiceTier (API-key lane only) and deliberately leaves OAuth unclassified (src/providers/registry.ts:1367-1376), with modelWireDefaults pinning forwardCallerServiceTier:false on grok-4.6/4.5 OAuth. That classification is now stale: the live probe (020_probe-evidence.md) shows the user's own Grok OAuth account accepts and honors priority on 7 of 8 catalog models. + +## Decision table + +- D1 Include set: grok-4.6, grok-4.5, grok-4.3, grok-4.20-0309-reasoning, grok-4.20-0309-non-reasoning, grok-build-0.1, grok-composer-2.5-fast. Exclude grok-4.20-multi-agent-0309 — upstream consistently answers service_tier "default" when sent priority (ocx log: fastOutcome downgraded, confirmation downgraded). +- D2 Registry shape: xai entry gains `modelSupportsServiceTier: {<7 ids>: true}` (OpenRouter precedent: provider stays unclassified, per-slug map, tests/service/service-tier-capability.test.ts) and `chatServiceTier: true`. No provider-wide supportsServiceTier; future/undiscovered ids stay unclassified. enrichProviderFromRegistry backfills both into saved configs at load (tests/service/service-tier-capability.test.ts:46-57), so existing installs get the lane without config edits; explicit config still wins. +- D2a (audit fold, scope honesty): `chatServiceTier: true` is provider-wide for the caller-forwarding gate (service-tier.ts:106-110 reads provider config first), so caller-sent tiers also forward verbatim on UNCLASSIFIED xai chat-wire models (future liveModels ids), not just the 7. Accepted: this matches the established unclassified-route forwarding contract pinned at tests/service/service-tier-capability.test.ts:444, `--fast` publication and proxy-owned fast injection stay capability-scoped per D2, and the probe showed the gateway accepts the field on every current model. Key-auth lane unchanged (backfilled true shadows the identical keyAuthServiceTier value). +- D3 Caller-tier parity: remove `forwardCallerServiceTier: false` from the grok-4.6/4.5 modelWireDefaults (rationale "unclassified route" is stale once D2 lands) so a caller-sent service_tier:"priority" on the OAuth responses lane forwards — the Codex fast-toggle path OpenAI native models already use. Chat-wire models forward caller tiers via the new chatServiceTier:true (fastwire.ts forwardCallerTier chain). +- D4 Echo relay: today the upstream service_tier echo reaches attempt telemetry but not the client on chat-wire paths (probe: 4.3/4.20/build/composer client bodies lack the field; 4.6/4.5 responses-wire bodies carry it). SHIPPED (b): the chat-inbound relay on every Chat Completions delivery shape — `responsesJsonToChatCompletion` (src/chat/outbound.ts:887), `collectChatCompletion` (src/chat/outbound.ts:969, 1095), `jsonCompletionSse` (src/server/chat-native-sse.ts:64-103), and the live Responses-SSE translator `responsesSseToChatCompletionsSse` (src/chat/outbound.ts:374, 541). SPLIT per the sizing rule: (a) the responses-lane assembly for chat-wire upstreams (adapter-event → bridge plumbing across the shared adapter contract) stays a follow-up in this unit; those turns keep the echo in attempt telemetry only. +- D5 Tests: registry pins in tests/providers/xai/xai-transport.test.ts; policy/backfill in tests/service/service-tier-capability.test.ts; fast-row publication/routing in tests/codex-integration/fast-row*.test.ts or tests/providers/fast-row-ingress.test.ts; relay tests beside the touched relay code. +- D6 Docs/SoT: structure/providers/xai-grok.md owns the xai provider surface — update it (structure/AGENTS.md ownership rule). docs-site configuration/providers docs only if they contradict the new lane (check at B). +- D7 Evidence: probe matrix recorded in 020_probe-evidence.md; PR Verification cites it (summarized, no secrets). +- D8 Failure semantics: no new recovery code. If upstream later rejects or downgrades priority, existing tierOutcome records fastOutcome/confirmation (downgrade path proven live by multi-agent) — documented in the PR, no silent fallback added. +- D9 (audit fold, revert residue): enrich backfill is fill-only in memory, but a config save while this change is live persists chatServiceTier:true and the 7-id map as EXPLICIT values, which then win every later merge — a revert commit cannot clear installs that saved in between. Accepted residue, recorded in the PR: the lane is upstream-verified behavior (not a hazard), the operator removal path is deleting the two keys, and the probe install (020 §4) deliberately keeps exactly this state at user request. No migration code. + +Architect consultation gap: the native spawn schema in this session has no architect role (registered in cxc config but native type rejected; registration requires a Codex restart, which would abandon this session's goal). Main wrote this plan from direct source reads; the A phase uses an independent reviewer subagent (role registered, xai/grok-4.6). Recorded per delegation contract; completion claims carry this note. + +## File change map + +1. src/providers/registry.ts — xai entry: add modelSupportsServiceTier (7 ids), chatServiceTier: true; drop forwardCallerServiceTier:false on grok-4.6/4.5; refresh the two stale comments (keyAuthServiceTier "OAuth unclassified", modelWireDefaults caller-tier note). No multi-agent entry. +2. Relay (D4, shipped): src/chat/outbound.ts:887, 969, 1095 and src/server/chat-native-sse.ts:64-103 (chat-inbound relay on all delivery shapes). Follow-up: adapter-event → bridge plumbing for the responses lane (audit note: no adapter event/result carries service_tier today — openai-chat.ts:1778-1781, 2080-2082 observe it into attempt telemetry only; AdapterTierMetadata is telemetry per src/adapters/base.ts:118). +3. Tests (D5 files above). +4. structure/providers/xai-grok.md — lane classification + probe date. +5. devlog: this unit moves to devlog/_fin/260913_xai_oauth_fast/ in the same PR after merge evidence exists. + +IN scope: the 7 models, both wires' caller-tier forwarding, echo relay, tests, xai-grok.md. OUT: multi-agent and future ids, provider-wide declarations, fastMode defaults (unchanged; operators opt in), recovery code, GUI changes, releases. + +## Accept criteria (activation in parentheses) + +- C1 fastPolicyForModel(xai-oauth, grok-4.6, "xai", "responses").eligibility === "eligible" (unit test constructs the xai provider with authMode oauth). +- C2 grok-4.20-multi-agent-0309 stays unclassified: capability undefined, no --fast row (unit test + catalog listing test). +- C3 caller service_tier:"priority" on the OAuth responses lane for grok-4.6 reaches the wire (policy test: before D3 the pin dropped it — activate by asserting forwardCallerTier true and decideTier output). +- C4 --fast rows publish for the 7 models on the catalog listing (catalogFastRowEligible path; test feeds an oauth xai config). +- C5 enrichProviderFromRegistry backfills chatServiceTier/modelSupportsServiceTier into a saved xai config missing them; explicit config values win (NEW capability tests — derive.ts:523 and derive.ts:552 via applyServiceTierModelDefaults:390-397 have no existing coverage for these two fields; do not lean on test:46-57). Existing pins that FLIP and must be rewritten, called out in the PR: tests/service/service-tier-capability.test.ts:108-160 and 424-445. +- C6 relay: a chat-upstream response carrying service_tier surfaces it in the chat-inbound client body on every delivery shape — tests/responses/chat-json-sse-fallback.test.ts:256-342 (JSON body, synthesized SSE, folded stream, endpoint, live SSE translator). The responses-lane half moved to the follow-up per D4. +- C7 PR template complete; hosted CI green on the exact head with no cancelled/skipped required jobs counted; merged to dev with maintainer decision recorded. + +## Verifiers (run pre-plan, exit 0, reads-target noted) + +- `bun test tests/providers/xai/xai-transport.test.ts` — exit 0, 47 tests; imports getProviderRegistryEntry (reads registry.ts). +- `bun test tests/service/service-tier-capability.test.ts` — exit 0, 35 tests; imports fastPolicyForModel/enrichProviderFromRegistry (reads service-tier.ts/derive.ts). +- `bun test tests/routing/fastwire-policy.test.ts` — exit 0, 236 tests; imports resolveFastPolicy (reads fastwire.ts + registry wire defaults). +- `bun test tests/providers/fast-row-ingress.test.ts` — exit 0, 9 tests; parseSyntheticRowId ingress (reads fast-row.ts). +- `bun test tests/codex-integration/fast-row.test.ts` — exit 0, 38 tests; fast-row grammar/listing. +- Hosted PR CI — certification gate (typecheck + full suite on 3 OS). + +## Enforcement/bypass note (PLAN-BYPASS-NAMED-01) + +This PR adds capability classification, not enforcement: it enables a wire field xAI already accepts. Bypass/residual: an operator can force supportsServiceTier for any model via config today (that is how the probe ran) — accepted, documented behavior; the registry change only makes the probed set native. Final layer: hosted CI + review on the PR. No bypass claim is made for upstream honesty: a silent upstream downgrade is observable via tierOutcome.confirmation in request logs, not prevented. diff --git a/devlog/_fin/260913_xai_oauth_fast/020_probe-evidence.md b/devlog/_fin/260913_xai_oauth_fast/020_probe-evidence.md new file mode 100644 index 0000000000..8ce00fe736 --- /dev/null +++ b/devlog/_fin/260913_xai_oauth_fast/020_probe-evidence.md @@ -0,0 +1,29 @@ +# Live probe evidence — xAI Grok OAuth service_tier "priority" (2026-09-13, KST) + +Mechanics: temporary `providers.xai.modelSupportsServiceTier` override in ~/.opencodex/config.json + POST /api/providers/reload (local one-shot capability, same path ocx's OAuth login uses). Probes: POST /v1/responses and /v1/chat/completions on the running proxy (127.0.0.1:10100, ocx 2.53.0) with model `xai/--fast`. Every attempt logged credentialSource "grok-oauth", account of97b31. No API key involved. + +## Matrix (Responses inbound) + +| Model | HTTP | Upstream adapter | service_tier sent | service_tier echoed (ocx telemetry) | ocx fastOutcome/confirmation | Client body echo | +|---|---|---|---|---|---|---| +| grok-4.6 | 200 | openai-responses | priority | priority | applied/confirmed | yes | +| grok-4.5 | 200 | openai-responses | priority | priority | applied/confirmed | yes | +| grok-4.3 | 200 | openai-chat | priority | priority | applied/confirmed | NO (relay gap) | +| grok-4.20-0309-reasoning | 200 | openai-chat | priority | priority | applied/confirmed | NO (relay gap) | +| grok-4.20-0309-non-reasoning | 200 | openai-chat | priority | priority | applied/confirmed | NO (relay gap) | +| grok-build-0.1 | 200 | openai-chat | priority | priority | applied/confirmed | NO (relay gap) | +| grok-composer-2.5-fast | 200 | openai-chat | priority | priority | applied/confirmed | NO (relay gap) | +| grok-4.20-multi-agent-0309 | 200 | openai-responses | priority | default | downgraded/downgraded | "default" | + +Request-id tails (ocx logs, 2026-09-13 00:4x KST): fb8c252e (4.20-non-reasoning), ffc1ac24 + f3bcff76 (multi-agent downgrades), 30869a34 + 1e2591ba (build), edc0cd22 + b2ba02e8 (composer), 6ff095a8 (4.6), 8fd2f267 (4.5), db85c0e5 (4.3, full entry captured), bf503c88 (4.20-reasoning), 54edc3de (4.20-non-reasoning r2). + +Chat inbound (/v1/chat/completions, --fast): grok-4.6 and grok-4.5 both 200 (chatcmpl-95b4236e…, chatcmpl-cb33c39d…). These two probes say nothing about the openai-chat wire — this install pins grok-4.6/4.5 to openai-responses via config modelAdapters. The chat-upstream echo evidence comes from the openai-chat rows above (4.3, 4.20, build, composer), and hermes-agent#89440 independently reports the echo on native chat completions over SuperGrok Heavy OAuth. + +Chat inbound direct relay probe (audit round 2): `xai/grok-4.3--fast` over /v1/chat/completions (openai-chat upstream, no modelAdapters override) — client body keys are exactly choices/created/id/model/object/usage with NO service_tier, while ocx telemetry for the same turn (request …6838b50b) records wireValue "priority", fastOutcome applied, confirmation confirmed, responseServiceTier "priority". The chat-inbound relay gap is therefore direct evidence, not code inference. + +## Findings + +1. The user's Grok OAuth (subscription) gateway accepts service_tier "priority" on every probed model — zero 400s, zero "Argument not supported" (the stale rejection hermes#28490 worked around). +2. Priority is honored (echoed priority) on 7 of 8 models. grok-4.20-multi-agent-0309 accepts the field but the gateway answers "default" — a live downgrade, excluded from the change. +3. Relay gap: for chat-wire models the echo reaches ocx attempt telemetry (tierOutcome.responseServiceTier "priority") but not the client body — 4.6/4.5 on the responses wire relay it, the chat-wire responses-lane and chat-inbound assemblies do not. D4 in 010_plan.md. +4. Post-probe live state (kept at user request): config override trimmed to the 7 honored models and reloaded; multi-agent re-disabled in the catalog; the 7 models left enabled with --fast rows live on the running proxy. Backup at ~/.opencodex/config.json.probe-bak. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 23b7e5e92d..67e072177c 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -381,11 +381,19 @@ need to rediscover. Every legacy variant id keeps routing unchanged. ### xAI Priority Processing -The built-in `xai` preset advertises and injects Fast only when its effective transport uses -`authMode: "key"`. API-key mode targets `https://api.x.ai/v1` through the `openai-chat` adapter and -sends `service_tier: "priority"` through Chat Completions. `ocx login xai` -instead stores OAuth credentials for the separate Grok CLI subscription-gateway flow, so OAuth -remains unclassified: its catalog rows do not advertise Fast and the proxy does not inject a tier. +The built-in `xai` preset supports Fast on both of its transports, with different scope. +API-key mode targets `https://api.x.ai/v1`; routes resolved to `openai-chat` send +`service_tier: "priority"` through Chat Completions, while model defaults and overrides can +select the `openai-responses` transport instead. `ocx login xai` +instead stores OAuth credentials for the Grok subscription gateway +(`https://cli-chat-proxy.grok.com/v1`; these credentials refresh automatically), where Fast +is classified per model (live-probed 2026-09-13): grok-4.6, grok-4.5, grok-4.3, grok-4.20-0309-reasoning, +grok-4.20-0309-non-reasoning, grok-build-0.1, and grok-composer-2.5-fast accept +`service_tier: "priority"` over Grok OAuth and echo it, so those rows advertise Fast, accept +`--fast` selectors, and forward a caller-sent tier on either wire. grok-4.20-multi-agent-0309 +is excluded: the gateway answers `service_tier: "default"` when sent `priority`, so it stays +unclassified and its caller tier is not forwarded. Unlisted models stay unclassified on both +transports. xAI charges Priority Processing at 2× the standard token price for input, output, cached, and reasoning tokens; cache discounts are applied before the multiplier. Cost estimates use that premium diff --git a/src/chat/outbound.ts b/src/chat/outbound.ts index e69d03f499..c07c4e344f 100644 --- a/src/chat/outbound.ts +++ b/src/chat/outbound.ts @@ -182,6 +182,10 @@ export function responsesSseToChatCompletionsSse( let cancelled = false; let started = false; let sawToolUse = false; + // The upstream response-level service-tier echo (xAI Priority Processing, OpenAI + // fast tier), captured from any response event and stamped on every emitted chunk, + // matching how the source annotates its own streamed chat chunks. + let serviceTier: string | undefined; const id = completionId(); const created = Math.floor(Date.now() / 1000); // tool call_id -> streaming index (OpenAI requires stable indices per tool call) @@ -366,6 +370,9 @@ export function responsesSseToChatCompletionsSse( }; const emit = (payload: Rec | "[DONE]") => { if (failed) return; + if (serviceTier !== undefined && isRec(payload) && Array.isArray(payload.choices)) { + payload.service_tier = serviceTier; + } if (terminalBatch) { const serialized = dataFrame(payload); const stringReservation = translatorBudget.reserveTransient(Buffer.byteLength(serialized), { kind: "live_transient" }); @@ -530,6 +537,9 @@ export function responsesSseToChatCompletionsSse( }; const handleFrame = (eventName: string, data: Rec) => { + if (isRec(data.response) && typeof data.response.service_tier === "string") { + serviceTier = data.response.service_tier; + } switch (eventName) { case "response.created": case "response.heartbeat": @@ -871,6 +881,10 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran logprobs: null, }], usage: chatCompletionsUsage(body.usage), + // Relay the upstream service-tier echo (xAI Priority Processing, OpenAI fast tier) + // so a Chat Completions caller can confirm the tier the turn actually used, the + // same field the Responses lane already relays for responses-wire upstreams. + ...(typeof body.service_tier === "string" ? { service_tier: body.service_tier } : {}), }; } @@ -891,6 +905,7 @@ export async function collectChatCompletion( const callScope = (index: number) => `chat_collect_${index}`; let finishReason = "stop"; let usage: unknown; + let serviceTier: unknown; const replaceRetained = (previous: string, next: string, kind: "live_transient" | "retained_collectors") => { const reservation = translatorBudget.reserveTransient(Buffer.byteLength(next), { kind }); reservation.commitRetained(); @@ -951,6 +966,7 @@ export async function collectChatCompletion( throw streamError; } if (parsed.usage) usage = parsed.usage; + if (typeof parsed.service_tier === "string") serviceTier = parsed.service_tier; const choices = Array.isArray(parsed.choices) ? parsed.choices : []; const choice = isRec(choices[0]) ? choices[0] : null; if (!choice) continue; @@ -1076,5 +1092,6 @@ export async function collectChatCompletion( logprobs: null, }], usage: usage && isRec(usage) ? usage : chatCompletionsUsage(undefined), + ...(typeof serviceTier === "string" ? { service_tier: serviceTier } : {}), }; } diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 2380699acb..da25a1e7d3 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -1367,12 +1367,35 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ authKind: "oauth", allowKeyAuthOverride: true, // Priority Processing is documented for xAI's public API-key Chat Completions and - // Responses endpoints. OAuth is a separate Grok CLI subscription gateway and remains - // unclassified; do not turn this into a provider-wide supportsServiceTier declaration. + // Responses endpoints. The OAuth lane is classified per-model below, not here: + // do not turn this into a provider-wide supportsServiceTier declaration. keyAuthServiceTier: { supportsServiceTier: true, chatServiceTier: true, }, + // OAuth (Grok subscription gateway) service-tier capability, classified by live probe + // on 2026-09-13 (devlog/_fin/260913_xai_oauth_fast/020_probe-evidence.md): each listed + // model accepted service_tier "priority" over grok-oauth and echoed priority upstream. + // Key-auth already declares provider-wide support above, so this map only newly opens + // the OAuth lane. grok-4.20-multi-agent-0309 is deliberately absent: the gateway accepts + // the field but answers service_tier "default" — a live downgrade, not a fast tier. + // Unlisted and future-discovered ids stay unclassified. + modelSupportsServiceTier: { + "grok-4.6": true, + "grok-4.5": true, + "grok-4.3": true, + "grok-4.20-0309-reasoning": true, + "grok-4.20-0309-non-reasoning": true, + "grok-build-0.1": true, + "grok-composer-2.5-fast": true, + }, + // Lets a caller-sent service_tier forward on the Chat wire (fastwire forwardCallerTier + // chain). Provider-wide by construction: unclassified chat-wire models then preserve a + // caller tier verbatim, the same contract other unclassified Responses routes already + // follow; --fast publication and proxy-owned fast injection stay capability-scoped by + // the map above. Key-auth declared the same value via keyAuthServiceTier, so the key + // lane is unchanged. + chatServiceTier: true, fastTierDescription: "Priority processing, 2x token price", featured: true, oauthId: "xai", @@ -1413,20 +1436,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // 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. + // grok-4.6/4.5 are classified OAuth fast-tier models (modelSupportsServiceTier above), + // so a caller-sent service_tier:"priority" forwards on this lane — the Codex fast-toggle + // path. Multi-agent keeps its pin: probed 2026-09-13, the gateway downgrades its tier to + // "default", so forwarding a caller tier would advertise a tier it does not get. modelWireDefaults: { "grok-4.6": { wire: "openai-responses", inbound: ["responses"], authModes: ["oauth"], - forwardCallerServiceTier: false, }, "grok-4.5": { wire: "openai-responses", inbound: ["responses"], authModes: ["oauth"], - forwardCallerServiceTier: false, }, "grok-4.20-multi-agent-0309": { // Even at high effort it emits no reasoning-summary deltas or encrypted replay diff --git a/src/server/chat-native-sse.ts b/src/server/chat-native-sse.ts index 0d80733057..eb598b7898 100644 --- a/src/server/chat-native-sse.ts +++ b/src/server/chat-native-sse.ts @@ -65,6 +65,10 @@ export function jsonCompletionSse(value: Rec, requestedModel: string, budget?: T const id = typeof value.id === "string" ? value.id : `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`; const created = typeof value.created === "number" ? value.created : Math.floor(Date.now() / 1000); const model = requestedModel; + // Relay the upstream service-tier echo on every chunk, matching how OpenAI/xAI + // annotate streamed chat chunks, so streaming callers see the same confirmation + // the non-streaming body carries. + const serviceTier = typeof value.service_tier === "string" ? { service_tier: value.service_tier } : {}; const choices = Array.isArray(value.choices) ? value.choices : []; const choice = isRec(choices[0]) ? choices[0] : {}; const message = isRec(choice.message) ? choice.message : {}; @@ -73,6 +77,7 @@ export function jsonCompletionSse(value: Rec, requestedModel: string, budget?: T object: "chat.completion.chunk", created, model, + ...serviceTier, choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], }]; const delta: Rec = {}; @@ -85,13 +90,14 @@ export function jsonCompletionSse(value: Rec, requestedModel: string, budget?: T delta.tool_calls = message.tool_calls.filter(isRec).map((tool, index) => ({ ...tool, index })); } if (Object.keys(delta).length > 0) { - frames.push({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: null }] }); + frames.push({ id, object: "chat.completion.chunk", created, model, ...serviceTier, choices: [{ index: 0, delta, finish_reason: null }] }); } frames.push({ id, object: "chat.completion.chunk", created, model, + ...serviceTier, choices: [{ index: 0, delta: {}, finish_reason: typeof choice.finish_reason === "string" ? choice.finish_reason : "stop" }], ...(value.usage !== undefined ? { usage: value.usage } : {}), }); diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 0593da7e6a..3e940b27a4 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -19,6 +19,16 @@ the native passthrough there is no canonical Fast injection and no wire mapping: and `fastMode` injects nothing here. Resolved-Fast-policy injection applies only to routes that take the Chat -> Responses -> Chat bridge below. `parallel_tool_calls` is emitted only for providers opted into parallel tools (or pinned false by the existing provider opt-out contract). + +On the response side, the upstream `service_tier` echo (xAI Priority Processing, OpenAI fast +tier) relays to the Chat Completions caller on every delivery shape: the non-streaming body +(`responsesJsonToChatCompletion` in `src/chat/outbound.ts`), the folded stream +(`collectChatCompletion` in `src/chat/outbound.ts`), and each synthesized SSE chunk +(`jsonCompletionSse` in `src/server/chat-native-sse.ts`). An upstream that sends no +`service_tier` gets no injected key. The Responses lane already relayed the same field for +responses-wire upstreams; the responses-lane assembly for chat-wire upstreams keeps it in +attempt telemetry only. + Combo/policy routes and requests that need Responses-only hosted tools, continuation, background, or storage semantics retain the existing Chat -> Responses -> Chat bridge. Chat-to-Responses traffic that lands on `api.meta.ai` inherits the same 64-character tool-name diff --git a/structure/providers/xai-grok.md b/structure/providers/xai-grok.md index 5f4217b093..fc6cf7c63f 100644 --- a/structure/providers/xai-grok.md +++ b/structure/providers/xai-grok.md @@ -69,3 +69,23 @@ privately to final dispatch; preliminary route selection does not inject Go-only Devin CLI credential path composition in `src/oauth/devin-cli.ts` follows the selected platform: Windows uses Win32 APPDATA paths, other platforms use POSIX XDG-data paths. The explicit absolute override remains verbatim; credential parsing and login behavior are unchanged. Native Chat applies qualifying effort ceilings independently of model pins; pin selection precedes the cap and only pins or cap rewrites enter wire mapping. The [catalog effort contract](../catalog.md#ultra-reasoning-level) records the V1/compaction exemptions and caller-preservation boundary. +### OAuth Fast Tier (Priority Processing) + +xAI's Priority Processing (`service_tier: "priority"` on Chat Completions and Responses, +documented for the API-key product) is honored by the Grok OAuth subscription gateway on a +probed model set (live probe 2026-09-13, `devlog/_fin/260913_xai_oauth_fast/`): grok-4.6, +grok-4.5, grok-4.3, grok-4.20-0309-reasoning, grok-4.20-0309-non-reasoning, grok-build-0.1 and +grok-composer-2.5-fast each echoed `priority` upstream. The registry entry classifies exactly +that set in `modelSupportsServiceTier` and declares `chatServiceTier: true`, so the OAuth lane +resolves Fast-eligible per model: `--fast` synthetic rows publish, `fastMode` can force the +tier, and a caller-sent tier forwards (the Codex fast-toggle path). grok-4.20-multi-agent-0309 +is deliberately excluded — the gateway answers `service_tier: "default"` when sent +`priority`, so it keeps `forwardCallerServiceTier: false` and publishes no fast row. +Classification reaches saved configs through the fill-only enrich backfill +(`src/providers/derive.ts`); an explicit config value always wins, and a config saved while +the lane is live keeps it as an explicit value even if the registry default later changes. + +The upstream tier echo relays to the client on every Chat Completions delivery shape +(`src/chat/outbound.ts` projections and `src/server/chat-native-sse.ts` chunks), matching +what the Responses lane already relayed for responses-wire upstreams; the responses-lane +assembly for chat-wire upstreams tracks the echo in attempt telemetry only. diff --git a/structure/runtime.md b/structure/runtime.md index ae3cc34bbb..7a0231f097 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -148,7 +148,7 @@ The server exposes `POST /api/stop` which restores native Codex config, stops an | `src/oauth/` | OAuth providers, token storage, refresh, and auth-token resolution. The login callback listener binds a per-provider FIXED loopback port, so consecutive logins reuse the same number; every response it sends ends its connection (`Connection: close`, including non-callback paths such as a stray `/favicon.ico` 404). Stopping the listener does not close an established socket, so without that a pooled client would deliver the next login's callback to the retired flow, which rejects the unknown state as a CSRF mismatch while the live flow waits. | | `src/adapters/openai-responses.ts` | Native OpenAI/ChatGPT Responses passthrough. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | -| `src/adapters/openai-chat.ts` | OpenAI-compatible Chat Completions bridge. | +| `src/adapters/openai-chat.ts` | OpenAI-compatible Chat Completions bridge. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | | `src/adapters/anthropic.ts` | Anthropic Messages bridge. | | `src/adapters/google.ts` | Gemini bridge. | | `src/adapters/azure.ts` | Azure OpenAI bridge. | diff --git a/structure/transports/inventory.md b/structure/transports/inventory.md index 7a7d2f9b4d..623abe10a8 100644 --- a/structure/transports/inventory.md +++ b/structure/transports/inventory.md @@ -15,7 +15,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Adapter execution support | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/image.ts`, `src/adapters/upstream-http-error.ts` | Shared machinery: turn ordering, tool-catalog nudging, client fingerprinting, image conversion, upstream error normalization. | | Cursor (beyond the sections above) | `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/http1-bidi.ts`, `src/adapters/cursor/live-models.ts`, `src/adapters/cursor/transport-retry.ts`, `src/adapters/cursor/mcp-manager.ts`, `src/adapters/cursor/thread-continuity.ts`, `src/adapters/cursor/checkpoint-store.ts` | Thread continuity is the point: a retry must not start a new Cursor thread, and a validated checkpoint must not rebuild the full root history. HTTP/2 remains the default; an explicit `http1.1`/`h1` pin maps the bidi run onto Cursor's `RunSSE` receive stream plus sequenced `BidiAppend` sends, and applies to live discovery too. | | Claude Messages | `src/server/claude-messages.ts` | Routed translation, a native Anthropic passthrough branch, and `count_tokens`. | -| Chat Completions inbound | `src/server/chat-completions.ts`, `src/chat/` | Inbound translation onto the same routing pipeline. The content mapper preserves image URLs and supported detail, including screenshot-bearing tool results; target adapters own image placement on their wire. Image-free tool results stay strings. | +| Chat Completions inbound | `src/server/chat-completions.ts`, `src/chat/` | Inbound translation onto the same routing pipeline. The content mapper preserves image URLs and supported detail, including screenshot-bearing tool results; target adapters own image placement on their wire. Image-free tool results stay strings. On the response side, the upstream `service_tier` echo relays on every delivery shape (`src/chat/outbound.ts` projections, `src/server/chat-native-sse.ts` chunks); an upstream without the field gets no injected key. | | Hosted search relay | `src/server/search.ts` | Direct relay; distinct from the web-search sidecar loop below. | | Image/video generation loop | `src/images/loop.ts`, `src/images/plan.ts`, `src/images/fulfill.ts`, `src/images/xai-client.ts`, `src/images/xai-video-client.ts`, `src/images/artifacts.ts` | A provider-returned image URL is downloaded into a local artifact once, then served locally; warnings stay URL-free because provider CDN URLs may embed credentials. | | GitHub Copilot | `src/providers/xai-transport.ts` (`resolveProviderTransport`), `src/providers/github-copilot-transport.ts` | `resolveProviderTransport` selects the Copilot transport when the routed provider name is `github-copilot`; the Copilot module then resolves its headers and base URL, and the registry seeds the provider row and model fallback. | diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 6d206195a3..9b2c0f883e 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -201,7 +201,14 @@ Responses requests default to native `openai-responses`. Existing namespace, hos 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. +existing wire and tier policy. The OAuth lane is service-tier classified per model +(`modelSupportsServiceTier` on the registry entry, live-probed 2026-09-13): grok-4.6, grok-4.5, +grok-4.3, grok-4.20-0309-reasoning, grok-4.20-0309-non-reasoning, grok-build-0.1 and +grok-composer-2.5-fast accept `service_tier: "priority"` over Grok OAuth and echo it, so those +routes resolve Fast-eligible, publish `--fast` rows, and forward a caller-sent tier on either +wire (`chatServiceTier: true`). grok-4.20-multi-agent-0309 stays unclassified with its +caller-tier pin: the gateway accepts the field but answers `service_tier: "default"`, a live +downgrade rather than a fast tier. 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 diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 794a92bd50..7cbbe01dcb 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -2040,7 +2040,10 @@ describe("provider discovered model display names", () => { expect(afterIdentity).toEqual({ ...beforeIdentity, maxOutputTokens: 500_000, - supportsServiceTier: false, + // Classified by the 2026-09-13 OAuth-lane probe: grok-4.6 is Fast-eligible on + // either xAI transport, so the hint fills the capability and its description. + supportsServiceTier: true, + fastTierDescription: "Priority processing, 2x token price", }); expect(catalogModelSlug(output)).toBe("xai/grok-4.6"); }); diff --git a/tests/responses/chat-json-sse-fallback.test.ts b/tests/responses/chat-json-sse-fallback.test.ts index 684917a05b..e3fc1bb871 100644 --- a/tests/responses/chat-json-sse-fallback.test.ts +++ b/tests/responses/chat-json-sse-fallback.test.ts @@ -1,8 +1,8 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, describe, expect, test } from "bun:test"; import { handleChatCompletions } from "../../src/server/chat-completions"; import { createTranslatorBudget, isTranslatorBudgetExceededError, translatorObservedBufferSnapshot } from "../../src/lib/translator-budget"; import type { OcxConfig } from "../../src/types"; -import { responsesJsonToChatCompletion, isChatCompletionsStreamError } from "../../src/chat/outbound"; +import { responsesJsonToChatCompletion, collectChatCompletion, responsesSseToChatCompletionsSse, isChatCompletionsStreamError } from "../../src/chat/outbound"; import { jsonCompletionSse } from "../../src/server/chat-native-sse"; import { getRequestLogEntries } from "../../src/server/request-log"; import { readUsageEntries } from "../../src/usage/log"; @@ -252,3 +252,113 @@ test("JSON-to-SSE rejects a call above 2 MiB without success output or duplicate await streamFixture([{ type: "function_call", call_id: "large-call", name: "lookup", arguments: JSON.stringify({ text: "x".repeat(2 * 1024 * 1024) }) }], "completed", false, "max_output_tokens", { error: true, errorCode: "translation_buffer_limit" }); }); + +// The upstream service-tier echo (xAI Priority Processing, OpenAI fast tier) must +// reach the Chat Completions caller on every delivery shape, matching the field the +// Responses lane already relays for responses-wire upstreams. +describe("service_tier echo relay", () => { + test("responses JSON echo lands in the converted non-streaming body", () => { + const completion = responsesJsonToChatCompletion({ + status: "completed", + service_tier: "priority", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "OK" }] }], + }, "model"); + expect(completion.service_tier).toBe("priority"); + expect(completion.choices).toMatchObject([{ message: { content: "OK" }, finish_reason: "stop" }]); + }); + + test("an upstream without service_tier gets no injected key", () => { + const completion = responsesJsonToChatCompletion({ + status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "OK" }] }], + }, "model"); + expect(completion).not.toHaveProperty("service_tier"); + }); + + test("synthesized SSE carries the echo on every chunk", () => { + const budget = createTranslatorBudget(); + try { + const completion = responsesJsonToChatCompletion({ + status: "completed", + service_tier: "priority", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "OK" }] }], + }, "model", budget); + const text = jsonCompletionSse(completion, "model", budget); + const frames = text.split(/\r?\n/) + .filter(line => line.startsWith("data: ") && line !== "data: [DONE]") + .map(line => JSON.parse(line.slice(6))); + expect(frames.length).toBeGreaterThan(1); + for (const frame of frames) expect(frame.service_tier).toBe("priority"); + } finally { budget.dispose(); } + }); + + test("collectChatCompletion folds a chunk-level echo into the final body", async () => { + const chunk = (delta: Record, finish: string | null, extra: Record = {}) => + `data: ${JSON.stringify({ id: "chatcmpl-fold", object: "chat.completion.chunk", created: 1, model: "model", choices: [{ index: 0, delta, finish_reason: finish }], ...extra })}\n\n`; + const sse = chunk({ role: "assistant", content: "" }, null, { service_tier: "priority" }) + + chunk({ content: "OK" }, null) + + chunk({}, "stop") + + "data: [DONE]\n\n"; + const budget = createTranslatorBudget(); + try { + const stream = new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode(sse)); controller.close(); }, + }); + const completion = await collectChatCompletion(stream, "model", budget); + expect(completion.service_tier).toBe("priority"); + expect(completion.choices).toMatchObject([{ message: { content: "OK" }, finish_reason: "stop" }]); + } finally { budget.dispose(); } + }); + + test("the endpoint relays the echo on both non-stream and stream delivery", async () => { + upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, async fetch() { + return Response.json({ + id: "resp_tier", status: "completed", service_tier: "priority", + output: [{ type: "message", role: "assistant", content: [{ type: "output_text", text: "OK" }] }], + usage: { input_tokens: 3, output_tokens: 1 }, + }); + } }); + const config: OcxConfig = { port: 0, defaultProvider: "fixture", providers: { fixture: { + adapter: "openai-responses", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, + authMode: "key", apiKey: "fixture-key", allowPrivateNetwork: true, models: ["model"], + } } }; + const post = (stream: boolean) => handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", stream, messages: [{ role: "user", content: "ping" }] }), + }), config, { model: "", provider: "" }, { requestId: `tier-relay-${stream}`, start: Date.now() }); + + const jsonResponse = await post(false); + expect(jsonResponse.status).toBe(200); + expect(await jsonResponse.json()).toMatchObject({ service_tier: "priority", choices: [{ finish_reason: "stop" }] }); + + const sseResponse = await post(true); + expect(sseResponse.status).toBe(200); + const frames = (await sseResponse.text()).split(/\r?\n/) + .filter(line => line.startsWith("data: ") && line !== "data: [DONE]") + .map(line => JSON.parse(line.slice(6))); + expect(frames.length).toBeGreaterThan(1); + for (const frame of frames) expect(frame.service_tier).toBe("priority"); + }); + + test("the live Responses-SSE translator stamps the echo on every emitted chunk", async () => { + const event = (type: string, data: Record) => + `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; + const upstreamSse = event("response.created", { response: { id: "resp_tier", service_tier: "priority" } }) + + event("response.output_text.delta", { delta: "OK" }) + + event("response.completed", { response: { id: "resp_tier", service_tier: "priority", usage: { input_tokens: 3, output_tokens: 1 } } }); + const budget = createTranslatorBudget(); + try { + const upstream = new ReadableStream({ + start(controller) { controller.enqueue(new TextEncoder().encode(upstreamSse)); controller.close(); }, + }); + const translated = responsesSseToChatCompletionsSse(upstream, "model", { translatorBudget: budget }); + const text = await new Response(translated).text(); + const frames = text.split(/\r?\n/) + .filter(line => line.startsWith("data: ") && line !== "data: [DONE]") + .map(line => JSON.parse(line.slice(6))); + expect(frames.length).toBeGreaterThan(1); + for (const frame of frames) expect(frame.service_tier).toBe("priority"); + expect(frames.at(-1)?.choices?.[0]?.finish_reason).toBe("stop"); + } finally { budget.dispose(); } + }); +}); diff --git a/tests/routing/fastwire-policy.test.ts b/tests/routing/fastwire-policy.test.ts index 36b64f6fb9..343f0248a0 100644 --- a/tests/routing/fastwire-policy.test.ts +++ b/tests/routing/fastwire-policy.test.ts @@ -268,9 +268,11 @@ describe("resolveFastPolicy matrix", () => { authMode: "oauth" as const, }, adapter: "openai-responses", - forwardCallerTier: false, + // Classified by live probe 2026-09-13: the caller-tier pin is gone, so a + // foreign caller tier forwards on the Responses wire. + forwardCallerTier: true, callerTier: "flex", - settledCallerTier: undefined, + settledCallerTier: "flex", }, { name: "xAI OAuth Responses override", @@ -283,9 +285,9 @@ describe("resolveFastPolicy matrix", () => { modelAdapters: { "grok-4.6": "openai-responses", "grok-4.5": "openai-responses" }, }, adapter: "openai-responses", - forwardCallerTier: false, + forwardCallerTier: true, callerTier: "flex", - settledCallerTier: undefined, + settledCallerTier: "flex", }, { // B2: key-auth Chat Completions is a documented Priority Processing transport. diff --git a/tests/server/server-xai-chat-reasoning-streaming.test.ts b/tests/server/server-xai-chat-reasoning-streaming.test.ts index 5019147189..23da211fd0 100644 --- a/tests/server/server-xai-chat-reasoning-streaming.test.ts +++ b/tests/server/server-xai-chat-reasoning-streaming.test.ts @@ -170,7 +170,10 @@ describe("xAI OAuth Chat reasoning streaming", () => { expect(outboundBody?.model).toBe("grok-4.6"); expect(outboundBody?.messages).toBeArray(); expect(outboundBody?.stream).toBe(true); - expect(outboundBody?.service_tier).toBeUndefined(); + // The OAuth lane is fast-classified for grok-4.6 (2026-09-13 probe), so a + // caller-sent priority tier now forwards on the Chat wire instead of being + // stripped by the old unclassified-route pin. + expect(outboundBody?.service_tier).toBe("priority"); expect(outboundBody?.reasoning_effort).toBe("xhigh"); expect(outboundBody?.input).toBeUndefined(); expect(outboundBody?.reasoning).toBeUndefined(); diff --git a/tests/service/service-tier-capability.test.ts b/tests/service/service-tier-capability.test.ts index 4f8161c3e9..efd7797ae2 100644 --- a/tests/service/service-tier-capability.test.ts +++ b/tests/service/service-tier-capability.test.ts @@ -111,7 +111,18 @@ describe("xAI Fast capability follows the captured authentication transport", () chatServiceTier: true, }); expect(entry.supportsServiceTier).toBeUndefined(); - expect(entry.chatServiceTier).toBeUndefined(); + // OAuth is classified per-model after the 2026-09-13 live probe + // (devlog/_fin/260913_xai_oauth_fast/020_probe-evidence.md), never provider-wide. + expect(entry.chatServiceTier).toBe(true); + expect(entry.modelSupportsServiceTier).toEqual({ + "grok-4.6": true, + "grok-4.5": true, + "grok-4.3": true, + "grok-4.20-0309-reasoning": true, + "grok-4.20-0309-non-reasoning": true, + "grok-build-0.1": true, + "grok-composer-2.5-fast": true, + }); const keyPolicy = fastPolicyForModel(xaiProvider("key"), "grok-4.6", "xai"); expect(keyPolicy).toMatchObject({ @@ -122,9 +133,18 @@ describe("xAI Fast capability follows the captured authentication transport", () }); const oauthPolicy = fastPolicyForModel(xaiProvider("oauth"), "grok-4.6", "xai"); - expect(oauthPolicy.capability).toBeUndefined(); - expect(oauthPolicy.eligibility).toBe("unclassified"); - expect(oauthPolicy.forwardCallerTier).toBe(false); + expect(oauthPolicy).toMatchObject({ + capability: true, + eligibility: "eligible", + forwardCallerTier: true, + }); + + // Probed but downgraded by the gateway (answers service_tier "default" when sent + // "priority"), so it stays unclassified and keeps its caller-tier pin. + const multiAgentPolicy = fastPolicyForModel(xaiProvider("oauth"), "grok-4.20-multi-agent-0309", "xai"); + expect(multiAgentPolicy.capability).toBeUndefined(); + expect(multiAgentPolicy.eligibility).toBe("unclassified"); + expect(multiAgentPolicy.forwardCallerTier).toBe(false); }); test("catalog and runtime publish the same key/OAuth conclusion", async () => { @@ -143,10 +163,59 @@ describe("xAI Fast capability follows the captured authentication transport", () const oauthProvider = xaiProvider("oauth"); const oauthPolicy = fastPolicyForModel(oauthProvider, "grok-4.6", "xai"); const oauthCatalog = await catalogEntry(oauthProvider); - expect(serviceTierSupportFromPolicy(oauthPolicy)).toBe(false); - expect(oauthCatalog).not.toHaveProperty("service_tiers"); - expect(oauthCatalog).not.toHaveProperty("additional_speed_tiers"); - expect(decideTier(oauthPolicy, true, undefined)).toEqual({ kind: "drop" }); + expect(serviceTierSupportFromPolicy(oauthPolicy)).toBe(true); + expect(oauthCatalog?.service_tiers).toEqual([{ + id: "priority", + name: "Fast", + description: "Priority processing, 2x token price", + }]); + expect(oauthCatalog?.additional_speed_tiers).toEqual(["fast"]); + expect(decideTier(oauthPolicy, true, undefined)).toEqual({ kind: "set", value: "priority" }); + + // A caller-sent tier forwards on the classified OAuth Responses lane (the Codex + // fast-toggle path); before the probe-driven classification the pin dropped it. + expect(decideTier(oauthPolicy, undefined, "priority")).toEqual({ kind: "set", value: "priority" }); + + // Multi-agent publishes no tier metadata on either lane. + const multiAgentCatalog = await gatherRoutedModels({ + providers: { xai: xaiProvider("oauth", { models: ["grok-4.20-multi-agent-0309"] }) }, + } as unknown as OcxConfig) + .then(models => buildCatalogEntries(null, [], models).find(entry => entry.slug === "xai/grok-4.20-multi-agent-0309")); + expect(multiAgentCatalog).not.toHaveProperty("service_tiers"); + expect(multiAgentCatalog).not.toHaveProperty("additional_speed_tiers"); + }); + + test("enrich backfills the OAuth lane into saved configs without overriding explicit values", () => { + const saved: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + apiKey: "oauth-test-token", + }; + enrichProviderFromRegistry("xai", saved); + expect(saved.chatServiceTier).toBe(true); + expect(saved.modelSupportsServiceTier).toMatchObject({ "grok-4.6": true, "grok-composer-2.5-fast": true }); + // A caller tier forwards on the chat wire only after this backfill: without an + // explicit or enriched chatServiceTier the chat-wire gate stays closed. + expect(fastPolicyForModel(saved, "grok-4.3", "xai").forwardCallerTier).toBe(true); + const seedOnly = providerConfigSeed(getProviderRegistryEntry("xai")!); + delete (seedOnly as Partial).chatServiceTier; + const unenriched: OcxProviderConfig = { ...seedOnly, authMode: "oauth", apiKey: "oauth-test-token" }; + expect(fastPolicyForModel(unenriched, "grok-4.3", "xai").forwardCallerTier).toBe(false); + + const explicit: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", + apiKey: "oauth-test-token", + chatServiceTier: false, + modelSupportsServiceTier: { "grok-4.6": false }, + }; + enrichProviderFromRegistry("xai", explicit); + expect(explicit.chatServiceTier).toBe(false); + expect(explicit.modelSupportsServiceTier?.["grok-4.6"]).toBe(false); + expect(explicit.modelSupportsServiceTier?.["grok-4.5"]).toBe(true); + expect(fastPolicyForModel(explicit, "grok-4.6", "xai").eligibility).toBe("capability-unsupported"); }); test("explicit supportsServiceTier=false wins in policy and catalog for both transports", async () => { @@ -424,11 +493,16 @@ describe("the gate fires on the live handleResponses path", () => { expect(body.service_tier).toBe("flex"); }); - test("xAI API-key runtime injects priority while OAuth does not", async () => { + test("xAI API-key runtime injects priority; OAuth injection is decided at policy level", async () => { const keyBody = await drive("xai", xaiKeyProvider(), "grok-4.6", {}, true); expect(keyBody.service_tier).toBe("priority"); - const oauthBody = await drive("xai", xaiOAuthProvider(), "grok-4.6", {}, true); - expect(oauthBody).not.toHaveProperty("service_tier"); + // The ad-hoc OAuth fixture has no account pool, so the drive never reaches fetch and a + // body assertion would be vacuous. The wire-level OAuth pin is the real-server test at + // tests/server/server-xai-chat-reasoning-streaming.test.ts (outbound carries + // service_tier "priority"); the policy-level decision is pinned here. + const oauthPolicy = fastPolicyForModel(xaiOAuthProvider(), "grok-4.6", "xai"); + expect(oauthPolicy.eligibility).toBe("eligible"); + expect(decideTier(oauthPolicy, true, undefined)).toEqual({ kind: "set", value: "priority" }); for (const provider of [xaiKeyProvider(), xaiOAuthProvider()]) { const optedOut = await drive( "xai",