@oh-my-pi/pi-catalog 18.2.0 → 18.2.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 (41) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/types/compat/behavior.d.ts +11 -0
  3. package/dist/types/compat/types.d.ts +21 -0
  4. package/dist/types/discovery/antigravity.d.ts +10 -1
  5. package/dist/types/model-thinking.d.ts +7 -0
  6. package/dist/types/provider-models/openai-compat.d.ts +2 -0
  7. package/dist/types/types.d.ts +26 -1
  8. package/package.json +4 -4
  9. package/src/compat/axes.ts +14 -0
  10. package/src/compat/behavior.ts +22 -2
  11. package/src/compat/cascade.ts +3 -1
  12. package/src/compat/context-window.ts +11 -1
  13. package/src/compat/resolve.ts +42 -16
  14. package/src/compat/rules/README.md +3 -1
  15. package/src/compat/rules/classes/deepseek.kdl +9 -1
  16. package/src/compat/rules/classes/kimi.kdl +6 -0
  17. package/src/compat/rules/providers/alibaba-token-plan.kdl +16 -8
  18. package/src/compat/rules/providers/amazon-bedrock.kdl +30 -0
  19. package/src/compat/rules/providers/azure.kdl +6 -0
  20. package/src/compat/rules/providers/cerebras.kdl +10 -0
  21. package/src/compat/rules/providers/commandcode.kdl +20 -4
  22. package/src/compat/rules/providers/cursor.kdl +32 -0
  23. package/src/compat/rules/providers/deepseek.kdl +5 -5
  24. package/src/compat/rules/providers/google-vertex.kdl +15 -0
  25. package/src/compat/rules/providers/meta.kdl +3 -0
  26. package/src/compat/rules/providers/muse-code.kdl +3 -0
  27. package/src/compat/rules/providers/openrouter.kdl +6 -0
  28. package/src/compat/rules/runtime/behavior.kdl +17 -0
  29. package/src/compat/rules/taxonomy/deepseek.kdl +5 -0
  30. package/src/compat/rules.json +1 -1
  31. package/src/compat/types.ts +23 -0
  32. package/src/discovery/antigravity.ts +80 -43
  33. package/src/identity/bundled.ts +4 -3
  34. package/src/model-cache.ts +115 -37
  35. package/src/model-thinking.ts +10 -7
  36. package/src/models.json +1 -1
  37. package/src/provider-models/bundled-references.ts +4 -3
  38. package/src/provider-models/cache-provider-id.ts +14 -8
  39. package/src/provider-models/ollama.ts +11 -31
  40. package/src/provider-models/openai-compat.ts +94 -24
  41. package/src/types.ts +28 -0
@@ -293,6 +293,12 @@ export interface CompiledExcludeModels {
293
293
  match: CompiledMatchList;
294
294
  }
295
295
 
296
+ /** Exact upstream discovery modes excluded from one provider's coding-model roster. */
297
+ export interface CompiledExcludeDiscoveryModes {
298
+ provider: string;
299
+ modes: string[];
300
+ }
301
+
296
302
  /** Provider plan-requirement tiers keyed by matcher token lists. */
297
303
  export interface CompiledPlanRequirement {
298
304
  provider: string;
@@ -306,6 +312,12 @@ export interface CompiledPricingPeer {
306
312
  aliases: { model: string; peerId: string }[];
307
313
  }
308
314
 
315
+ /** Provider timezone assumption for offset-less absolute retry-reset timestamps. */
316
+ export interface CompiledRetryResetTimezone {
317
+ provider: string;
318
+ offset: string;
319
+ }
320
+
309
321
  /** Compiled runtime behavior vocabulary (`runtime/behavior.kdl`). */
310
322
  export interface CompiledBehavior {
311
323
  openaiResponsesHeuristic?: CompiledResponsesHeuristic;
@@ -316,10 +328,13 @@ export interface CompiledBehavior {
316
328
  hostedDefaults: CompiledHostedDefault[];
317
329
  apiRoutes: CompiledApiRoutes[];
318
330
  modelLimits: CompiledModelLimits[];
331
+ excludeDiscoveryModes: CompiledExcludeDiscoveryModes[];
319
332
  excludeModels: CompiledExcludeModels[];
320
333
  planRequirements: CompiledPlanRequirement[];
321
334
  pricingPeers: CompiledPricingPeer[];
335
+ retryResetTimezones: CompiledRetryResetTimezone[];
322
336
  retiredProviders: string[];
337
+ referenceIsolatedProviders: string[];
323
338
  }
324
339
 
325
340
  /**
@@ -672,4 +687,12 @@ export interface ResolvedAxes {
672
687
  wire: Record<string, unknown>;
673
688
  thinking: Record<string, unknown>;
674
689
  catalog: Record<string, unknown>;
690
+ /**
691
+ * Reasoning capability after the exact-model effort upgrade: `true` when the
692
+ * target reported reasoning or an exact rule declares a ladder for it (the
693
+ * reviewed correction to metadata-less discovery rows). Compat resolvers
694
+ * read this instead of the raw spec flag, or one id resolves two different
695
+ * wire contracts depending on whether it came from discovery or the bake.
696
+ */
697
+ reasoning: boolean;
675
698
  }
@@ -1,4 +1,5 @@
1
1
  import { type } from "@oh-my-pi/omptype";
2
+ import type { FetchImpl } from "@oh-my-pi/pi-utils";
2
3
  import { collapseVariants, type VariantCollapseTable } from "../compat/collapse";
3
4
  import type { ModelSpec } from "../types";
4
5
  import { discoveryFetch, toPositiveNumber } from "../utils";
@@ -51,6 +52,7 @@ export interface AntigravityDiscoveryAgentModelSort {
51
52
  export interface AntigravityDiscoveryApiResponse {
52
53
  models?: Record<string, AntigravityDiscoveryApiModel>;
53
54
  agentModelSorts?: AntigravityDiscoveryAgentModelSort[];
55
+ imageGenerationModelIds?: string[];
54
56
  }
55
57
  const AntigravityDiscoveryApiModelSchema = type({
56
58
  "displayName?": type("unknown").pipe(value => (typeof value === "string" ? value : undefined)),
@@ -123,6 +125,9 @@ const AntigravityDiscoveryApiResponseSchema = type({
123
125
  }
124
126
  return result;
125
127
  }),
128
+ "imageGenerationModelIds?": type("unknown").pipe(value =>
129
+ Array.isArray(value) ? value.filter((modelId): modelId is string => typeof modelId === "string") : undefined,
130
+ ),
126
131
  });
127
132
  /**
128
133
  * Options for fetching Antigravity discovery models.
@@ -139,7 +144,7 @@ export interface FetchAntigravityDiscoveryModelsOptions {
139
144
  /** Optional abort signal for request cancellation. */
140
145
  signal?: AbortSignal;
141
146
  /** Optional fetch implementation override for tests. */
142
- fetcher?: typeof fetch;
147
+ fetcher?: FetchImpl;
143
148
  /**
144
149
  * Hand collapse table to apply to the discovered list. Defaults to the
145
150
  * Antigravity (budget-transport) table; `googleGeminiCli` passes the
@@ -157,6 +162,78 @@ export interface FetchAntigravityDiscoveryModelsOptions {
157
162
  export async function fetchAntigravityDiscoveryModels(
158
163
  options: FetchAntigravityDiscoveryModelsOptions,
159
164
  ): Promise<ModelSpec<"google-gemini-cli">[] | null> {
165
+ const discovered = await fetchAntigravityDiscoveryResponse(options);
166
+ if (!discovered) {
167
+ return null;
168
+ }
169
+
170
+ const models: ModelSpec<"google-gemini-cli">[] = [];
171
+ const apiModels = discovered.payload.models;
172
+ if (apiModels) {
173
+ for (const modelId in apiModels) {
174
+ const model = apiModels[modelId];
175
+ if (ANTIGRAVITY_DISCOVERY_DENYLIST.has(modelId)) {
176
+ continue;
177
+ }
178
+ if (model.isInternal === true) {
179
+ continue;
180
+ }
181
+
182
+ const supportsImages = model.supportsImages === true;
183
+ models.push({
184
+ id: modelId,
185
+ name: model.displayName || modelId,
186
+ api: "google-gemini-cli",
187
+ provider: "google-antigravity",
188
+ baseUrl: discovered.endpoint,
189
+ reasoning: model.supportsThinking === true,
190
+ input: supportsImages ? ["text", "image"] : ["text"],
191
+ cost: {
192
+ input: 0,
193
+ output: 0,
194
+ cacheRead: 0,
195
+ cacheWrite: 0,
196
+ },
197
+ contextWindow: toPositiveNumber(model.maxTokens, DEFAULT_CONTEXT_WINDOW),
198
+ maxTokens: toPositiveNumber(model.maxOutputTokens, DEFAULT_MAX_TOKENS),
199
+ });
200
+ }
201
+ }
202
+
203
+ // Collapse effort-tier variants at the source so runtime discovery,
204
+ // the gemini-cli re-provision, and the catalog generator all see
205
+ // logical ids only.
206
+ const collapsed = collapseVariants(
207
+ models,
208
+ options.collapseTable === undefined ? undefined : { table: options.collapseTable },
209
+ );
210
+ collapsed.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
211
+ return collapsed;
212
+ }
213
+
214
+ /** Advertised image model and serving endpoint for one Antigravity account. */
215
+ export interface AntigravityImageModel {
216
+ id: string;
217
+ endpoint: string;
218
+ }
219
+
220
+ /** Resolves the first image-generation model advertised by an Antigravity account. */
221
+ export async function fetchAntigravityImageModel(
222
+ options: FetchAntigravityDiscoveryModelsOptions,
223
+ ): Promise<AntigravityImageModel | null> {
224
+ const discovered = await fetchAntigravityDiscoveryResponse(options);
225
+ const id = discovered?.payload.imageGenerationModelIds?.find(modelId => modelId.length > 0);
226
+ return id && discovered ? { id, endpoint: discovered.endpoint } : null;
227
+ }
228
+
229
+ interface AntigravityDiscoveryResponse {
230
+ payload: AntigravityDiscoveryApiResponse;
231
+ endpoint: string;
232
+ }
233
+
234
+ async function fetchAntigravityDiscoveryResponse(
235
+ options: FetchAntigravityDiscoveryModelsOptions,
236
+ ): Promise<AntigravityDiscoveryResponse | null> {
160
237
  if (options.userAgent === undefined) {
161
238
  await ensureAntigravityVersion(options.fetcher ?? fetch, options.signal);
162
239
  }
@@ -195,49 +272,9 @@ export async function fetchAntigravityDiscoveryModels(
195
272
  }
196
273
 
197
274
  const parsed = parseAntigravityDiscoveryResponse(payload);
198
- if (!parsed) {
199
- continue;
275
+ if (parsed) {
276
+ return { payload: parsed, endpoint };
200
277
  }
201
-
202
- const models: ModelSpec<"google-gemini-cli">[] = [];
203
-
204
- for (const [modelId, model] of Object.entries(parsed.models ?? {})) {
205
- if (ANTIGRAVITY_DISCOVERY_DENYLIST.has(modelId)) {
206
- continue;
207
- }
208
- if (model.isInternal === true) {
209
- continue;
210
- }
211
-
212
- const supportsImages = model.supportsImages === true;
213
- models.push({
214
- id: modelId,
215
- name: model.displayName || modelId,
216
- api: "google-gemini-cli",
217
- provider: "google-antigravity",
218
- baseUrl: endpoint,
219
- reasoning: model.supportsThinking === true,
220
- input: supportsImages ? ["text", "image"] : ["text"],
221
- cost: {
222
- input: 0,
223
- output: 0,
224
- cacheRead: 0,
225
- cacheWrite: 0,
226
- },
227
- contextWindow: toPositiveNumber(model.maxTokens, DEFAULT_CONTEXT_WINDOW),
228
- maxTokens: toPositiveNumber(model.maxOutputTokens, DEFAULT_MAX_TOKENS),
229
- });
230
- }
231
-
232
- // Collapse effort-tier variants at the source so runtime discovery,
233
- // the gemini-cli re-provision, and the catalog generator all see
234
- // logical ids only.
235
- const collapsed = collapseVariants(
236
- models,
237
- options.collapseTable === undefined ? undefined : { table: options.collapseTable },
238
- );
239
- collapsed.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
240
- return collapsed;
241
278
  }
242
279
 
243
280
  return null;
@@ -6,6 +6,7 @@
6
6
  * non-bundled reference data use the pure builder directly
7
7
  * ({@link buildModelReferenceIndex}).
8
8
  */
9
+ import { isBareIdReferenceProvider } from "../compat/behavior";
9
10
  import { getBundledModels, getBundledProviders } from "../models";
10
11
  import type { Api, Model } from "../types";
11
12
  import { buildModelReferenceIndex, type ModelReferenceIndex } from "./reference";
@@ -13,9 +14,9 @@ import { buildModelReferenceIndex, type ModelReferenceIndex } from "./reference"
13
14
  let bundledModels: readonly Model<Api>[] | undefined;
14
15
 
15
16
  function getBundledModelList(): readonly Model<Api>[] {
16
- bundledModels ??= getBundledProviders().flatMap(
17
- provider => getBundledModels(provider as Parameters<typeof getBundledModels>[0]) as Model<Api>[],
18
- );
17
+ bundledModels ??= getBundledProviders()
18
+ .filter(isBareIdReferenceProvider)
19
+ .flatMap(provider => getBundledModels(provider as Parameters<typeof getBundledModels>[0]) as Model<Api>[]);
19
20
  return bundledModels;
20
21
  }
21
22
 
@@ -31,12 +31,19 @@ const HEADER_RESTORE_VERSION = 1;
31
31
  * Explicit compatibility gate for materialized rows. Bump whenever buildModel
32
32
  * semantics change without an app-version change. The compiled-rules content
33
33
  * hash catches every KDL policy edit even when the package version is unchanged;
34
- * it is computed once per process rather than once per provider or model.
34
+ * computed lazily on first cache access and memoized, so processes that never
35
+ * touch the model cache skip the stringify entirely.
35
36
  */
36
37
  const MODEL_MATERIALIZATION_VERSION = 1;
37
- const MATERIALIZATION_POLICY =
38
- `app-${VERSION}:builder-${MODEL_MATERIALIZATION_VERSION}:rules-${RULES.version}-` +
39
- Bun.hash(JSON.stringify(RULES)).toString(36);
38
+ let cachedMaterializationPolicy: string | undefined;
39
+ function materializationPolicy(): string {
40
+ if (cachedMaterializationPolicy === undefined) {
41
+ cachedMaterializationPolicy =
42
+ `app-${VERSION}:builder-${MODEL_MATERIALIZATION_VERSION}:rules-${RULES.version}-` +
43
+ Bun.hash(JSON.stringify(RULES)).toString(36);
44
+ }
45
+ return cachedMaterializationPolicy;
46
+ }
40
47
 
41
48
  interface CacheRow {
42
49
  provider_id: string;
@@ -88,6 +95,46 @@ export interface CacheEntry<TApi extends Api = Api> {
88
95
  let sharedDb: Database | null = null;
89
96
  let sharedDbPath: string | null = null;
90
97
 
98
+ const readRowCache = new Map<string, { dataVersion: number; entry: CacheEntry<Api> | null }>();
99
+ const READ_ROW_CACHE_MAX = 64;
100
+
101
+ function readCacheKey(resolvedPath: string, providerId: string): string {
102
+ return `${resolvedPath} ${providerId}`;
103
+ }
104
+
105
+ function dbDataVersion(db: Database): number | null {
106
+ try {
107
+ const row = db.query<{ data_version: number }, []>("PRAGMA data_version").get();
108
+ return typeof row?.data_version === "number" ? row.data_version : null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ function withFreshness<TApi extends Api>(
115
+ entry: CacheEntry<TApi> | null,
116
+ ttlMs: number,
117
+ now: () => number,
118
+ ): CacheEntry<TApi> | null {
119
+ if (entry === null) return null;
120
+ const ageMs = now() - entry.updatedAt;
121
+ const fresh = Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= ttlMs;
122
+ return fresh === entry.fresh ? entry : { ...entry, fresh };
123
+ }
124
+ function invalidateReadRow(providerId: string, dbPath?: string): void {
125
+ try {
126
+ readRowCache.delete(readCacheKey(dbPath ?? getModelDbPath(), providerId));
127
+ } catch {
128
+ // Best-effort only; a missed invalidation just costs one extra parse.
129
+ }
130
+ }
131
+
132
+ function invalidateReadPath(resolvedPath: string): void {
133
+ for (const key of readRowCache.keys()) {
134
+ if (key.startsWith(`${resolvedPath} `)) readRowCache.delete(key);
135
+ }
136
+ }
137
+
91
138
  function openDb(resolvedPath: string): Database {
92
139
  const db = new Database(resolvedPath, { create: true });
93
140
  // Install the busy handler BEFORE any lock-taking statement. See
@@ -178,6 +225,7 @@ function healCorruptModelCache(resolvedPath: string, shared: boolean, err: unkno
178
225
  sharedDb = null;
179
226
  sharedDbPath = null;
180
227
  }
228
+ invalidateReadPath(resolvedPath);
181
229
  quarantineCorruptModelCache(resolvedPath);
182
230
  const code = err && typeof err === "object" && "code" in err ? err.code : undefined;
183
231
  if (reportedCorruptPaths.has(resolvedPath)) {
@@ -232,7 +280,7 @@ function migrateCacheSchema(db: Database): void {
232
280
  // compaction path even after CACHE_SCHEMA_VERSION was bumped).
233
281
  db.run("DELETE FROM model_cache WHERE version <> ? OR materialization_policy <> ?", [
234
282
  CACHE_SCHEMA_VERSION,
235
- MATERIALIZATION_POLICY,
283
+ materializationPolicy(),
236
284
  ]);
237
285
  }
238
286
 
@@ -313,7 +361,6 @@ function parseModelIds(serialized: string): string[] | null {
313
361
  return null;
314
362
  }
315
363
  }
316
-
317
364
  export function readModelCache<TApi extends Api>(
318
365
  providerId: string,
319
366
  ttlMs: number,
@@ -321,44 +368,74 @@ export function readModelCache<TApi extends Api>(
321
368
  dbPath?: string,
322
369
  ): CacheEntry<TApi> | null {
323
370
  try {
324
- return withModelCacheDb(dbPath, db => {
325
- const stmt = db.query<CacheRow, [string]>("SELECT * FROM model_cache WHERE provider_id = ?");
326
- try {
327
- const row = stmt.get(providerId);
328
- if (!row || row.version !== CACHE_SCHEMA_VERSION || row.materialization_policy !== MATERIALIZATION_POLICY) {
329
- return null;
371
+ const resolvedPath = dbPath ?? getModelDbPath();
372
+ const key = readCacheKey(resolvedPath, providerId);
373
+ // Monotonic change signal: same-shaped WAL overwrites after checkpoint
374
+ // can leave every size:mtime pair identical, so file metadata alone
375
+ // cannot invalidate. PRAGMA data_version increments on each committed
376
+ // write transaction visible to a new reader.
377
+ const entry = withModelCacheDb(dbPath, db => {
378
+ const dataVersion = dbDataVersion(db);
379
+ if (dataVersion !== null) {
380
+ const cached = readRowCache.get(key);
381
+ if (cached !== undefined && cached.dataVersion === dataVersion) {
382
+ // Freshness is time-relative: recompute per call from the
383
+ // cached row's updatedAt so a long-lived process goes stale.
384
+ return { hit: true as const, entry: withFreshness(cached.entry as CacheEntry<TApi> | null, ttlMs, now) };
330
385
  }
331
- const models = parseMaterializedModels<TApi>(row.models);
332
- const headerOmittedModelIds = parseModelIds(row.header_omitted_model_ids);
333
- const unrestorableHeaderModelIds = parseModelIds(row.unrestorable_header_model_ids);
334
- if (models === null || headerOmittedModelIds === null || unrestorableHeaderModelIds === null) {
335
- // Fail closed on corrupt header provenance: treating malformed
336
- // markers as empty could return a model with required credentials
337
- // silently absent. secure_delete scrubs the rejected payload.
338
- db.run("DELETE FROM model_cache WHERE provider_id = ?", [providerId]);
339
- return null;
340
- }
341
- const ageMs = now() - row.updated_at;
342
- const fresh = Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= ttlMs;
343
- return {
344
- models,
345
- fresh,
346
- authoritative: row.authoritative === 1,
347
- updatedAt: row.updated_at,
348
- headerOmittedModelIds,
349
- unrestorableHeaderModelIds,
350
- legacyHeaderRestoreMarkers: row.header_restore_version < HEADER_RESTORE_VERSION,
351
- staticFingerprint: row.static_fingerprint ?? "",
352
- };
353
- } finally {
354
- stmt.finalize();
355
386
  }
387
+ const fresh = readRowUncached<TApi>(db, providerId, ttlMs, now);
388
+ if (dataVersion !== null) {
389
+ if (readRowCache.size >= READ_ROW_CACHE_MAX) readRowCache.clear();
390
+ readRowCache.set(key, { dataVersion, entry: fresh as CacheEntry<Api> | null });
391
+ }
392
+ return { hit: false as const, entry: fresh };
356
393
  });
394
+ return entry.entry;
357
395
  } catch {
358
396
  return null;
359
397
  }
360
398
  }
361
399
 
400
+ function readRowUncached<TApi extends Api>(
401
+ db: Database,
402
+ providerId: string,
403
+ ttlMs: number,
404
+ now: () => number,
405
+ ): CacheEntry<TApi> | null {
406
+ const stmt = db.query<CacheRow, [string]>("SELECT * FROM model_cache WHERE provider_id = ?");
407
+ try {
408
+ const row = stmt.get(providerId);
409
+ if (!row || row.version !== CACHE_SCHEMA_VERSION || row.materialization_policy !== materializationPolicy()) {
410
+ return null;
411
+ }
412
+ const models = parseMaterializedModels<TApi>(row.models);
413
+ const headerOmittedModelIds = parseModelIds(row.header_omitted_model_ids);
414
+ const unrestorableHeaderModelIds = parseModelIds(row.unrestorable_header_model_ids);
415
+ if (models === null || headerOmittedModelIds === null || unrestorableHeaderModelIds === null) {
416
+ // Fail closed on corrupt header provenance: treating malformed
417
+ // markers as empty could return a model with required credentials
418
+ // silently absent. secure_delete scrubs the rejected payload.
419
+ db.run("DELETE FROM model_cache WHERE provider_id = ?", [providerId]);
420
+ return null;
421
+ }
422
+ const ageMs = now() - row.updated_at;
423
+ const fresh = Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= ttlMs;
424
+ return {
425
+ models,
426
+ fresh,
427
+ authoritative: row.authoritative === 1,
428
+ updatedAt: row.updated_at,
429
+ headerOmittedModelIds,
430
+ unrestorableHeaderModelIds,
431
+ legacyHeaderRestoreMarkers: row.header_restore_version < HEADER_RESTORE_VERSION,
432
+ staticFingerprint: row.static_fingerprint ?? "",
433
+ };
434
+ } finally {
435
+ stmt.finalize();
436
+ }
437
+ }
438
+
362
439
  /** Whether a live model carries at least one request header. */
363
440
  function hasModelHeaders(model: Model<Api>): boolean {
364
441
  const headers = model.headers;
@@ -406,6 +483,7 @@ export function writeModelCache<TApi extends Api>(
406
483
  restorableHeaderFallback?: Record<string, string>,
407
484
  ): void {
408
485
  try {
486
+ invalidateReadRow(providerId, dbPath);
409
487
  withModelCacheDb(dbPath, db => {
410
488
  const headerOmittedModelIds: string[] = [];
411
489
  const unrestorableHeaderModelIds: string[] = [];
@@ -442,7 +520,7 @@ export function writeModelCache<TApi extends Api>(
442
520
  [
443
521
  providerId,
444
522
  CACHE_SCHEMA_VERSION,
445
- MATERIALIZATION_POLICY,
523
+ materializationPolicy(),
446
524
  updatedAt,
447
525
  authoritative ? 1 : 0,
448
526
  staticFingerprint,
@@ -108,19 +108,22 @@ export function mapEffortToGoogleThinkingLevel<TApi extends Api>(
108
108
  /**
109
109
  * Maps a normalized thinking effort to Anthropic adaptive effort values via
110
110
  * the model's baked `thinking.effortMap` (identity for unmapped efforts).
111
+ *
112
+ * The Anthropic adaptive wire vocabulary has no `minimal` tier (valid values:
113
+ * `low`, `medium`, `high`, `xhigh`, `max`); a model ladder that exposes
114
+ * `minimal` — custom `anthropic-messages` providers do, unlike the built-in
115
+ * Claude ladders — would otherwise serialize `output_config.effort: "minimal"`
116
+ * and 400 (`level "minimal" not supported`). Clamp it to the lowest real tier,
117
+ * mirroring {@link mapEffortToGoogleThinkingLevel}'s `minimal` handling.
111
118
  */
112
119
  export function mapEffortToAnthropicAdaptiveEffort<TApi extends Api>(
113
120
  model: ApiModel<TApi>,
114
121
  effort: Effort,
115
122
  ): "low" | "medium" | "high" | "xhigh" | "max" | "adaptive" {
116
123
  const supported = requireSupportedEffort(model, effort);
117
- return (model.thinking?.effortMap?.[supported] ?? supported) as
118
- | "low"
119
- | "medium"
120
- | "high"
121
- | "xhigh"
122
- | "max"
123
- | "adaptive";
124
+ const mapped = model.thinking?.effortMap?.[supported] ?? supported;
125
+ if (mapped === Effort.Minimal) return "low";
126
+ return mapped as "low" | "medium" | "high" | "xhigh" | "max" | "adaptive";
124
127
  }
125
128
 
126
129
  /**