dsh-plugin-subscriptions 0.4.0 → 0.4.2

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.
@@ -71,6 +71,9 @@ function sanitizeModel(value) {
71
71
  const reasoning = raw.reasoning === undefined ? undefined : sanitizeReasoning(raw.reasoning);
72
72
  if (raw.reasoning !== undefined && reasoning === undefined)
73
73
  return undefined;
74
+ const thinkingType = raw.thinkingType;
75
+ if (thinkingType !== undefined && thinkingType !== 'enabled' && thinkingType !== 'adaptive')
76
+ return undefined;
74
77
  return {
75
78
  id: raw.id,
76
79
  name: raw.name,
@@ -78,6 +81,7 @@ function sanitizeModel(value) {
78
81
  ...raw.contextWindow === undefined ? {} : { contextWindow: raw.contextWindow },
79
82
  ...raw.priority === undefined ? {} : { priority: raw.priority },
80
83
  ...reasoning === undefined ? {} : { reasoning },
84
+ ...thinkingType === undefined ? {} : { thinkingType: thinkingType },
81
85
  };
82
86
  }
83
87
  /**
@@ -9,14 +9,23 @@ import type { FlowSpec } from '../auth/oauth-flow.js';
9
9
  import type { ClaudeSession } from '../auth/store.js';
10
10
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
11
11
  import { TokenManager } from './common.js';
12
- import type { FetchFn, ModelEntry, ProviderUsage } from './common.js';
12
+ import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
13
13
  export declare const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
14
- export declare const CLAUDE_AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
15
- export declare const CLAUDE_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
14
+ export declare const CLAUDE_AUTHORIZE_URL = "https://claude.com/cai/oauth/authorize";
15
+ export declare const CLAUDE_TOKEN_URL = "https://claude.ai/v1/oauth/token";
16
16
  export declare const CLAUDE_API_URL = "https://api.anthropic.com/v1/messages?beta=true";
17
17
  export declare const CLAUDE_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
18
+ export declare const CLAUDE_MODELS_URL = "https://api.anthropic.com/v1/models?beta=true";
18
19
  /** Refresh when the access token has less than this much life left. */
19
20
  export declare const CLAUDE_PREEMPT_MS: number;
21
+ /**
22
+ * The subscription endpoint only serves requests presenting as Claude Code,
23
+ * so these headers impersonate the CLI; the harness attribution user-agent
24
+ * cannot be sent here (one user-agent slot, and the CLI's wins).
25
+ */
26
+ export declare const CLAUDE_CLI_FALLBACK_VERSION = "2.1.234";
27
+ export declare function detectClaudeVersion(): string;
28
+ export declare const CLAUDE_BETA_FALLBACK: string;
20
29
  /** Static claude flow facts for the OAuth flow engine. */
21
30
  export declare const claudeFlow: FlowSpec;
22
31
  /**
@@ -52,21 +61,44 @@ export declare const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usa
52
61
  * @returns the mapped usage snapshot.
53
62
  */
54
63
  export declare function fetchClaudeUsage(session: ClaudeSession, fetchFn?: FetchFn, signal?: AbortSignal): Promise<ProviderUsage>;
64
+ /** Fetch the live model catalog from the subscription endpoint. */
65
+ export declare function fetchClaudeModels(session: ClaudeSession, fetchFn?: FetchFn): Promise<DiscoveredModel[]>;
55
66
  /** Constructor dependencies for {@link ClaudeAdapter}. */
56
67
  export interface ClaudeAdapterOptions {
57
68
  models: readonly ModelEntry[];
58
69
  streamIdleTimeoutMs: number;
59
70
  tokens: TokenManager<ClaudeSession>;
71
+ /** Whether to fetch the live catalog when logged in (false when config `models` overrides). */
72
+ discovery: boolean;
73
+ fetchFn?: FetchFn;
74
+ onWarn?: (message: string) => void;
75
+ /** Max retries on a retryable failure before giving up; matches Claude Code's own client-side retry count. Defaults to the dsh-llm default (2) when unset. */
76
+ maxRetries?: number;
60
77
  /** Resolve the attachment service per request; absent means image requests fail loudly. */
61
78
  resolveAttachments?: () => AttachmentStore | undefined;
79
+ /** Durable catalog store seeding capability metadata across restarts. */
80
+ catalogStore?: CatalogPersistence;
62
81
  }
63
82
  /** Claude wire adapter: one instance serves the `claude` provider route. */
64
83
  export declare class ClaudeAdapter extends LlmAdapter {
65
84
  private readonly options;
85
+ private readonly catalog;
66
86
  constructor(options: ClaudeAdapterOptions);
87
+ private fetchCatalog;
88
+ private discovered;
89
+ private staticModels;
67
90
  providerInfo(provider: string): LlmProviderInfo;
91
+ providerRetryPolicy(provider: string): import("@deepseek-ai/dsh-llm").ResolvedRetryPolicy | undefined;
68
92
  listModels(provider: string): Promise<readonly LlmModelInfo[]>;
69
93
  resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
70
94
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
95
+ /**
96
+ * `display: 'summarized'` is set explicitly on both shapes: `adaptive`-type
97
+ * models default to `display: 'omitted'`, which returns thinking blocks with
98
+ * an empty `thinking` field — without this override the "Think" panel would
99
+ * always render empty even though real reasoning (and billed thinking_tokens)
100
+ * ran.
101
+ */
102
+ private thinkingParam;
71
103
  private request;
72
104
  }
@@ -3,15 +3,17 @@
3
3
  * platform.claude.com with the Claude Code client id, and streaming against
4
4
  * the Anthropic Messages API with the Claude Code identity headers.
5
5
  */
6
- import { EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm';
6
+ import { execFileSync } from 'node:child_process';
7
+ import { EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortId, resolveRetryPolicy } from '@deepseek-ai/dsh-llm';
7
8
  import { resolveImages } from '../translate/resolved.js';
8
9
  import { streamAnthropic, toAnthropicMessages, toAnthropicSystem, toAnthropicTools, } from '../translate/anthropic.js';
9
- import { httpLlmError, idleWatchdog, mapFetchFailure, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
10
+ import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
10
11
  export const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
11
- export const CLAUDE_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize';
12
- export const CLAUDE_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
12
+ export const CLAUDE_AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
13
+ export const CLAUDE_TOKEN_URL = 'https://claude.ai/v1/oauth/token';
13
14
  export const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages?beta=true';
14
15
  export const CLAUDE_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
16
+ export const CLAUDE_MODELS_URL = 'https://api.anthropic.com/v1/models?beta=true';
15
17
  const CLAUDE_SCOPE = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';
16
18
  const CLAUDE_CALLBACK_PATH = '/callback';
17
19
  const CLAUDE_CONTEXT_WINDOW = 200_000;
@@ -23,19 +25,39 @@ export const CLAUDE_PREEMPT_MS = 5 * 60_000;
23
25
  * so these headers impersonate the CLI; the harness attribution user-agent
24
26
  * cannot be sent here (one user-agent slot, and the CLI's wins).
25
27
  */
26
- const CLAUDE_CLI_USER_AGENT = 'claude-cli/2.1.97 (external, cli)';
27
- const CLAUDE_BETA_FLAGS = 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27';
28
+ export const CLAUDE_CLI_FALLBACK_VERSION = '2.1.234';
29
+ export function detectClaudeVersion() {
30
+ try {
31
+ const raw = execFileSync('claude', ['--version'], { timeout: 3000, encoding: 'utf8' });
32
+ const match = raw.match(/^(\d+\.\d+\.\d+)/);
33
+ if (match)
34
+ return match[1];
35
+ }
36
+ catch { }
37
+ return CLAUDE_CLI_FALLBACK_VERSION;
38
+ }
39
+ const CLAUDE_CLI_USER_AGENT = `claude-cli/${detectClaudeVersion()} (external, cli)`;
40
+ export const CLAUDE_BETA_FALLBACK = [
41
+ 'claude-code-20250219',
42
+ 'oauth-2025-04-20',
43
+ 'interleaved-thinking-2025-05-14',
44
+ 'context-management-2025-06-27',
45
+ 'effort-2025-11-24',
46
+ 'compact-2026-01-12',
47
+ 'files-api-2025-04-14',
48
+ ].join(',');
49
+ const CLAUDE_BETA_FLAGS = CLAUDE_BETA_FALLBACK;
28
50
  /** Static claude flow facts for the OAuth flow engine. */
29
51
  export const claudeFlow = {
30
52
  callbackPath: CLAUDE_CALLBACK_PATH,
31
53
  // The redirect URI embeds the port, so it must be an ephemeral one.
32
- listen: { host: 'localhost', ports: [0] },
54
+ listen: { host: '127.0.0.1', ports: [0] },
33
55
  buildAuthorizeUrl({ redirectUri, state, pkce }) {
34
56
  const params = new URLSearchParams({
35
57
  code: 'true',
36
58
  client_id: CLAUDE_CLIENT_ID,
37
59
  response_type: 'code',
38
- redirect_uri: redirectUri,
60
+ redirect_uri: 'https://platform.claude.com/oauth/code/callback',
39
61
  scope: CLAUDE_SCOPE,
40
62
  code_challenge: pkce.challenge,
41
63
  code_challenge_method: 'S256',
@@ -236,24 +258,88 @@ export async function fetchClaudeUsage(session, fetchFn = fetch, signal) {
236
258
  }
237
259
  return { supported: true, windows };
238
260
  }
261
+ function claudeThinkingType(capabilities) {
262
+ const types = capabilities?.thinking?.types;
263
+ if (types?.enabled?.supported === true)
264
+ return 'enabled';
265
+ if (types?.adaptive?.supported === true)
266
+ return 'adaptive';
267
+ return undefined;
268
+ }
269
+ /** Effort levels in display order; a model exposes only the ones it advertises as supported. */
270
+ const CLAUDE_EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
271
+ function claudeReasoning(capabilities) {
272
+ const effort = capabilities?.effort;
273
+ if (effort?.supported !== true)
274
+ return undefined;
275
+ const efforts = CLAUDE_EFFORT_LEVELS
276
+ .filter(level => effort[level]?.supported === true)
277
+ .map(level => ({ id: ReasoningEffortId(level), name: level[0].toUpperCase() + level.slice(1) }));
278
+ return efforts.length > 0 ? { efforts } : undefined;
279
+ }
280
+ /** Fetch the live model catalog from the subscription endpoint. */
281
+ export async function fetchClaudeModels(session, fetchFn = fetch) {
282
+ const response = await fetchFn(CLAUDE_MODELS_URL, {
283
+ headers: {
284
+ 'authorization': `Bearer ${session.accessToken}`,
285
+ 'anthropic-version': '2023-06-01',
286
+ 'user-agent': CLAUDE_CLI_USER_AGENT,
287
+ 'anthropic-dangerous-direct-browser-access': 'true',
288
+ 'accept': 'application/json',
289
+ },
290
+ });
291
+ if (!response.ok)
292
+ throw await httpLlmError(response, 'claude models API');
293
+ const payload = await response.json();
294
+ if (!Array.isArray(payload.data)) {
295
+ throw new Error('claude models API returned an invalid catalog');
296
+ }
297
+ const models = payload.data
298
+ .filter((m) => typeof m.id === 'string')
299
+ .map((m) => {
300
+ const thinkingType = claudeThinkingType(m.capabilities);
301
+ const reasoning = claudeReasoning(m.capabilities);
302
+ return {
303
+ id: m.id,
304
+ name: m.display_name ?? m.id,
305
+ ...thinkingType === undefined ? {} : { thinkingType },
306
+ ...reasoning === undefined ? {} : { reasoning },
307
+ };
308
+ });
309
+ if (models.length === 0) {
310
+ throw new Error('claude models API returned an empty catalog');
311
+ }
312
+ return models;
313
+ }
314
+ /**
315
+ * Claude Code's own SDK retry shape: exponential backoff starting at 1s,
316
+ * doubling per attempt, capped at 60s, plus jitter. `maxRetries` is the
317
+ * count of retries after the first attempt (Claude Code defaults to 10).
318
+ */
319
+ const CLAUDE_RETRY_INITIAL_DELAY_MS = 1_000;
320
+ const CLAUDE_RETRY_MAX_DELAY_MS = 60_000;
321
+ const CLAUDE_RETRY_JITTER_RATIO = 0.2;
239
322
  /** The Claude 4.5 family accepts image input. */
240
323
  const CLAUDE_MODALITIES = ['text', 'image'];
241
324
  /** Claude wire adapter: one instance serves the `claude` provider route. */
242
325
  export class ClaudeAdapter extends LlmAdapter {
243
326
  options;
327
+ catalog;
244
328
  constructor(options) {
245
329
  super();
246
330
  this.options = options;
331
+ this.catalog = new ModelCatalogCache(options.catalogStore);
247
332
  }
248
- providerInfo(provider) {
249
- return { id: provider, name: 'Claude (Subscription)' };
333
+ async fetchCatalog() {
334
+ return fetchClaudeModels(await this.options.tokens.session(), this.options.fetchFn);
250
335
  }
251
- async listModels(provider) {
252
- // Not logged in → empty catalog, so the web picker drops the provider.
253
- // Claude has no subscription model-list endpoint, so the static catalog
254
- // is the whole answer when logged in.
255
- if (!await this.options.tokens.hasSession())
256
- return [];
336
+ async discovered(model) {
337
+ if (!this.options.discovery)
338
+ return undefined;
339
+ const models = await this.catalog.resolve(() => this.fetchCatalog());
340
+ return models?.find(entry => entry.id === model);
341
+ }
342
+ staticModels(provider) {
257
343
  return this.options.models.map(model => ({
258
344
  provider,
259
345
  id: model.id,
@@ -261,18 +347,61 @@ export class ClaudeAdapter extends LlmAdapter {
261
347
  inputModalities: model.inputModalities ?? CLAUDE_MODALITIES,
262
348
  }));
263
349
  }
264
- resolveModel(provider, model) {
350
+ providerInfo(provider) {
351
+ return { id: provider, name: 'Claude (Subscription)' };
352
+ }
353
+ providerRetryPolicy(provider) {
354
+ if (this.options.maxRetries === undefined)
355
+ return undefined;
356
+ return resolveRetryPolicy({
357
+ mode: 'normal',
358
+ maxRetries: this.options.maxRetries,
359
+ backoff: {
360
+ initialDelayMs: CLAUDE_RETRY_INITIAL_DELAY_MS,
361
+ maxDelayMs: CLAUDE_RETRY_MAX_DELAY_MS,
362
+ jitterRatio: CLAUDE_RETRY_JITTER_RATIO,
363
+ },
364
+ }, `claude: provider "${provider}" retryPolicy`);
365
+ }
366
+ async listModels(provider) {
367
+ if (await this.options.tokens.peek() === undefined)
368
+ return [];
369
+ if (!this.options.discovery)
370
+ return this.staticModels(provider);
371
+ try {
372
+ const models = await this.catalog.get(() => this.fetchCatalog());
373
+ return models.map(model => ({
374
+ provider,
375
+ id: model.id,
376
+ name: model.name,
377
+ inputModalities: CLAUDE_MODALITIES,
378
+ }));
379
+ }
380
+ catch (error) {
381
+ if (error instanceof LlmError
382
+ && (error.code === 'MISSING_CREDENTIAL' || error.code === 'INVALID_CREDENTIAL'))
383
+ return [];
384
+ if (error instanceof LlmError && error.code === 'AUTH')
385
+ this.catalog.invalidate();
386
+ this.options.onWarn?.(`claude model discovery failed; using the built-in catalog (${errorChain(error)})`);
387
+ return this.staticModels(provider);
388
+ }
389
+ }
390
+ async resolveModel(provider, model) {
391
+ const disc = await this.discovered(model);
265
392
  const configured = this.options.models.find(entry => entry.id === model);
266
- return Promise.resolve({
393
+ const reasoning = disc?.reasoning;
394
+ return {
267
395
  provider,
268
396
  id: model,
269
- name: configured?.name ?? model,
397
+ name: disc?.name ?? configured?.name ?? model,
270
398
  inputModalities: configured?.inputModalities ?? CLAUDE_MODALITIES,
271
- context: { contextWindow: configured?.contextWindow ?? CLAUDE_CONTEXT_WINDOW },
399
+ context: {
400
+ contextWindow: disc?.contextWindow ?? configured?.contextWindow ?? CLAUDE_CONTEXT_WINDOW,
401
+ },
272
402
  defaultMaxTokens: configured?.maxTokens ?? CLAUDE_DEFAULT_MAX_TOKENS,
273
- // No reasoning metadata: the subscription endpoint's thinking support is
274
- // not exercised, so effort requests reject as unsupported.
275
- });
403
+ ...(reasoning === undefined ? {} : { reasoning }),
404
+ };
276
405
  }
277
406
  async *stream(options) {
278
407
  const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
@@ -280,7 +409,6 @@ export class ClaudeAdapter extends LlmAdapter {
280
409
  let session = await this.options.tokens.session();
281
410
  let response = await this.request(options, session, watchdog.signal);
282
411
  if (response.status === 401) {
283
- // One forced refresh + retry on an unexpired-but-rejected token.
284
412
  session = await this.options.tokens.session(true);
285
413
  response = await this.request(options, session, watchdog.signal);
286
414
  }
@@ -298,18 +426,44 @@ export class ClaudeAdapter extends LlmAdapter {
298
426
  watchdog.stop();
299
427
  }
300
428
  }
429
+ /**
430
+ * `display: 'summarized'` is set explicitly on both shapes: `adaptive`-type
431
+ * models default to `display: 'omitted'`, which returns thinking blocks with
432
+ * an empty `thinking` field — without this override the "Think" panel would
433
+ * always render empty even though real reasoning (and billed thinking_tokens)
434
+ * ran.
435
+ */
436
+ thinkingParam(thinkingType, maxTokens) {
437
+ if (thinkingType === 'adaptive')
438
+ return { type: 'adaptive', display: 'summarized' };
439
+ if (thinkingType === 'enabled') {
440
+ const budget = Math.min(Math.max(1_024, Math.floor(maxTokens * 0.5)), maxTokens - 100);
441
+ if (budget < 1_024)
442
+ return undefined;
443
+ return { type: 'enabled', budget_tokens: budget, display: 'summarized' };
444
+ }
445
+ return undefined;
446
+ }
301
447
  async request(options, session, signal) {
302
448
  const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
449
+ const maxTokens = options.maxTokens
450
+ ?? this.options.models.find(entry => entry.id === options.model)?.maxTokens
451
+ ?? CLAUDE_DEFAULT_MAX_TOKENS;
452
+ const disc = await this.discovered(options.model);
453
+ const thinking = this.thinkingParam(disc?.thinkingType, maxTokens);
454
+ const effort = options.reasoningEffort !== undefined && disc?.reasoning !== undefined
455
+ ? { output_config: { effort: String(options.reasoningEffort) } }
456
+ : {};
303
457
  const body = {
304
458
  model: options.model,
305
- max_tokens: options.maxTokens
306
- ?? this.options.models.find(entry => entry.id === options.model)?.maxTokens
307
- ?? CLAUDE_DEFAULT_MAX_TOKENS,
459
+ max_tokens: maxTokens,
308
460
  system: toAnthropicSystem(options.system, messages),
309
461
  messages: toAnthropicMessages(messages),
310
462
  ...options.tools !== undefined && options.tools.length > 0
311
463
  ? { tools: toAnthropicTools(options.tools) }
312
464
  : {},
465
+ ...thinking === undefined ? {} : { thinking },
466
+ ...effort,
313
467
  stream: true,
314
468
  ...options.sessionId !== undefined ? { metadata: { user_id: String(options.sessionId) } } : {},
315
469
  };
@@ -175,6 +175,8 @@ export interface DiscoveredModel {
175
175
  }[];
176
176
  defaultEffort?: ReasoningEffortId;
177
177
  };
178
+ /** Claude-specific: which extended-thinking wire shape this model accepts. */
179
+ thinkingType?: 'enabled' | 'adaptive';
178
180
  }
179
181
  /** How long a discovered catalog is trusted before re-fetching. */
180
182
  export declare const DISCOVERY_TTL_MS: number;
@@ -1,27 +1,37 @@
1
1
  /**
2
- * `image_generate` tool: generate images through the ChatGPT/Codex
3
- * subscription's image endpoint, save them as PNG files under the harness
4
- * home, and — when the deployment mounts an attachment store and the calling
5
- * route declares image input — also commit the bytes as durable attachments
6
- * so the images render inline and enter model context (the same path
7
- * `read_image` uses). Mirrors codex-rs `codex-api/src/images.rs`: POST
8
- * `/backend-api/codex/images/generations` with the responses call's auth
9
- * headers; the response carries base64 PNG data.
2
+ * `image_generate` tool: generate images through a subscription image
3
+ * endpoint, save them under the harness home, and — when the deployment
4
+ * mounts an attachment store and the calling route declares image input —
5
+ * also commit the bytes as durable attachments so the images render inline
6
+ * and enter model context (the same path `read_image` uses).
7
+ *
8
+ * Provider selection: the `provider` argument names the preferred provider
9
+ * (default `gpt`, i.e. the ChatGPT/Codex subscription serving gpt-image-2
10
+ * mirrors codex-rs `codex-api/src/images.rs`); when the preferred one is
11
+ * logged out the other serves as fallback (`grok` is grok-imagine-image-2.0
12
+ * via `api.x.ai/v1/images/generations` with `response_format: 'b64_json'`).
13
+ * Both answer the OpenAI images shape (`data[].b64_json`).
10
14
  */
11
15
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
12
16
  import type { LlmRuntime } from '@deepseek-ai/dsh-llm';
13
17
  import type { ToolDefinition } from '@deepseek-ai/dsh-tools';
14
- import type { CodexSession } from '../auth/store.js';
18
+ import type { CodexSession, GrokSession } from '../auth/store.js';
15
19
  import { TokenManager } from '../providers/common.js';
16
20
  import type { FetchFn } from '../providers/common.js';
17
- /** Endpoint the generation request is posted to. */
21
+ /** Endpoint the codex generation request is posted to. */
18
22
  export declare const IMAGE_GENERATE_URL = "https://chatgpt.com/backend-api/codex/images/generations";
19
23
  /** The image model the codex subscription endpoint serves. */
20
24
  export declare const IMAGE_GENERATE_MODEL = "gpt-image-2";
25
+ /** Endpoint the grok generation request is posted to. */
26
+ export declare const GROK_IMAGE_GENERATE_URL = "https://api.x.ai/v1/images/generations";
27
+ /** The image model the grok subscription endpoint serves. */
28
+ export declare const GROK_IMAGE_GENERATE_MODEL = "grok-imagine-image-2.0";
21
29
  /** Dependencies of the `image_generate` tool. */
22
30
  export interface ImageGenerateToolOptions {
23
- /** Codex session source; a missing session throws the log-in hint. */
24
- tokens: TokenManager<CodexSession>;
31
+ /** Codex session source; the default preferred provider (`provider: 'gpt'`). */
32
+ codexTokens?: TokenManager<CodexSession>;
33
+ /** Grok session source; preferred when the call passes `provider: 'grok'`. */
34
+ grokTokens?: TokenManager<GrokSession>;
25
35
  /** Fetch implementation (injectable for tests). */
26
36
  fetchFn?: FetchFn;
27
37
  /** Directory override for saved images (defaults under the harness home). */
@@ -38,15 +48,33 @@ export interface ImageGenerateRequestBody {
38
48
  size?: string;
39
49
  quality?: string;
40
50
  }
41
- /**
42
- * Assemble the request body from tool arguments (hand-checks the non-empty
43
- * prompt the schema DSL cannot express).
44
- */
45
- export declare function buildImageGenerateBody(args: {
51
+ /** The tool's own argument shape, shared by both provider body builders. */
52
+ export interface ImageGenerateArgs {
46
53
  prompt: string;
47
54
  size?: '1024x1024' | '1024x1536' | '1536x1024' | 'auto';
48
55
  quality?: 'low' | 'medium' | 'high' | 'auto';
49
- }): ImageGenerateRequestBody;
56
+ /** Preferred provider; the other one serves when the preferred is logged out. */
57
+ provider?: 'gpt' | 'grok';
58
+ }
59
+ /**
60
+ * Assemble the codex request body from tool arguments (hand-checks the
61
+ * non-empty prompt the schema DSL cannot express).
62
+ */
63
+ export declare function buildImageGenerateBody(args: ImageGenerateArgs): ImageGenerateRequestBody;
64
+ /** The wire request body for one grok generation call. */
65
+ export interface GrokImageGenerateRequestBody {
66
+ prompt: string;
67
+ model: string;
68
+ response_format: 'b64_json';
69
+ aspect_ratio?: string;
70
+ quality?: 'low' | 'medium';
71
+ }
72
+ /**
73
+ * Assemble the grok request body from the same tool arguments: `size` maps
74
+ * onto the nearest `aspect_ratio`, and `quality` folds into grok's low/medium
75
+ * pair (`high` → `medium`, `auto` → provider default).
76
+ */
77
+ export declare function buildGrokImageGenerateBody(args: ImageGenerateArgs): GrokImageGenerateRequestBody;
50
78
  /** One generated image decoded from the response. */
51
79
  export interface GeneratedImage {
52
80
  /** PNG bytes. */
@@ -59,8 +87,16 @@ export interface GeneratedImage {
59
87
  * payload carries no usable `b64_json` entries.
60
88
  */
61
89
  export declare function parseImageGenerateResponse(payload: unknown): GeneratedImage[];
62
- /** Directory the generated PNG files are written to. */
90
+ /** Directory the generated image files are written to. */
63
91
  export declare function imagesDirectory(): string;
92
+ /** Media types the attachment store accepts and this tool can produce. */
93
+ export type GeneratedImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp';
94
+ /**
95
+ * Sniff a generated image's media type from its magic bytes (codex serves
96
+ * PNG; grok's format is undocumented, so trust the bytes). Unrecognized data
97
+ * defaults to PNG, matching the historical behavior.
98
+ */
99
+ export declare function sniffImageMediaType(data: Buffer): GeneratedImageMediaType;
64
100
  /**
65
101
  * Build the `image_generate` tool definition.
66
102
  * @param options - codex session source, fetch implementation, and image directory.