@bitkyc08/opencodex 2.7.21 → 2.7.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,8 +15,8 @@
15
15
  * same human on every re-login. Cursor login extracts JWT `sub` as accountId so multiauth
16
16
  * can append distinct accounts.
17
17
  */
18
- import { createHash } from "node:crypto";
19
- import { copyFileSync, existsSync, mkdirSync, readFileSync, chmodSync } from "node:fs";
18
+ import { createHash, randomUUID } from "node:crypto";
19
+ import { chmodSync, closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
20
20
  import { join } from "node:path";
21
21
  import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "../config";
22
22
  import type { OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types";
@@ -26,12 +26,21 @@ type AuthStore = Record<string, ProviderAccountSet>;
26
26
  /** Providers whose account set is pinned to a single slot (see module doc). */
27
27
  const SINGLE_SLOT_PROVIDERS = new Set(["chatgpt"]);
28
28
 
29
- function authPath(): string {
29
+ export function getAuthStorePath(): string {
30
30
  return join(getConfigDir(), "auth.json");
31
31
  }
32
+ export function getAuthStoreLockPath(): string { return join(getConfigDir(), "auth.store.lock"); }
33
+ export function getAuthRefreshIntentLockPath(provider: string, accountId: string): string {
34
+ const safeProvider = provider.replace(/[^a-zA-Z0-9_-]/g, "_");
35
+ const accountHash = createHash("sha256").update(accountId).digest("hex").slice(0, 24);
36
+ return join(getConfigDir(), `auth.refresh.${safeProvider}.${accountHash}.lock`);
37
+ }
38
+ export function credentialGeneration(cred: OAuthCredentials): string {
39
+ return createHash("sha256").update(JSON.stringify([cred.refresh, cred.access, cred.expires])).digest("hex");
40
+ }
32
41
 
33
42
  function loadAuthStoreInternal(): { store: AuthStore; hadLegacy: boolean } {
34
- const path = authPath();
43
+ const path = getAuthStorePath();
35
44
  hardenConfigDir();
36
45
  hardenExistingSecret(path);
37
46
  if (!existsSync(path)) return { store: {}, hadLegacy: false };
@@ -55,8 +64,24 @@ function persist(store: AuthStore): void {
55
64
  try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ }
56
65
  }
57
66
  hardenConfigDir();
58
- atomicWriteFile(authPath(), JSON.stringify(store, null, 2) + "\n");
67
+ atomicWriteFile(getAuthStorePath(), JSON.stringify(store, null, 2) + "\n");
68
+ }
69
+
70
+ export class OAuthFileLockError extends Error { readonly code = "OAUTH_FILE_LOCK_UNAVAILABLE"; constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "OAuthFileLockError"; } }
71
+ interface LockSnapshot { bytes: string; dev: number; ino: number; mtimeMs: number; size: number }
72
+ export interface OAuthFileLockOptions { path: string; waitTimeoutMs?: number; staleAfterMs?: number; pollMinMs?: number; pollMaxMs?: number; sleep?: (ms: number) => Promise<void>; now?: () => number; random?: () => number; beforeStaleUnlink?: () => void; beforeReleaseUnlink?: () => void; beforeFailedCreateUnlink?: () => void; writeMetadata?: (fd: number, bytes: string) => void }
73
+ export interface OAuthFileLockGuard { readonly ownerId: string; release(): void }
74
+ function errorCode(error: unknown): string | undefined { return error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : undefined; }
75
+ function snapshot(path: string): LockSnapshot { const bytes = readFileSync(path, "utf8"); const s = statSync(path); return { bytes, dev:s.dev, ino:s.ino, mtimeMs:s.mtimeMs, size:s.size }; }
76
+ function sameSnapshot(a: LockSnapshot,b: LockSnapshot): boolean { return a.bytes===b.bytes&&a.dev===b.dev&&a.ino===b.ino&&a.mtimeMs===b.mtimeMs&&a.size===b.size; }
77
+ function sameFd(a: LockSnapshot,b: ReturnType<typeof fstatSync>): boolean { return a.dev===b.dev&&a.ino===b.ino&&a.mtimeMs===b.mtimeMs&&a.size===b.size; }
78
+ export function createOAuthFileLock(options: OAuthFileLockOptions): { acquire(): Promise<OAuthFileLockGuard> } {
79
+ const wait=options.waitTimeoutMs??5000, stale=options.staleAfterMs??120000, min=options.pollMinMs??25,max=options.pollMaxMs??100,sleep=options.sleep??(ms=>Bun.sleep(ms)),now=options.now??Date.now,random=options.random??Math.random,write=options.writeMetadata??((fd,b)=>writeFileSync(fd,b,"utf8"));
80
+ if(wait<0||stale<=0||min<0||max<min) throw new OAuthFileLockError("Invalid OAuth file-lock timing options");
81
+ return { async acquire() { hardenConfigDir(); if(!existsSync(getConfigDir())) mkdirSync(getConfigDir(),{recursive:true,mode:0o700}); const ownerId=randomUUID(),started=now(); for(;;){ let fd:number|undefined; try { fd=openSync(options.path,"wx",0o600); const bytes=`${JSON.stringify({version:1,ownerId,pid:process.pid,createdAt:now()})}\n`; write(fd,bytes); const fs=fstatSync(fd); closeSync(fd); fd=undefined; const owned=snapshot(options.path); if(owned.bytes!==bytes||!sameFd(owned,fs)) throw new OAuthFileLockError("OAuth lock changed during creation"); let released=false; return {ownerId,release(){if(released)return;released=true;try{const a=snapshot(options.path);if(!sameSnapshot(owned,a))return;options.beforeReleaseUnlink?.();const b=snapshot(options.path);if(sameSnapshot(owned,b))unlinkSync(options.path);}catch(e){if(errorCode(e)!=="ENOENT")console.warn(`[oauth] lock release failed: ${e instanceof Error?e.message:String(e)}`);}}}; } catch(e) { if(fd!==undefined){let fs;try{fs=fstatSync(fd);}catch{}try{closeSync(fd);}catch{}if(fs)try{const a=snapshot(options.path);if(sameFd(a,fs)){options.beforeFailedCreateUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b)&&sameFd(b,fs))unlinkSync(options.path);}}catch{}} if(errorCode(e)!=="EEXIST")throw e instanceof OAuthFileLockError?e:new OAuthFileLockError("Could not create OAuth file lock",{cause:e}); }
82
+ try{const a=snapshot(options.path);let created=a.mtimeMs;try{const p=JSON.parse(a.bytes);if(typeof p.createdAt==="number")created=Math.max(created,p.createdAt);}catch{}if(now()-created>stale){options.beforeStaleUnlink?.();const b=snapshot(options.path);if(sameSnapshot(a,b))unlinkSync(options.path);continue;}}catch(e){if(errorCode(e)==="ENOENT")continue;throw new OAuthFileLockError("Could not inspect OAuth file lock",{cause:e});} const elapsed=now()-started;if(elapsed>=wait)throw new OAuthFileLockError(`Timed out after ${wait}ms waiting for OAuth file lock`);await sleep(Math.min(wait-elapsed,min+Math.floor(random()*(max-min+1)))); } } };
59
83
  }
84
+ export function createOAuthRefreshIntentLock(provider:string,accountId:string,overrides:Partial<OAuthFileLockOptions>={}) { return createOAuthFileLock({path:getAuthRefreshIntentLockPath(provider,accountId),staleAfterMs:120000,...overrides}); }
60
85
 
61
86
  /**
62
87
  * One-time downgrade safety net: the first time we persist the NEW shape over a file that
@@ -65,7 +90,7 @@ function persist(store: AuthStore): void {
65
90
  * store, destroying refresh tokens; the backup makes that recoverable.
66
91
  */
67
92
  function backupLegacyOnce(): void {
68
- const path = authPath();
93
+ const path = getAuthStorePath();
69
94
  const backup = `${path}.pre-multiauth`;
70
95
  if (!existsSync(path) || existsSync(backup)) return;
71
96
  try {
@@ -155,23 +180,15 @@ function normalizeAuthStore(raw: unknown): { store: AuthStore; hadLegacy: boolea
155
180
  * a guardian refresh persisting a non-active account cannot roll back a concurrent
156
181
  * active-account switch (lost update). Cross-process races are accepted (single proxy).
157
182
  */
158
- let writeQueue: Promise<unknown> = Promise.resolve();
159
- function enqueueWrite<T>(fn: () => T): T {
160
- // Synchronous mutations: chain onto the queue for ordering, but run eagerly since all
161
- // current callers are sync. The queue exists so future async mutators serialize too.
162
- const result = fn();
163
- writeQueue = writeQueue.then(() => result);
164
- return result;
165
- }
166
-
167
- function mutateStore<T>(fn: (store: AuthStore) => T): T {
168
- return enqueueWrite(() => {
183
+ let mutationTail: Promise<void> = Promise.resolve();
184
+ function serializeMutation<T>(work:()=>Promise<T>):Promise<T>{const result=mutationTail.then(work,work);mutationTail=result.then(()=>undefined,()=>undefined);return result;}
185
+ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
169
186
  const { store, hadLegacy } = loadAuthStoreInternal();
170
187
  if (hadLegacy) backupLegacyOnce();
171
- const result = fn(store);
188
+ const result = await fn(store);
172
189
  persist(store);
173
190
  return result;
174
- });
191
+ }finally{guard.release();}});
175
192
  }
176
193
 
177
194
  /** The ACTIVE account's credential for a provider (what requests should use). */
@@ -187,10 +204,10 @@ export function getCredential(provider: string): OAuthCredentials | null {
187
204
  * (rotating refresh tokens would fabricate duplicates) and single-slot providers replace the
188
205
  * active slot / whole set instead.
189
206
  */
190
- export function saveCredential(provider: string, cred: OAuthCredentials): void {
207
+ export async function saveCredential(provider: string, cred: OAuthCredentials): Promise<void> {
191
208
  const safe = normalizeCredential(cred);
192
209
  if (!safe) return;
193
- mutateStore(store => {
210
+ await mutateStore(store => {
194
211
  const set = store[provider];
195
212
  const identity = safe.accountId ?? safe.email;
196
213
  if (!set || SINGLE_SLOT_PROVIDERS.has(provider)) {
@@ -225,8 +242,8 @@ export function saveCredential(provider: string, cred: OAuthCredentials): void {
225
242
  }
226
243
 
227
244
  /** Remove the ACTIVE account; remaining accounts promote the first one. */
228
- export function removeCredential(provider: string): void {
229
- mutateStore(store => {
245
+ export async function removeCredential(provider: string): Promise<void> {
246
+ await mutateStore(store => {
230
247
  const set = store[provider];
231
248
  if (!set) return;
232
249
  set.accounts = set.accounts.filter(a => a.id !== set.activeAccountId);
@@ -255,10 +272,10 @@ export function getAccountCredential(provider: string, accountId: string): OAuth
255
272
  }
256
273
 
257
274
  /** Persist a refreshed credential for a SPECIFIC account without touching activeAccountId. */
258
- export function saveAccountCredential(provider: string, accountId: string, cred: OAuthCredentials): void {
275
+ export async function saveAccountCredential(provider: string, accountId: string, cred: OAuthCredentials): Promise<void> {
259
276
  const safe = normalizeCredential(cred);
260
277
  if (!safe) return;
261
- mutateStore(store => {
278
+ await mutateStore(store => {
262
279
  const account = store[provider]?.accounts.find(a => a.id === accountId);
263
280
  if (!account) return;
264
281
  account.credential = safe;
@@ -266,8 +283,8 @@ export function saveAccountCredential(provider: string, accountId: string, cred:
266
283
  });
267
284
  }
268
285
 
269
- export function setActiveAccount(provider: string, accountId: string): boolean {
270
- return mutateStore(store => {
286
+ export async function setActiveAccount(provider: string, accountId: string): Promise<boolean> {
287
+ return await mutateStore(store => {
271
288
  const set = store[provider];
272
289
  if (!set || !set.accounts.some(a => a.id === accountId)) return false;
273
290
  set.activeAccountId = accountId;
@@ -276,8 +293,8 @@ export function setActiveAccount(provider: string, accountId: string): boolean {
276
293
  }
277
294
 
278
295
  /** Remove one account by id; active removal promotes the first remaining account. */
279
- export function removeAccount(provider: string, accountId: string): boolean {
280
- return mutateStore(store => {
296
+ export async function removeAccount(provider: string, accountId: string): Promise<boolean> {
297
+ return await mutateStore(store => {
281
298
  const set = store[provider];
282
299
  if (!set) return false;
283
300
  const before = set.accounts.length;
@@ -292,11 +309,14 @@ export function removeAccount(provider: string, accountId: string): boolean {
292
309
  });
293
310
  }
294
311
 
295
- export function markAccountNeedsReauth(provider: string, accountId: string, needsReauth: boolean): void {
296
- mutateStore(store => {
312
+ export async function markAccountNeedsReauth(provider: string, accountId: string, needsReauth: boolean): Promise<void> {
313
+ await mutateStore(store => {
297
314
  const account = store[provider]?.accounts.find(a => a.id === accountId);
298
315
  if (!account) return;
299
316
  if (needsReauth) account.needsReauth = true;
300
317
  else delete account.needsReauth;
301
318
  });
302
319
  }
320
+
321
+ export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise<void>}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};account.credential=safe;delete account.needsReauth;return{superseded:false};});}
322
+ export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string):Promise<boolean>{return await mutateStore(store=>{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||credentialGeneration(account.credential)!==generation)return false;account.needsReauth=true;return true;});}
package/src/oauth/xai.ts CHANGED
@@ -12,6 +12,9 @@ const XAI_OAUTH_CALLBACK_PATH = "/callback";
12
12
  const XAI_OAUTH_REFRESH_SKEW_MS = 2 * 60 * 1000;
13
13
  const TOKEN_REQUEST_TIMEOUT_MS = 30_000;
14
14
 
15
+ export const XAI_LOCAL_CLI_DETACH_WARNING =
16
+ "[oauth:xai] Grok CLI credential was stale; refreshed into OpenCodex ownership. Grok CLI may require login again.";
17
+
15
18
  interface XaiDiscovery {
16
19
  authorizationEndpoint: string;
17
20
  tokenEndpoint: string;
@@ -22,7 +25,7 @@ interface XaiDiscoveryPayload {
22
25
  token_endpoint?: unknown;
23
26
  }
24
27
 
25
- interface XaiTokenPayload {
28
+ export interface XaiTokenPayload {
26
29
  access_token?: unknown;
27
30
  refresh_token?: unknown;
28
31
  expires_in?: unknown;
@@ -89,12 +92,18 @@ function getTokenIdentity(accessToken: string, idToken: string | undefined): { a
89
92
  return { accountId, email };
90
93
  }
91
94
 
92
- async function postXaiToken(
95
+ export class XaiTokenRequestError extends Error { constructor(public readonly status?:number,public readonly oauthError?:string,message="xAI token request failed",options?:{cause?:unknown}){super(message,options);this.name="XaiTokenRequestError";} }
96
+ export interface XaiTokenRetryDeps { sleep?:(ms:number)=>Promise<void>; random?:()=>number }
97
+ function isAbortError(error:unknown):boolean{return error instanceof DOMException&&error.name==="AbortError";}
98
+ function retryDelay(attempt:number,retryAfter:string|null,random:()=>number):number{const base=attempt===1?100:250,j=Math.round(base*(.75+random()*.5)),seconds=retryAfter!==null&&/^\d+$/.test(retryAfter)?Number(retryAfter):0;return Math.min(2000,Math.max(j,seconds*1000));}
99
+ async function readTokenError(response:Response):Promise<XaiTokenRequestError>{let oauthError:string|undefined,detail="";try{const body=await response.json() as {error?:unknown;error_description?:unknown};if(typeof body.error==="string")oauthError=body.error;if(typeof body.error_description==="string")detail=body.error_description;}catch{}const suffix=detail?`: ${detail}`:oauthError?`: ${oauthError}`:"";return new XaiTokenRequestError(response.status,oauthError,`xAI token request failed: ${response.status}${suffix}`);}
100
+ export async function postXaiToken(
93
101
  tokenEndpoint: string,
94
102
  body: Record<string, string>,
95
- signal?: AbortSignal,
103
+ signal?: AbortSignal, deps:XaiTokenRetryDeps={},
96
104
  ): Promise<XaiTokenPayload> {
97
- const response = await fetch(tokenEndpoint, {
105
+ const sleep=deps.sleep??(ms=>Bun.sleep(ms)),random=deps.random??Math.random;let last:unknown;
106
+ for(let attempt=1;attempt<=3;attempt++){let response:Response;try{response=await fetch(tokenEndpoint, {
98
107
  method: "POST",
99
108
  headers: {
100
109
  Accept: "application/json",
@@ -102,11 +111,7 @@ async function postXaiToken(
102
111
  },
103
112
  body: new URLSearchParams(body).toString(),
104
113
  signal: requestSignal(signal),
105
- });
106
- if (!response.ok) {
107
- throw new Error(`xAI token request failed: ${response.status} ${await response.text()}`);
108
- }
109
- return (await response.json()) as XaiTokenPayload;
114
+ });}catch(error){if(isAbortError(error)&&signal?.aborted)throw error;last=error;if(attempt===3)throw new XaiTokenRequestError(undefined,undefined,"xAI token request failed: network error",{cause:error});await sleep(retryDelay(attempt,null,random));continue;}if(response.ok)return await response.json() as XaiTokenPayload;const error=await readTokenError(response);last=error;if(!(response.status===429||response.status>=500)||attempt===3)throw error;await sleep(retryDelay(attempt,response.headers.get("retry-after"),random));}throw last;
110
115
  }
111
116
 
112
117
  function credentialsFromTokenPayload(payload: XaiTokenPayload, refreshFallback = ""): OAuthCredentials {
@@ -200,7 +205,9 @@ export async function loginXai(
200
205
  ctrl.onProgress?.("Found Grok CLI token, importing automatically");
201
206
  if (local.expires >= Date.now() + 60_000) return local;
202
207
  try {
203
- return { ...(await refreshXaiToken(local.refresh, ctrl.signal)), source: "local-cli" };
208
+ const fresh = await refreshXaiToken(local.refresh, ctrl.signal);
209
+ ctrl.onProgress?.(XAI_LOCAL_CLI_DETACH_WARNING);
210
+ return { ...fresh, source: "oauth" };
204
211
  } catch (error) {
205
212
  if (importLocal === "only") {
206
213
  throw new Error(
@@ -17,8 +17,6 @@ export interface ProviderQuotaWindow {
17
17
  }
18
18
 
19
19
  export interface ProviderQuota {
20
- fiveHourPercent?: number;
21
- fiveHourResetAt?: number;
22
20
  weeklyPercent?: number;
23
21
  weeklyResetAt?: number;
24
22
  monthlyPercent?: number;
@@ -58,8 +56,7 @@ function cacheKey(config: OcxConfig): string {
58
56
 
59
57
  function hasQuotaRows(quota: ProviderQuota | null | undefined): quota is ProviderQuota {
60
58
  if (!quota) return false;
61
- return typeof quota.fiveHourPercent === "number"
62
- || typeof quota.weeklyPercent === "number"
59
+ return typeof quota.weeklyPercent === "number"
63
60
  || typeof quota.monthlyPercent === "number"
64
61
  || !!quota.customWindows?.some(window => typeof window.percent === "number");
65
62
  }
@@ -188,11 +185,10 @@ async function fetchAnthropicQuota(provider: string): Promise<ProviderQuotaRepor
188
185
  const opus = parseClaudeBucket(body.seven_day_opus);
189
186
  const sonnet = parseClaudeBucket(body.seven_day_sonnet);
190
187
  const customWindows: ProviderQuotaWindow[] = [];
188
+ if (fiveHour?.percent !== undefined) customWindows.push({ label: "5h", percent: fiveHour.percent, ...(fiveHour.resetAt !== undefined ? { resetAt: fiveHour.resetAt } : {}) });
191
189
  if (opus?.percent !== undefined) customWindows.push({ label: "Opus", percent: opus.percent, ...(opus.resetAt !== undefined ? { resetAt: opus.resetAt } : {}) });
192
190
  if (sonnet?.percent !== undefined) customWindows.push({ label: "Sonnet", percent: sonnet.percent, ...(sonnet.resetAt !== undefined ? { resetAt: sonnet.resetAt } : {}) });
193
191
  const quota: ProviderQuota = {
194
- ...(fiveHour?.percent !== undefined ? { fiveHourPercent: fiveHour.percent } : {}),
195
- ...(fiveHour?.resetAt !== undefined ? { fiveHourResetAt: fiveHour.resetAt } : {}),
196
192
  ...(sevenDay?.percent !== undefined ? { weeklyPercent: sevenDay.percent } : {}),
197
193
  ...(sevenDay?.resetAt !== undefined ? { weeklyResetAt: sevenDay.resetAt } : {}),
198
194
  ...(customWindows.length > 0 ? { customWindows } : {}),
@@ -1,48 +1,79 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import type { OcxProviderConfig } from "../types";
3
3
 
4
- /**
5
- * xAI account OAuth and xAI API keys share a bearer shape but not a billing
6
- * transport. OAuth represents the Grok CLI subscription entitlement, while a
7
- * key represents the API team. Keep the saved provider preset compatible with
8
- * the dashboard's "Use an API key instead" switch and resolve the transport at
9
- * request time.
10
- */
11
4
  export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
12
5
 
13
- /** Minimum-compatible official Grok CLI wire version verified with the proxy. */
14
- export const XAI_GROK_CLIENT_VERSION = "0.2.93";
6
+ export const XAI_GROK_COMPATIBILITY = {
7
+ version: "0.2.93",
8
+ userAgent: "opencodex-grok/0.2.93",
9
+ headers: {
10
+ clientIdentifier: "x-grok-client-identifier",
11
+ clientVersion: "x-grok-client-version",
12
+ tokenAuth: "x-xai-token-auth",
13
+ authenticateResponse: "x-authenticateresponse",
14
+ conversationId: "x-grok-conv-id",
15
+ requestId: "x-grok-req-id",
16
+ sessionId: "x-grok-session-id",
17
+ userAgent: "User-Agent",
18
+ },
19
+ } as const;
15
20
 
16
- const XAI_GROK_CLI_HEADERS: Readonly<Record<string, string>> = {
17
- "x-grok-client-identifier": "opencodex",
18
- "x-grok-client-version": XAI_GROK_CLIENT_VERSION,
19
- "x-xai-token-auth": "xai-grok-cli",
21
+ export const XAI_GROK_CLIENT_VERSION = XAI_GROK_COMPATIBILITY.version;
22
+ export const XAI_CONV_ID_HEADER = XAI_GROK_COMPATIBILITY.headers.conversationId;
23
+
24
+ export type OcxProviderTransport = OcxProviderConfig & {
25
+ /** Request executor used only at runtime; never persisted. */
26
+ fetch?: typeof globalThis.fetch;
20
27
  };
21
28
 
22
- /**
23
- * Sticky-routing hint for xAI's automatic prefix cache. xAI routes requests
24
- * carrying the same `x-grok-conv-id` to the same server, which is where the
25
- * prompt cache lives (docs.x.ai prompt-caching best-practices; verified
26
- * 2026-07-13, devlog/_plan/260713_grok_caching). Codex clients send a stable
27
- * per-conversation `prompt_cache_key`; hash it so the raw session id never
28
- * leaves the proxy.
29
- */
30
- export const XAI_CONV_ID_HEADER = "x-grok-conv-id";
29
+ const XAI_GROK_CLI_HEADERS: Readonly<Record<string, string>> = {
30
+ [XAI_GROK_COMPATIBILITY.headers.clientIdentifier]: "opencodex",
31
+ [XAI_GROK_COMPATIBILITY.headers.clientVersion]: XAI_GROK_CLIENT_VERSION,
32
+ [XAI_GROK_COMPATIBILITY.headers.tokenAuth]: "xai-grok-cli",
33
+ [XAI_GROK_COMPATIBILITY.headers.authenticateResponse]: "authenticate-response",
34
+ };
31
35
 
32
- function hasHeaderCaseInsensitive(headers: Record<string, string> | undefined, name: string): boolean {
33
- if (!headers) return false;
36
+ function hasHeaderCaseInsensitive(
37
+ headers: Record<string, string> | undefined,
38
+ name: string,
39
+ ): boolean {
34
40
  const target = name.toLowerCase();
35
- return Object.keys(headers).some(key => key.toLowerCase() === target);
41
+ return Object.keys(headers ?? {}).some(key => key.toLowerCase() === target);
42
+ }
43
+
44
+ function withoutUserOverridden(
45
+ defaults: Readonly<Record<string, string>>,
46
+ userHeaders: Record<string, string> | undefined,
47
+ ): Record<string, string> {
48
+ return Object.fromEntries(
49
+ Object.entries(defaults).filter(([name]) => !hasHeaderCaseInsensitive(userHeaders, name)),
50
+ );
36
51
  }
37
52
 
38
- /** Drop default entries the user already overrides under any header-name casing. */
39
- function withoutUserOverridden(defaults: Readonly<Record<string, string>>, userHeaders: Record<string, string> | undefined): Record<string, string> {
40
- if (!userHeaders) return { ...defaults };
41
- const out: Record<string, string> = {};
42
- for (const [key, value] of Object.entries(defaults)) {
43
- if (!hasHeaderCaseInsensitive(userHeaders, key)) out[key] = value;
53
+ function withGeneratedRequestId(
54
+ init: RequestInit | undefined,
55
+ configuredRequestId: string | undefined,
56
+ stableHeaders: Readonly<Record<string, string>>,
57
+ ): RequestInit {
58
+ const headers = new Headers(init?.headers);
59
+ for (const [name, value] of Object.entries(stableHeaders)) {
60
+ if (!headers.has(name)) headers.set(name, value);
44
61
  }
45
- return out;
62
+ if (!headers.has(XAI_GROK_COMPATIBILITY.headers.requestId)) {
63
+ headers.set(
64
+ XAI_GROK_COMPATIBILITY.headers.requestId,
65
+ configuredRequestId ?? randomUUID(),
66
+ );
67
+ }
68
+ return { ...init, headers };
69
+ }
70
+
71
+ function findHeaderCaseInsensitive(
72
+ headers: Record<string, string> | undefined,
73
+ name: string,
74
+ ): string | undefined {
75
+ const target = name.toLowerCase();
76
+ return Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === target)?.[1];
46
77
  }
47
78
 
48
79
  export function deriveXaiConvId(promptCacheKey: string): string {
@@ -50,40 +81,56 @@ export function deriveXaiConvId(promptCacheKey: string): string {
50
81
  }
51
82
 
52
83
  /**
53
- * Resolve the effective xAI transport without mutating persisted config.
54
- * User-provided headers are preserved and may advance the compatibility
55
- * version without waiting for an opencodex release.
56
- *
57
- * `promptCacheKey` (the client's stable conversation key) additionally pins
58
- * cache-affinity routing via `x-grok-conv-id` in BOTH auth modes. Blank or
59
- * whitespace-only keys are ignored so unrelated requests can never collapse
60
- * onto one shared conv id, and any user-configured header (any case) wins.
84
+ * Resolve xAI's runtime transport without mutating persisted config. Conversation/session
85
+ * affinity is stable for this resolved transport; request identity is generated per fetch.
86
+ * Agent, deployment, model-override, turn, mode, and user identity headers are intentionally
87
+ * omitted because opencodex has no truthful values for the official fields.
61
88
  */
62
89
  export function resolveProviderTransport(
63
90
  providerName: string,
64
- provider: OcxProviderConfig,
91
+ provider: OcxProviderTransport,
65
92
  promptCacheKey?: string,
66
- ): OcxProviderConfig {
93
+ ): OcxProviderTransport {
67
94
  if (providerName !== "xai") return provider;
95
+
68
96
  const cacheKey = promptCacheKey?.trim();
69
- const convIdHeaders: Record<string, string> =
70
- cacheKey && !hasHeaderCaseInsensitive(provider.headers, XAI_CONV_ID_HEADER)
71
- ? { [XAI_CONV_ID_HEADER]: deriveXaiConvId(cacheKey) }
72
- : {};
73
- if (provider.authMode !== "oauth") {
74
- if (Object.keys(convIdHeaders).length === 0) return provider;
75
- return {
76
- ...provider,
77
- headers: { ...convIdHeaders, ...(provider.headers ?? {}) },
78
- };
79
- }
97
+ const affinity = cacheKey ? deriveXaiConvId(cacheKey) : undefined;
98
+ const stableDefaults: Record<string, string> = {
99
+ [XAI_GROK_COMPATIBILITY.headers.userAgent]: XAI_GROK_COMPATIBILITY.userAgent,
100
+ ...(affinity
101
+ ? {
102
+ [XAI_GROK_COMPATIBILITY.headers.conversationId]: affinity,
103
+ [XAI_GROK_COMPATIBILITY.headers.sessionId]: affinity,
104
+ }
105
+ : {}),
106
+ ...(provider.authMode === "oauth" ? XAI_GROK_CLI_HEADERS : {}),
107
+ };
108
+ const stableHeaders = {
109
+ ...withoutUserOverridden(stableDefaults, provider.headers),
110
+ ...(provider.headers ?? {}),
111
+ };
112
+ // Keep API-key provider metadata compatible with key-pool rotation; session/UA defaults
113
+ // remain transport-scoped and are applied by the wrapper immediately below.
114
+ const headers = provider.authMode === "oauth"
115
+ ? stableHeaders
116
+ : {
117
+ ...(affinity && !hasHeaderCaseInsensitive(provider.headers, XAI_GROK_COMPATIBILITY.headers.conversationId)
118
+ ? { [XAI_GROK_COMPATIBILITY.headers.conversationId]: affinity }
119
+ : {}),
120
+ ...(provider.headers ?? {}),
121
+ };
122
+ const configuredRequestId = findHeaderCaseInsensitive(
123
+ provider.headers,
124
+ XAI_GROK_COMPATIBILITY.headers.requestId,
125
+ );
126
+ const baseFetch = provider.fetch ?? globalThis.fetch;
127
+ const attemptFetch = ((input, init) =>
128
+ baseFetch(input, withGeneratedRequestId(init, configuredRequestId, stableHeaders))) as typeof globalThis.fetch;
129
+
80
130
  return {
81
131
  ...provider,
82
- baseUrl: XAI_GROK_CLI_BASE_URL,
83
- headers: {
84
- ...withoutUserOverridden(XAI_GROK_CLI_HEADERS, provider.headers),
85
- ...convIdHeaders,
86
- ...(provider.headers ?? {}),
87
- },
132
+ ...(provider.authMode === "oauth" ? { baseUrl: XAI_GROK_CLI_BASE_URL } : {}),
133
+ headers,
134
+ fetch: attemptFetch,
88
135
  };
89
136
  }
@@ -228,6 +228,20 @@ export function parseRequest(body: unknown): OcxParsedRequest {
228
228
  const now = Date.now();
229
229
  const messages: OcxMessage[] = [];
230
230
  const systemPrompt: string[] = [];
231
+ // Responses reasoning siblings belong to the following assistant, including across call items.
232
+ // Keep them off the message list until that assistant arrives; turn boundaries clear the array.
233
+ const pendingReasoning: Array<{ part: OcxThinkingContent; envelopeSigned: boolean }> = [];
234
+ // Assistant placeholder that first folds any pending reasoning into the same turn (official
235
+ // grok-build preserves reasoning across call items; Anthropic replay requires thinking to
236
+ // precede tool_use inside one assistant message).
237
+ const assistantHolderWithReasoning = (): OcxAssistantMessage => {
238
+ const holder = ensureAssistantPlaceholder(messages, data.model, now);
239
+ if (pendingReasoning.length > 0) {
240
+ holder.content.push(...pendingReasoning.map(entry => entry.part));
241
+ pendingReasoning.length = 0;
242
+ }
243
+ return holder;
244
+ };
231
245
  // Tool specs surfaced by a prior tool_search (deferred tools, e.g. subagents). Codex does not
232
246
  // re-list these in `tools`, but chat models can only call listed tools — so we re-inject them.
233
247
  const loadedToolSpecs: unknown[] = [];
@@ -270,6 +284,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
270
284
  // is dropped silently. It must NOT flag _compactionRequest.
271
285
  const encrypted = (item as { encrypted_content?: unknown }).encrypted_content;
272
286
  if (effectiveType === "context_compaction" && typeof encrypted !== "string") continue;
287
+ pendingReasoning.length = 0;
273
288
  messages.push({
274
289
  role: "user",
275
290
  content: compactionItemToText(typeof encrypted === "string" ? encrypted : undefined),
@@ -297,6 +312,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
297
312
  // An agent_message is external input delivered to the parent agent.
298
313
  // Preserve it as a user-role turn so signed Anthropic thinking blocks
299
314
  // on either side are never merged into one modified assistant response.
315
+ pendingReasoning.length = 0;
300
316
  messages.push({
301
317
  role: "user",
302
318
  content: hasContent ? content : "(sub-agent message received)",
@@ -310,6 +326,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
310
326
  const msg = item as { role?: string; content?: unknown };
311
327
  switch (msg.role) {
312
328
  case "system": {
329
+ pendingReasoning.length = 0;
313
330
  const text = inputContentParts(msg.content as unknown[] | string | undefined);
314
331
  const flat = typeof text === "string" ? text : text.map(p => (p.type === "text" ? p.text : "")).join("");
315
332
  if (flat.length > 0) systemPrompt.push(flat);
@@ -317,13 +334,22 @@ export function parseRequest(body: unknown): OcxParsedRequest {
317
334
  }
318
335
  case "user":
319
336
  case "developer": {
337
+ pendingReasoning.length = 0;
320
338
  const content = inputContentParts(msg.content as unknown[] | string | undefined);
321
339
  messages.push({ role: msg.role, content, timestamp: now });
322
340
  break;
323
341
  }
324
342
  case "assistant": {
325
343
  const parts = outputTextOf(msg.content as unknown[] | string | undefined);
326
- messages.push({ role: "assistant", content: parts, model: data.model, timestamp: now });
344
+ messages.push({
345
+ role: "assistant",
346
+ content: pendingReasoning.length > 0
347
+ ? [...pendingReasoning.map(entry => entry.part), ...parts]
348
+ : parts,
349
+ model: data.model,
350
+ timestamp: now,
351
+ });
352
+ pendingReasoning.length = 0;
327
353
  break;
328
354
  }
329
355
  }
@@ -334,20 +360,33 @@ export function parseRequest(body: unknown): OcxParsedRequest {
334
360
  const reasoning = item as { id?: string; summary?: { text: string }[]; content?: { text: string }[]; encrypted_content?: string };
335
361
  const fromSummary = (reasoning.summary ?? []).map(c => c.text).join("");
336
362
  const text = fromSummary || (reasoning.content ?? []).map(c => c.text).join("");
337
- // ocxr1 envelope: the REAL Anthropic signature (+ redacted blocks, + hidden signed text)
338
- // captured by the bridge. Native OpenAI-encrypted blobs decode to null and keep today's
339
- // placeholder signature (which the anthropic adapter correctly rejects on replay).
340
363
  const envelope = typeof reasoning.encrypted_content === "string"
341
364
  ? decodeReasoningEnvelope(reasoning.encrypted_content)
342
365
  : null;
343
- const thinking: OcxThinkingContent = {
344
- type: "thinking",
345
- thinking: envelope?.txt || text,
346
- signature: envelope?.sig ?? JSON.stringify(reasoning),
347
- ...(envelope?.red ? { redacted: envelope.red } : {}),
348
- ...(reasoning.id ? { itemId: reasoning.id } : {}),
349
- };
350
- ensureAssistantPlaceholder(messages, data.model, now).content.push(thinking);
366
+ const thinkingText = envelope?.txt || text;
367
+
368
+ // Native/non-ocxr1 encrypted-only reasoning is opaque here. Do not create a detached
369
+ // assistant turn or invent replayable plaintext/signatures from the encrypted payload.
370
+ if (thinkingText.length > 0) {
371
+ const part: OcxThinkingContent = {
372
+ type: "thinking",
373
+ thinking: thinkingText,
374
+ signature: envelope?.sig ?? JSON.stringify(reasoning),
375
+ ...(envelope?.red ? { redacted: envelope.red } : {}),
376
+ ...(reasoning.id ? { itemId: reasoning.id } : {}),
377
+ };
378
+ const envelopeSigned = typeof envelope?.sig === "string";
379
+ const previous = pendingReasoning[pendingReasoning.length - 1];
380
+
381
+ if (!envelopeSigned && previous && !previous.envelopeSigned) {
382
+ previous.part = {
383
+ ...part,
384
+ thinking: `${previous.part.thinking}\n${part.thinking}`,
385
+ };
386
+ } else {
387
+ pendingReasoning.push({ part, envelopeSigned });
388
+ }
389
+ }
351
390
  continue;
352
391
  }
353
392
 
@@ -370,7 +409,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
370
409
  ...(call.id ? { thoughtSignature: call.id } : {}),
371
410
  ...(call.namespace ? { namespace: call.namespace } : {}),
372
411
  };
373
- ensureAssistantPlaceholder(messages, data.model, now).content.push(toolCall);
412
+ assistantHolderWithReasoning().content.push(toolCall);
374
413
  continue;
375
414
  }
376
415
 
@@ -382,7 +421,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
382
421
  customWireName: call.name,
383
422
  ...(call.id ? { thoughtSignature: call.id } : {}),
384
423
  };
385
- ensureAssistantPlaceholder(messages, data.model, now).content.push(toolCall);
424
+ assistantHolderWithReasoning().content.push(toolCall);
386
425
  continue;
387
426
  }
388
427
 
@@ -393,7 +432,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
393
432
  const callId = call.call_id ?? call.id;
394
433
  if (callId) {
395
434
  const command = Array.isArray(call.action?.command) ? call.action.command : [];
396
- ensureAssistantPlaceholder(messages, data.model, now).content.push({
435
+ assistantHolderWithReasoning().content.push({
397
436
  type: "toolCall", id: callId, name: "shell",
398
437
  arguments: command.length > 0 ? { command } : {},
399
438
  });
@@ -406,7 +445,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
406
445
  // knows the search already ran (prevents re-search loops); there is no output to pair.
407
446
  const call = item as { action?: { type?: string; query?: string } };
408
447
  const query = typeof call.action?.query === "string" ? call.action.query : "";
409
- ensureAssistantPlaceholder(messages, data.model, now).content.push({
448
+ assistantHolderWithReasoning().content.push({
410
449
  type: "text", text: query ? `[web search performed: ${query}]` : "[web search performed]",
411
450
  });
412
451
  continue;
@@ -417,7 +456,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
417
456
  // history stays complete (otherwise the model re-issues tool_search forever).
418
457
  const call = item as { id?: string; call_id?: string; arguments?: unknown };
419
458
  const callId = call.call_id ?? call.id ?? "";
420
- ensureAssistantPlaceholder(messages, data.model, now).content.push({
459
+ assistantHolderWithReasoning().content.push({
421
460
  type: "toolCall", id: callId, name: "tool_search",
422
461
  arguments: isObj(call.arguments) ? call.arguments : {},
423
462
  });
@@ -425,6 +464,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
425
464
  }
426
465
 
427
466
  if (effectiveType === "tool_search_output") {
467
+ pendingReasoning.length = 0;
428
468
  // Pair the tool_search call with its result so the model sees what was loaded.
429
469
  const out = item as { call_id?: string; status?: string; tools?: unknown[] };
430
470
  const specs = Array.isArray(out.tools) ? (out.tools as Record<string, unknown>[]) : [];
@@ -455,6 +495,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
455
495
  }
456
496
 
457
497
  if (effectiveType === "function_call_output") {
498
+ pendingReasoning.length = 0;
458
499
  const output = item as { call_id: string; output?: string | unknown[] };
459
500
  const toolInfo = findToolById(messages, output.call_id);
460
501
  messages.push({
@@ -466,6 +507,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
466
507
  }
467
508
 
468
509
  if (effectiveType === "custom_tool_call_output") {
510
+ pendingReasoning.length = 0;
469
511
  const output = item as { call_id: string; output: string | unknown[] };
470
512
  const toolInfo = findToolById(messages, output.call_id);
471
513
  messages.push({