@bitkyc08/opencodex 2.7.21 → 2.7.23

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 } : {}),
@@ -146,16 +146,33 @@ const DEEPSEEK_THINKING_REASONING_MAP: Record<string, string> = {
146
146
  xhigh: "max",
147
147
  max: "max",
148
148
  };
149
- // 260710 Kimi model aliases and context windows: Tier-2 evidence in
150
- // devlog/_plan/260710_provider_hardening/002_research_cn.md.
151
- const KIMI_API_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"];
152
- const KIMI_CODING_MODELS = [...KIMI_API_MODELS, "kimi-for-coding"];
149
+ // 260717 Kimi K3: the subscription endpoint uses one upstream id (`k3`) for both
150
+ // entitlement tiers. Bare `k3` advertises the Moderato 256K ceiling; the local `[1m]`
151
+ // alias advertises Allegretto's 1M ceiling and is stripped before the upstream request.
152
+ // The separately billed Moonshot API uses `kimi-k3`.
153
+ // Evidence: https://www.kimi.com/code/docs/en/kimi-code/models.html
154
+ // https://www.kimi.com/code/docs/en/kimi-code/error-reference.html
155
+ const KIMI_K3_STANDARD_CONTEXT_WINDOW = 262_144;
156
+ const KIMI_K3_1M_CONTEXT_WINDOW = 1_048_576;
157
+ const KIMI_CODING_K3_MODELS = ["k3", "k3[1m]"];
158
+ const KIMI_LEGACY_API_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k2.6", "kimi-k2.5"];
159
+ const KIMI_API_MODELS = ["kimi-k3", ...KIMI_LEGACY_API_MODELS];
160
+ const KIMI_CODING_MODELS = [...KIMI_CODING_K3_MODELS, ...KIMI_LEGACY_API_MODELS, "kimi-for-coding"];
153
161
  const KIMI_THINKING_MODELS = KIMI_CODING_MODELS;
162
+ const KIMI_CODING_NO_REASONING_MODELS = KIMI_CODING_MODELS.filter(id => !KIMI_CODING_K3_MODELS.includes(id));
163
+ const KIMI_API_NO_REASONING_MODELS = KIMI_API_MODELS.filter(id => id !== "kimi-k3");
164
+ const KIMI_CODING_REASONING_EFFORTS = Object.fromEntries(
165
+ KIMI_CODING_MODELS.map(id => [id, KIMI_CODING_K3_MODELS.includes(id) ? ["max"] : []]),
166
+ );
167
+ const KIMI_API_REASONING_EFFORTS = Object.fromEntries(
168
+ KIMI_API_MODELS.map(id => [id, id === "kimi-k3" ? ["max"] : []]),
169
+ );
154
170
  const KIMI_LOCKED_PARAMETER_MODELS = KIMI_CODING_MODELS;
155
171
  const KIMI_AUTO_TOOL_CHOICE_ONLY_MODELS = ["kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-for-coding"];
156
172
  const KIMI_API_MODEL_CONTEXT_WINDOWS: Record<string, number> = Object.fromEntries(
157
- KIMI_API_MODELS.map(id => [id, 262_144]),
173
+ KIMI_API_MODELS.map(id => [id, id === "kimi-k3" ? KIMI_K3_1M_CONTEXT_WINDOW : 262_144]),
158
174
  );
175
+ const KIMI_API_MODEL_INPUT_MODALITIES = { "kimi-k3": ["text", "image"] };
159
176
 
160
177
  // 260715 NVIDIA NIM kimi family (issue #126): documented served ids on integrate
161
178
  // chat/completions per docs.api.nvidia.com/nim/reference/llm-apis; live /v1/models
@@ -168,7 +185,10 @@ const NVIDIA_NIM_KIMI_MODELS = [
168
185
  "moonshotai/kimi-k2-instruct", "moonshotai/kimi-k2-instruct-0905",
169
186
  ];
170
187
  const KIMI_CODING_MODEL_CONTEXT_WINDOWS: Record<string, number> = Object.fromEntries(
171
- KIMI_CODING_MODELS.map(id => [id, 262_144]),
188
+ KIMI_CODING_MODELS.map(id => [id, id === "k3[1m]" ? KIMI_K3_1M_CONTEXT_WINDOW : KIMI_K3_STANDARD_CONTEXT_WINDOW]),
189
+ );
190
+ const KIMI_CODING_MODEL_INPUT_MODALITIES = Object.fromEntries(
191
+ KIMI_CODING_K3_MODELS.map(id => [id, ["text", "image"]]),
172
192
  );
173
193
  const NEURALWATT_REASONING_HISTORY_MODELS = [
174
194
  "glm-5.2", "glm-5.2-short",
@@ -306,6 +326,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
306
326
  adapter: "openai-chat",
307
327
  baseUrl: "https://api.kimi.com/coding/v1",
308
328
  authKind: "oauth",
329
+ modelSuffixBracketStrip: true,
309
330
  featured: true,
310
331
  oauthId: "kimi",
311
332
  jawcodeBundle: "moonshot",
@@ -313,9 +334,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
313
334
  models: KIMI_CODING_MODELS,
314
335
  defaultModel: "kimi-k2.7-code",
315
336
  modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS,
316
- // Kimi thinking is controlled by Kimi's `thinking` extension, not OpenAI `reasoning_effort`.
317
- noReasoningModels: KIMI_THINKING_MODELS,
318
- modelReasoningEfforts: Object.fromEntries(KIMI_THINKING_MODELS.map(id => [id, []])),
337
+ modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES,
338
+ // K3 accepts reasoning_effort=max; older Kimi models use Kimi's private thinking control.
339
+ noReasoningModels: KIMI_CODING_NO_REASONING_MODELS,
340
+ modelReasoningEfforts: KIMI_CODING_REASONING_EFFORTS,
319
341
  noTemperatureModels: KIMI_LOCKED_PARAMETER_MODELS,
320
342
  noTopPModels: KIMI_LOCKED_PARAMETER_MODELS,
321
343
  noPenaltyModels: KIMI_LOCKED_PARAMETER_MODELS,
@@ -502,8 +524,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
502
524
  dashboardUrl: "https://platform.moonshot.ai/console/api-keys", defaultModel: "kimi-k2.7-code", jawcodeBundle: "moonshot",
503
525
  models: KIMI_API_MODELS,
504
526
  modelContextWindows: KIMI_API_MODEL_CONTEXT_WINDOWS,
505
- noReasoningModels: KIMI_API_MODELS,
506
- modelReasoningEfforts: Object.fromEntries(KIMI_API_MODELS.map(id => [id, []])),
527
+ modelInputModalities: KIMI_API_MODEL_INPUT_MODALITIES,
528
+ noReasoningModels: KIMI_API_NO_REASONING_MODELS,
529
+ modelReasoningEfforts: KIMI_API_REASONING_EFFORTS,
507
530
  noTemperatureModels: KIMI_API_MODELS,
508
531
  noTopPModels: KIMI_API_MODELS,
509
532
  noPenaltyModels: KIMI_API_MODELS,
@@ -599,10 +622,12 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
599
622
  {
600
623
  id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key",
601
624
  dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-k2.7-code",
625
+ modelSuffixBracketStrip: true,
602
626
  models: KIMI_CODING_MODELS,
603
627
  modelContextWindows: KIMI_CODING_MODEL_CONTEXT_WINDOWS,
604
- noReasoningModels: KIMI_THINKING_MODELS,
605
- modelReasoningEfforts: Object.fromEntries(KIMI_THINKING_MODELS.map(id => [id, []])),
628
+ modelInputModalities: KIMI_CODING_MODEL_INPUT_MODALITIES,
629
+ noReasoningModels: KIMI_CODING_NO_REASONING_MODELS,
630
+ modelReasoningEfforts: KIMI_CODING_REASONING_EFFORTS,
606
631
  noTemperatureModels: KIMI_LOCKED_PARAMETER_MODELS,
607
632
  noTopPModels: KIMI_LOCKED_PARAMETER_MODELS,
608
633
  noPenaltyModels: KIMI_LOCKED_PARAMETER_MODELS,
@@ -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
  }