talon-agent 1.6.1 → 1.7.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.
@@ -12,61 +12,386 @@ import { registerModels, clearModelsByProvider } from "../../core/models.js";
12
12
  import type { ModelInfo } from "../../core/models.js";
13
13
  import { log, logError } from "../../util/log.js";
14
14
 
15
+ type SdkModelInfo = {
16
+ value: string;
17
+ displayName: string;
18
+ description: string;
19
+ };
20
+
21
+ type ParsedModelIdentity = {
22
+ family: string | null;
23
+ version: string | null;
24
+ claudeId: string | null;
25
+ isOneMillion: boolean;
26
+ };
27
+
28
+ type SdkModelRecord = SdkModelInfo & {
29
+ index: number;
30
+ identity: ParsedModelIdentity;
31
+ familyKey: string | null;
32
+ variantKey: string | null;
33
+ };
34
+
15
35
  // ── Tier / fallback inference ───────────────────────────────────────────────
16
36
 
17
- /** Infer tier from model ID. */
18
- function inferTier(modelId: string): ModelInfo["tier"] {
19
- if (modelId.includes("opus")) return "premium";
20
- if (modelId.includes("haiku")) return "economy";
37
+ const FAMILY_VERSION_PATTERN = /\b([A-Za-z][A-Za-z-]*)\s+(\d+(?:\.\d+)*)\b/;
38
+ const FAMILY_ONLY_PATTERN = /^\s*([A-Za-z][A-Za-z-]*)\b/;
39
+
40
+ function normalizeFamilyName(family: string): string {
41
+ return family.trim().toLowerCase().replace(/\s+/g, "-");
42
+ }
43
+
44
+ function stripOneMillionSuffix(value: string): string {
45
+ return value.endsWith("[1m]") ? value.slice(0, -4) : value;
46
+ }
47
+
48
+ function toDashVersion(version: string): string {
49
+ return version.replace(/\./g, "-");
50
+ }
51
+
52
+ function parseFamilyAndVersionFromTexts(
53
+ texts: readonly string[],
54
+ ): Pick<ParsedModelIdentity, "family" | "version"> {
55
+ for (const text of texts) {
56
+ const match = text.match(FAMILY_VERSION_PATTERN);
57
+ if (!match) continue;
58
+ return {
59
+ family: normalizeFamilyName(match[1]),
60
+ version: match[2],
61
+ };
62
+ }
63
+
64
+ for (const text of texts) {
65
+ const match = text.match(FAMILY_ONLY_PATTERN);
66
+ if (!match) continue;
67
+ const family = normalizeFamilyName(match[1]);
68
+ if (family === "default") continue;
69
+ return { family, version: null };
70
+ }
71
+
72
+ return { family: null, version: null };
73
+ }
74
+
75
+ function parseClaudeId(
76
+ value: string,
77
+ ): Pick<ParsedModelIdentity, "family" | "version" | "claudeId"> {
78
+ const claudeId = stripOneMillionSuffix(value);
79
+ if (!claudeId.startsWith("claude-")) {
80
+ return { family: null, version: null, claudeId: null };
81
+ }
82
+
83
+ const tokens = claudeId.slice("claude-".length).split("-");
84
+ let boundary = tokens.length;
85
+ while (boundary > 0 && /^\d+$/.test(tokens[boundary - 1] ?? "")) {
86
+ boundary -= 1;
87
+ }
88
+
89
+ const familyTokens = tokens.slice(0, boundary);
90
+ const versionTokens = tokens.slice(boundary);
91
+
92
+ return {
93
+ family:
94
+ familyTokens.length > 0
95
+ ? normalizeFamilyName(familyTokens.join("-"))
96
+ : null,
97
+ version: versionTokens.length > 0 ? versionTokens.join(".") : null,
98
+ claudeId,
99
+ };
100
+ }
101
+
102
+ function describeSdkModel(model: SdkModelInfo): ParsedModelIdentity {
103
+ const textIdentity = parseFamilyAndVersionFromTexts([
104
+ model.description,
105
+ model.displayName,
106
+ model.value,
107
+ ]);
108
+ const claudeIdentity = parseClaudeId(model.value);
109
+ const family = textIdentity.family ?? claudeIdentity.family;
110
+ const version = textIdentity.version ?? claudeIdentity.version;
111
+
112
+ return {
113
+ family,
114
+ version,
115
+ claudeId:
116
+ claudeIdentity.claudeId ??
117
+ (family && version ? `claude-${family}-${toDashVersion(version)}` : null),
118
+ isOneMillion: model.value.endsWith("[1m]"),
119
+ };
120
+ }
121
+
122
+ function buildFamilyKey(identity: ParsedModelIdentity): string | null {
123
+ return identity.family
124
+ ? `${identity.family}:${identity.version ?? "*"}`
125
+ : null;
126
+ }
127
+
128
+ function buildVariantKey(identity: ParsedModelIdentity): string | null {
129
+ const familyKey = buildFamilyKey(identity);
130
+ return familyKey
131
+ ? `${familyKey}:${identity.isOneMillion ? "1m" : "base"}`
132
+ : null;
133
+ }
134
+
135
+ function appendOneMillionSuffix(alias: string, isOneMillion: boolean): string {
136
+ return isOneMillion ? `${alias}[1m]` : alias;
137
+ }
138
+
139
+ function buildGeneratedAliases(identity: ParsedModelIdentity): string[] {
140
+ if (!identity.family) return [];
141
+
142
+ const aliases = [
143
+ appendOneMillionSuffix(identity.family, identity.isOneMillion),
144
+ ];
145
+
146
+ if (identity.version) {
147
+ aliases.push(
148
+ appendOneMillionSuffix(
149
+ `${identity.family}-${identity.version}`,
150
+ identity.isOneMillion,
151
+ ),
152
+ appendOneMillionSuffix(
153
+ `${identity.family}-${toDashVersion(identity.version)}`,
154
+ identity.isOneMillion,
155
+ ),
156
+ );
157
+ }
158
+
159
+ if (identity.claudeId) {
160
+ aliases.push(
161
+ appendOneMillionSuffix(identity.claudeId, identity.isOneMillion),
162
+ );
163
+ }
164
+
165
+ return aliases;
166
+ }
167
+
168
+ function inferTierFromText(searchableText: string): ModelInfo["tier"] {
169
+ const lower = searchableText.toLowerCase();
170
+ if (
171
+ lower.includes("most capable") ||
172
+ lower.includes("smartest") ||
173
+ lower.includes("complex work")
174
+ ) {
175
+ return "premium";
176
+ }
177
+ if (
178
+ lower.includes("fastest") ||
179
+ lower.includes("quick answers") ||
180
+ lower.includes("cheapest")
181
+ ) {
182
+ return "economy";
183
+ }
21
184
  return "balanced";
22
185
  }
23
186
 
24
- /** Build short aliases from a model ID like "claude-sonnet-4-6". */
25
- function buildAliases(modelId: string): string[] {
187
+ function getPreferredModelPriority(value: string): number {
188
+ if (value === "default") return 0;
189
+ if (!value.startsWith("claude-")) return 1;
190
+ return 2;
191
+ }
192
+
193
+ function buildSdkModelRecords(sdkModels: SdkModelInfo[]): SdkModelRecord[] {
194
+ return sdkModels.map((model, index) => {
195
+ const identity = describeSdkModel(model);
196
+ return {
197
+ ...model,
198
+ index,
199
+ identity,
200
+ familyKey: buildFamilyKey(identity),
201
+ variantKey: buildVariantKey(identity),
202
+ };
203
+ });
204
+ }
205
+
206
+ function buildPreferredCanonicalIds(
207
+ records: readonly SdkModelRecord[],
208
+ ): Map<string, string> {
209
+ const grouped = new Map<string, SdkModelRecord[]>();
210
+
211
+ for (const record of records) {
212
+ if (!record.variantKey) continue;
213
+ const variants = grouped.get(record.variantKey) ?? [];
214
+ variants.push(record);
215
+ grouped.set(record.variantKey, variants);
216
+ }
217
+
218
+ const preferred = new Map<string, string>();
219
+ for (const [variantKey, variants] of grouped) {
220
+ const canonical = [...variants].sort((left, right) => {
221
+ const priorityDelta =
222
+ getPreferredModelPriority(left.value) -
223
+ getPreferredModelPriority(right.value);
224
+ if (priorityDelta !== 0) return priorityDelta;
225
+ return left.index - right.index;
226
+ })[0];
227
+ if (canonical) preferred.set(variantKey, canonical.value);
228
+ }
229
+
230
+ return preferred;
231
+ }
232
+
233
+ function mergeAliases(...lists: readonly string[][]): string[] {
234
+ const seen = new Set<string>();
26
235
  const aliases: string[] = [];
27
- const match = modelId.match(/claude-(\w+)-(.+)/);
28
- if (match) {
29
- const family = match[1];
30
- const version = match[2];
31
- aliases.push(family);
32
- aliases.push(`${family}-${version}`);
33
- aliases.push(`${family}-${version.replace(/-/g, ".")}`);
236
+
237
+ for (const list of lists) {
238
+ for (const alias of list) {
239
+ const key = alias.toLowerCase();
240
+ if (seen.has(key)) continue;
241
+ seen.add(key);
242
+ aliases.push(alias);
243
+ }
34
244
  }
245
+
35
246
  return aliases;
36
247
  }
37
248
 
249
+ function buildHiddenModelAliases(
250
+ records: readonly SdkModelRecord[],
251
+ preferredCanonicalIds: ReadonlyMap<string, string>,
252
+ ): Map<string, string[]> {
253
+ const hiddenAliases = new Map<string, string[]>();
254
+
255
+ for (const record of records) {
256
+ if (!record.variantKey) continue;
257
+ const preferredId = preferredCanonicalIds.get(record.variantKey);
258
+ if (!preferredId || preferredId === record.value) continue;
259
+
260
+ hiddenAliases.set(
261
+ preferredId,
262
+ mergeAliases(
263
+ hiddenAliases.get(preferredId) ?? [],
264
+ [record.value],
265
+ buildGeneratedAliases(record.identity),
266
+ ),
267
+ );
268
+ }
269
+
270
+ return hiddenAliases;
271
+ }
272
+
273
+ function buildOneMillionContextVariants(
274
+ records: readonly SdkModelRecord[],
275
+ preferredCanonicalIds: ReadonlyMap<string, string>,
276
+ ): Map<string, string> {
277
+ const variants = new Map<string, string>();
278
+
279
+ for (const record of records) {
280
+ if (
281
+ !record.identity.isOneMillion ||
282
+ !record.familyKey ||
283
+ !record.variantKey
284
+ ) {
285
+ continue;
286
+ }
287
+
288
+ const preferredId = preferredCanonicalIds.get(record.variantKey);
289
+ if (preferredId && preferredId !== record.value) continue;
290
+
291
+ variants.set(record.familyKey, record.value);
292
+ }
293
+
294
+ return variants;
295
+ }
296
+
297
+ function buildTierByFamily(
298
+ records: readonly SdkModelRecord[],
299
+ ): Map<string, ModelInfo["tier"]> {
300
+ const textsByFamily = new Map<string, string[]>();
301
+
302
+ for (const record of records) {
303
+ if (!record.familyKey) continue;
304
+ const texts = textsByFamily.get(record.familyKey) ?? [];
305
+ texts.push(record.displayName, record.description, record.value);
306
+ textsByFamily.set(record.familyKey, texts);
307
+ }
308
+
309
+ const tiers = new Map<string, ModelInfo["tier"]>();
310
+ for (const [familyKey, texts] of textsByFamily) {
311
+ tiers.set(familyKey, inferTierFromText(texts.join(" ")));
312
+ }
313
+
314
+ return tiers;
315
+ }
316
+
38
317
  // ── SDK → registry conversion ───────────────────────────────────────────────
39
318
 
40
319
  /**
41
320
  * Convert SDK ModelInfo to our registry format.
42
- * Sorts by tier and builds fallback chains automatically.
321
+ * Keeps SDK model IDs/display names intact while deriving compatibility aliases
322
+ * and duplicate collapsing from the SDK metadata instead of hardcoded versions.
43
323
  */
44
- function convertSdkModels(
45
- sdkModels: Array<{
46
- value: string;
47
- displayName: string;
48
- description: string;
49
- }>,
50
- ): ModelInfo[] {
51
- // Filter out SDK artifacts:
52
- // - [1m] variants: we add this suffix ourselves in options.ts
53
- // - "default" pseudo-model: not a real model, just an alias for the default
54
- // - any model that doesn't start with "claude-": not an Anthropic model
55
- const filtered = sdkModels.filter(
56
- (m) => m.value.startsWith("claude-") && !m.value.includes("["),
324
+ function convertSdkModels(sdkModels: SdkModelInfo[]): ModelInfo[] {
325
+ const records = buildSdkModelRecords(sdkModels);
326
+ const preferredCanonicalIds = buildPreferredCanonicalIds(records);
327
+ const hiddenModelAliases = buildHiddenModelAliases(
328
+ records,
329
+ preferredCanonicalIds,
57
330
  );
331
+ const oneMillionContextVariants = buildOneMillionContextVariants(
332
+ records,
333
+ preferredCanonicalIds,
334
+ );
335
+ const tierByFamily = buildTierByFamily(records);
336
+ const hiddenModels = new Set(
337
+ records
338
+ .filter(
339
+ (record) =>
340
+ !!record.variantKey &&
341
+ preferredCanonicalIds.get(record.variantKey) !== undefined &&
342
+ preferredCanonicalIds.get(record.variantKey) !== record.value,
343
+ )
344
+ .map((record) => record.value),
345
+ );
346
+
347
+ const usedKeys = new Set<string>();
348
+ const models: ModelInfo[] = [];
58
349
 
59
- const models: ModelInfo[] = filtered.map((m) => ({
60
- id: m.value,
61
- displayName: m.displayName,
62
- description: m.description,
63
- aliases: buildAliases(m.value),
64
- provider: "anthropic",
65
- capabilities: {
66
- supports1mContext: !m.value.includes("haiku"),
67
- },
68
- tier: inferTier(m.value),
69
- }));
350
+ for (const record of records) {
351
+ if (hiddenModels.has(record.value)) continue;
352
+
353
+ const canonicalKey = record.value.toLowerCase();
354
+ if (usedKeys.has(canonicalKey)) continue;
355
+
356
+ const aliases = mergeAliases(
357
+ buildGeneratedAliases(record.identity),
358
+ hiddenModelAliases.get(record.value) ?? [],
359
+ )
360
+ .filter((alias) => alias.toLowerCase() !== canonicalKey)
361
+ .filter((alias) => !usedKeys.has(alias.toLowerCase()));
362
+
363
+ usedKeys.add(canonicalKey);
364
+ for (const alias of aliases) {
365
+ usedKeys.add(alias.toLowerCase());
366
+ }
367
+
368
+ const oneMillionContextModelId = record.identity.isOneMillion
369
+ ? undefined
370
+ : record.familyKey
371
+ ? oneMillionContextVariants.get(record.familyKey)
372
+ : undefined;
373
+
374
+ models.push({
375
+ id: record.value,
376
+ displayName: record.displayName,
377
+ description: record.description,
378
+ aliases,
379
+ provider: "anthropic",
380
+ capabilities: {
381
+ supports1mContext:
382
+ record.identity.isOneMillion ||
383
+ oneMillionContextModelId !== undefined,
384
+ ...(oneMillionContextModelId !== undefined
385
+ ? { oneMillionContextModelId }
386
+ : {}),
387
+ },
388
+ tier:
389
+ (record.familyKey ? tierByFamily.get(record.familyKey) : undefined) ??
390
+ inferTierFromText(
391
+ `${record.value} ${record.displayName} ${record.description}`,
392
+ ),
393
+ });
394
+ }
70
395
 
71
396
  const tierOrder = { premium: 0, balanced: 1, economy: 2 };
72
397
  models.sort((a, b) => tierOrder[a.tier] - tierOrder[b.tier]);
@@ -137,11 +462,7 @@ export async function registerClaudeModels(sdkOptions: {
137
462
  );
138
463
  });
139
464
 
140
- let sdkModels: Array<{
141
- value: string;
142
- displayName: string;
143
- description: string;
144
- }>;
465
+ let sdkModels: SdkModelInfo[];
145
466
  try {
146
467
  sdkModels = await Promise.race([q.supportedModels(), timeout]);
147
468
  } finally {
@@ -183,34 +504,30 @@ export function registerClaudeModelsStatic(models: ModelInfo[]): void {
183
504
  }
184
505
 
185
506
  /** Default model definitions for CLI setup wizard and tests. */
186
- export const CLAUDE_MODELS_STATIC: ModelInfo[] = [
507
+ export const CLAUDE_MODELS_STATIC: ModelInfo[] = convertSdkModels([
508
+ {
509
+ value: "default",
510
+ displayName: "Default (recommended)",
511
+ description: "Sonnet · Best for everyday tasks",
512
+ },
513
+ {
514
+ value: "sonnet[1m]",
515
+ displayName: "Sonnet (1M context)",
516
+ description: "Sonnet with 1M context · Large context window",
517
+ },
187
518
  {
188
- id: "claude-opus-4-6",
189
- displayName: "Opus 4.6",
190
- description: "smartest",
191
- aliases: ["opus", "opus-4.6", "opus-4-6"],
192
- provider: "anthropic",
193
- capabilities: { supports1mContext: true },
194
- tier: "premium",
195
- fallback: "claude-sonnet-4-6",
519
+ value: "opus",
520
+ displayName: "Opus",
521
+ description: "Opus · Most capable for complex work",
196
522
  },
197
523
  {
198
- id: "claude-sonnet-4-6",
199
- displayName: "Sonnet 4.6",
200
- description: "fast, balanced",
201
- aliases: ["sonnet", "sonnet-4.6", "sonnet-4-6"],
202
- provider: "anthropic",
203
- capabilities: { supports1mContext: true },
204
- tier: "balanced",
205
- fallback: "claude-haiku-4-5",
524
+ value: "opus[1m]",
525
+ displayName: "Opus (1M context)",
526
+ description: "Opus with 1M context · Large context window",
206
527
  },
207
528
  {
208
- id: "claude-haiku-4-5",
209
- displayName: "Haiku 4.5",
210
- description: "fastest, cheapest",
211
- aliases: ["haiku", "haiku-4.5", "haiku-4-5"],
212
- provider: "anthropic",
213
- capabilities: { supports1mContext: false },
214
- tier: "economy",
529
+ value: "haiku",
530
+ displayName: "Haiku",
531
+ description: "Haiku · Fastest for quick answers",
215
532
  },
216
- ];
533
+ ]);
@@ -10,7 +10,7 @@ import type { Options } from "@anthropic-ai/claude-agent-sdk";
10
10
  import { getSession } from "../../storage/sessions.js";
11
11
  import { getChatSettings } from "../../storage/chat-settings.js";
12
12
  import { getPluginMcpServers } from "../../core/plugin.js";
13
- import { supports1mContext } from "../../core/models.js";
13
+ import { get1mContextModelId, resolveModelId } from "../../core/models.js";
14
14
  import { getConfig, getBridgePort } from "./state.js";
15
15
  import { DISALLOWED_TOOLS_CHAT, EFFORT_MAP } from "./constants.js";
16
16
 
@@ -96,14 +96,16 @@ export function buildSdkOptions(chatId: string): BuildSdkOptionsResult {
96
96
  const chatSettings = getChatSettings(chatId);
97
97
  const activeModel = chatSettings.model ?? config.model;
98
98
  const activeEffort = chatSettings.effort ?? "adaptive";
99
+ const resolvedActiveModel = resolveModelId(activeModel);
99
100
 
100
101
  const thinkingConfig = EFFORT_MAP[activeEffort] ?? {
101
102
  thinking: { type: "adaptive" as const },
102
103
  };
103
104
 
104
- const canUse1m =
105
- supports1mContext(activeModel) && !activeModel.includes("[1m]");
106
- const sdkModel = canUse1m ? `${activeModel}[1m]` : activeModel;
105
+ const oneMillionContextModelId = resolvedActiveModel.endsWith("[1m]")
106
+ ? null
107
+ : get1mContextModelId(resolvedActiveModel);
108
+ const sdkModel = oneMillionContextModelId ?? resolvedActiveModel;
107
109
 
108
110
  const session = getSession(chatId);
109
111
 
@@ -193,7 +193,7 @@ export function processResultMessage(
193
193
  }
194
194
 
195
195
  // Read token counts from the ACTIVE model's usage only.
196
- // modelUsage is keyed by the exact SDK model string (e.g. "claude-sonnet-4-6[1m]")
196
+ // modelUsage is keyed by the exact SDK model string (e.g. "sonnet[1m]")
197
197
  // and contains cumulative session totals per model — summing all entries
198
198
  // double-counts when switching models mid-session.
199
199
  const modelUsage: Record<string, ModelUsage> = msg.modelUsage;
package/src/cli.ts CHANGED
@@ -60,7 +60,7 @@ type Config = {
60
60
 
61
61
  const DEFAULTS: Config = {
62
62
  frontend: "telegram",
63
- model: "claude-sonnet-4-6",
63
+ model: "default",
64
64
  concurrency: 1,
65
65
  pulse: true,
66
66
  pulseIntervalMs: 300000,
@@ -14,10 +14,12 @@ export type ModelTier = "premium" | "balanced" | "economy";
14
14
  export type ModelCapabilities = {
15
15
  /** Whether the model supports the 1M token context window. */
16
16
  supports1mContext: boolean;
17
+ /** Exact model ID to use for the 1M token context window, if available. */
18
+ oneMillionContextModelId?: string;
17
19
  };
18
20
 
19
21
  export type ModelInfo = {
20
- /** Canonical model ID (e.g. "claude-sonnet-4-6"). */
22
+ /** Canonical SDK model ID (e.g. "default", "opus", "sonnet[1m]"). */
21
23
  id: string;
22
24
  /** Human-readable display name for UIs (e.g. "Sonnet 4.6"). */
23
25
  displayName: string;
@@ -48,6 +50,34 @@ const TIER_ORDER: Record<ModelTier, number> = {
48
50
  const models = new Map<string, ModelInfo>();
49
51
  const aliasIndex = new Map<string, string>();
50
52
 
53
+ function resolveGenericFamilyAlias(input: string): string | null {
54
+ const trimmed = input.trim().toLowerCase();
55
+ if (!trimmed) return null;
56
+
57
+ const isOneMillion = trimmed.endsWith("[1m]");
58
+ let base = isOneMillion ? trimmed.slice(0, -4) : trimmed;
59
+ if (base.startsWith("claude-")) {
60
+ base = base.slice("claude-".length);
61
+ }
62
+
63
+ const tokens = base.replace(/\./g, "-").split("-").filter(Boolean);
64
+ if (tokens.length === 0) return null;
65
+
66
+ let boundary = tokens.length;
67
+ while (boundary > 0 && /^\d+$/.test(tokens[boundary - 1] ?? "")) {
68
+ boundary -= 1;
69
+ }
70
+
71
+ const family = tokens.slice(
72
+ 0,
73
+ boundary === tokens.length ? tokens.length : boundary,
74
+ );
75
+ if (family.length === 0) return null;
76
+
77
+ const alias = family.join("-");
78
+ return isOneMillion ? `${alias}[1m]` : alias;
79
+ }
80
+
51
81
  // ── Registration ────────────────────────────────────────────────────────────
52
82
 
53
83
  /** Register one or more models. Idempotent — re-registration overwrites. */
@@ -93,7 +123,16 @@ export function getModels(provider?: string): ModelInfo[] {
93
123
  */
94
124
  export function resolveModelId(input: string): string {
95
125
  const lower = input.trim().toLowerCase();
96
- return aliasIndex.get(lower) ?? input.trim();
126
+ const direct = aliasIndex.get(lower);
127
+ if (direct) return direct;
128
+
129
+ const genericAlias = resolveGenericFamilyAlias(input);
130
+ if (genericAlias) {
131
+ const resolved = aliasIndex.get(genericAlias);
132
+ if (resolved) return resolved;
133
+ }
134
+
135
+ return input.trim();
97
136
  }
98
137
 
99
138
  /**
@@ -106,16 +145,21 @@ export function resolveModel(input: string): ModelInfo | undefined {
106
145
 
107
146
  /** Get the fallback model ID for a given model, or null if none configured. */
108
147
  export function getFallbackModel(modelId: string): string | null {
109
- return models.get(modelId)?.fallback ?? null;
148
+ return resolveModel(modelId)?.fallback ?? null;
110
149
  }
111
150
 
112
151
  /** Check whether a model supports the 1M token context window. */
113
152
  export function supports1mContext(modelId: string): boolean {
114
- const info = models.get(modelId);
153
+ const info = resolveModel(modelId);
115
154
  // Default to true for unknown models (don't restrict capabilities we can't check)
116
155
  return info?.capabilities.supports1mContext ?? true;
117
156
  }
118
157
 
158
+ /** Resolve the exact 1M-context model ID for a given model, if one exists. */
159
+ export function get1mContextModelId(modelId: string): string | null {
160
+ return resolveModel(modelId)?.capabilities.oneMillionContextModelId ?? null;
161
+ }
162
+
119
163
  /**
120
164
  * Get the default model for a given tier. Returns the first registered model
121
165
  * matching the tier, or the first model overall, or the hardcoded fallback.
@@ -125,7 +169,7 @@ export function getDefaultModel(tier: ModelTier = "balanced"): string {
125
169
  if (byTier) return byTier.id;
126
170
  const first = models.values().next();
127
171
  if (!first.done) return first.value.id;
128
- return "claude-sonnet-4-6"; // ultimate fallback if registry is empty
172
+ return "default"; // ultimate fallback if the registry is still empty
129
173
  }
130
174
 
131
175
  // ── Provider-scoped clearing ────────────────────────────────────────────────