dsh-plugin-subscriptions 0.4.2 → 0.5.1

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.
Files changed (44) hide show
  1. package/README.md +19 -6
  2. package/README.zh.md +18 -6
  3. package/lib/auth/device-flow.d.ts +64 -0
  4. package/lib/auth/device-flow.js +176 -0
  5. package/lib/auth/oauth-flow.js +1 -1
  6. package/lib/auth/rpc.d.ts +21 -2
  7. package/lib/auth/rpc.js +23 -3
  8. package/lib/auth/store.d.ts +20 -2
  9. package/lib/auth/store.js +45 -9
  10. package/lib/client/ImageGallery.d.ts +54 -0
  11. package/lib/client/ImageGallery.js +112 -0
  12. package/lib/client/ImageGenerateToolview.d.ts +1 -1
  13. package/lib/client/ImageGenerateToolview.js +2 -2
  14. package/lib/client/SpeedSelect.d.ts +48 -0
  15. package/lib/client/SpeedSelect.js +173 -0
  16. package/lib/client/SubscriptionsSection.d.ts +10 -1
  17. package/lib/client/SubscriptionsSection.js +48 -5
  18. package/lib/client/index.d.ts +1 -0
  19. package/lib/client/index.js +42 -0
  20. package/lib/client/locales.d.ts +22 -0
  21. package/lib/client/locales.js +22 -0
  22. package/lib/client.js +679 -77
  23. package/lib/client.js.map +1 -1
  24. package/lib/index.d.ts +3 -2
  25. package/lib/index.js +1868 -183
  26. package/lib/providers/catalog-store.js +19 -0
  27. package/lib/providers/claude.d.ts +20 -1
  28. package/lib/providers/claude.js +58 -31
  29. package/lib/providers/codex.d.ts +27 -0
  30. package/lib/providers/codex.js +117 -27
  31. package/lib/providers/common.d.ts +34 -1
  32. package/lib/providers/common.js +48 -1
  33. package/lib/providers/copilot.d.ts +315 -0
  34. package/lib/providers/copilot.js +786 -0
  35. package/lib/providers/grok.d.ts +7 -2
  36. package/lib/providers/grok.js +46 -18
  37. package/lib/tools/image-generate.js +3 -10
  38. package/lib/translate/anthropic.d.ts +47 -6
  39. package/lib/translate/anthropic.js +135 -20
  40. package/lib/translate/chat-completions.d.ts +120 -0
  41. package/lib/translate/chat-completions.js +363 -0
  42. package/lib/translate/responses.d.ts +49 -5
  43. package/lib/translate/responses.js +40 -7
  44. package/package.json +6 -3
@@ -74,6 +74,21 @@ function sanitizeModel(value) {
74
74
  const thinkingType = raw.thinkingType;
75
75
  if (thinkingType !== undefined && thinkingType !== 'enabled' && thinkingType !== 'adaptive')
76
76
  return undefined;
77
+ const fastTier = raw.fastTier;
78
+ if (fastTier !== undefined && typeof fastTier !== 'boolean')
79
+ return undefined;
80
+ const copilotWire = raw.copilotWire;
81
+ if (copilotWire !== undefined && copilotWire !== 'chat-completions' && copilotWire !== 'responses') {
82
+ return undefined;
83
+ }
84
+ const copilotResponses = raw.copilotResponses;
85
+ if (copilotResponses !== undefined && typeof copilotResponses !== 'boolean')
86
+ return undefined;
87
+ const inputModalities = raw.inputModalities;
88
+ if (inputModalities !== undefined
89
+ && (!Array.isArray(inputModalities) || inputModalities.length === 0
90
+ || inputModalities.some(modality => modality !== 'text' && modality !== 'image')))
91
+ return undefined;
77
92
  return {
78
93
  id: raw.id,
79
94
  name: raw.name,
@@ -82,6 +97,10 @@ function sanitizeModel(value) {
82
97
  ...raw.priority === undefined ? {} : { priority: raw.priority },
83
98
  ...reasoning === undefined ? {} : { reasoning },
84
99
  ...thinkingType === undefined ? {} : { thinkingType: thinkingType },
100
+ ...fastTier === undefined ? {} : { fastTier },
101
+ ...copilotWire === undefined ? {} : { copilotWire: copilotWire },
102
+ ...copilotResponses === undefined ? {} : { copilotResponses },
103
+ ...inputModalities === undefined ? {} : { inputModalities: [...inputModalities] },
85
104
  };
86
105
  }
87
106
  /**
@@ -8,14 +8,17 @@ import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelIn
8
8
  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
+ import type { TranslatableMessage } from '../translate/resolved.js';
11
12
  import { TokenManager } from './common.js';
12
13
  import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
13
14
  export declare const CLAUDE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
14
- export declare const CLAUDE_AUTHORIZE_URL = "https://claude.com/cai/oauth/authorize";
15
+ export declare const CLAUDE_AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
15
16
  export declare const CLAUDE_TOKEN_URL = "https://claude.ai/v1/oauth/token";
16
17
  export declare const CLAUDE_API_URL = "https://api.anthropic.com/v1/messages?beta=true";
17
18
  export declare const CLAUDE_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
18
19
  export declare const CLAUDE_MODELS_URL = "https://api.anthropic.com/v1/models?beta=true";
20
+ export declare const CLAUDE_SCOPE = "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload";
21
+ export declare const CLAUDE_CALLBACK_PATH = "/callback";
19
22
  /** Refresh when the access token has less than this much life left. */
20
23
  export declare const CLAUDE_PREEMPT_MS: number;
21
24
  /**
@@ -79,6 +82,22 @@ export interface ClaudeAdapterOptions {
79
82
  /** Durable catalog store seeding capability metadata across restarts. */
80
83
  catalogStore?: CatalogPersistence;
81
84
  }
85
+ /**
86
+ * Assemble the Anthropic request body.
87
+ *
88
+ * Extracted from the adapter so the wire shape — cache breakpoints above all —
89
+ * is testable without a network round trip. The message array is marked before
90
+ * it is placed so the breakpoints land on the blocks the body ships: one on the
91
+ * last `system` block (covering `tools` + `system`, which render ahead of it)
92
+ * and up to three across the history, Anthropic's four-slot maximum.
93
+ * @param options - the generate request.
94
+ * @param messages - conversation messages with images already resolved.
95
+ * @param maxTokens - the resolved output cap.
96
+ * @param thinking - the thinking parameter, when the model takes one.
97
+ * @param effort - the reasoning effort, when the model advertises efforts.
98
+ * @returns the JSON body to POST.
99
+ */
100
+ export declare function claudeRequestBody(options: GenerateOptions, messages: readonly TranslatableMessage[], maxTokens: number, thinking?: Record<string, unknown>, effort?: string): Record<string, unknown>;
82
101
  /** Claude wire adapter: one instance serves the `claude` provider route. */
83
102
  export declare class ClaudeAdapter extends LlmAdapter {
84
103
  private readonly options;
@@ -6,16 +6,16 @@
6
6
  import { execFileSync } from 'node:child_process';
7
7
  import { EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortId, resolveRetryPolicy } from '@deepseek-ai/dsh-llm';
8
8
  import { resolveImages } from '../translate/resolved.js';
9
- import { streamAnthropic, toAnthropicMessages, toAnthropicSystem, toAnthropicTools, } from '../translate/anthropic.js';
10
- import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
9
+ import { markMessageCache, streamAnthropic, toAnthropicMessages, toAnthropicSystem, toAnthropicTools, } from '../translate/anthropic.js';
10
+ import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, discoverOrRetryAuth, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
11
11
  export const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
12
- export const CLAUDE_AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize';
12
+ export const CLAUDE_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize';
13
13
  export const CLAUDE_TOKEN_URL = 'https://claude.ai/v1/oauth/token';
14
14
  export const CLAUDE_API_URL = 'https://api.anthropic.com/v1/messages?beta=true';
15
15
  export const CLAUDE_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
16
16
  export const CLAUDE_MODELS_URL = 'https://api.anthropic.com/v1/models?beta=true';
17
- const CLAUDE_SCOPE = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';
18
- const CLAUDE_CALLBACK_PATH = '/callback';
17
+ export const CLAUDE_SCOPE = 'org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload';
18
+ export const CLAUDE_CALLBACK_PATH = '/callback';
19
19
  const CLAUDE_CONTEXT_WINDOW = 200_000;
20
20
  const CLAUDE_DEFAULT_MAX_TOKENS = 32_000;
21
21
  /** Refresh when the access token has less than this much life left. */
@@ -36,7 +36,17 @@ export function detectClaudeVersion() {
36
36
  catch { }
37
37
  return CLAUDE_CLI_FALLBACK_VERSION;
38
38
  }
39
- const CLAUDE_CLI_USER_AGENT = `claude-cli/${detectClaudeVersion()} (external, cli)`;
39
+ // Lazy + memoized: detectClaudeVersion() shells out to `claude --version`,
40
+ // so this must not run at module-evaluation time (it would fire for every
41
+ // consumer of this module regardless of whether Claude is a configured
42
+ // provider). Computed on first use of getClaudeCliUserAgent() instead.
43
+ let claudeCliUserAgent;
44
+ function getClaudeCliUserAgent() {
45
+ if (claudeCliUserAgent === undefined) {
46
+ claudeCliUserAgent = `claude-cli/${detectClaudeVersion()} (external, cli)`;
47
+ }
48
+ return claudeCliUserAgent;
49
+ }
40
50
  export const CLAUDE_BETA_FALLBACK = [
41
51
  'claude-code-20250219',
42
52
  'oauth-2025-04-20',
@@ -51,13 +61,13 @@ const CLAUDE_BETA_FLAGS = CLAUDE_BETA_FALLBACK;
51
61
  export const claudeFlow = {
52
62
  callbackPath: CLAUDE_CALLBACK_PATH,
53
63
  // The redirect URI embeds the port, so it must be an ephemeral one.
54
- listen: { host: '127.0.0.1', ports: [0] },
64
+ listen: { host: 'localhost', ports: [0] },
55
65
  buildAuthorizeUrl({ redirectUri, state, pkce }) {
56
66
  const params = new URLSearchParams({
57
67
  code: 'true',
58
68
  client_id: CLAUDE_CLIENT_ID,
59
69
  response_type: 'code',
60
- redirect_uri: 'https://platform.claude.com/oauth/code/callback',
70
+ redirect_uri: redirectUri,
61
71
  scope: CLAUDE_SCOPE,
62
72
  code_challenge: pkce.challenge,
63
73
  code_challenge_method: 'S256',
@@ -234,7 +244,7 @@ export async function fetchClaudeUsage(session, fetchFn = fetch, signal) {
234
244
  'anthropic-beta': 'oauth-2025-04-20',
235
245
  // Unrecognized clients are aggressively rate-limited on this endpoint,
236
246
  // so it presents as the CLI like every other subscription request.
237
- 'user-agent': CLAUDE_CLI_USER_AGENT,
247
+ 'user-agent': getClaudeCliUserAgent(),
238
248
  'accept': 'application/json',
239
249
  },
240
250
  ...signal === undefined ? {} : { signal },
@@ -283,7 +293,7 @@ export async function fetchClaudeModels(session, fetchFn = fetch) {
283
293
  headers: {
284
294
  'authorization': `Bearer ${session.accessToken}`,
285
295
  'anthropic-version': '2023-06-01',
286
- 'user-agent': CLAUDE_CLI_USER_AGENT,
296
+ 'user-agent': getClaudeCliUserAgent(),
287
297
  'anthropic-dangerous-direct-browser-access': 'true',
288
298
  'accept': 'application/json',
289
299
  },
@@ -321,6 +331,38 @@ const CLAUDE_RETRY_MAX_DELAY_MS = 60_000;
321
331
  const CLAUDE_RETRY_JITTER_RATIO = 0.2;
322
332
  /** The Claude 4.5 family accepts image input. */
323
333
  const CLAUDE_MODALITIES = ['text', 'image'];
334
+ /**
335
+ * Assemble the Anthropic request body.
336
+ *
337
+ * Extracted from the adapter so the wire shape — cache breakpoints above all —
338
+ * is testable without a network round trip. The message array is marked before
339
+ * it is placed so the breakpoints land on the blocks the body ships: one on the
340
+ * last `system` block (covering `tools` + `system`, which render ahead of it)
341
+ * and up to three across the history, Anthropic's four-slot maximum.
342
+ * @param options - the generate request.
343
+ * @param messages - conversation messages with images already resolved.
344
+ * @param maxTokens - the resolved output cap.
345
+ * @param thinking - the thinking parameter, when the model takes one.
346
+ * @param effort - the reasoning effort, when the model advertises efforts.
347
+ * @returns the JSON body to POST.
348
+ */
349
+ export function claudeRequestBody(options, messages, maxTokens, thinking, effort) {
350
+ const anthropicMessages = toAnthropicMessages(messages);
351
+ markMessageCache(anthropicMessages);
352
+ return {
353
+ model: options.model,
354
+ max_tokens: maxTokens,
355
+ system: toAnthropicSystem(options.system, messages),
356
+ messages: anthropicMessages,
357
+ ...options.tools !== undefined && options.tools.length > 0
358
+ ? { tools: toAnthropicTools(options.tools) }
359
+ : {},
360
+ ...thinking === undefined ? {} : { thinking },
361
+ ...effort === undefined ? {} : { output_config: { effort } },
362
+ stream: true,
363
+ ...options.sessionId !== undefined ? { metadata: { user_id: String(options.sessionId) } } : {},
364
+ };
365
+ }
324
366
  /** Claude wire adapter: one instance serves the `claude` provider route. */
325
367
  export class ClaudeAdapter extends LlmAdapter {
326
368
  options;
@@ -369,7 +411,7 @@ export class ClaudeAdapter extends LlmAdapter {
369
411
  if (!this.options.discovery)
370
412
  return this.staticModels(provider);
371
413
  try {
372
- const models = await this.catalog.get(() => this.fetchCatalog());
414
+ const models = await discoverOrRetryAuth(force => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()));
373
415
  return models.map(model => ({
374
416
  provider,
375
417
  id: model.id,
@@ -378,11 +420,8 @@ export class ClaudeAdapter extends LlmAdapter {
378
420
  }));
379
421
  }
380
422
  catch (error) {
381
- if (error instanceof LlmError
382
- && (error.code === 'MISSING_CREDENTIAL' || error.code === 'INVALID_CREDENTIAL'))
423
+ if (isMissingOrInvalidCredential(error))
383
424
  return [];
384
- if (error instanceof LlmError && error.code === 'AUTH')
385
- this.catalog.invalidate();
386
425
  this.options.onWarn?.(`claude model discovery failed; using the built-in catalog (${errorChain(error)})`);
387
426
  return this.staticModels(provider);
388
427
  }
@@ -452,28 +491,16 @@ export class ClaudeAdapter extends LlmAdapter {
452
491
  const disc = await this.discovered(options.model);
453
492
  const thinking = this.thinkingParam(disc?.thinkingType, maxTokens);
454
493
  const effort = options.reasoningEffort !== undefined && disc?.reasoning !== undefined
455
- ? { output_config: { effort: String(options.reasoningEffort) } }
456
- : {};
457
- const body = {
458
- model: options.model,
459
- max_tokens: maxTokens,
460
- system: toAnthropicSystem(options.system, messages),
461
- messages: toAnthropicMessages(messages),
462
- ...options.tools !== undefined && options.tools.length > 0
463
- ? { tools: toAnthropicTools(options.tools) }
464
- : {},
465
- ...thinking === undefined ? {} : { thinking },
466
- ...effort,
467
- stream: true,
468
- ...options.sessionId !== undefined ? { metadata: { user_id: String(options.sessionId) } } : {},
469
- };
494
+ ? String(options.reasoningEffort)
495
+ : undefined;
496
+ const body = claudeRequestBody(options, messages, maxTokens, thinking, effort);
470
497
  return fetch(CLAUDE_API_URL, {
471
498
  method: 'POST',
472
499
  headers: {
473
500
  'authorization': `Bearer ${session.accessToken}`,
474
501
  'anthropic-version': '2023-06-01',
475
502
  'anthropic-beta': CLAUDE_BETA_FLAGS,
476
- 'user-agent': CLAUDE_CLI_USER_AGENT,
503
+ 'user-agent': getClaudeCliUserAgent(),
477
504
  'x-app': 'cli',
478
505
  'anthropic-dangerous-direct-browser-access': 'true',
479
506
  'accept': 'text/event-stream',
@@ -8,6 +8,7 @@ import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, LlmResolvedModelIn
8
8
  import type { FlowSpec } from '../auth/oauth-flow.js';
9
9
  import type { CodexSession } from '../auth/store.js';
10
10
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
11
+ import type { ResponsesRequestInput } from '../translate/responses.js';
11
12
  import { TokenManager } from './common.js';
12
13
  import type { CatalogPersistence, DiscoveredModel, FetchFn, ModelEntry, ProviderUsage } from './common.js';
13
14
  export declare const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -16,6 +17,15 @@ export declare const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
16
17
  export declare const CODEX_API_URL = "https://chatgpt.com/backend-api/codex/responses";
17
18
  /** Refresh when the access token has less than this much life left. */
18
19
  export declare const CODEX_PREEMPT_MS: number;
20
+ /**
21
+ * Fast tier (the codex CLI's "fast mode"): the Responses `service_tier` wire
22
+ * value for priority processing, mirroring codex-rs
23
+ * `ServiceTier::Fast.request_value()`. The legacy catalog spelling is the
24
+ * `additional_speed_tiers` entry "fast".
25
+ */
26
+ export declare const CODEX_FAST_SERVICE_TIER = "priority";
27
+ /** One session's speed choice: standard routing or the fast (priority) tier. */
28
+ export type CodexSpeedTier = 'standard' | 'fast';
19
29
  /** Static codex flow facts for the OAuth flow engine. */
20
30
  export declare const codexFlow: FlowSpec;
21
31
  /** User identity claims decoded from a codex id token. */
@@ -98,7 +108,20 @@ export interface CodexAdapterOptions {
98
108
  resolveAttachments?: () => AttachmentStore | undefined;
99
109
  /** Durable catalog store seeding capability metadata across restarts. */
100
110
  catalogStore?: CatalogPersistence;
111
+ /**
112
+ * Per-request speed lookup (the composer Speed toggle's host half). Returns
113
+ * whether this session's current choice sends the model on the fast tier;
114
+ * absent means every request stays on standard routing.
115
+ */
116
+ speedFor?: (sessionId: string | undefined, model: string) => Promise<boolean> | boolean;
101
117
  }
118
+ /**
119
+ * The Responses request body for one generation. A fast-tier request (the
120
+ * composer Speed toggle, the codex CLI's fast mode) carries
121
+ * `service_tier: priority`; the tier field is omitted entirely otherwise,
122
+ * matching the CLI (it never sends an explicit standard tier).
123
+ */
124
+ export declare function codexRequestBody(options: GenerateOptions, resolved: ResponsesRequestInput, fast: boolean): Record<string, unknown>;
102
125
  /** Codex wire adapter: one instance serves the `codex` provider route. */
103
126
  export declare class CodexAdapter extends LlmAdapter {
104
127
  private readonly options;
@@ -117,6 +140,10 @@ export declare class CodexAdapter extends LlmAdapter {
117
140
  * call — just because the TTL lapsed mid-turn.
118
141
  */
119
142
  private discovered;
143
+ /** Whether the discovered catalog advertises a fast tier for this model. */
144
+ supportsFastTier(model: string): Promise<boolean>;
145
+ /** Ids of every discovered model with a fast tier (the Speed toggle's visibility list). */
146
+ fastCapableModels(): Promise<string[]>;
120
147
  resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo>;
121
148
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
122
149
  private request;
@@ -3,12 +3,12 @@
3
3
  * Codex CLI client id, and streaming against the ChatGPT backend Responses
4
4
  * endpoint.
5
5
  */
6
- import { randomUUID } from 'node:crypto';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
7
  import { attributionHeaders, EMPTY_RESPONSE_CODE, errorChain, LlmAdapter, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm';
8
8
  import { decodeJwtPayload } from '../auth/jwt.js';
9
9
  import { resolveImages } from '../translate/resolved.js';
10
10
  import { streamResponses, toResponsesInput, toResponsesTools } from '../translate/responses.js';
11
- import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
11
+ import { httpLlmError, idleWatchdog, mapFetchFailure, ModelCatalogCache, discoverOrRetryAuth, isMissingOrInvalidCredential, oauthEndpointError, OAuthEndpointError, TokenManager, } from './common.js';
12
12
  export const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
13
13
  export const CODEX_AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
14
14
  export const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
@@ -39,6 +39,14 @@ const CODEX_EFFORTS = [
39
39
  const CODEX_DEFAULT_EFFORT = ReasoningEffortId('high');
40
40
  /** Every gpt-5.x codex model accepts image input. */
41
41
  const CODEX_MODALITIES = ['text', 'image'];
42
+ /**
43
+ * Fast tier (the codex CLI's "fast mode"): the Responses `service_tier` wire
44
+ * value for priority processing, mirroring codex-rs
45
+ * `ServiceTier::Fast.request_value()`. The legacy catalog spelling is the
46
+ * `additional_speed_tiers` entry "fast".
47
+ */
48
+ export const CODEX_FAST_SERVICE_TIER = 'priority';
49
+ const CODEX_FAST_SPEED_TIER = 'fast';
42
50
  /** Static codex flow facts for the OAuth flow engine. */
43
51
  export const codexFlow = {
44
52
  callbackPath: CODEX_CALLBACK_PATH,
@@ -286,6 +294,15 @@ export const CODEX_CLIENT_VERSION = '0.147.0';
286
294
  function effortName(effort) {
287
295
  return effort === 'xhigh' ? 'Extra High' : effort.charAt(0).toUpperCase() + effort.slice(1);
288
296
  }
297
+ /**
298
+ * Whether a catalog entry advertises the fast tier. Mirrors codex-rs
299
+ * `ModelPreset::supports_fast_mode`: a `service_tiers` id matching the fast
300
+ * wire value, or the legacy `additional_speed_tiers` "fast" entry.
301
+ */
302
+ function supportsFastTier(entry) {
303
+ return (entry.service_tiers ?? []).some(tier => tier.id === CODEX_FAST_SERVICE_TIER)
304
+ || (entry.additional_speed_tiers ?? []).includes(CODEX_FAST_SPEED_TIER);
305
+ }
289
306
  /**
290
307
  * Fetch the live codex model catalog with the session's auth headers.
291
308
  * @param session - the stored session (used as-is; never refreshed here).
@@ -328,7 +345,7 @@ export async function fetchCodexModels(session, fetchFn = fetch) {
328
345
  && efforts.some(effort => effort.id === ReasoningEffortId(entry.default_reasoning_level))
329
346
  ? ReasoningEffortId(entry.default_reasoning_level)
330
347
  : undefined;
331
- discovered.push({
348
+ const model = {
332
349
  id: entry.slug,
333
350
  name: typeof entry.display_name === 'string' && entry.display_name.length > 0
334
351
  ? entry.display_name
@@ -343,7 +360,9 @@ export async function fetchCodexModels(session, fetchFn = fetch) {
343
360
  ...efforts.length > 0
344
361
  ? { reasoning: { efforts, ...defaultEffort === undefined ? {} : { defaultEffort } } }
345
362
  : {},
346
- });
363
+ ...supportsFastTier(entry) ? { fastTier: true } : {},
364
+ };
365
+ discovered.push(model);
347
366
  }
348
367
  discovered.sort((a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER));
349
368
  // An empty catalog from a 200 response means the backend gated us out (e.g.
@@ -354,6 +373,79 @@ export async function fetchCodexModels(session, fetchFn = fetch) {
354
373
  }
355
374
  return discovered;
356
375
  }
376
+ const CODEX_CALL_ID_MAX_LENGTH = 64;
377
+ const CODEX_CALL_ID_PREFIX = 'call_';
378
+ /**
379
+ * Bound tool-call ids at the Codex wire boundary without changing the shared
380
+ * Responses translation used by Grok. Short ids stay verbatim. Oversized ids
381
+ * become deterministic hashes, and every id already present in this request
382
+ * is reserved first so a generated id cannot collide with a legitimate short
383
+ * one (or another oversized id).
384
+ */
385
+ function normalizeCodexCallIds(input) {
386
+ const mapping = new Map();
387
+ const used = new Set();
388
+ const callId = (item) => (item.type === 'function_call' || item.type === 'function_call_output') && typeof item.call_id === 'string'
389
+ ? item.call_id
390
+ : undefined;
391
+ for (const item of input) {
392
+ const id = callId(item);
393
+ if (id !== undefined && id.length <= CODEX_CALL_ID_MAX_LENGTH) {
394
+ mapping.set(id, id);
395
+ used.add(id);
396
+ }
397
+ }
398
+ for (const item of input) {
399
+ const id = callId(item);
400
+ if (id === undefined || mapping.has(id))
401
+ continue;
402
+ let attempt = 0;
403
+ let normalized;
404
+ do {
405
+ const hash = createHash('sha256');
406
+ if (attempt > 0)
407
+ hash.update(String(attempt)).update('\0');
408
+ const digest = hash.update(id).digest('hex');
409
+ normalized = `${CODEX_CALL_ID_PREFIX}${digest.slice(0, CODEX_CALL_ID_MAX_LENGTH - CODEX_CALL_ID_PREFIX.length)}`;
410
+ attempt += 1;
411
+ } while (used.has(normalized));
412
+ mapping.set(id, normalized);
413
+ used.add(normalized);
414
+ }
415
+ return input.map((item) => {
416
+ const id = callId(item);
417
+ if (id === undefined)
418
+ return item;
419
+ const normalized = mapping.get(id) ?? id;
420
+ return normalized === id ? item : { ...item, call_id: normalized };
421
+ });
422
+ }
423
+ /**
424
+ * The Responses request body for one generation. A fast-tier request (the
425
+ * composer Speed toggle, the codex CLI's fast mode) carries
426
+ * `service_tier: priority`; the tier field is omitted entirely otherwise,
427
+ * matching the CLI (it never sends an explicit standard tier).
428
+ */
429
+ export function codexRequestBody(options, resolved, fast) {
430
+ return {
431
+ model: options.model,
432
+ instructions: resolved.instructions ?? DEFAULT_CODEX_INSTRUCTIONS,
433
+ input: normalizeCodexCallIds(resolved.input),
434
+ ...options.tools !== undefined && options.tools.length > 0
435
+ ? { tools: toResponsesTools(options.tools) }
436
+ : {},
437
+ tool_choice: 'auto',
438
+ parallel_tool_calls: true,
439
+ ...options.reasoningEffort !== undefined
440
+ ? { reasoning: { effort: String(options.reasoningEffort), summary: 'auto' } }
441
+ : {},
442
+ store: false,
443
+ stream: true,
444
+ include: ['reasoning.encrypted_content'],
445
+ ...options.sessionId !== undefined ? { prompt_cache_key: String(options.sessionId) } : {},
446
+ ...fast ? { service_tier: CODEX_FAST_SERVICE_TIER } : {},
447
+ };
448
+ }
357
449
  /** Codex wire adapter: one instance serves the `codex` provider route. */
358
450
  export class CodexAdapter extends LlmAdapter {
359
451
  options;
@@ -389,7 +481,7 @@ export class CodexAdapter extends LlmAdapter {
389
481
  // The fetcher runs only on a cache miss, and resolves the session
390
482
  // through the refresh-aware path so an expired access token renews here
391
483
  // instead of failing discovery into the static fallback.
392
- const discovered = await this.catalog.get(() => this.fetchCatalog());
484
+ const discovered = await discoverOrRetryAuth(force => this.options.tokens.session(force), this.catalog, () => this.catalog.get(() => this.fetchCatalog()));
393
485
  return discovered.map(model => ({
394
486
  provider,
395
487
  id: model.id,
@@ -401,11 +493,8 @@ export class CodexAdapter extends LlmAdapter {
401
493
  catch (error) {
402
494
  // A permanent refresh failure deletes the stored session: the provider
403
495
  // is logged out, so hide it instead of showing a stale static catalog.
404
- if (error instanceof LlmError
405
- && (error.code === 'MISSING_CREDENTIAL' || error.code === 'INVALID_CREDENTIAL'))
496
+ if (isMissingOrInvalidCredential(error))
406
497
  return [];
407
- if (error instanceof OAuthEndpointError && error.status === 401)
408
- this.catalog.invalidate();
409
498
  this.options.onWarn?.(`codex model discovery failed; using the built-in catalog (${errorChain(error)})`);
410
499
  return this.staticModels(provider);
411
500
  }
@@ -423,6 +512,22 @@ export class CodexAdapter extends LlmAdapter {
423
512
  const models = await this.catalog.resolve(() => this.fetchCatalog());
424
513
  return models?.find(entry => entry.id === model);
425
514
  }
515
+ /** Whether the discovered catalog advertises a fast tier for this model. */
516
+ async supportsFastTier(model) {
517
+ return (await this.discovered(model))?.fastTier === true;
518
+ }
519
+ /** Ids of every discovered model with a fast tier (the Speed toggle's visibility list). */
520
+ async fastCapableModels() {
521
+ if (!this.options.discovery)
522
+ return [];
523
+ // Not logged in → no fast models, so the Speed toggle hides after logout
524
+ // (mirrors the listModels guard above).
525
+ const session = await this.options.tokens.peek();
526
+ if (session === undefined)
527
+ return [];
528
+ const models = await this.catalog.resolve(() => this.fetchCatalog());
529
+ return (models ?? []).filter(model => model.fastTier === true).map(model => model.id);
530
+ }
426
531
  async resolveModel(provider, model) {
427
532
  // Discovered metadata (when discovery is on) wins over the static entry;
428
533
  // the static entry wins over the built-in defaults.
@@ -465,24 +570,9 @@ export class CodexAdapter extends LlmAdapter {
465
570
  }
466
571
  async request(options, session, signal) {
467
572
  const messages = await resolveImages(options.messages, this.options.resolveAttachments?.(), signal);
468
- const { instructions, input } = toResponsesInput(messages, options.system);
469
- const body = {
470
- model: options.model,
471
- instructions: instructions ?? DEFAULT_CODEX_INSTRUCTIONS,
472
- input,
473
- ...options.tools !== undefined && options.tools.length > 0
474
- ? { tools: toResponsesTools(options.tools) }
475
- : {},
476
- tool_choice: 'auto',
477
- parallel_tool_calls: true,
478
- ...options.reasoningEffort !== undefined
479
- ? { reasoning: { effort: String(options.reasoningEffort), summary: 'auto' } }
480
- : {},
481
- store: false,
482
- stream: true,
483
- include: ['reasoning.encrypted_content'],
484
- ...options.sessionId !== undefined ? { prompt_cache_key: String(options.sessionId) } : {},
485
- };
573
+ const fast = this.options.speedFor !== undefined
574
+ && await this.options.speedFor(options.sessionId, options.model);
575
+ const body = codexRequestBody(options, toResponsesInput(messages, options.system), fast);
486
576
  return fetch(CODEX_API_URL, {
487
577
  method: 'POST',
488
578
  headers: {
@@ -19,6 +19,12 @@ export interface ModelEntry {
19
19
  maxTokens?: number;
20
20
  /** Accepted request modalities; when set, wins over the provider default. */
21
21
  inputModalities?: ('text' | 'image')[];
22
+ /**
23
+ * Force this model's upstream protocol. Only the copilot adapter consumes
24
+ * the semantics; the union is inlined here to avoid a circular import of
25
+ * the copilot module's `CopilotWire`.
26
+ */
27
+ wire?: 'chat-completions' | 'responses';
22
28
  }
23
29
  /**
24
30
  * Validate a configured model catalog (mirrors llm-deepseek's resolveModels).
@@ -175,8 +181,20 @@ export interface DiscoveredModel {
175
181
  }[];
176
182
  defaultEffort?: ReasoningEffortId;
177
183
  };
184
+ /** Accepted request modalities the endpoint advertised (e.g. Copilot's vision support flag). */
185
+ inputModalities?: ('text' | 'image')[];
178
186
  /** Claude-specific: which extended-thinking wire shape this model accepts. */
179
187
  thinkingType?: 'enabled' | 'adaptive';
188
+ /** Codex-specific: the catalog advertises a fast (priority) service tier. */
189
+ fastTier?: boolean;
190
+ /** Copilot-specific: which upstream protocol the model's endpoints speak. */
191
+ copilotWire?: 'chat-completions' | 'responses';
192
+ /**
193
+ * Copilot-specific: the catalog also lists `/responses` for this model
194
+ * (dual-protocol entries, e.g. gpt-5.4), so a chat-wire request may reroute
195
+ * there when it combines function tools with a reasoning effort.
196
+ */
197
+ copilotResponses?: boolean;
180
198
  }
181
199
  /** How long a discovered catalog is trusted before re-fetching. */
182
200
  export declare const DISCOVERY_TTL_MS: number;
@@ -205,7 +223,7 @@ export interface CatalogPersistence {
205
223
  * while a stale entry refreshes in the background, and only awaits the fetch
206
224
  * when nothing is known yet. An optional {@link CatalogPersistence} seeds the
207
225
  * last-known state across restarts and receives every successful fetch. A 401
208
- * during a fetch must call {@link invalidate}.
226
+ * that still fails after a forced token refresh must call {@link invalidate}.
209
227
  */
210
228
  export declare class ModelCatalogCache {
211
229
  private readonly persistence?;
@@ -222,6 +240,12 @@ export declare class ModelCatalogCache {
222
240
  * @returns the cached models, or `undefined` when absent or stale.
223
241
  */
224
242
  cached(): readonly DiscoveredModel[] | undefined;
243
+ /**
244
+ * The last successfully fetched catalog, ignoring TTL. Used to carry
245
+ * capability metadata forward when a later fetch cannot re-enrich.
246
+ * @returns the last-known models, or `undefined` when nothing has been stored.
247
+ */
248
+ lastKnown(): readonly DiscoveredModel[] | undefined;
225
249
  /** Load the persisted snapshot once; a fetch or invalidate that landed first wins. */
226
250
  private ensureSeeded;
227
251
  /** Run (or join) the single in-flight fetch, updating memory and disk on success. */
@@ -246,4 +270,13 @@ export declare class ModelCatalogCache {
246
270
  /** Drop the cached catalog (e.g. after a 401 proved the credential changed). */
247
271
  invalidate(): void;
248
272
  }
273
+ /** Whether discovery failed because the stored login is gone. */
274
+ export declare function isMissingOrInvalidCredential(error: unknown): boolean;
275
+ /**
276
+ * Run a catalog fetch, retrying once after a forced token refresh when the
277
+ * first attempt is a 401/AUTH. Only {@link ModelCatalogCache.invalidate}s
278
+ * when the retry is also an auth failure, so a refresh race cannot erase
279
+ * last-known capability metadata.
280
+ */
281
+ export declare function discoverOrRetryAuth<T>(session: (forceRefresh?: boolean) => Promise<unknown>, catalog: ModelCatalogCache, run: () => Promise<T>): Promise<T>;
249
282
  export {};
@@ -32,6 +32,9 @@ export function validateModels(models, label) {
32
32
  || model.inputModalities.some(modality => modality !== 'text' && modality !== 'image'))) {
33
33
  throw new Error(`${label}: catalog model "${model.id}" inputModalities must be a non-empty list of "text"/"image"`);
34
34
  }
35
+ if (model.wire !== undefined && model.wire !== 'chat-completions' && model.wire !== 'responses') {
36
+ throw new Error(`${label}: catalog model "${model.id}" wire must be "chat-completions" or "responses"`);
37
+ }
35
38
  if (seen.has(model.id))
36
39
  throw new Error(`${label}: duplicate catalog model "${model.id}"`);
37
40
  seen.add(model.id);
@@ -41,6 +44,7 @@ export function validateModels(models, label) {
41
44
  ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
42
45
  ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
43
46
  ...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] },
47
+ ...model.wire === undefined ? {} : { wire: model.wire },
44
48
  };
45
49
  });
46
50
  }
@@ -271,7 +275,7 @@ export const DISCOVERY_TTL_MS = 5 * 60_000;
271
275
  * while a stale entry refreshes in the background, and only awaits the fetch
272
276
  * when nothing is known yet. An optional {@link CatalogPersistence} seeds the
273
277
  * last-known state across restarts and receives every successful fetch. A 401
274
- * during a fetch must call {@link invalidate}.
278
+ * that still fails after a forced token refresh must call {@link invalidate}.
275
279
  */
276
280
  export class ModelCatalogCache {
277
281
  persistence;
@@ -295,6 +299,14 @@ export class ModelCatalogCache {
295
299
  return undefined;
296
300
  return this.entry.models;
297
301
  }
302
+ /**
303
+ * The last successfully fetched catalog, ignoring TTL. Used to carry
304
+ * capability metadata forward when a later fetch cannot re-enrich.
305
+ * @returns the last-known models, or `undefined` when nothing has been stored.
306
+ */
307
+ lastKnown() {
308
+ return this.entry?.models;
309
+ }
298
310
  /** Load the persisted snapshot once; a fetch or invalidate that landed first wins. */
299
311
  ensureSeeded() {
300
312
  if (this.persistence === undefined)
@@ -363,3 +375,38 @@ export class ModelCatalogCache {
363
375
  void this.persistence?.clear().catch(() => undefined);
364
376
  }
365
377
  }
378
+ /** Whether discovery failed because the stored login is gone. */
379
+ export function isMissingOrInvalidCredential(error) {
380
+ return error instanceof LlmError
381
+ && (error.code === 'MISSING_CREDENTIAL' || error.code === 'INVALID_CREDENTIAL');
382
+ }
383
+ /** Whether discovery failed because the access token was rejected. */
384
+ function isDiscoveryAuthFailure(error) {
385
+ return (error instanceof OAuthEndpointError && error.status === 401)
386
+ || (error instanceof LlmError && error.code === 'AUTH');
387
+ }
388
+ /**
389
+ * Run a catalog fetch, retrying once after a forced token refresh when the
390
+ * first attempt is a 401/AUTH. Only {@link ModelCatalogCache.invalidate}s
391
+ * when the retry is also an auth failure, so a refresh race cannot erase
392
+ * last-known capability metadata.
393
+ */
394
+ export async function discoverOrRetryAuth(session, catalog, run) {
395
+ try {
396
+ return await run();
397
+ }
398
+ catch (error) {
399
+ if (isMissingOrInvalidCredential(error) || !isDiscoveryAuthFailure(error))
400
+ throw error;
401
+ try {
402
+ await session(true);
403
+ return await run();
404
+ }
405
+ catch (retryError) {
406
+ if (!isMissingOrInvalidCredential(retryError) && isDiscoveryAuthFailure(retryError)) {
407
+ catalog.invalidate();
408
+ }
409
+ throw retryError;
410
+ }
411
+ }
412
+ }