@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.
- package/gui/dist/assets/{index-CILVKWmx.css → index-Bk_GgFrh.css} +1 -1
- package/gui/dist/assets/index-BqbMn1sv.js +40 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/claude/outbound.ts +15 -4
- package/src/cli/index.ts +1 -1
- package/src/codex/auth-api.ts +7 -6
- package/src/codex/quota.ts +13 -27
- package/src/codex/routing.ts +1 -2
- package/src/lib/abort.ts +55 -0
- package/src/lib/upstream-retry.ts +62 -0
- package/src/oauth/index.ts +82 -24
- package/src/oauth/local-token-detect.ts +23 -1
- package/src/oauth/store.ts +51 -31
- package/src/oauth/xai.ts +17 -10
- package/src/providers/quota.ts +2 -6
- package/src/providers/xai-transport.ts +107 -60
- package/src/responses/parser.ts +59 -17
- package/src/server/claude-messages.ts +240 -14
- package/src/server/management-api.ts +3 -3
- package/src/server/request-log.ts +13 -1
- package/src/server/responses.ts +67 -18
- package/src/types.ts +13 -0
- package/src/usage/log.ts +11 -0
- package/gui/dist/assets/index-DYIS0tTL.js +0 -40
package/src/oauth/store.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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 =
|
|
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(
|
|
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 =
|
|
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
|
|
159
|
-
function
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
package/src/providers/quota.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(
|
|
33
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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:
|
|
91
|
+
provider: OcxProviderTransport,
|
|
65
92
|
promptCacheKey?: string,
|
|
66
|
-
):
|
|
93
|
+
): OcxProviderTransport {
|
|
67
94
|
if (providerName !== "xai") return provider;
|
|
95
|
+
|
|
68
96
|
const cacheKey = promptCacheKey?.trim();
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/responses/parser.ts
CHANGED
|
@@ -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({
|
|
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
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|