@demicodes/provider-grok-build 0.10.3 → 0.12.0

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/README.md CHANGED
@@ -5,8 +5,28 @@ stored by the official Grok CLI in `~/.grok/auth.json` (subscription login).
5
5
 
6
6
  ```ts
7
7
  import { createGrokBuildProvider } from '@demicodes/provider-grok-build'
8
+
9
+ const provider = createGrokBuildProvider()
10
+ // provider.auth, provider.quota, provider.credentials (multi-account pool by default)
8
11
  ```
9
12
 
10
- Requires a prior `grok login` (or equivalent) so `~/.grok/auth.json` contains an
11
- OIDC session. Implements the [`@demicodes/provider`](../provider/README.md)
12
- contract. Part of [Demi](../../README.md). Apache-2.0.
13
+ ## Auth and credentials
14
+
15
+ - Default material: `~/.grok/auth.json` (or `$GROK_HOME`); multi-entry map supported.
16
+ - Multi-credential pool under `$DEMI_HOME/credentials/grok-build/`; `importDefault`
17
+ can snapshot each OIDC entry as its own pool credential.
18
+ - Lifecycle: `beginLogin` → `grok login` → `importDefault` → `setActive`.
19
+
20
+ Requires a prior login (or `beginLogin`) so vendor material exists before import.
21
+ See [docs/provider-global-credentials.md](../../docs/provider-global-credentials.md).
22
+
23
+ ## Quota
24
+
25
+ - **probe** (cost: `free`): `/v1/billing` + `/v1/user?include=subscription` on
26
+ cli-chat-proxy.
27
+ - **observe**: short-window ratelimit headers on chat responses (separate from monthly).
28
+
29
+ See [docs/provider-quota.md](../../docs/provider-quota.md).
30
+
31
+ Implements the [`@demicodes/provider`](../provider/README.md) contract. Part of
32
+ [Demi](../../README.md). Apache-2.0.
package/dist/index.d.mts CHANGED
@@ -1,6 +1,20 @@
1
- import { Provider, ProviderAuthState, ProviderModelList, ProviderQuota, ProviderQuotaProbeResult } from "@demicodes/provider";
2
-
1
+ import { Provider, ProviderAuthState, ProviderCredentials, ProviderModelList, ProviderQuota, ProviderQuotaProbeResult } from "@demicodes/provider";
2
+ import { FileCredentialPool } from "@demicodes/provider/credentials-pool";
3
3
  //#region src/auth.d.ts
4
+ /** One credential entry as written by the Grok CLI (`~/.grok/auth.json`). */
5
+ interface GrokAuthEntry {
6
+ key?: string;
7
+ auth_mode?: string;
8
+ refresh_token?: string;
9
+ expires_at?: string;
10
+ oidc_issuer?: string;
11
+ oidc_client_id?: string;
12
+ email?: string;
13
+ first_name?: string;
14
+ user_id?: string;
15
+ team_id?: string;
16
+ [key: string]: unknown;
17
+ }
4
18
  interface GrokResolvedAuth {
5
19
  accessToken: string;
6
20
  refreshToken: string | null;
@@ -19,6 +33,13 @@ interface GrokAuthStore {
19
33
  }
20
34
  interface FileGrokAuthStoreOptions {
21
35
  grokHome?: string;
36
+ /** Override auth.json path (e.g. demi credential pool entry). */
37
+ authFile?: string;
38
+ /**
39
+ * Prefer this map key when the auth file has multiple OIDC entries.
40
+ * When unset, uses {@link selectAuthEntry} scoring.
41
+ */
42
+ entryKey?: string;
22
43
  refresh?: GrokTokenRefresh;
23
44
  now?: () => Date;
24
45
  lockRetryDelayMs?: number;
@@ -48,6 +69,10 @@ interface GrokBuildProviderOptions {
48
69
  authStore?: GrokAuthStore;
49
70
  headers?: Record<string, string>;
50
71
  fetch?: GrokBuildFetch;
72
+ /** Demi state root for credential pool (`$DEMI_HOME` / `~/.demi`). */
73
+ stateDir?: string;
74
+ /** When true (default if `authStore` unset), attach multi-credential pool. */
75
+ credentials?: boolean;
51
76
  }
52
77
  declare function createGrokBuildProvider(options?: GrokBuildProviderOptions): Provider;
53
78
  declare function parseGrokBuildProviderConfig(config: unknown): Pick<GrokBuildProviderOptions, 'grokHome' | 'baseUrl' | 'headers'>;
@@ -86,4 +111,51 @@ declare function mapGrokQuotaProbe(user: unknown, billing: unknown, auth?: Pick<
86
111
  /** Short-window chat ratelimits — not subscription monthly quota. */
87
112
  declare function observeGrokRateLimitHeaders(headers: Headers | undefined): ProviderQuotaProbeResult | null;
88
113
  //#endregion
89
- export { type GrokBuildFetch, type GrokBuildProviderOptions, type GrokBuildQuotaOptions, createGrokBuildProvider, createGrokBuildQuota, grokBuildAuthStatus, grokBuildFallbackModels, listGrokBuildModels, mapGrokQuotaProbe, observeGrokRateLimitHeaders, parseGrokBuildProviderConfig };
114
+ //#region src/credentials.d.ts
115
+ declare class PoolAwareGrokAuthStore implements GrokAuthStore {
116
+ private readonly pool;
117
+ private readonly vendorHome;
118
+ private readonly fileAuthOptions;
119
+ constructor(pool: FileCredentialPool, options?: {
120
+ grokHome?: string;
121
+ fileAuthOptions?: Omit<FileGrokAuthStoreOptions, 'grokHome' | 'authFile' | 'entryKey'>;
122
+ });
123
+ status(): Promise<import("@demicodes/provider").ProviderAuthState>;
124
+ resolveAuth(options?: {
125
+ forceRefresh?: boolean;
126
+ }): Promise<GrokResolvedAuth>;
127
+ private currentStore;
128
+ }
129
+ declare function openGrokCredentialPool(options?: {
130
+ stateDir?: string;
131
+ }): FileCredentialPool;
132
+ declare function createGrokBuildCredentials(pool: FileCredentialPool, authStore: GrokAuthStore, options?: {
133
+ grokHome?: string;
134
+ quota?: ProviderQuota | null;
135
+ /** Injectable fetch for the device-code login flow (tests). */
136
+ loginFetch?: typeof fetch;
137
+ }): ProviderCredentials;
138
+ //#endregion
139
+ //#region src/device-login.d.ts
140
+ interface GrokDeviceLoginPending {
141
+ verificationUrl: string;
142
+ userCode: string;
143
+ expiresAt: string;
144
+ }
145
+ interface GrokDeviceLoginOptions {
146
+ signal?: AbortSignal;
147
+ /** Fires once with the URL + one-time code the user needs. */
148
+ onPending?: (pending: GrokDeviceLoginPending) => void;
149
+ fetch?: typeof fetch;
150
+ issuer?: string;
151
+ clientId?: string;
152
+ scope?: string;
153
+ }
154
+ interface GrokDeviceLoginResult {
155
+ entryKey: string;
156
+ entry: GrokAuthEntry;
157
+ }
158
+ /** Runs the full device flow and returns a vendor-shaped auth.json entry keyed like the Grok CLI. */
159
+ declare function runGrokDeviceLogin(options?: GrokDeviceLoginOptions): Promise<GrokDeviceLoginResult>;
160
+ //#endregion
161
+ export { type GrokBuildFetch, type GrokBuildProviderOptions, type GrokBuildQuotaOptions, type GrokDeviceLoginOptions, type GrokDeviceLoginPending, type GrokDeviceLoginResult, PoolAwareGrokAuthStore, createGrokBuildCredentials, createGrokBuildProvider, createGrokBuildQuota, grokBuildAuthStatus, grokBuildFallbackModels, listGrokBuildModels, mapGrokQuotaProbe, observeGrokRateLimitHeaders, openGrokCredentialPool, parseGrokBuildProviderConfig, runGrokDeviceLogin };
package/dist/index.mjs CHANGED
@@ -1,32 +1,40 @@
1
- import { delay, errorMessage, isAbortError, isRecord, nonEmptyString, normalizeBaseUrl, numberOrNull, numberOrZero, parseJsonObject, parseJsonOrString, stringOrNull } from "@demicodes/utils";
2
- import { createProviderQuota, defineProvider, httpRequestFailedEvent, normalizeErrorCode, providerErrorFromUnknown, severityFromUsedPercent, usedPercentFromRatio } from "@demicodes/provider";
1
+ import { delay, errorCode, errorMessage, isAbortError, isRecord, nonEmptyString, normalizeBaseUrl, numberOrNull, numberOrZero, parseJsonObject, parseJsonOrString, stringOrNull } from "@demicodes/utils";
2
+ import { createProviderQuota, defineProvider, httpRequestFailedEvent, normalizeErrorCode, numberHeader, providerErrorFromUnknown, redactCredentialText, severityFromUsedPercent, toolResultContentToText, usedPercentFromRatio } from "@demicodes/provider";
3
3
  import { Buffer } from "node:buffer";
4
4
  import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join } from "node:path";
7
7
  import process from "node:process";
8
8
  import { zeroUsage } from "@demicodes/core";
9
+ import { FileCredentialPool, credentialIdFromIdentity } from "@demicodes/provider/credentials-pool";
9
10
  import { readFileSync } from "node:fs";
10
11
  //#region src/auth.ts
12
+ /** Grok auth.json stores the access token under `key`; redact it alongside the standard fields. */
13
+ const SECRET_FIELD_PATTERNS = ["\\bkey\\b"];
14
+ function redactGrokSecretText(text) {
15
+ return redactCredentialText(text, SECRET_FIELD_PATTERNS);
16
+ }
11
17
  const DEFAULT_TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token";
12
- const REFRESH_EXPIRY_SKEW_MS = 300 * 1e3;
18
+ const REFRESH_EXPIRY_SKEW_MS = 3e5;
13
19
  async function grokBuildAuthStatus(options = {}) {
14
20
  return new FileGrokAuthStore(options).status();
15
21
  }
16
22
  var FileGrokAuthStore = class {
17
23
  grokHome;
18
24
  authFile;
25
+ entryKey;
19
26
  refreshImpl;
20
27
  now;
21
28
  lockRetryDelayMs;
22
29
  lockTimeoutMs;
23
30
  constructor(options = {}) {
24
31
  this.grokHome = options.grokHome ?? defaultGrokHome();
25
- this.authFile = join(this.grokHome, "auth.json");
32
+ this.authFile = options.authFile ?? join(this.grokHome, "auth.json");
33
+ this.entryKey = nonEmptyString(options.entryKey) ?? null;
26
34
  this.refreshImpl = options.refresh ?? refreshGrokOidcToken;
27
35
  this.now = options.now ?? (() => /* @__PURE__ */ new Date());
28
36
  this.lockRetryDelayMs = options.lockRetryDelayMs ?? 25;
29
- this.lockTimeoutMs = options.lockTimeoutMs ?? 5e3;
37
+ this.lockTimeoutMs = options.lockTimeoutMs ?? 3e4;
30
38
  }
31
39
  async status() {
32
40
  try {
@@ -42,14 +50,14 @@ var FileGrokAuthStore = class {
42
50
  };
43
51
  return {
44
52
  status: "error",
45
- message: redactSecretText(error instanceof Error ? error.message : String(error))
53
+ message: redactGrokSecretText(error instanceof Error ? error.message : String(error))
46
54
  };
47
55
  }
48
56
  }
49
57
  async resolveAuth(options = {}) {
50
58
  const file = await this.readAuthFile();
51
- const selected = selectAuthEntry(file);
52
- if (!selected) throw new GrokAuthError("auth_missing", `No Grok OAuth session found in ${this.authFile}. Run \`grok login\` first.`);
59
+ const selected = this.entryKey ? selectAuthEntryByKey(file, this.entryKey) : selectAuthEntry(file);
60
+ if (!selected) throw new GrokAuthError("auth_missing", this.entryKey ? `No Grok OAuth entry "${this.entryKey}" in ${this.authFile}` : `No Grok OAuth session found in ${this.authFile}. Run \`grok login\` first.`);
53
61
  const { entryKey, entry } = selected;
54
62
  const accessToken = nonEmptyString(entry.key);
55
63
  if (!accessToken) throw new GrokAuthError("auth_missing", `Grok auth entry "${entryKey}" has no access token (key)`);
@@ -57,7 +65,7 @@ var FileGrokAuthStore = class {
57
65
  const clientId = nonEmptyString(entry.oidc_client_id) ?? parseClientIdFromEntryKey(entryKey);
58
66
  const issuer = nonEmptyString(entry.oidc_issuer) ?? parseIssuerFromEntryKey(entryKey);
59
67
  const expiresAt = parseExpiresAt(entry.expires_at) ?? parseJwtExpiration(accessToken);
60
- if ((options.forceRefresh === true || expiresWithin(expiresAt, this.now(), REFRESH_EXPIRY_SKEW_MS)) && refreshToken && clientId) return this.refreshAndResolve(file, entryKey, {
68
+ if ((options.forceRefresh === true || expiresWithin(expiresAt, this.now(), REFRESH_EXPIRY_SKEW_MS)) && refreshToken && clientId) return this.refreshAndResolve(accessToken, entryKey, {
61
69
  refreshToken,
62
70
  clientId,
63
71
  tokenEndpoint: tokenEndpointForIssuer(issuer)
@@ -73,7 +81,7 @@ var FileGrokAuthStore = class {
73
81
  authFile: this.authFile
74
82
  };
75
83
  }
76
- async refreshAndResolve(_file, entryKey, refreshInput) {
84
+ async refreshAndResolve(staleAccessToken, entryKey, refreshInput) {
77
85
  return this.withAuthFileLock(async () => {
78
86
  const latest = await this.readAuthFile();
79
87
  const latestEntry = latest[entryKey];
@@ -81,6 +89,20 @@ var FileGrokAuthStore = class {
81
89
  const refreshToken = nonEmptyString(latestEntry.refresh_token) ?? refreshInput.refreshToken;
82
90
  const clientId = nonEmptyString(latestEntry.oidc_client_id) ?? refreshInput.clientId;
83
91
  const issuer = nonEmptyString(latestEntry.oidc_issuer) ?? parseIssuerFromEntryKey(entryKey);
92
+ const latestAccessToken = nonEmptyString(latestEntry.key);
93
+ if (latestAccessToken && latestAccessToken !== staleAccessToken) {
94
+ const latestExpiresAt = parseExpiresAt(latestEntry.expires_at) ?? parseJwtExpiration(latestAccessToken);
95
+ if (!expiresWithin(latestExpiresAt, this.now(), REFRESH_EXPIRY_SKEW_MS)) return {
96
+ accessToken: latestAccessToken,
97
+ refreshToken: nonEmptyString(latestEntry.refresh_token) ?? null,
98
+ expiresAt: latestExpiresAt,
99
+ email: stringOrNull(latestEntry.email),
100
+ issuer,
101
+ clientId,
102
+ entryKey,
103
+ authFile: this.authFile
104
+ };
105
+ }
84
106
  const response = await this.refreshImpl({
85
107
  refreshToken,
86
108
  clientId,
@@ -119,8 +141,8 @@ var FileGrokAuthStore = class {
119
141
  return parsed;
120
142
  } catch (error) {
121
143
  if (error instanceof GrokAuthError) throw error;
122
- if (isNodeError(error) && error.code === "ENOENT") throw new GrokAuthError("auth_missing", `Grok auth file not found: ${this.authFile}. Run \`grok login\` first.`);
123
- throw new GrokAuthError("auth_invalid", `Failed to read Grok auth file ${this.authFile}: ${redactSecretText(errorMessage(error))}`);
144
+ if (errorCode(error) === "ENOENT") throw new GrokAuthError("auth_missing", `Grok auth file not found: ${this.authFile}. Run \`grok login\` first.`);
145
+ throw new GrokAuthError("auth_invalid", `Failed to read Grok auth file ${this.authFile}: ${redactGrokSecretText(errorMessage(error))}`);
124
146
  }
125
147
  }
126
148
  async withAuthFileLock(fn) {
@@ -132,10 +154,10 @@ var FileGrokAuthStore = class {
132
154
  while (!handle) try {
133
155
  handle = await open(lockFile, "wx", 384);
134
156
  } catch (error) {
135
- if (!isNodeError(error) || error.code !== "EEXIST") throw new GrokAuthError("auth_lock_failed", `Failed to lock Grok auth file: ${redactSecretText(errorMessage(error))}`);
136
- if (!brokeStaleLock && await isAbandonedGrokAuthLock(lockFile, this.now())) {
137
- await rm(lockFile, { force: true }).catch(() => void 0);
138
- brokeStaleLock = true;
157
+ if (errorCode(error) !== "EEXIST") throw new GrokAuthError("auth_lock_failed", `Failed to lock Grok auth file: ${redactGrokSecretText(errorMessage(error))}`);
158
+ const staleIdentity = brokeStaleLock ? null : await fileIdentity(lockFile);
159
+ if (staleIdentity && await isAbandonedGrokAuthLock(lockFile, this.now())) {
160
+ if (await removeLockFileIfSame(lockFile, staleIdentity)) brokeStaleLock = true;
139
161
  continue;
140
162
  }
141
163
  if (Date.now() - started > this.lockTimeoutMs) throw new GrokAuthError("auth_lock_failed", `Timed out waiting for Grok auth lock ${lockFile}. If no other Grok process is running, delete the lock file and retry.`);
@@ -145,8 +167,9 @@ var FileGrokAuthStore = class {
145
167
  await writeFile(lockFile, `${process.pid}:${Math.floor(this.now().getTime() / 1e3)}`, { mode: 384 });
146
168
  return await fn();
147
169
  } finally {
170
+ const ownedIdentity = await handle.stat().then(toFileIdentity).catch(() => null);
148
171
  await handle.close().catch(() => void 0);
149
- await rm(lockFile, { force: true }).catch(() => void 0);
172
+ if (ownedIdentity) await removeLockFileIfSame(lockFile, ownedIdentity);
150
173
  }
151
174
  }
152
175
  };
@@ -181,6 +204,16 @@ function selectAuthEntry(file) {
181
204
  candidates.sort((a, b) => b.score - a.score || a.entryKey.localeCompare(b.entryKey));
182
205
  return candidates[0] ?? null;
183
206
  }
207
+ function selectAuthEntryByKey(file, entryKey) {
208
+ const value = file[entryKey];
209
+ if (!isRecord(value)) return null;
210
+ const entry = value;
211
+ if (!nonEmptyString(entry.key)) return null;
212
+ return {
213
+ entryKey,
214
+ entry
215
+ };
216
+ }
184
217
  async function refreshGrokOidcToken(input, signal) {
185
218
  const body = new URLSearchParams({
186
219
  grant_type: "refresh_token",
@@ -199,9 +232,6 @@ async function refreshGrokOidcToken(input, signal) {
199
232
  if (!response.ok) throw new GrokAuthError("auth_refresh_failed", `Grok token refresh failed with HTTP ${response.status}`);
200
233
  return await response.json();
201
234
  }
202
- function redactSecretText(text) {
203
- return text.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/g, "Bearer [REDACTED]").replace(/(access_token|refresh_token|id_token|\bkey\b)["'=:\s]+[A-Za-z0-9._~+/=-]+/gi, "$1=[REDACTED]");
204
- }
205
235
  function parseJwtExpiration(jwt) {
206
236
  const exp = decodeJwtPayload(jwt)?.exp;
207
237
  return typeof exp === "number" ? /* @__PURE__ */ new Date(exp * 1e3) : null;
@@ -247,10 +277,7 @@ function decodeJwtPayload(jwt) {
247
277
  return null;
248
278
  }
249
279
  }
250
- function isNodeError(error) {
251
- return error instanceof Error && "code" in error;
252
- }
253
- /** Grok CLI lock format is `pid:unix_seconds`. Steal only when the owner is dead or very stale. */
280
+ /** Grok CLI lock format is `pid:unix_seconds`. A valid live PID always owns its lock. */
254
281
  async function isAbandonedGrokAuthLock(lockFile, now, maxAgeMs = 3e4) {
255
282
  try {
256
283
  const raw = (await readFile(lockFile, "utf8")).trim();
@@ -258,7 +285,7 @@ async function isAbandonedGrokAuthLock(lockFile, now, maxAgeMs = 3e4) {
258
285
  if (match) {
259
286
  const pid = Number(match[1]);
260
287
  const tsSec = Number(match[2]);
261
- if (Number.isFinite(pid) && pid > 0 && !isProcessAlive(pid)) return true;
288
+ if (Number.isFinite(pid) && pid > 0) return !isProcessAlive(pid);
262
289
  if (Number.isFinite(tsSec) && now.getTime() - tsSec * 1e3 > maxAgeMs) return true;
263
290
  return false;
264
291
  }
@@ -268,12 +295,26 @@ async function isAbandonedGrokAuthLock(lockFile, now, maxAgeMs = 3e4) {
268
295
  return true;
269
296
  }
270
297
  }
298
+ function toFileIdentity(info) {
299
+ return {
300
+ dev: info.dev,
301
+ ino: info.ino
302
+ };
303
+ }
304
+ async function fileIdentity(path) {
305
+ return stat(path).then(toFileIdentity).catch(() => null);
306
+ }
307
+ async function removeLockFileIfSame(lockFile, expected) {
308
+ const current = await fileIdentity(lockFile);
309
+ if (!current || current.dev !== expected.dev || current.ino !== expected.ino) return false;
310
+ return rm(lockFile).then(() => true).catch(() => false);
311
+ }
271
312
  function isProcessAlive(pid) {
272
313
  try {
273
314
  process.kill(pid, 0);
274
315
  return true;
275
316
  } catch (error) {
276
- if (isNodeError(error) && error.code === "EPERM") return true;
317
+ if (errorCode(error) === "EPERM") return true;
277
318
  return false;
278
319
  }
279
320
  }
@@ -303,53 +344,52 @@ async function* mapGrokChatCompletionStream(events, signal) {
303
344
  yield { type: "abort" };
304
345
  return;
305
346
  }
306
- for (const data of event.data) {
307
- if (data === "[DONE]") {
308
- yield* flushToolCalls(toolCalls);
309
- yield {
310
- type: "response",
311
- usage
312
- };
313
- return;
314
- }
315
- const chunk = parseJsonObject(data);
316
- if (!chunk) continue;
317
- const error = isRecord(chunk.error) ? chunk.error : null;
318
- if (error) {
319
- const message = stringOrNull(error.message) ?? "Grok Build stream error";
320
- yield {
321
- type: "error",
322
- message,
323
- code: normalizeErrorCode(stringOrNull(error.code) ?? stringOrNull(error.type), message)
324
- };
325
- return;
326
- }
327
- if (isRecord(chunk.usage)) usage = grokUsage(chunk.usage);
328
- const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
329
- for (const choice of choices) {
330
- if (!isRecord(choice)) continue;
331
- const delta = isRecord(choice.delta) ? choice.delta : null;
332
- if (delta) {
333
- const reasoning = stringOrNull(delta.reasoning_content);
334
- if (reasoning) {
335
- if (!thinkingStarted) {
336
- thinkingStarted = true;
337
- yield { type: "thinking_start" };
338
- }
339
- yield {
340
- type: "thinking_delta",
341
- text: reasoning
342
- };
347
+ const data = event.data;
348
+ if (data === "[DONE]") {
349
+ yield* flushToolCalls(toolCalls);
350
+ yield {
351
+ type: "response",
352
+ usage
353
+ };
354
+ return;
355
+ }
356
+ const chunk = parseJsonObject(data);
357
+ if (!chunk) continue;
358
+ const error = isRecord(chunk.error) ? chunk.error : null;
359
+ if (error) {
360
+ const message = stringOrNull(error.message) ?? "Grok Build stream error";
361
+ yield {
362
+ type: "error",
363
+ message,
364
+ code: normalizeErrorCode(stringOrNull(error.code) ?? stringOrNull(error.type), message)
365
+ };
366
+ return;
367
+ }
368
+ if (isRecord(chunk.usage)) usage = grokUsage(chunk.usage);
369
+ const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
370
+ for (const choice of choices) {
371
+ if (!isRecord(choice)) continue;
372
+ const delta = isRecord(choice.delta) ? choice.delta : null;
373
+ if (delta) {
374
+ const reasoning = stringOrNull(delta.reasoning_content);
375
+ if (reasoning) {
376
+ if (!thinkingStarted) {
377
+ thinkingStarted = true;
378
+ yield { type: "thinking_start" };
343
379
  }
344
- const content = stringOrNull(delta.content);
345
- if (content) yield {
346
- type: "text_delta",
347
- text: content
380
+ yield {
381
+ type: "thinking_delta",
382
+ text: reasoning
348
383
  };
349
- if (Array.isArray(delta.tool_calls)) collectToolCalls(delta.tool_calls, toolCalls);
350
384
  }
351
- if (choice.finish_reason === "tool_calls") yield* flushToolCalls(toolCalls);
385
+ const content = stringOrNull(delta.content);
386
+ if (content) yield {
387
+ type: "text_delta",
388
+ text: content
389
+ };
390
+ if (Array.isArray(delta.tool_calls)) collectToolCalls(delta.tool_calls, toolCalls);
352
391
  }
392
+ if (choice.finish_reason === "tool_calls") yield* flushToolCalls(toolCalls);
353
393
  }
354
394
  }
355
395
  yield* flushToolCalls(toolCalls);
@@ -364,15 +404,15 @@ async function* readServerSentEvents(body, signal) {
364
404
  const decoder = new TextDecoder();
365
405
  let buffer = "";
366
406
  let eventName = null;
367
- let data = [];
407
+ let dataLines = [];
368
408
  const flush = function* () {
369
- if (data.length === 0) return;
409
+ if (dataLines.length === 0) return;
370
410
  yield {
371
411
  event: eventName,
372
- data
412
+ data: dataLines.join("\n")
373
413
  };
374
414
  eventName = null;
375
- data = [];
415
+ dataLines = [];
376
416
  };
377
417
  try {
378
418
  while (true) {
@@ -387,14 +427,14 @@ async function* readServerSentEvents(body, signal) {
387
427
  const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
388
428
  if (line === "") yield* flush();
389
429
  else if (line.startsWith("event:")) eventName = line.slice(6).trim();
390
- else if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
430
+ else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
391
431
  newline = buffer.indexOf("\n");
392
432
  }
393
433
  }
394
434
  buffer += decoder.decode();
395
435
  if (buffer) {
396
436
  const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer;
397
- if (line.startsWith("data:")) data.push(line.slice(5).trimStart());
437
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
398
438
  else if (line.startsWith("event:")) eventName = line.slice(6).trim();
399
439
  }
400
440
  yield* flush();
@@ -455,9 +495,6 @@ function inferenceItemsToMessages(systemPrompt, items) {
455
495
  tool_call_id: item.toolUseId,
456
496
  content: toolResultContentToText(item.output)
457
497
  });
458
- break;
459
- case "assistant_thinking":
460
- case "assistant_redacted_thinking": break;
461
498
  }
462
499
  flushAssistant();
463
500
  return messages;
@@ -476,6 +513,10 @@ function userContentToParts(content) {
476
513
  type: "text",
477
514
  text: `[document:${block.source.fileName} ${block.source.mediaType}]`
478
515
  });
516
+ else if (block.type === "video") parts.push({
517
+ type: "text",
518
+ text: `[video:${block.source.type === "url" ? block.source.url : block.source.mediaType}]`
519
+ });
479
520
  else if (block.source.type === "url") parts.push({
480
521
  type: "image_url",
481
522
  image_url: {
@@ -493,9 +534,6 @@ function userContentToParts(content) {
493
534
  if (parts.every((part) => part.type === "text")) return parts.map((part) => part.text).join("\n");
494
535
  return parts;
495
536
  }
496
- function toolResultContentToText(output) {
497
- return output.map((block) => block.type === "text" ? block.text : `[image:${block.source.mediaType}]`).join("\n");
498
- }
499
537
  function toolToGrokTool(tool) {
500
538
  return {
501
539
  type: "function",
@@ -557,6 +595,280 @@ function grokUsage(usage) {
557
595
  cacheWriteTokens: 0
558
596
  };
559
597
  }
598
+ //#endregion
599
+ //#region src/device-login.ts
600
+ const GROK_ISSUER = "https://auth.x.ai";
601
+ const GROK_CLI_CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
602
+ const GROK_LOGIN_SCOPE = "openid profile email offline_access grok-cli:access";
603
+ const GROK_LOGIN_FALLBACK_INTERVAL_S = 5;
604
+ const GROK_LOGIN_FALLBACK_EXPIRES_S = 900;
605
+ async function postForm(fetchImpl, url, params, signal) {
606
+ return fetchImpl(url, {
607
+ method: "POST",
608
+ headers: { "content-type": "application/x-www-form-urlencoded" },
609
+ body: new URLSearchParams(params).toString(),
610
+ signal
611
+ });
612
+ }
613
+ async function jsonBody(response, what) {
614
+ const body = await response.json().catch(() => null);
615
+ if (!isRecord(body)) throw new GrokAuthError("auth_invalid", `${what} response is not a JSON object`);
616
+ return body;
617
+ }
618
+ async function requestDeviceCode(fetchImpl, issuer, clientId, scope, signal) {
619
+ const response = await postForm(fetchImpl, `${issuer}/oauth2/device/code`, {
620
+ client_id: clientId,
621
+ scope
622
+ }, signal);
623
+ if (!response.ok) throw new GrokAuthError("auth_invalid", `Grok device code request failed with HTTP ${response.status}`);
624
+ const body = await jsonBody(response, "Grok device code");
625
+ const deviceCode = nonEmptyString(body.device_code);
626
+ const userCode = nonEmptyString(body.user_code);
627
+ const verificationUrl = nonEmptyString(body.verification_uri_complete) ?? nonEmptyString(body.verification_uri);
628
+ if (!deviceCode || !userCode || !verificationUrl) throw new GrokAuthError("auth_invalid", "Grok device code response is missing device_code, user_code, or verification_uri");
629
+ const interval = Number(body.interval);
630
+ const expiresIn = Number(body.expires_in);
631
+ return {
632
+ deviceCode,
633
+ userCode,
634
+ verificationUrl,
635
+ intervalSeconds: Number.isFinite(interval) && interval >= 0 ? interval : GROK_LOGIN_FALLBACK_INTERVAL_S,
636
+ expiresAt: Date.now() + (Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : GROK_LOGIN_FALLBACK_EXPIRES_S) * 1e3
637
+ };
638
+ }
639
+ async function pollForTokens(fetchImpl, issuer, clientId, device, signal) {
640
+ let intervalSeconds = device.intervalSeconds;
641
+ for (;;) {
642
+ signal?.throwIfAborted();
643
+ const response = await postForm(fetchImpl, `${issuer}/oauth2/token`, {
644
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
645
+ device_code: device.deviceCode,
646
+ client_id: clientId
647
+ }, signal);
648
+ const body = await jsonBody(response, "Grok device token");
649
+ if (response.ok) {
650
+ const accessToken = nonEmptyString(body.access_token);
651
+ if (!accessToken) throw new GrokAuthError("auth_invalid", "Grok token response is missing access_token");
652
+ const expiresIn = Number(body.expires_in);
653
+ return {
654
+ accessToken,
655
+ refreshToken: nonEmptyString(body.refresh_token) ?? null,
656
+ expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : null
657
+ };
658
+ }
659
+ const error = nonEmptyString(body.error);
660
+ if (error === "slow_down") intervalSeconds += 5;
661
+ else if (error !== "authorization_pending") throw new GrokAuthError("auth_invalid", `Grok device login failed: ${error ?? `HTTP ${response.status}`}`);
662
+ if (Date.now() >= device.expiresAt) throw new GrokAuthError("auth_invalid", "Grok device login timed out before the user confirmed");
663
+ await delay(intervalSeconds * 1e3);
664
+ }
665
+ }
666
+ async function fetchUserinfo(fetchImpl, issuer, accessToken, signal) {
667
+ const response = await fetchImpl(`${issuer}/oauth2/userinfo`, {
668
+ headers: { authorization: `Bearer ${accessToken}` },
669
+ signal
670
+ });
671
+ if (!response.ok) return {};
672
+ const body = await response.json().catch(() => null);
673
+ return isRecord(body) ? body : {};
674
+ }
675
+ /** Runs the full device flow and returns a vendor-shaped auth.json entry keyed like the Grok CLI. */
676
+ async function runGrokDeviceLogin(options = {}) {
677
+ const fetchImpl = options.fetch ?? fetch;
678
+ const issuer = (options.issuer ?? GROK_ISSUER).replace(/\/+$/, "");
679
+ const clientId = options.clientId ?? GROK_CLI_CLIENT_ID;
680
+ const device = await requestDeviceCode(fetchImpl, issuer, clientId, options.scope ?? GROK_LOGIN_SCOPE, options.signal);
681
+ options.onPending?.({
682
+ verificationUrl: device.verificationUrl,
683
+ userCode: device.userCode,
684
+ expiresAt: new Date(device.expiresAt).toISOString()
685
+ });
686
+ const tokens = await pollForTokens(fetchImpl, issuer, clientId, device, options.signal);
687
+ const userinfo = await fetchUserinfo(fetchImpl, issuer, tokens.accessToken, options.signal);
688
+ const entry = {
689
+ key: tokens.accessToken,
690
+ auth_mode: "oidc",
691
+ ...tokens.refreshToken ? { refresh_token: tokens.refreshToken } : {},
692
+ ...tokens.expiresIn ? { expires_at: new Date(Date.now() + tokens.expiresIn * 1e3).toISOString() } : {},
693
+ oidc_issuer: issuer,
694
+ oidc_client_id: clientId,
695
+ ...nonEmptyString(userinfo.email) ? { email: nonEmptyString(userinfo.email) } : {},
696
+ ...nonEmptyString(userinfo.given_name) ? { first_name: nonEmptyString(userinfo.given_name) } : {},
697
+ ...nonEmptyString(userinfo.sub) ? { user_id: nonEmptyString(userinfo.sub) } : {}
698
+ };
699
+ return {
700
+ entryKey: `${issuer}::${clientId}`,
701
+ entry
702
+ };
703
+ }
704
+ //#endregion
705
+ //#region src/credentials.ts
706
+ var PoolAwareGrokAuthStore = class {
707
+ pool;
708
+ vendorHome;
709
+ fileAuthOptions;
710
+ constructor(pool, options = {}) {
711
+ this.pool = pool;
712
+ this.vendorHome = options.grokHome ?? defaultGrokHome();
713
+ this.fileAuthOptions = options.fileAuthOptions ?? {};
714
+ }
715
+ async status() {
716
+ return this.currentStore().then((s) => s.status());
717
+ }
718
+ async resolveAuth(options) {
719
+ return this.currentStore().then((s) => s.resolveAuth(options));
720
+ }
721
+ async currentStore() {
722
+ await this.pool.ensureActivePointer();
723
+ const activeId = await this.pool.getActiveId();
724
+ if (activeId) {
725
+ const entryKey = (await this.pool.readMeta(activeId))?.identityKey ?? void 0;
726
+ return new FileGrokAuthStore({
727
+ ...this.fileAuthOptions,
728
+ authFile: this.pool.secretPath(activeId),
729
+ grokHome: this.pool.entryDir(activeId),
730
+ entryKey
731
+ });
732
+ }
733
+ return new FileGrokAuthStore({
734
+ ...this.fileAuthOptions,
735
+ grokHome: this.vendorHome
736
+ });
737
+ }
738
+ };
739
+ function openGrokCredentialPool(options = {}) {
740
+ return new FileCredentialPool({
741
+ stateDir: options.stateDir,
742
+ providerKey: "grok-build",
743
+ secretFileName: "auth.json"
744
+ });
745
+ }
746
+ function createGrokBuildCredentials(pool, authStore, options = {}) {
747
+ const vendorHome = options.grokHome ?? defaultGrokHome();
748
+ const capability = () => ({
749
+ mode: "supported",
750
+ canBeginLogin: true,
751
+ canImportDefault: true,
752
+ canAdd: true,
753
+ multi: true
754
+ });
755
+ const getActive = async () => {
756
+ await pool.ensureActivePointer();
757
+ return {
758
+ credentialId: await pool.getActiveId(),
759
+ status: await authStore.status()
760
+ };
761
+ };
762
+ const setActive = async (credentialId) => {
763
+ await pool.setActiveId(credentialId);
764
+ options.quota?.clearLatest?.();
765
+ return getActive();
766
+ };
767
+ const importEntry = async (entryKey, entry, source) => {
768
+ const label = nonEmptyString(entry.email) ?? entryKey;
769
+ const identityKey = entryKey;
770
+ const id = (await pool.findByIdentityKey(identityKey))?.id ?? credentialIdFromIdentity(identityKey, label);
771
+ const file = { [entryKey]: entry };
772
+ const meta = {
773
+ id,
774
+ label,
775
+ detail: nonEmptyString(entry.auth_mode) ?? "oidc",
776
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
777
+ source,
778
+ identityKey
779
+ };
780
+ await pool.writeEntry(meta, `${JSON.stringify(file, null, 2)}\n`);
781
+ if (!await pool.getActiveId()) await pool.setActiveId(id);
782
+ options.quota?.clearLatest?.();
783
+ return {
784
+ id: meta.id,
785
+ label: meta.label,
786
+ detail: meta.detail,
787
+ updatedAt: meta.updatedAt
788
+ };
789
+ };
790
+ const importFromAuthJsonText = async (text, source) => {
791
+ let file;
792
+ try {
793
+ file = JSON.parse(text);
794
+ } catch {
795
+ throw new GrokAuthError("auth_invalid", "Grok auth material is not valid JSON");
796
+ }
797
+ if (!isRecord(file)) throw new GrokAuthError("auth_invalid", "Grok auth material is not an object");
798
+ const imported = [];
799
+ for (const [entryKey, value] of Object.entries(file)) {
800
+ if (!isRecord(value)) continue;
801
+ const entry = value;
802
+ if (!nonEmptyString(entry.key)) continue;
803
+ imported.push(await importEntry(entryKey, entry, source));
804
+ }
805
+ if (imported.length === 0) throw new GrokAuthError("auth_missing", "No Grok OAuth entries with access tokens found to import");
806
+ return imported;
807
+ };
808
+ return {
809
+ capability,
810
+ list: () => pool.list(),
811
+ getActive,
812
+ setActive,
813
+ beginLogin: async (loginOptions) => {
814
+ try {
815
+ const { entryKey, entry } = await runGrokDeviceLogin({
816
+ signal: loginOptions?.signal,
817
+ onPending: loginOptions?.onPending,
818
+ fetch: options.loginFetch
819
+ });
820
+ return {
821
+ status: "completed",
822
+ credentialId: (await importEntry(entryKey, entry, "login:device")).id
823
+ };
824
+ } catch (error) {
825
+ if (loginOptions?.signal?.aborted) return { status: "cancelled" };
826
+ return {
827
+ status: "failed",
828
+ message: errorMessage(error)
829
+ };
830
+ }
831
+ },
832
+ importDefault: async () => {
833
+ const authFile = join(vendorHome, "auth.json");
834
+ let text;
835
+ try {
836
+ text = await readFile(authFile, "utf8");
837
+ } catch {
838
+ throw new GrokAuthError("auth_missing", `No Grok auth at ${authFile}. Run grok login or beginLogin first.`);
839
+ }
840
+ const all = await importFromAuthJsonText(text, `vendor:${authFile}`);
841
+ const preferred = selectAuthEntry(JSON.parse(text));
842
+ if (preferred) {
843
+ const byKey = (await pool.listMeta()).find((m) => m.identityKey === preferred.entryKey);
844
+ if (byKey) {
845
+ await pool.setActiveId(byKey.id);
846
+ options.quota?.clearLatest?.();
847
+ return {
848
+ id: byKey.id,
849
+ label: byKey.label,
850
+ detail: byKey.detail,
851
+ updatedAt: byKey.updatedAt
852
+ };
853
+ }
854
+ }
855
+ return all[0];
856
+ },
857
+ add: async (input) => {
858
+ if (typeof input.authJsonText === "string") return (await importFromAuthJsonText(input.authJsonText, "add:authJsonText"))[0];
859
+ if (typeof input.authFile === "string") {
860
+ const text = await readFile(input.authFile, "utf8");
861
+ return (await importFromAuthJsonText(text, `add:authFile:${input.authFile}`))[0];
862
+ }
863
+ if (typeof input.entryKey === "string" && isRecord(input.entry)) return importEntry(input.entryKey, input.entry, "add:entry");
864
+ throw new Error("Grok credentials.add expects authJsonText, authFile, or { entryKey, entry }");
865
+ },
866
+ remove: async (credentialId) => {
867
+ await pool.remove(credentialId);
868
+ options.quota?.clearLatest?.();
869
+ }
870
+ };
871
+ }
560
872
  const GROK_CLI_TOKEN_AUTH = "xai-grok-cli";
561
873
  const GROK_CLIENT_SURFACE = "grok-build";
562
874
  function buildGrokBuildHeaders(auth, request, options) {
@@ -858,19 +1170,16 @@ function moneyVal(value) {
858
1170
  if (isRecord(value) && typeof value.val === "number" && Number.isFinite(value.val)) return value.val;
859
1171
  return null;
860
1172
  }
861
- function numberHeader(headers, name) {
862
- const raw = headers.get(name);
863
- if (raw == null || raw === "") return null;
864
- const n = Number(raw);
865
- return Number.isFinite(n) ? n : null;
866
- }
867
1173
  //#endregion
868
1174
  //#region src/provider.ts
869
- var GrokBuildProvider = class {
1175
+ var GrokBuildProvider = class GrokBuildProvider {
870
1176
  options;
871
1177
  constructor(options) {
872
1178
  this.options = options;
873
1179
  }
1180
+ clone() {
1181
+ return new GrokBuildProvider(this.options);
1182
+ }
874
1183
  async *run(request) {
875
1184
  if (request.cancel.aborted) {
876
1185
  yield { type: "abort" };
@@ -916,6 +1225,7 @@ var GrokBuildProvider = class {
916
1225
  });
917
1226
  } catch {}
918
1227
  if (response.status === 401 && !forceRefresh) {
1228
+ await response.body?.cancel().catch(() => {});
919
1229
  forceRefresh = true;
920
1230
  continue;
921
1231
  }
@@ -939,7 +1249,9 @@ var GrokBuildProvider = class {
939
1249
  function createGrokBuildProvider(options = {}) {
940
1250
  const id = options.id ?? "grok-build";
941
1251
  const displayName = options.displayName ?? "Grok Build";
942
- const authStore = options.authStore ?? new FileGrokAuthStore({ grokHome: options.grokHome });
1252
+ const enableCredentials = options.credentials ?? options.authStore === void 0;
1253
+ const pool = !options.authStore && enableCredentials ? openGrokCredentialPool({ stateDir: options.stateDir }) : null;
1254
+ const authStore = options.authStore ?? (pool ? new PoolAwareGrokAuthStore(pool, { grokHome: options.grokHome }) : new FileGrokAuthStore({ grokHome: options.grokHome }));
943
1255
  const baseUrl = normalizeBaseUrl(options.baseUrl ?? "https://cli-chat-proxy.grok.com/v1");
944
1256
  const fetchImpl = options.fetch ?? fetch;
945
1257
  const quota = createGrokBuildQuota({
@@ -950,6 +1262,10 @@ function createGrokBuildProvider(options = {}) {
950
1262
  authStore,
951
1263
  fetch: fetchImpl
952
1264
  });
1265
+ const credentialsApi = pool ? createGrokBuildCredentials(pool, authStore, {
1266
+ grokHome: options.grokHome,
1267
+ quota
1268
+ }) : void 0;
953
1269
  const runtimeOptions = {
954
1270
  baseUrl,
955
1271
  grokHome: options.grokHome,
@@ -964,9 +1280,10 @@ function createGrokBuildProvider(options = {}) {
964
1280
  displayName,
965
1281
  auth: { status: () => authStore.status() },
966
1282
  quota,
1283
+ ...credentialsApi ? { credentials: credentialsApi } : {},
967
1284
  state: () => ({
968
1285
  status: "ready",
969
- message: "Uses Grok CLI OAuth session (~/.grok/auth.json) via cli-chat-proxy"
1286
+ message: credentialsApi ? "Uses Grok CLI OAuth + demi credential pool via cli-chat-proxy" : "Uses Grok CLI OAuth session (~/.grok/auth.json) via cli-chat-proxy"
970
1287
  }),
971
1288
  listModels: () => listGrokBuildModels({
972
1289
  providerId: id,
@@ -1007,4 +1324,4 @@ function chatCompletionsUrl(baseUrl) {
1007
1324
  return normalized.endsWith("/chat/completions") ? normalized : `${normalized}/chat/completions`;
1008
1325
  }
1009
1326
  //#endregion
1010
- export { createGrokBuildProvider, createGrokBuildQuota, grokBuildAuthStatus, grokBuildFallbackModels, listGrokBuildModels, mapGrokQuotaProbe, observeGrokRateLimitHeaders, parseGrokBuildProviderConfig };
1327
+ export { PoolAwareGrokAuthStore, createGrokBuildCredentials, createGrokBuildProvider, createGrokBuildQuota, grokBuildAuthStatus, grokBuildFallbackModels, listGrokBuildModels, mapGrokQuotaProbe, observeGrokRateLimitHeaders, openGrokCredentialPool, parseGrokBuildProviderConfig, runGrokDeviceLogin };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@demicodes/provider-grok-build",
3
3
  "description": "Grok Build provider adapter for Demi (reuses ~/.grok OAuth session).",
4
- "version": "0.10.3",
4
+ "version": "0.12.0",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "exports": {
@@ -11,9 +11,9 @@
11
11
  }
12
12
  },
13
13
  "dependencies": {
14
- "@demicodes/core": "^0.10.3",
15
- "@demicodes/provider": "^0.10.3",
16
- "@demicodes/utils": "^0.10.3"
14
+ "@demicodes/core": "^0.12.0",
15
+ "@demicodes/provider": "^0.12.0",
16
+ "@demicodes/utils": "^0.12.0"
17
17
  },
18
18
  "license": "Apache-2.0",
19
19
  "main": "./dist/index.mjs",