@oh-my-pi/pi-catalog 16.0.4 → 16.0.6

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.
@@ -13,12 +13,22 @@ import {
13
13
  isClaudeModelId,
14
14
  isDeepseekModelIdOrName,
15
15
  isGlm52ReasoningEffortModelId,
16
+ isGrokReasoningEffortCapable,
16
17
  isKimiK26ModelId,
17
18
  isKimiModelId,
18
19
  isMimoModelIdOrName,
19
20
  isQwenModelId,
21
+ modelFamilyToken,
20
22
  } from "../identity/family";
21
- import type { ModelSpec, OpenAICompat, ResolvedOpenAICompat, ResolvedOpenAIResponsesCompat } from "../types";
23
+ import type {
24
+ ModelSpec,
25
+ OpenAICompat,
26
+ OpenAIStreamMarkupHealingPattern,
27
+ ResolvedOpenAICompat,
28
+ ResolvedOpenAIResponsesCompat,
29
+ ResolvedOpenAISharedCompat,
30
+ ResolvedOpenRouterCompat,
31
+ } from "../types";
22
32
  import { applyCompatOverrides } from "./apply";
23
33
 
24
34
  /** GLM coding-plan SKUs idle for minutes mid-reasoning; see `streamIdleTimeoutMs`. */
@@ -28,6 +38,76 @@ const GLM_CODING_PLAN_STREAM_IDLE_TIMEOUT_MS = 600_000;
28
38
  const DEEPSEEK_REASONING_STREAM_IDLE_TIMEOUT_MS = 300_000;
29
39
  /** Kimi K2.6 can spend several minutes reasoning before the first visible token. */
30
40
  const KIMI_K26_REASONING_STREAM_IDLE_TIMEOUT_MS = 300_000;
41
+ const MINIMAX_PROVIDER_OR_ID_PATTERN = /minimax/i;
42
+ const DSML_HEALING_PROVIDERS = new Set([
43
+ "ollama",
44
+ "ollama-cloud",
45
+ "nvidia",
46
+ "deepseek",
47
+ "fireworks",
48
+ "nanogpt",
49
+ "opencode-go",
50
+ "openrouter",
51
+ ]);
52
+
53
+ /**
54
+ * Ollama's OpenAI-compatible `reasoning.effort` only accepts
55
+ * `high|medium|low|max|none`; OMP's `minimal`/`xhigh` levels make the server
56
+ * reject the turn with HTTP 400 `invalid reasoning value`. Map the two
57
+ * unsupported levels onto the closest accepted ones. Stamped in the compat
58
+ * builder (not only at discovery) so stale-cached and custom `ollama`-provider
59
+ * specs are backfilled on every `buildModel`, not just on a fresh
60
+ * `omp models refresh`. Custom OpenAI-compatible providers pointed at a local
61
+ * Ollama port under a different provider id are not covered — they must set
62
+ * `compat.reasoningEffortMap` themselves.
63
+ */
64
+ const OLLAMA_REASONING_EFFORT_MAP: ResolvedOpenAISharedCompat["reasoningEffortMap"] = { minimal: "low", xhigh: "max" };
65
+
66
+ /**
67
+ * Merge the Ollama default effort map under any explicit overrides (overrides
68
+ * win). No-op off the local `ollama` provider or for non-reasoning models.
69
+ */
70
+ function mergeOllamaReasoningEffortMap(
71
+ compat: ResolvedOpenAISharedCompat,
72
+ provider: string,
73
+ reasoning: boolean | undefined,
74
+ ): void {
75
+ if (provider !== "ollama" || !reasoning) return;
76
+ compat.reasoningEffortMap = { ...OLLAMA_REASONING_EFFORT_MAP, ...compat.reasoningEffortMap };
77
+ }
78
+
79
+ function resolveReasoningDisableMode(
80
+ thinkingFormat: ResolvedOpenAISharedCompat["thinkingFormat"],
81
+ ): ResolvedOpenAISharedCompat["reasoningDisableMode"] {
82
+ switch (thinkingFormat) {
83
+ case "openrouter":
84
+ return "openrouter-enabled-false";
85
+ case "zai":
86
+ return "zai-thinking-disabled";
87
+ case "qwen":
88
+ return "qwen-enable-thinking-false";
89
+ case "qwen-chat-template":
90
+ return "qwen-template-false";
91
+ default:
92
+ return "lowest-effort";
93
+ }
94
+ }
95
+
96
+ function detectStreamMarkupHealingPattern(
97
+ provider: string,
98
+ modelId: string,
99
+ ): OpenAIStreamMarkupHealingPattern | undefined {
100
+ if (MINIMAX_PROVIDER_OR_ID_PATTERN.test(provider) || MINIMAX_PROVIDER_OR_ID_PATTERN.test(modelId)) {
101
+ return "thinking";
102
+ }
103
+ if (provider === "kimi-code" || provider === "moonshot" || /kimi[-/_.]?k2/i.test(modelId)) {
104
+ return "kimi";
105
+ }
106
+ if (isDeepseekModelIdOrName(modelId) && DSML_HEALING_PROVIDERS.has(provider)) {
107
+ return "dsml";
108
+ }
109
+ return undefined;
110
+ }
31
111
 
32
112
  /**
33
113
  * OpenCode's gateways (https://opencode.ai/zen|go) gate `reasoning_content`
@@ -196,6 +276,25 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
196
276
  ? DEEPSEEK_REASONING_STREAM_IDLE_TIMEOUT_MS
197
277
  : undefined;
198
278
 
279
+ const wireModelIdMode: ResolvedOpenAISharedCompat["wireModelIdMode"] =
280
+ provider === "firepass"
281
+ ? "firepass"
282
+ : provider === "fireworks"
283
+ ? "fireworks"
284
+ : isOpenRouter
285
+ ? "openrouter"
286
+ : "raw";
287
+ const thinkingFormat: ResolvedOpenAISharedCompat["thinkingFormat"] =
288
+ isZai || isZhipu || isMoonshotKimi || isXiaomiMimo
289
+ ? "zai"
290
+ : isOpenRouter
291
+ ? "openrouter"
292
+ : isQwen && isNvidiaNim
293
+ ? "qwen-chat-template"
294
+ : isAlibaba || isQwen
295
+ ? "qwen"
296
+ : "openai";
297
+
199
298
  const compat: ResolvedOpenAICompat = {
200
299
  supportsStore: !isNonStandard,
201
300
  // `developer` is an OpenAI-Responses-era extension to the chat-completions schema. Almost
@@ -211,6 +310,10 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
211
310
  supportsReasoningParams: provider !== "github-copilot",
212
311
  reasoningEffortMap: {},
213
312
  supportsUsageInStreaming: !isCerebras,
313
+ // pi-ai's thinking-loop guard is gemini-only; default the flag from the
314
+ // family classifier so OpenAI-compat proxies serving Gemini are covered.
315
+ // An opaque alias can opt in via `compat.enableGeminiThinkingLoopGuard`.
316
+ enableGeminiThinkingLoopGuard: modelFamilyToken(spec.id) === "gemini",
214
317
  // Kimi (including via OpenRouter and Fireworks router-form IDs such as
215
318
  // `accounts/fireworks/routers/kimi-*`) calculates TPM rate limits based on
216
319
  // max_tokens, not actual output. The official Kimi K2 model guidance
@@ -224,7 +327,7 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
224
327
  supportsForcedToolChoice: true,
225
328
  maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
226
329
  requiresToolResultName: isMistral,
227
- requiresAssistantAfterToolResult: false,
330
+ requiresAssistantAfterToolResult: isMistral,
228
331
  requiresThinkingAsText: isMistral,
229
332
  requiresMistralToolIds: isMistral,
230
333
  // Only Kimi's native hosts (Moonshot / Kimi-code, matched by `isMoonshotKimi`)
@@ -236,16 +339,11 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
236
339
  // (`chat_template_kwargs.enable_thinking`); top-level `enable_thinking`
237
340
  // is rejected by NIM's `additionalProperties: false` request schema
238
341
  // (issue #2299).
239
- thinkingFormat:
240
- isZai || isZhipu || isMoonshotKimi || isXiaomiMimo
241
- ? "zai"
242
- : isOpenRouter
243
- ? "openrouter"
244
- : isQwen && isNvidiaNim
245
- ? "qwen-chat-template"
246
- : isAlibaba || isQwen
247
- ? "qwen"
248
- : "openai",
342
+ thinkingFormat,
343
+ reasoningDisableMode: resolveReasoningDisableMode(thinkingFormat),
344
+ omitReasoningEffort: false,
345
+ includeEncryptedReasoning: true,
346
+ filterReasoningHistory: false,
249
347
  thinkingKeep: usesMoonshotKimiPreservedThinking ? "all" : undefined,
250
348
  reasoningContentField: "reasoning_content",
251
349
  // Backends that 400 follow-up requests when prior assistant tool-call turns lack `reasoning_content`:
@@ -266,6 +364,8 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
266
364
  (isDeepseekFamily && Boolean(spec.reasoning)) ||
267
365
  isXiaomiMimo ||
268
366
  (isOpenRouter && Boolean(spec.reasoning)),
367
+ requiresReasoningContentForAllAssistantTurns:
368
+ ((isDeepseekFamily && Boolean(spec.reasoning)) || isXiaomiMimo) && !isOpenRouter,
269
369
  // DeepSeek V4 and Xiaomi MiMo reject synthetic reasoning_content placeholders (".") on tool-call turns.
270
370
  // Kimi and OpenRouter accept them when actual reasoning is unavailable.
271
371
  allowsSyntheticReasoningContentForToolCalls: (!isDeepseekFamily || !spec.reasoning) && !isXiaomiMimo,
@@ -274,20 +374,45 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
274
374
  openRouterRouting: undefined,
275
375
  vercelGatewayRouting: undefined,
276
376
  isOpenRouterHost: isOpenRouter,
377
+ wireModelIdMode,
277
378
  isVercelGatewayHost: isVercelGateway,
278
379
  supportsStrictMode: detectStrictModeSupport(provider, baseUrl),
279
380
  extraBody: isDirectDeepseekReasoning ? { thinking: { type: "enabled" } } : undefined,
280
381
  toolStrictMode: isCerebras ? "all_strict" : "mixed",
382
+ toolSchemaFlavor: isMoonshotNative ? "moonshot-mfjs" : undefined,
281
383
  streamIdleTimeoutMs,
384
+ stripDeepseekSpecialTokens:
385
+ isDeepseekModelIdOrName(spec.id) && (provider === "nvidia" || provider === "deepseek"),
386
+ streamMarkupHealingPattern: detectStreamMarkupHealingPattern(provider, spec.id),
387
+ reasoningDeltasMayBeCumulative:
388
+ MINIMAX_PROVIDER_OR_ID_PATTERN.test(provider) || MINIMAX_PROVIDER_OR_ID_PATTERN.test(spec.id),
389
+ emptyLengthFinishIsContextError: provider === "ollama",
390
+ usesOpenAIToolCallIdLimit: provider === "openai",
391
+ promptCacheSessionHeader: undefined,
392
+ dropThinkingWhenReasoningEffort: provider === "fireworks",
282
393
  };
283
394
 
284
395
  applyCompatOverrides(compat, spec.compat);
396
+ if (spec.compat?.reasoningDisableMode === undefined) {
397
+ compat.reasoningDisableMode = resolveReasoningDisableMode(compat.thinkingFormat);
398
+ }
399
+ if (spec.compat?.omitReasoningEffort === undefined && !compat.supportsReasoningEffort) {
400
+ compat.omitReasoningEffort = true;
401
+ }
402
+ mergeOllamaReasoningEffortMap(compat, provider, spec.reasoning);
285
403
 
286
404
  const whenThinkingPolicy =
287
405
  spec.compat?.whenThinking ?? (isOpenCodeProvider && spec.reasoning ? OPENCODE_WHEN_THINKING : undefined);
288
406
  if (whenThinkingPolicy) {
289
407
  const variant: ResolvedOpenAICompat = { ...compat };
290
408
  applyCompatOverrides(variant, whenThinkingPolicy);
409
+ if (whenThinkingPolicy.reasoningDisableMode === undefined) {
410
+ variant.reasoningDisableMode = resolveReasoningDisableMode(variant.thinkingFormat);
411
+ }
412
+ if (whenThinkingPolicy.omitReasoningEffort === undefined && !variant.supportsReasoningEffort) {
413
+ variant.omitReasoningEffort = true;
414
+ }
415
+ mergeOllamaReasoningEffortMap(variant, provider, spec.reasoning);
291
416
  compat.whenThinking = variant;
292
417
  }
293
418
 
@@ -295,9 +420,11 @@ export function buildOpenAICompat(spec: ModelSpec<"openai-completions">): Resolv
295
420
  }
296
421
 
297
422
  interface OpenAIResponsesSpecLike {
423
+ id?: string;
298
424
  provider: string;
299
425
  name: string;
300
426
  baseUrl: string;
427
+ reasoning?: boolean;
301
428
  compat?: OpenAICompat;
302
429
  }
303
430
 
@@ -315,21 +442,88 @@ interface OpenAIResponsesSpecLike {
315
442
  export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): ResolvedOpenAIResponsesCompat {
316
443
  const baseUrl = spec.baseUrl ?? "";
317
444
  const isAzure = modelMatchesHost({ provider: spec.provider, baseUrl }, "azureOpenAI");
445
+ const isOpenRouter = modelMatchesHost({ provider: spec.provider, baseUrl }, "openrouter");
446
+ const isOpenAIUrl = hostMatchesUrl(baseUrl, "openai");
447
+ const id = spec.id ?? "";
448
+ const thinkingFormat: ResolvedOpenAISharedCompat["thinkingFormat"] = isOpenRouter ? "openrouter" : "openai";
449
+ const isKimiModel = id ? isKimiModelId(id) : false;
450
+ const isDeepseekFamily = id ? isDeepseekModelIdOrName(id) || isDeepseekModelIdOrName(spec.name) : false;
451
+ const reasoningCapable = Boolean(spec.reasoning);
452
+
318
453
  const compat: ResolvedOpenAIResponsesCompat = {
319
- supportsDeveloperRole: isAzure || hostMatchesUrl(baseUrl, "openai") || hostMatchesUrl(baseUrl, "githubCopilot"),
454
+ supportsDeveloperRole: isAzure || isOpenAIUrl || hostMatchesUrl(baseUrl, "githubCopilot"),
320
455
  supportsStrictMode:
321
- spec.provider === "openai" ||
322
- isAzure ||
323
- spec.provider === "github-copilot" ||
324
- hostMatchesUrl(baseUrl, "openai"),
325
- supportsReasoningEffort: true,
326
- supportsLongPromptCacheRetention: hostMatchesUrl(baseUrl, "openai"),
456
+ spec.provider === "openai" || isAzure || spec.provider === "github-copilot" || isOpenRouter || isOpenAIUrl,
457
+ supportsReasoningEffort: spec.provider !== "xai-oauth" || isGrokReasoningEffortCapable(id),
458
+ supportsLongPromptCacheRetention: isOpenAIUrl,
327
459
  // Azure OpenAI and GitHub Copilot Responses paths require tool results
328
460
  // to strictly match prior tool calls when building Responses inputs.
329
461
  strictResponsesPairing: isAzure || spec.provider === "github-copilot",
330
462
  requiresJuiceZeroHack: spec.name.toLowerCase().startsWith("gpt-5"),
331
463
  reasoningEffortMap: {},
464
+ supportsReasoningParams: true,
465
+ thinkingFormat,
466
+ reasoningDisableMode: resolveReasoningDisableMode(thinkingFormat),
467
+ omitReasoningEffort: false,
468
+ includeEncryptedReasoning: spec.provider !== "xai-oauth",
469
+ filterReasoningHistory: spec.provider === "xai-oauth",
470
+ disableReasoningOnForcedToolChoice: isKimiModel,
471
+ disableReasoningOnToolChoice: isDeepseekFamily && reasoningCapable && !isOpenRouter,
472
+ supportsToolChoice: true,
473
+ supportsForcedToolChoice: true,
474
+ reasoningContentField: "reasoning_content",
475
+ requiresReasoningContentForToolCalls:
476
+ (isKimiModel || (isDeepseekFamily && reasoningCapable) || (isOpenRouter && reasoningCapable)) &&
477
+ reasoningCapable,
478
+ requiresReasoningContentForAllAssistantTurns: isDeepseekFamily && reasoningCapable && !isOpenRouter,
479
+ allowsSyntheticReasoningContentForToolCalls: !isDeepseekFamily || !reasoningCapable,
480
+ requiresThinkingAsText: false,
481
+ requiresMistralToolIds: false,
482
+ requiresToolResultName: false,
483
+ requiresAssistantAfterToolResult: false,
484
+ requiresAssistantContentForToolCalls: isKimiModel,
485
+ openRouterRouting: undefined,
486
+ isOpenRouterHost: isOpenRouter,
487
+ wireModelIdMode: isOpenRouter ? "openrouter" : "raw",
488
+ alwaysSendMaxTokens: spec.id ? isKimiModelId(spec.id) : false,
489
+ enableGeminiThinkingLoopGuard: modelFamilyToken(spec.id ?? "") === "gemini",
490
+ supportsObfuscationOptOut: isOpenAIUrl || spec.provider === "openai",
491
+ stripDeepseekSpecialTokens:
492
+ Boolean(id) && isDeepseekModelIdOrName(id) && (spec.provider === "nvidia" || spec.provider === "deepseek"),
493
+ streamMarkupHealingPattern: id ? detectStreamMarkupHealingPattern(spec.provider, id) : undefined,
494
+ reasoningDeltasMayBeCumulative:
495
+ MINIMAX_PROVIDER_OR_ID_PATTERN.test(spec.provider) || (id ? MINIMAX_PROVIDER_OR_ID_PATTERN.test(id) : false),
496
+ emptyLengthFinishIsContextError: spec.provider === "ollama",
497
+ usesOpenAIToolCallIdLimit: spec.provider === "openai",
498
+ promptCacheSessionHeader: spec.provider === "xai-oauth" ? "x-grok-conv-id" : undefined,
332
499
  };
333
500
  applyCompatOverrides(compat, spec.compat);
501
+ if (spec.compat?.reasoningDisableMode === undefined) {
502
+ compat.reasoningDisableMode = resolveReasoningDisableMode(compat.thinkingFormat);
503
+ }
504
+ if (spec.compat?.omitReasoningEffort === undefined && !compat.supportsReasoningEffort) {
505
+ compat.omitReasoningEffort = true;
506
+ }
507
+ mergeOllamaReasoningEffortMap(compat, spec.provider, spec.reasoning);
334
508
  return compat;
335
509
  }
510
+
511
+ type ResponsesOnlyCompat = Omit<ResolvedOpenAIResponsesCompat, keyof ResolvedOpenAISharedCompat>;
512
+
513
+ function pickResponsesOnly(compat: ResolvedOpenAIResponsesCompat): ResponsesOnlyCompat {
514
+ return {
515
+ supportsLongPromptCacheRetention: compat.supportsLongPromptCacheRetention,
516
+ strictResponsesPairing: compat.strictResponsesPairing,
517
+ requiresJuiceZeroHack: compat.requiresJuiceZeroHack,
518
+ supportsObfuscationOptOut: compat.supportsObfuscationOptOut,
519
+ } satisfies ResponsesOnlyCompat;
520
+ }
521
+
522
+ export function buildOpenRouterCompat(spec: ModelSpec<"openrouter">): ResolvedOpenRouterCompat {
523
+ const chat = buildOpenAICompat({
524
+ ...spec,
525
+ api: "openai-completions",
526
+ } as ModelSpec<"openai-completions">);
527
+ const responses = buildOpenAIResponsesCompat(spec);
528
+ return { ...chat, ...pickResponsesOnly(responses) } as ResolvedOpenRouterCompat;
529
+ }
@@ -1,13 +1,16 @@
1
- import { z } from "zod/v4";
1
+ import { type } from "arktype";
2
2
  import type { ModelSpec } from "../types";
3
3
  import { toPositiveNumber } from "../utils";
4
- import { ANTIGRAVITY_VARIANT_COLLAPSE_TABLE, collapseEffortVariants } from "../variant-collapse";
4
+ import {
5
+ ANTIGRAVITY_VARIANT_COLLAPSE_TABLE,
6
+ collapseEffortVariants,
7
+ type VariantCollapseTable,
8
+ } from "../variant-collapse";
5
9
  import { getAntigravityUserAgent } from "../wire/gemini-headers";
6
10
 
7
- const DEFAULT_ANTIGRAVITY_DISCOVERY_ENDPOINTS = [
8
- "https://daily-cloudcode-pa.googleapis.com",
9
- "https://daily-cloudcode-pa.sandbox.googleapis.com",
10
- ] as const;
11
+ export const ANTIGRAVITY_PRIMARY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
12
+ export const ANTIGRAVITY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
13
+ const DEFAULT_ANTIGRAVITY_DISCOVERY_ENDPOINTS = [ANTIGRAVITY_PRIMARY_ENDPOINT, ANTIGRAVITY_SANDBOX_ENDPOINT] as const;
11
14
  const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels";
12
15
 
13
16
  const DEFAULT_CONTEXT_WINDOW = 200_000;
@@ -53,94 +56,78 @@ export interface AntigravityDiscoveryApiResponse {
53
56
  models?: Record<string, AntigravityDiscoveryApiModel>;
54
57
  agentModelSorts?: AntigravityDiscoveryAgentModelSort[];
55
58
  }
56
- const AntigravityDiscoveryApiModelSchema: z.ZodType<AntigravityDiscoveryApiModel> = z
57
- .object({
58
- displayName: z.preprocess(value => (typeof value === "string" ? value : undefined), z.string().optional()),
59
- supportsImages: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()),
60
- supportsThinking: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()),
61
- thinkingBudget: z.preprocess(
62
- value => (typeof value === "number" && Number.isFinite(value) ? value : undefined),
63
- z.number().optional(),
64
- ),
65
- recommended: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()),
66
- maxTokens: z.preprocess(
67
- value => (typeof value === "number" && Number.isFinite(value) ? value : undefined),
68
- z.number().optional(),
69
- ),
70
- maxOutputTokens: z.preprocess(
71
- value => (typeof value === "number" && Number.isFinite(value) ? value : undefined),
72
- z.number().optional(),
73
- ),
74
- model: z.preprocess(value => (typeof value === "string" ? value : undefined), z.string().optional()),
75
- apiProvider: z.preprocess(value => (typeof value === "string" ? value : undefined), z.string().optional()),
76
- modelProvider: z.preprocess(value => (typeof value === "string" ? value : undefined), z.string().optional()),
77
- isInternal: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()),
78
- supportsVideo: z.preprocess(value => (typeof value === "boolean" ? value : undefined), z.boolean().optional()),
79
- })
80
- .loose();
81
- const AntigravityDiscoveryAgentModelGroupSchema: z.ZodType<AntigravityDiscoveryAgentModelGroup> = z
82
- .object({
83
- modelIds: z.preprocess(
84
- value =>
85
- Array.isArray(value)
86
- ? value.filter((modelId): modelId is string => typeof modelId === "string")
87
- : undefined,
88
- z.array(z.string()).optional(),
89
- ),
90
- })
91
- .loose();
92
- const AntigravityDiscoveryAgentModelSortSchema: z.ZodType<AntigravityDiscoveryAgentModelSort> = z
93
- .object({
94
- groups: z.preprocess(
95
- value => (Array.isArray(value) ? value : undefined),
96
- z
97
- .array(z.unknown())
98
- .transform(groups =>
99
- groups.flatMap(group => {
100
- const parsedGroup = AntigravityDiscoveryAgentModelGroupSchema.safeParse(group);
101
- return parsedGroup.success ? [parsedGroup.data] : [];
102
- }),
103
- )
104
- .optional(),
105
- ),
106
- })
107
- .loose();
108
- const AntigravityDiscoveryApiResponseSchema: z.ZodType<AntigravityDiscoveryApiResponse> = z
109
- .object({
110
- models: z.preprocess(
111
- value => (typeof value === "object" && value !== null ? value : undefined),
112
- z
113
- .record(z.string(), z.unknown())
114
- .transform(models => {
115
- const normalized: Record<string, AntigravityDiscoveryApiModel> = {};
116
- for (const [modelId, modelValue] of Object.entries(models)) {
117
- if (typeof modelValue !== "object" || modelValue === null) {
118
- continue;
119
- }
120
- const parsedModel = AntigravityDiscoveryApiModelSchema.safeParse(modelValue);
121
- if (parsedModel.success) {
122
- normalized[modelId] = parsedModel.data;
123
- }
124
- }
125
- return normalized;
126
- })
127
- .optional(),
128
- ),
129
- agentModelSorts: z.preprocess(
130
- value => (Array.isArray(value) ? value : undefined),
131
- z
132
- .array(z.unknown())
133
- .transform(sorts =>
134
- sorts.flatMap(sort => {
135
- const parsedSort = AntigravityDiscoveryAgentModelSortSchema.safeParse(sort);
136
- return parsedSort.success ? [parsedSort.data] : [];
137
- }),
138
- )
139
- .optional(),
140
- ),
141
- })
142
- .loose();
143
-
59
+ const AntigravityDiscoveryApiModelSchema = type({
60
+ "displayName?": type("unknown").pipe(value => (typeof value === "string" ? value : undefined)),
61
+ "supportsImages?": type("unknown").pipe(value => (typeof value === "boolean" ? value : undefined)),
62
+ "supportsThinking?": type("unknown").pipe(value => (typeof value === "boolean" ? value : undefined)),
63
+ "thinkingBudget?": type("unknown").pipe(value =>
64
+ typeof value === "number" && Number.isFinite(value) ? value : undefined,
65
+ ),
66
+ "recommended?": type("unknown").pipe(value => (typeof value === "boolean" ? value : undefined)),
67
+ "maxTokens?": type("unknown").pipe(value =>
68
+ typeof value === "number" && Number.isFinite(value) ? value : undefined,
69
+ ),
70
+ "maxOutputTokens?": type("unknown").pipe(value =>
71
+ typeof value === "number" && Number.isFinite(value) ? value : undefined,
72
+ ),
73
+ "model?": type("unknown").pipe(value => (typeof value === "string" ? value : undefined)),
74
+ "apiProvider?": type("unknown").pipe(value => (typeof value === "string" ? value : undefined)),
75
+ "modelProvider?": type("unknown").pipe(value => (typeof value === "string" ? value : undefined)),
76
+ "isInternal?": type("unknown").pipe(value => (typeof value === "boolean" ? value : undefined)),
77
+ "supportsVideo?": type("unknown").pipe(value => (typeof value === "boolean" ? value : undefined)),
78
+ });
79
+
80
+ const AntigravityDiscoveryAgentModelGroupSchema = type({
81
+ "modelIds?": type("unknown").pipe(value =>
82
+ Array.isArray(value) ? value.filter((modelId): modelId is string => typeof modelId === "string") : undefined,
83
+ ),
84
+ });
85
+
86
+ const AntigravityDiscoveryAgentModelSortSchema = type({
87
+ "groups?": type("unknown").pipe(value => {
88
+ if (!Array.isArray(value)) return undefined;
89
+ const result: AntigravityDiscoveryAgentModelGroup[] = [];
90
+ for (const group of value) {
91
+ const parsedGroup = AntigravityDiscoveryAgentModelGroupSchema(group);
92
+ if (!(parsedGroup instanceof type.errors)) {
93
+ result.push(parsedGroup);
94
+ }
95
+ }
96
+ return result;
97
+ }),
98
+ });
99
+
100
+ const AntigravityDiscoveryApiResponseSchema = type({
101
+ "models?": type("unknown").pipe(value => {
102
+ if (typeof value !== "object" || value === null) {
103
+ return undefined;
104
+ }
105
+ const normalized: Record<string, AntigravityDiscoveryApiModel> = {};
106
+ for (const [modelId, modelValue] of Object.entries(value)) {
107
+ if (typeof modelValue !== "object" || modelValue === null) {
108
+ continue;
109
+ }
110
+ const parsedModel = AntigravityDiscoveryApiModelSchema(modelValue);
111
+ if (!(parsedModel instanceof type.errors)) {
112
+ normalized[modelId] = parsedModel;
113
+ }
114
+ }
115
+ return normalized;
116
+ }),
117
+ "agentModelSorts?": type("unknown").pipe(value => {
118
+ if (!Array.isArray(value)) {
119
+ return undefined;
120
+ }
121
+ const result: AntigravityDiscoveryAgentModelSort[] = [];
122
+ for (const sort of value) {
123
+ const parsedSort = AntigravityDiscoveryAgentModelSortSchema(sort);
124
+ if (!(parsedSort instanceof type.errors)) {
125
+ result.push(parsedSort);
126
+ }
127
+ }
128
+ return result;
129
+ }),
130
+ });
144
131
  /**
145
132
  * Options for fetching Antigravity discovery models.
146
133
  */
@@ -157,6 +144,12 @@ export interface FetchAntigravityDiscoveryModelsOptions {
157
144
  signal?: AbortSignal;
158
145
  /** Optional fetch implementation override for tests. */
159
146
  fetcher?: typeof fetch;
147
+ /**
148
+ * Hand collapse table to apply to the discovered list. Defaults to the
149
+ * Antigravity (budget-transport) table; `googleGeminiCli` passes the
150
+ * level-transport table so cloudcode-pa keeps `thinkingLevel`.
151
+ */
152
+ collapseTable?: VariantCollapseTable;
160
153
  }
161
154
 
162
155
  /**
@@ -239,7 +232,7 @@ export async function fetchAntigravityDiscoveryModels(
239
232
  // Collapse effort-tier variants at the source so runtime discovery,
240
233
  // the gemini-cli re-provision, and the catalog generator all see
241
234
  // logical ids only.
242
- const collapsed = collapseEffortVariants(models, ANTIGRAVITY_VARIANT_COLLAPSE_TABLE);
235
+ const collapsed = collapseEffortVariants(models, options.collapseTable ?? ANTIGRAVITY_VARIANT_COLLAPSE_TABLE);
243
236
  collapsed.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id));
244
237
  return collapsed;
245
238
  }
@@ -248,11 +241,11 @@ export async function fetchAntigravityDiscoveryModels(
248
241
  }
249
242
 
250
243
  function parseAntigravityDiscoveryResponse(value: unknown): AntigravityDiscoveryApiResponse | null {
251
- const parsed = AntigravityDiscoveryApiResponseSchema.safeParse(value);
252
- if (!parsed.success) {
244
+ const parsed = AntigravityDiscoveryApiResponseSchema(value);
245
+ if (parsed instanceof type.errors) {
253
246
  return null;
254
247
  }
255
- return parsed.data;
248
+ return parsed;
256
249
  }
257
250
 
258
251
  function trimTrailingSlashes(value: string): string {
@@ -1,4 +1,4 @@
1
- import { z } from "zod/v4";
1
+ import { type } from "arktype";
2
2
  import type { ModelSpec } from "../types";
3
3
  import { isRecord } from "../utils";
4
4
  import { CODEX_BASE_URL, OPENAI_HEADER_VALUES, OPENAI_HEADERS } from "../wire/codex";
@@ -9,36 +9,29 @@ const DEFAULT_MAX_TOKENS = 128_000;
9
9
  const DEFAULT_CODEX_CLIENT_VERSION = "0.99.0";
10
10
  const NPM_CODEX_LATEST_URL = "https://registry.npmjs.org/@openai%2Fcodex/latest";
11
11
 
12
- const codexReasoningPresetSchema = z
13
- .object({
14
- effort: z.unknown().optional(),
15
- })
16
- .loose();
17
-
18
- const codexModelEntrySchema = z
19
- .object({
20
- slug: z.unknown().optional(),
21
- id: z.unknown().optional(),
22
- display_name: z.unknown().optional(),
23
- context_window: z.unknown().optional(),
24
- default_reasoning_level: z.unknown().optional(),
25
- supported_reasoning_levels: z.unknown().optional(),
26
- input_modalities: z.unknown().optional(),
27
- supported_in_api: z.unknown().optional(),
28
- priority: z.unknown().optional(),
29
- prefer_websockets: z.unknown().optional(),
30
- })
31
- .loose();
32
-
33
- const codexModelsResponseSchema = z
34
- .object({
35
- models: z.array(z.unknown()).optional(),
36
- data: z.array(z.unknown()).optional(),
37
- })
38
- .loose();
39
-
40
- type CodexModelEntry = z.infer<typeof codexModelEntrySchema>;
41
-
12
+ const codexReasoningPresetSchema = type({
13
+ "effort?": "unknown",
14
+ });
15
+
16
+ const codexModelEntrySchema = type({
17
+ "slug?": "unknown",
18
+ "id?": "unknown",
19
+ "display_name?": "unknown",
20
+ "context_window?": "unknown",
21
+ "default_reasoning_level?": "unknown",
22
+ "supported_reasoning_levels?": "unknown",
23
+ "input_modalities?": "unknown",
24
+ "supported_in_api?": "unknown",
25
+ "priority?": "unknown",
26
+ "prefer_websockets?": "unknown",
27
+ });
28
+
29
+ const codexModelsResponseSchema = type({
30
+ "models?": "unknown[]",
31
+ "data?": "unknown[]",
32
+ });
33
+
34
+ type CodexModelEntry = typeof codexModelEntrySchema.infer;
42
35
  interface NormalizedCodexModel {
43
36
  model: ModelSpec<"openai-codex-responses">;
44
37
  priority: number;
@@ -216,12 +209,12 @@ function isAbortError(error: unknown): error is Error {
216
209
  }
217
210
 
218
211
  function normalizeCodexModels(payload: unknown, baseUrl: string): ModelSpec<"openai-codex-responses">[] | null {
219
- const parsedResponse = codexModelsResponseSchema.safeParse(payload);
220
- if (!parsedResponse.success) {
212
+ const parsedResponse = codexModelsResponseSchema(payload);
213
+ if (parsedResponse instanceof type.errors) {
221
214
  return null;
222
215
  }
223
216
 
224
- const entries = parsedResponse.data.models ?? parsedResponse.data.data ?? [];
217
+ const entries = parsedResponse.models ?? parsedResponse.data ?? [];
225
218
  const normalized: NormalizedCodexModel[] = [];
226
219
  for (const entry of entries) {
227
220
  const model = normalizeCodexModelEntry(entry, baseUrl);
@@ -241,12 +234,12 @@ function normalizeCodexModels(payload: unknown, baseUrl: string): ModelSpec<"ope
241
234
  }
242
235
 
243
236
  function normalizeCodexModelEntry(entry: unknown, baseUrl: string): NormalizedCodexModel | null {
244
- const parsedEntry = codexModelEntrySchema.safeParse(entry);
245
- if (!parsedEntry.success) {
237
+ const parsedEntry = codexModelEntrySchema(entry);
238
+ if (parsedEntry instanceof type.errors) {
246
239
  return null;
247
240
  }
248
241
 
249
- const payload: CodexModelEntry = parsedEntry.data;
242
+ const payload: CodexModelEntry = parsedEntry;
250
243
  const slug = toNonEmptyString(payload.slug) ?? toNonEmptyString(payload.id);
251
244
  if (!slug) {
252
245
  return null;
@@ -295,11 +288,11 @@ function supportsReasoning(defaultReasoningLevel: unknown, supportedReasoningLev
295
288
  }
296
289
 
297
290
  for (const level of supportedReasoningLevels) {
298
- const parsedLevel = codexReasoningPresetSchema.safeParse(level);
299
- if (!parsedLevel.success) {
291
+ const parsedLevel = codexReasoningPresetSchema(level);
292
+ if (parsedLevel instanceof type.errors) {
300
293
  continue;
301
294
  }
302
- const effort = toNonEmptyString(parsedLevel.data.effort)?.toLowerCase();
295
+ const effort = toNonEmptyString(parsedLevel.effort)?.toLowerCase();
303
296
  if (effort && effort !== "none") {
304
297
  return true;
305
298
  }