@linxiraos/pi-catalog 1.2.0 → 1.3.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.
Files changed (127) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +4 -2
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/build.d.ts +3 -2
  5. package/dist/types/compat/auth-ids.d.ts +2 -2
  6. package/dist/types/compat/axes.d.ts +1 -1
  7. package/dist/types/compat/behavior.d.ts +5 -0
  8. package/dist/types/compat/catalog-endpoint.d.ts +8 -0
  9. package/dist/types/compat/image-tokenization.d.ts +9 -1
  10. package/dist/types/compat/judgment-images.d.ts +25 -0
  11. package/dist/types/compat/provider-ids.d.ts +1 -1
  12. package/dist/types/compat/request-compression.d.ts +23 -0
  13. package/dist/types/compat/request-size.d.ts +12 -3
  14. package/dist/types/compat/thinking-replay.d.ts +9 -0
  15. package/dist/types/compat/types.d.ts +4 -0
  16. package/dist/types/discovery/antigravity.d.ts +7 -0
  17. package/dist/types/model-cache.d.ts +7 -0
  18. package/dist/types/model-manager.d.ts +6 -0
  19. package/dist/types/model-thinking.d.ts +11 -1
  20. package/dist/types/models.d.ts +16 -1
  21. package/dist/types/pricing.d.ts +37 -1
  22. package/dist/types/provider-models/openai-compat.d.ts +18 -46
  23. package/dist/types/types.d.ts +63 -12
  24. package/dist/types/wire/codex.d.ts +2 -0
  25. package/package.json +6 -6
  26. package/src/build.d.ts +3 -2
  27. package/src/build.ts +159 -80
  28. package/src/compat/auth-ids.ts +2 -0
  29. package/src/compat/axes.d.ts +1 -1
  30. package/src/compat/axes.ts +129 -4
  31. package/src/compat/behavior.d.ts +5 -0
  32. package/src/compat/behavior.ts +9 -1
  33. package/src/compat/cascade.ts +4 -1
  34. package/src/compat/catalog-endpoint.ts +43 -0
  35. package/src/compat/image-tokenization.ts +28 -10
  36. package/src/compat/judgment-images.ts +83 -0
  37. package/src/compat/provider-ids.d.ts +1 -1
  38. package/src/compat/provider-ids.ts +1 -0
  39. package/src/compat/request-compression.ts +43 -0
  40. package/src/compat/request-size.ts +20 -5
  41. package/src/compat/resolve.ts +90 -53
  42. package/src/compat/rules/README.md +32 -3
  43. package/src/compat/rules/auth/_order.kdl +1 -1
  44. package/src/compat/rules/auth/google-antigravity.kdl +1 -1
  45. package/src/compat/rules/auth/sambanova.kdl +9 -0
  46. package/src/compat/rules/auth/stencil.kdl +1 -1
  47. package/src/compat/rules/classes/anthropic.kdl +42 -2
  48. package/src/compat/rules/classes/deepseek.kdl +4 -0
  49. package/src/compat/rules/classes/gemini.kdl +46 -2
  50. package/src/compat/rules/classes/meta.kdl +7 -0
  51. package/src/compat/rules/classes/minimax.kdl +11 -1
  52. package/src/compat/rules/classes/openai.kdl +34 -3
  53. package/src/compat/rules/classes/xai.kdl +26 -0
  54. package/src/compat/rules/providers/amazon-bedrock.kdl +1 -0
  55. package/src/compat/rules/providers/anthropic.kdl +23 -0
  56. package/src/compat/rules/providers/azure.kdl +2 -0
  57. package/src/compat/rules/providers/cerebras.kdl +3 -0
  58. package/src/compat/rules/providers/cline-pass.kdl +16 -0
  59. package/src/compat/rules/providers/commandcode.kdl +10 -0
  60. package/src/compat/rules/providers/cursor.kdl +50 -1
  61. package/src/compat/rules/providers/deepinfra.kdl +3 -1
  62. package/src/compat/rules/providers/devin.kdl +117 -6
  63. package/src/compat/rules/providers/factory-droid.kdl +6 -0
  64. package/src/compat/rules/providers/firepass.kdl +15 -0
  65. package/src/compat/rules/providers/fireworks.kdl +26 -0
  66. package/src/compat/rules/providers/github-copilot.kdl +24 -0
  67. package/src/compat/rules/providers/google-antigravity.kdl +37 -0
  68. package/src/compat/rules/providers/google-gemini-cli.kdl +1 -0
  69. package/src/compat/rules/providers/google-vertex.kdl +5 -0
  70. package/src/compat/rules/providers/google.kdl +1 -0
  71. package/src/compat/rules/providers/image-tokenization.kdl +2 -1
  72. package/src/compat/rules/providers/local.kdl +8 -1
  73. package/src/compat/rules/providers/meta.kdl +16 -0
  74. package/src/compat/rules/providers/moonshot.kdl +2 -0
  75. package/src/compat/rules/providers/muse-code.kdl +49 -0
  76. package/src/compat/rules/providers/native-tools.kdl +3 -0
  77. package/src/compat/rules/providers/ollama-cloud.kdl +10 -0
  78. package/src/compat/rules/providers/openai-codex.kdl +29 -5
  79. package/src/compat/rules/providers/openai.kdl +1 -0
  80. package/src/compat/rules/providers/opencode-go.kdl +35 -0
  81. package/src/compat/rules/providers/opencode-zen.kdl +4 -0
  82. package/src/compat/rules/providers/openrouter.kdl +19 -0
  83. package/src/compat/rules/providers/sambanova.kdl +60 -0
  84. package/src/compat/rules/providers/tool-call-names.kdl +37 -0
  85. package/src/compat/rules/providers/typesafe.kdl +11 -0
  86. package/src/compat/rules/providers/umans.kdl +7 -0
  87. package/src/compat/rules/providers/venice.kdl +7 -0
  88. package/src/compat/rules/providers/xai-oauth.kdl +16 -6
  89. package/src/compat/rules/providers/xai.kdl +3 -0
  90. package/src/compat/rules/providers/zai.kdl +5 -0
  91. package/src/compat/rules/runtime/behavior.kdl +128 -8
  92. package/src/compat/rules/taxonomy/_collapse.kdl +11 -0
  93. package/src/compat/rules/taxonomy/gemma.kdl +1 -0
  94. package/src/compat/rules/taxonomy/meta.kdl +7 -0
  95. package/src/compat/rules/taxonomy/openai.kdl +2 -0
  96. package/src/compat/rules.json +1 -1
  97. package/src/compat/thinking-replay.ts +33 -0
  98. package/src/compat/types.d.ts +4 -0
  99. package/src/compat/types.ts +4 -0
  100. package/src/discovery/antigravity.d.ts +7 -0
  101. package/src/discovery/antigravity.ts +13 -0
  102. package/src/discovery/codex.ts +0 -4
  103. package/src/discovery/cursor.ts +28 -20
  104. package/src/discovery/devin.ts +7 -8
  105. package/src/model-cache.d.ts +7 -0
  106. package/src/model-cache.ts +8 -0
  107. package/src/model-manager.d.ts +6 -0
  108. package/src/model-manager.ts +41 -21
  109. package/src/model-thinking.d.ts +80 -0
  110. package/src/model-thinking.ts +13 -1
  111. package/src/model-tokenizer.ts +7 -1
  112. package/src/models.d.ts +16 -1
  113. package/src/models.json +1 -1
  114. package/src/models.ts +26 -12
  115. package/src/pricing.d.ts +37 -1
  116. package/src/pricing.ts +123 -12
  117. package/src/provider-models/bundled-references.ts +5 -2
  118. package/src/provider-models/cache-provider-id.ts +1 -1
  119. package/src/provider-models/descriptors.ts +2 -0
  120. package/src/provider-models/google.ts +7 -1
  121. package/src/provider-models/openai-compat.d.ts +18 -46
  122. package/src/provider-models/openai-compat.ts +188 -104
  123. package/src/provider-models/special.ts +1 -0
  124. package/src/types.d.ts +63 -12
  125. package/src/types.ts +71 -9
  126. package/src/wire/codex.d.ts +2 -0
  127. package/src/wire/codex.ts +17 -0
@@ -0,0 +1,43 @@
1
+ import { getBundledModels, isGeneratedProvider } from "../models";
2
+ import type { Model } from "../types";
3
+ import { resolveCatalogPolicy } from "./catalog-policy";
4
+
5
+ const bundledHosts = new Map<string, string | null>();
6
+
7
+ /** Hostname of the provider's bundled base URL; every provider bundles one origin. */
8
+ function bundledHost(provider: string): string | null {
9
+ let host = bundledHosts.get(provider);
10
+ if (host !== undefined) return host;
11
+ host = null;
12
+ if (isGeneratedProvider(provider)) {
13
+ for (const row of getBundledModels(provider)) {
14
+ try {
15
+ host = new URL(row.baseUrl).hostname;
16
+ break;
17
+ } catch {}
18
+ }
19
+ }
20
+ bundledHosts.set(provider, host);
21
+ return host;
22
+ }
23
+
24
+ /**
25
+ * Whether {@link url} is HTTPS to {@link model}'s provider's own endpoint: the
26
+ * host of its bundled base URL or one of its `endpoint-hosts`. A custom model
27
+ * or provider override that keeps a catalog provider id but points `baseUrl`
28
+ * at a gateway, proxy, or local server is not.
29
+ */
30
+ export function isCatalogEndpointUrl(model: Model, url: string | undefined): boolean {
31
+ if (!url) return false;
32
+ let parsed: URL;
33
+ try {
34
+ parsed = new URL(url);
35
+ } catch {
36
+ return false;
37
+ }
38
+ if (parsed.protocol !== "https:") return false;
39
+ if (parsed.hostname === bundledHost(model.provider)) return true;
40
+ // The rule compiler validates `endpoint-hosts` as an array of strings.
41
+ const hosts = resolveCatalogPolicy(model).endpointHosts as readonly string[] | undefined;
42
+ return hosts?.includes(parsed.hostname) === true;
43
+ }
@@ -37,12 +37,16 @@ export interface OpenAiPatchTokenization {
37
37
  }
38
38
 
39
39
  /** One model line's image billing rule: the vendor formula and its per-model numbers. */
40
- export type ImageTokenization =
40
+ export type ImageTokenization = (
41
41
  | OpenAiPatchTokenization
42
42
  /** Anthropic 28px patches, resized to fit a padded-edge limit and a visual-token budget. */
43
43
  | { regime: "anthropic-patch"; maxEdge: number; maxTokens: number }
44
44
  /** A fixed per-image budget regardless of pixels (Gemini 3 `media_resolution`). */
45
- | { regime: "fixed"; tokens: number };
45
+ | { regime: "fixed"; tokens: number }
46
+ ) & {
47
+ /** Provider billing was measured to match this rule; unset for docs-only rules and wire fallbacks. */
48
+ verified?: boolean;
49
+ };
46
50
 
47
51
  /** A `class: "unknown"` identity for targets resolved by wire API alone. */
48
52
  const UNCLASSIFIED: Pick<ModelIdentity, "class"> = { class: "unknown" };
@@ -62,17 +66,13 @@ export interface ImageTokenizationTarget {
62
66
  identity?: Pick<ModelIdentity, "class" | "family" | "revision">;
63
67
  }
64
68
 
65
- /**
66
- * The reader's image billing rule: its lineage rule, else its wire API's
67
- * fallback; undefined when neither covers it (an unclassified model on an
68
- * unregistered API).
69
- */
70
- export function resolveImageTokenization(target: ImageTokenizationTarget): ImageTokenization | undefined {
69
+ /** Catalog axes for `target`'s identity on its host and wire API. */
70
+ function resolveImageAxes(target: ImageTokenizationTarget): Record<string, unknown> {
71
71
  const provider = target.provider ?? "";
72
72
  const model = target.id ?? "";
73
73
  const identity: Pick<ModelIdentity, "class" | "family" | "revision"> =
74
74
  target.identity ?? (model ? classifyModel(provider, model, { lenient: true }) : UNCLASSIFIED);
75
- const rule = resolveCascade({
75
+ return resolveCascade({
76
76
  provider,
77
77
  api: target.api ?? "",
78
78
  class: identity.class,
@@ -80,10 +80,28 @@ export function resolveImageTokenization(target: ImageTokenizationTarget): Image
80
80
  reasoning: false,
81
81
  ...(identity.family !== undefined && { family: identity.family }),
82
82
  ...(identity.revision !== undefined && { revision: identity.revision }),
83
- }).catalog.imageTokenization;
83
+ }).catalog;
84
+ }
85
+
86
+ /**
87
+ * The reader's image billing rule: its lineage rule, else its wire API's
88
+ * fallback; undefined when neither covers it (an unclassified model on an
89
+ * unregistered API).
90
+ */
91
+ export function resolveImageTokenization(target: ImageTokenizationTarget): ImageTokenization | undefined {
92
+ const rule = resolveImageAxes(target).imageTokenization;
84
93
  return isImageTokenization(rule) ? rule : undefined;
85
94
  }
86
95
 
96
+ /**
97
+ * Longest image edge the reader's host passes to the model (`image-max-edge`),
98
+ * or undefined when the host keeps the image's size.
99
+ */
100
+ export function resolveImageMaxEdge(target: ImageTokenizationTarget): number | undefined {
101
+ const edge = resolveImageAxes(target).imageMaxEdge;
102
+ return typeof edge === "number" && edge > 0 ? edge : undefined;
103
+ }
104
+
87
105
  /** Scale `size` down (never up) so its longest side is at most `maxEdge`. */
88
106
  function fitLongEdge(size: ImageSize, maxEdge: number): ImageSize {
89
107
  const longest = Math.max(size.width, size.height);
@@ -0,0 +1,83 @@
1
+ import { isRecord } from "../utils";
2
+ import type { Model } from "../types";
3
+ import { resolveCatalogPolicy } from "./catalog-policy";
4
+
5
+ /** Bounds a judgment model applies to image inputs. */
6
+ export interface JudgmentImageInput {
7
+ /** Accepted lowercase MIME types; unset adds no model-specific format restriction. */
8
+ mimeTypes?: readonly string[];
9
+ /** Images per request. */
10
+ maxCount?: number;
11
+ /** Decoded bytes per image. */
12
+ maxBytes?: number;
13
+ /** Decoded bytes across all images. */
14
+ maxTotalBytes?: number;
15
+ /** Pixels per image, when the image header reports dimensions. */
16
+ maxPixels?: number;
17
+ /** Minimum pixels per image, when the image header reports dimensions. */
18
+ minPixels?: number;
19
+ /** Inclusive bounds on each side, when the image header reports dimensions. */
20
+ minSide?: number;
21
+ maxSide?: number;
22
+ }
23
+
24
+ type NumericField = "maxCount" | "maxBytes" | "maxTotalBytes" | "maxPixels" | "minPixels" | "minSide" | "maxSide";
25
+
26
+ const NUMERIC_FIELDS: readonly NumericField[] = [
27
+ "maxCount",
28
+ "maxBytes",
29
+ "maxTotalBytes",
30
+ "maxPixels",
31
+ "minPixels",
32
+ "minSide",
33
+ "maxSide",
34
+ ];
35
+ const POLICY_FIELDS: Record<string, true> = {
36
+ mimeTypes: true,
37
+ maxCount: true,
38
+ maxBytes: true,
39
+ maxTotalBytes: true,
40
+ maxPixels: true,
41
+ minPixels: true,
42
+ minSide: true,
43
+ maxSide: true,
44
+ };
45
+ const MIME_TYPE = /^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/;
46
+
47
+ function positiveInteger(value: unknown): value is number {
48
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
49
+ }
50
+
51
+ /**
52
+ * Resolve image bounds assigned to this model by the catalog cascade. KDL
53
+ * object payloads have scalar children, so `mime-types` is authored as a
54
+ * comma-separated string and normalized here to the public array type.
55
+ */
56
+ export function resolveJudgmentImageInput(model: Model): JudgmentImageInput | undefined {
57
+ const value = resolveCatalogPolicy(model).judgmentImageInput;
58
+ if (!isRecord(value) || Object.keys(value).some(key => !Object.hasOwn(POLICY_FIELDS, key))) return undefined;
59
+
60
+ let mimeTypes: readonly string[] | undefined;
61
+ if (value.mimeTypes !== undefined) {
62
+ if (typeof value.mimeTypes !== "string") return undefined;
63
+ const parsed = value.mimeTypes.split(",");
64
+ if (parsed.some(mimeType => !MIME_TYPE.test(mimeType))) return undefined;
65
+ mimeTypes = Object.freeze(parsed);
66
+ }
67
+
68
+ const numeric: Partial<Record<NumericField, number>> = {};
69
+ for (const field of NUMERIC_FIELDS) {
70
+ const bound = value[field];
71
+ if (bound === undefined) continue;
72
+ if (!positiveInteger(bound)) return undefined;
73
+ numeric[field] = bound;
74
+ }
75
+ if (
76
+ (numeric.minPixels !== undefined && numeric.maxPixels !== undefined && numeric.minPixels > numeric.maxPixels) ||
77
+ (numeric.minSide !== undefined && numeric.maxSide !== undefined && numeric.minSide > numeric.maxSide)
78
+ ) {
79
+ return undefined;
80
+ }
81
+
82
+ return { ...(mimeTypes !== undefined && { mimeTypes }), ...numeric };
83
+ }
@@ -1,2 +1,2 @@
1
1
  /** Every chat-model provider with a catalog entry (`default-model` in its provider rules). */
2
- export type KnownProvider = "abliteration" | "aiand" | "aimlapi" | "alibaba-coding-plan" | "alibaba-token-plan" | "amazon-bedrock" | "anthropic" | "apple" | "azure" | "baseten" | "bedrock-mantle" | "cerebras" | "charm-hyper" | "cline-pass" | "cloudflare-ai-gateway" | "cloudflare-workers-ai" | "commandcode" | "coralbricks" | "coreweave" | "cursor" | "deepinfra" | "deepseek" | "devin" | "factory-droid" | "firepass" | "fireworks" | "github-copilot" | "gitlab-duo" | "gitlab-duo-agent" | "gmi-cloud" | "google" | "google-antigravity" | "google-gemini-cli" | "google-vertex" | "groq" | "helmcode" | "huggingface" | "kilo" | "kimi-code" | "litellm" | "lm-studio" | "local" | "meta" | "minimax" | "minimax-code" | "minimax-code-cn" | "mistral" | "moonshot" | "muse-code" | "nanogpt" | "novita" | "nvidia" | "ollama" | "ollama-cloud" | "openai" | "openai-codex" | "opencode-go" | "opencode-zen" | "openrouter" | "qianfan" | "qwen-portal" | "sakana" | "siliconflow" | "siliconflow-cn" | "singularityapi-dev" | "singularityapi-tech" | "snowflake" | "stepfun" | "synthetic" | "together" | "typesafe" | "umans" | "venice" | "vercel-ai-gateway" | "vllm" | "wafer-serverless" | "web" | "xai" | "xai-oauth" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "yolo-auto" | "zai" | "zenmux" | "zhipu-coding-plan";
2
+ export type KnownProvider = "abliteration" | "aiand" | "aimlapi" | "alibaba-coding-plan" | "alibaba-token-plan" | "amazon-bedrock" | "anthropic" | "apple" | "azure" | "baseten" | "bedrock-mantle" | "cerebras" | "charm-hyper" | "cline-pass" | "cloudflare-ai-gateway" | "cloudflare-workers-ai" | "commandcode" | "coralbricks" | "coreweave" | "cursor" | "deepinfra" | "deepseek" | "devin" | "factory-droid" | "firepass" | "fireworks" | "github-copilot" | "gitlab-duo" | "gitlab-duo-agent" | "gmi-cloud" | "google" | "google-antigravity" | "google-gemini-cli" | "google-vertex" | "groq" | "helmcode" | "huggingface" | "kilo" | "kimi-code" | "litellm" | "lm-studio" | "local" | "meta" | "minimax" | "minimax-code" | "minimax-code-cn" | "mistral" | "moonshot" | "muse-code" | "nanogpt" | "novita" | "nvidia" | "ollama" | "ollama-cloud" | "openai" | "openai-codex" | "opencode-go" | "opencode-zen" | "openrouter" | "qianfan" | "qwen-portal" | "sakana" | "sambanova" | "siliconflow" | "siliconflow-cn" | "singularityapi-dev" | "singularityapi-tech" | "snowflake" | "stepfun" | "synthetic" | "together" | "typesafe" | "umans" | "venice" | "vercel-ai-gateway" | "vllm" | "wafer-serverless" | "web" | "xai" | "xai-oauth" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "yolo-auto" | "zai" | "zenmux" | "zhipu-coding-plan";
@@ -64,6 +64,7 @@ export type KnownProvider =
64
64
  | "qianfan"
65
65
  | "qwen-portal"
66
66
  | "sakana"
67
+ | "sambanova"
67
68
  | "siliconflow"
68
69
  | "siliconflow-cn"
69
70
  | "singularityapi-dev"
@@ -0,0 +1,43 @@
1
+ import type { Model } from "../types";
2
+ import { isCatalogEndpointUrl } from "./catalog-endpoint";
3
+ import { resolveCatalogPolicy } from "./catalog-policy";
4
+
5
+ /** `Content-Encoding` values a host may accept on request bodies. */
6
+ export type RequestBodyEncoding = "zstd" | "gzip";
7
+
8
+ /** Request-body compression contract for one request. */
9
+ export interface RequestCompressionPolicy {
10
+ /** Accepted encodings, most preferred first. */
11
+ readonly encodings: readonly RequestBodyEncoding[];
12
+ /**
13
+ * Pattern of a JSON 400/415 error body that rejects the encoding;
14
+ * `undefined` treats every JSON 400/415 as a possible rejection. A
15
+ * 400/403/415 whose body is not JSON (presumed a proxy's answer, not the
16
+ * host's) is a possible rejection either way.
17
+ */
18
+ readonly rejection: RegExp | undefined;
19
+ }
20
+
21
+ const rejectionPatterns = new Map<string, RegExp>();
22
+
23
+ /**
24
+ * Compression contract for a request from {@link model} to {@link url}, or
25
+ * `undefined` when the body must go uncompressed. The `request-encodings` axis
26
+ * applies only while the URL reaches the provider's own endpoint
27
+ * ({@link isCatalogEndpointUrl}): a proxy, relay, or rerouted base URL never
28
+ * receives bytes it cannot read.
29
+ */
30
+ export function resolveRequestCompression(model: Model, url: string): RequestCompressionPolicy | undefined {
31
+ const policy = resolveCatalogPolicy(model);
32
+ // The rule compiler validates encodings against the axis vocabulary.
33
+ const encodings = policy.requestEncodings as readonly RequestBodyEncoding[] | undefined;
34
+ if (!encodings?.length || !isCatalogEndpointUrl(model, url)) return undefined;
35
+ const source = policy.requestEncodingRejection;
36
+ if (typeof source !== "string") return { encodings, rejection: undefined };
37
+ let rejection = rejectionPatterns.get(source);
38
+ if (!rejection) {
39
+ rejection = new RegExp(source);
40
+ rejectionPatterns.set(source, rejection);
41
+ }
42
+ return { encodings, rejection };
43
+ }
@@ -1,5 +1,6 @@
1
1
  import { toModelSpec } from "../provider-models/bundled-references";
2
2
  import type { Model } from "../types";
3
+ import { resolveCatalogPolicy } from "./catalog-policy";
3
4
  import { resolveCatalogAxes } from "./resolve";
4
5
 
5
6
  const imageBudgetCache = new Map<string, number | null>();
@@ -7,12 +8,12 @@ const IMAGE_BUDGET_CACHE_MAX = 8192;
7
8
 
8
9
  /**
9
10
  * Maximum encoded inline-image bytes retained per request for a deployment with a documented body-size limit.
10
- * The axis is a host contract: a rerouted `baseUrl` (proxy, gateway) imposes its own limit, so the budget
11
- * applies only while the model targets its official endpoint.
11
+ * The axis is a host contract: a rerouted endpoint (proxy, gateway) imposes its own limit, so the budget applies
12
+ * only when `officialEndpoint` says the request reaches the model's official endpoint. `compat.officialEndpoint`
13
+ * classifies only the configured `baseUrl`; pi-ai's `reachesOfficialEndpoint` also follows request-time reroutes.
12
14
  */
13
- export function resolveInlineImageByteBudget(model: Model): number | undefined {
14
- const compat = model.compat;
15
- if (!compat || !("officialEndpoint" in compat) || !compat.officialEndpoint) return undefined;
15
+ export function resolveInlineImageByteBudget(model: Model, officialEndpoint: boolean): number | undefined {
16
+ if (!officialEndpoint) return undefined;
16
17
  const key = `${model.provider} ${model.id} ${model.api}`;
17
18
  let budget = imageBudgetCache.get(key);
18
19
  if (budget === undefined) {
@@ -23,3 +24,17 @@ export function resolveInlineImageByteBudget(model: Model): number | undefined {
23
24
  }
24
25
  return budget ?? undefined;
25
26
  }
27
+
28
+ /**
29
+ * Most images one request to this deployment may carry, or `undefined` when no rule names the host. A configured
30
+ * `model.imageCountBudget` wins: the user knows the host behind a custom gateway. Otherwise the
31
+ * `official-image-count-budget` axis replaces `image-count-budget` only when `officialEndpoint` says the request
32
+ * reaches the model's official endpoint (see {@link resolveInlineImageByteBudget}); a rerouted endpoint keeps the
33
+ * provider's generic budget.
34
+ */
35
+ export function resolveImageCountBudget(model: Model, officialEndpoint: boolean): number | undefined {
36
+ if (model.imageCountBudget !== undefined) return model.imageCountBudget;
37
+ const policy = resolveCatalogPolicy(model);
38
+ const value = (officialEndpoint && policy.officialImageCountBudget) || policy.imageCountBudget;
39
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
40
+ }
@@ -17,6 +17,7 @@
17
17
  */
18
18
  import { Effort, THINKING_EFFORTS } from "../effort";
19
19
  import { hostMatchesUrl, modelMatchesHost } from "../hosts";
20
+ import { withoutUnverifiedThinking } from "../model-thinking";
20
21
  import type {
21
22
  Api,
22
23
  CompatOf,
@@ -26,11 +27,13 @@ import type {
26
27
  ResolvedBedrockCompat,
27
28
  ResolvedDevinCompat,
28
29
  ResolvedGoogleCompat,
30
+ ResolvedOllamaCompat,
29
31
  ResolvedOpenAICompat,
30
32
  ResolvedOpenAIResponsesCompat,
31
33
  ResolvedOpenAISharedCompat,
32
34
  ThinkingConfig,
33
35
  } from "../types";
36
+ import { isOfficialCodexApiUrl } from "../wire/codex";
34
37
  import {
35
38
  isAnthropicSigningProxyUrl,
36
39
  isAzureAnthropicRoute,
@@ -117,11 +120,24 @@ class IdentityFacts {
117
120
  }
118
121
  }
119
122
 
123
+ /**
124
+ * The id a spec resolves identity and rules under: its own, unless that names
125
+ * no model and the row sends a model that does on the wire. A Devin Fusion
126
+ * pairing runs only its lead, so the lead's reviewed rules (cache-write rate,
127
+ * long-context tier) apply to it at build time.
128
+ */
129
+ function policyModelId<TApi extends Api>(spec: ModelSpec<TApi>): string {
130
+ const wireId = spec.requestModelId;
131
+ if (wireId === undefined || wireId === spec.id) return spec.id;
132
+ if (classifyModel(spec.provider, spec.id).class !== "unknown") return spec.id;
133
+ return classifyModel(spec.provider, wireId).class === "unknown" ? spec.id : wireId;
134
+ }
135
+
120
136
  function resolveIdentity<TApi extends Api>(spec: ModelSpec<TApi>): ModelIdentity {
121
137
  // Strict on purpose: ambiguous identity is a rule-authoring defect surfaced
122
138
  // at build/CI time, never silently degraded to `unknown`. Discovery
123
139
  // normalization opts into leniency through `classifyModel` directly.
124
- return classifyModel(spec.provider, spec.id);
140
+ return classifyModel(spec.provider, policyModelId(spec));
125
141
  }
126
142
 
127
143
  // ---------------------------------------------------------------------------
@@ -261,6 +277,7 @@ function hasLocalLoopbackBaseUrl(baseUrl: string | undefined): boolean {
261
277
  let hostname: string;
262
278
  try {
263
279
  hostname = new URL(baseUrl).hostname.toLowerCase();
280
+ if (hostname.endsWith(".")) hostname = hostname.slice(0, -1);
264
281
  } catch {
265
282
  return false;
266
283
  }
@@ -313,7 +330,6 @@ function isOfficialOpenAIEndpoint(provider: string, baseUrl: string): boolean {
313
330
 
314
331
  interface OpenAIDetection {
315
332
  facts: IdentityFacts;
316
- isClinePass: boolean;
317
333
  isZai: boolean;
318
334
  isZhipu: boolean;
319
335
  isMoonshotNative: boolean;
@@ -351,7 +367,6 @@ function detectOpenAI(
351
367
  (LOCAL_OPENAI_COMPAT_PROVIDERS[backendProvider] === true || hasLocalLoopbackBaseUrl(baseUrl));
352
368
  return {
353
369
  facts,
354
- isClinePass: provider === "cline-pass",
355
370
  isZai,
356
371
  isZhipu,
357
372
  isMoonshotNative,
@@ -457,7 +472,9 @@ function detectOpenAICompat(
457
472
  provider === "github-copilot" ||
458
473
  provider === "zenmux");
459
474
 
460
- // Endpoint-only watchdogs remain here; provider and lineage policy lives in KDL.
475
+ // URL-keyed watchdogs: they also cover custom provider ids pointed at these
476
+ // hosts, which provider-keyed rules (`stream-idle-timeout-ms` in
477
+ // classes/{glm,kimi,mimo}.kdl) cannot match. KDL rules override them.
461
478
  const streamIdleTimeoutMs =
462
479
  facts.is("glm") &&
463
480
  facts.revGte("5") &&
@@ -504,21 +521,17 @@ function detectOpenAICompat(
504
521
  supportsMultipleSystemMessages: supportsMultipleSystemMessagesDefault,
505
522
  supportsReasoningEffort: !isGrok && !d.isXiaomiMimo && (!(d.isZai || d.isZhipu) || supportsZaiReasoningEffort),
506
523
  trustExplicitThinkingOnly: undefined,
507
- // API-conditional: this completions-only Copilot exclusion cannot be a
508
- // provider rule without changing Copilot Responses rows.
509
- supportsReasoningParams: provider !== "github-copilot",
524
+ supportsReasoningParams: true,
510
525
  supportsSamplingParams: true,
511
526
  supportsPenaltyAndStopParams: !(isGrok && reasoningCapable),
512
527
  reasoningEffortMap: {},
513
528
  supportsUsageInStreaming: !isCerebrasHost,
514
529
  alwaysSendMaxTokens: facts.is("kimi"),
515
- disableReasoningOnForcedToolChoice:
516
- !d.isClinePass && ((facts.is("kimi") && !isMoonshotKimiK3) || isAnthropicModel),
517
- disableReasoningOnToolChoice: !d.isClinePass && isDeepseekFamily && reasoningCapable && !d.isOpenRouter,
530
+ disableReasoningOnForcedToolChoice: (facts.is("kimi") && !isMoonshotKimiK3) || isAnthropicModel,
531
+ disableReasoningOnToolChoice: isDeepseekFamily && reasoningCapable && !d.isOpenRouter,
518
532
  disableReasoningWithTools: false,
519
- supportsToolChoice: d.isClinePass || !d.isDirectDeepseekReasoning,
520
- supportsForcedToolChoice:
521
- !d.requiresEnabledThinking && !(d.isOpenCodeHost && d.isDeepseekReasoning) && !(d.isClinePass && isQwen),
533
+ supportsToolChoice: !d.isDirectDeepseekReasoning,
534
+ supportsForcedToolChoice: !d.requiresEnabledThinking && !(d.isOpenCodeHost && d.isDeepseekReasoning),
522
535
  supportsNamedToolChoice: true,
523
536
  maxTokensField: useMaxTokens ? "max_tokens" : "max_completion_tokens",
524
537
  requiresToolResultName: isMistral,
@@ -527,16 +540,12 @@ function detectOpenAICompat(
527
540
  requiresMistralToolIds: isMistral,
528
541
  thinkingFormat,
529
542
  kimiApiFormat: undefined,
530
- reasoningDisableMode: d.isClinePass
531
- ? "cline-enabled-false"
532
- : d.isVenice
533
- ? "venice-disable-thinking"
534
- : resolveReasoningDisableMode(thinkingFormat),
543
+ reasoningDisableMode: d.isVenice ? "venice-disable-thinking" : resolveReasoningDisableMode(thinkingFormat),
535
544
  omitReasoningEffort: false,
536
545
  includeEncryptedReasoning: true,
537
546
  filterReasoningHistory: d.isOpenRouter && isAnthropicModel,
538
547
  thinkingKeep: usesMoonshotKimiPreservedThinking ? "all" : undefined,
539
- reasoningContentField: d.isClinePass ? "reasoning" : "reasoning_content",
548
+ reasoningContentField: "reasoning_content",
540
549
  mistralReasoningContentParts: undefined,
541
550
  requiresReasoningContentForToolCalls:
542
551
  (facts.is("kimi") && !d.isOpenCodeProvider) ||
@@ -554,10 +563,7 @@ function detectOpenAICompat(
554
563
  // Template effort support is a reviewed backend × model contract in KDL.
555
564
  qwenTemplateReasoningEffort: false,
556
565
  requiresAssistantContentForToolCalls: facts.is("kimi") || d.isDirectDeepseekReasoning,
557
- cacheControlFormat:
558
- (d.isClinePass && (isQwen || isAnthropicModel)) || (d.isOpenRouter && isAnthropicModel)
559
- ? "anthropic"
560
- : undefined,
566
+ cacheControlFormat: d.isOpenRouter && isAnthropicModel ? "anthropic" : undefined,
561
567
  supportsPromptCacheBreakpoints,
562
568
  promptCacheBreakpointTtl: supportsPromptCacheBreakpoints ? "30m" : undefined,
563
569
  openRouterRouting: undefined,
@@ -582,7 +588,11 @@ function detectOpenAICompat(
582
588
  : undefined,
583
589
  streamFirstEventTimeoutMs: d.isLocalServingBackend ? 0 : undefined,
584
590
  streamIdleTimeoutMs,
585
- stripDeepseekSpecialTokens: facts.is("deepseek") && (provider === "nvidia" || provider === "deepseek"),
591
+ stripDeepseekSpecialTokens: false,
592
+ // Present as a key so models.yml `compat` can set it; unset unless a rule assigns it.
593
+ stripThinkingHistory: undefined,
594
+ // URL-derived like the Responses field; unset off the official OpenAI API.
595
+ officialEndpoint: isOfficialOpenAIEndpoint(spec.provider, baseUrl) || undefined,
586
596
  streamMarkupHealingPattern: detectStreamMarkupHealing(spec.provider, facts, baseUrl),
587
597
  reasoningDeltasMayBeCumulative: false,
588
598
  emptyLengthFinishIsContextError: false,
@@ -640,15 +650,13 @@ function fixupOpenAICompat(
640
650
  compat.extraBody = Object.keys(extraBody).length > 0 ? extraBody : undefined;
641
651
  }
642
652
  if (spec.compat?.reasoningDisableMode === undefined && !("reasoningDisableMode" in axes.wire)) {
643
- compat.reasoningDisableMode = d.isClinePass
644
- ? "cline-enabled-false"
645
- : d.requiresEnabledThinking
646
- ? "omit"
647
- : d.isDirectDeepseekReasoning
648
- ? "zai-thinking-disabled"
649
- : d.isVenice
650
- ? "venice-disable-thinking"
651
- : resolveReasoningDisableMode(compat.thinkingFormat);
653
+ compat.reasoningDisableMode = d.requiresEnabledThinking
654
+ ? "omit"
655
+ : d.isDirectDeepseekReasoning
656
+ ? "zai-thinking-disabled"
657
+ : d.isVenice
658
+ ? "venice-disable-thinking"
659
+ : resolveReasoningDisableMode(compat.thinkingFormat);
652
660
  }
653
661
  if (
654
662
  spec.compat?.omitReasoningEffort === undefined &&
@@ -737,7 +745,8 @@ function resolveOpenAIResponsesPolicy(
737
745
  supportsLongPromptCacheRetention: isOpenAIUrl,
738
746
  supportsPromptCacheBreakpoints,
739
747
  promptCacheBreakpointTtl: supportsPromptCacheBreakpoints ? "30m" : undefined,
740
- strictResponsesPairing: isAzure || provider === "github-copilot",
748
+ // Provider ids are rule-owned; the URL arm keeps custom Azure endpoints paired.
749
+ strictResponsesPairing: hostMatchesUrl(baseUrl, "azureOpenAI"),
741
750
  // Azure's provider id alone only implies support while its endpoint is
742
751
  // resolved at runtime; an explicit non-Azure baseUrl is a proxy, like Codex.
743
752
  supportsImageDetailOriginal:
@@ -766,7 +775,7 @@ function resolveOpenAIResponsesPolicy(
766
775
  disableReasoningOnToolChoice: isDeepseekFamily && reasoningCapable && !isOpenRouter,
767
776
  disableReasoningWithTools: false,
768
777
  supportsToolChoice: true,
769
- supportsForcedToolChoice: provider !== "opencode-go" && provider !== "opencode-zen",
778
+ supportsForcedToolChoice: true,
770
779
  supportsNamedToolChoice: true,
771
780
  reasoningContentField: "reasoning_content",
772
781
  requiresReasoningContentForToolCalls:
@@ -796,12 +805,16 @@ function resolveOpenAIResponsesPolicy(
796
805
  supportsObfuscationOptOut: isOpenAIUrl || provider === "openai",
797
806
  storeResponses: false,
798
807
  connectionBoundNativeHistory: false,
799
- officialEndpoint: isOfficialOpenAIEndpoint(provider, baseUrl),
808
+ // The Codex backend is official at the URL its requests default to when `baseUrl` is unset, and at paths
809
+ // under it such as `/codex` and `/codex/responses`, which its requests resolve to the same endpoint.
810
+ officialEndpoint:
811
+ isOfficialOpenAIEndpoint(provider, baseUrl) ||
812
+ (api === "openai-codex-responses" && isOfficialCodexApiUrl(baseUrl)),
800
813
  harmonyLeakMitigation: false,
801
814
  rejectRootObjectUnion: false,
802
815
  retryWithoutStrictOnGrammarError: false,
803
816
  cacheControlFormat: isOpenRouter && isAnthropicModel ? "anthropic" : undefined,
804
- stripDeepseekSpecialTokens: facts.is("deepseek") && (provider === "nvidia" || provider === "deepseek"),
817
+ stripDeepseekSpecialTokens: false,
805
818
  streamMarkupHealingPattern: detectStreamMarkupHealing(provider, facts, baseUrl),
806
819
  reasoningDeltasMayBeCumulative: false,
807
820
  emptyLengthFinishIsContextError: false,
@@ -892,6 +905,7 @@ function resolveAnthropicPolicy(
892
905
  supportsContextManagement: true,
893
906
  supportsServerCompaction: false,
894
907
  firstPartyProvider: false,
908
+ honorsClaudeCodeBetaKillSwitch: false,
895
909
  supportsOutputEffort: true,
896
910
  disableStrictTools: rejectsStrictTools,
897
911
  // Present as a key so models.yml `compat` can set it; unset unless detected.
@@ -953,9 +967,17 @@ function resolveBedrockPolicy(spec: ModelSpec<"bedrock-converse-stream">, axes:
953
967
  return compat;
954
968
  }
955
969
 
970
+ function resolveOllamaPolicy(spec: ModelSpec<"ollama-chat">, axes: ResolvedAxes): ResolvedOllamaCompat {
971
+ const compat: ResolvedOllamaCompat = { stripThinkingHistory: false };
972
+ applyWireAxes(compat, axes.wire, "ollama-chat");
973
+ applyCompatOverrides(compat, spec.compat);
974
+ return compat;
975
+ }
976
+
956
977
  function resolveDevinPolicy(spec: ModelSpec<"devin-agent">, axes: ResolvedAxes): ResolvedDevinCompat {
957
978
  const compat: ResolvedDevinCompat = {
958
- trustExplicitThinkingOnly: true,
979
+ // Rule-owned: `on-api "devin-agent"` in providers/devin.kdl opts in.
980
+ trustExplicitThinkingOnly: false,
959
981
  modelRouter: false,
960
982
  supportsParallelToolCalls: false,
961
983
  };
@@ -979,6 +1001,10 @@ function resolveGooglePolicy(
979
1001
  claudeThinkingBetaHeader: false,
980
1002
  antigravityClaudeToolMode: false,
981
1003
  stripImageInput: false,
1004
+ // Unset means "no image contract": images go out as sent. Keys exist so
1005
+ // a KDL rule or a user `compat` block can declare one.
1006
+ maxImageDimension: undefined,
1007
+ maxImagePayloadBytes: undefined,
982
1008
  };
983
1009
  applyWireAxes(compat, axes.wire, spec.api);
984
1010
  applyCompatOverrides(compat, spec.compat);
@@ -1004,7 +1030,11 @@ function readCompatEffortMap(compat: CompatOf<Api>): Partial<Record<Effort, stri
1004
1030
  const map = compat.reasoningEffortMap;
1005
1031
  return map && Object.keys(map).length > 0 ? map : undefined;
1006
1032
  }
1007
- /** Host-quirk effort remaps detected outside the rules (URL/API compounds). */
1033
+ /**
1034
+ * URL-keyed Fireworks effort remap: covers custom provider ids pointed at the
1035
+ * Fireworks host, which the `fireworks`/`firepass` `thinking-effort-map`
1036
+ * rules cannot match. Rule and configured maps override it.
1037
+ */
1008
1038
  const FIREWORKS_THINKING_EFFORT_MAP: Readonly<Partial<Record<Effort, string>>> = {
1009
1039
  [Effort.Minimal]: "none",
1010
1040
  };
@@ -1015,12 +1045,9 @@ function defaultThinkingMode<TApi extends Api>(spec: ModelSpec<TApi>, facts: Ide
1015
1045
  case "google-generative-ai":
1016
1046
  case "google-gemini-cli":
1017
1047
  case "google-vertex":
1018
- return facts.is("gemini") && facts.revMajor() === 3 ? "google-level" : "budget";
1048
+ // Gemini 3+ `google-level` is a `thinking-mode` rule in classes/gemini.kdl.
1049
+ return "budget";
1019
1050
  case "anthropic-messages":
1020
- if (facts.is("minimax") && facts.family("m2", "m3")) return "anthropic-adaptive";
1021
- if (facts.is("glm") && facts.revGte("5.2") && (spec.provider === "umans" || spec.provider === "zai")) {
1022
- return "anthropic-budget-effort";
1023
- }
1024
1051
  if (facts.is("anthropic")) {
1025
1052
  // Haiku stays on budget thinking until 5.5, its first adaptive generation.
1026
1053
  if (facts.revGte(facts.family("haiku") ? "5.5" : "4.6")) return "anthropic-adaptive";
@@ -1088,6 +1115,7 @@ interface RuleThinking {
1088
1115
  supportsDisplay?: boolean;
1089
1116
  prefixBinding?: boolean;
1090
1117
  upgradeNeutral?: boolean;
1118
+ unverified?: boolean;
1091
1119
  }
1092
1120
 
1093
1121
  function readRuleThinking(axes: ResolvedAxes): RuleThinking {
@@ -1108,6 +1136,7 @@ function readRuleThinking(axes: ResolvedAxes): RuleThinking {
1108
1136
  if (typeof raw.supportsDisplay === "boolean") out.supportsDisplay = raw.supportsDisplay;
1109
1137
  if (typeof raw.prefixBinding === "boolean") out.prefixBinding = raw.prefixBinding;
1110
1138
  if (typeof raw.upgradeNeutral === "boolean") out.upgradeNeutral = raw.upgradeNeutral;
1139
+ if (typeof raw.unverified === "boolean") out.unverified = raw.unverified;
1111
1140
  return out;
1112
1141
  }
1113
1142
 
@@ -1155,13 +1184,13 @@ function resolveThinkingPolicy<TApi extends Api>(
1155
1184
  // not re-expand it into an unadvertised ladder. Absent metadata is
1156
1185
  // repaired only where KDL opts in with `thinking-upgrade-neutral`
1157
1186
  // alongside a reviewed `thinking-efforts` ladder (the cascade upgrade for
1158
- // stale source capability data); otherwise the neutral default holds.
1159
- if (!spec.reasoning && (explicitThinking !== undefined || rule.upgradeNeutral !== true)) return undefined;
1187
+ // stale source capability data). On deployments whose discovery reports no
1188
+ // capabilities (`thinking-unverified`), an unclassified neutral spec — nothing
1189
+ // says whether it reasons — gets an unverified ladder the user may opt into.
1190
+ const unverified = !compatReasoning(spec, axes);
1160
1191
  if (
1161
- spec.provider === "cline-pass" &&
1162
- compat !== undefined &&
1163
- "supportsReasoningEffort" in compat &&
1164
- compat.supportsReasoningEffort === false
1192
+ unverified &&
1193
+ (explicitThinking !== undefined || facts.identity.class !== "unknown" || rule.unverified !== true)
1165
1194
  ) {
1166
1195
  return undefined;
1167
1196
  }
@@ -1180,9 +1209,14 @@ function resolveThinkingPolicy<TApi extends Api>(
1180
1209
  if (config.efforts.length === 0) {
1181
1210
  throw new Error(`Model ${spec.provider}/${spec.id} resolved to an empty thinking range`);
1182
1211
  }
1183
- if (rule.defaultLevel !== undefined) config.defaultLevel = rule.defaultLevel;
1184
1212
  const effortMap = mergeEffortMap(spec, rule.effortMap, compat, config.efforts);
1185
1213
  if (effortMap !== undefined) config.effortMap = effortMap;
1214
+ if (unverified) {
1215
+ // Off-by-default opt-in: no default level, mandatory effort, or wire off-switch.
1216
+ config.unverified = true;
1217
+ return config;
1218
+ }
1219
+ if (rule.defaultLevel !== undefined) config.defaultLevel = rule.defaultLevel;
1186
1220
  if (rule.effortBudgets !== undefined) config.effortBudgets = rule.effortBudgets;
1187
1221
  const supportsDisplay = rule.supportsDisplay ?? defaultSupportsDisplay(spec, facts);
1188
1222
  if (supportsDisplay) config.supportsDisplay = true;
@@ -1279,7 +1313,7 @@ function buildResolveTarget<TApi extends Api>(
1279
1313
  provider: providerType,
1280
1314
  api: spec.api,
1281
1315
  class: identity.class,
1282
- model: spec.id,
1316
+ model: policyModelId(spec),
1283
1317
  reasoning: Boolean(spec.reasoning),
1284
1318
  };
1285
1319
  if (identity.family !== undefined) target.family = identity.family;
@@ -1315,7 +1349,8 @@ export function resolveModelPolicy<TApi extends Api>(
1315
1349
  spec: ModelSpec<TApi>,
1316
1350
  route?: ResolveRoute,
1317
1351
  ): ResolvedModelPolicy<TApi>;
1318
- export function resolveModelPolicy(spec: ModelSpec<Api>, route?: ResolveRoute): ResolvedModelPolicy<Api> {
1352
+ export function resolveModelPolicy(input: ModelSpec<Api>, route?: ResolveRoute): ResolvedModelPolicy<Api> {
1353
+ const spec = withoutUnverifiedThinking(input);
1319
1354
  const identity = resolveIdentity(spec);
1320
1355
  const facts = new IdentityFacts(identity);
1321
1356
  const target = buildResolveTarget(spec, identity);
@@ -1338,6 +1373,8 @@ export function resolveModelPolicy(spec: ModelSpec<Api>, route?: ResolveRoute):
1338
1373
  compat = resolveAnthropicPolicy(spec, facts, axes);
1339
1374
  } else if (specUsesApi(spec, "bedrock-converse-stream")) {
1340
1375
  compat = resolveBedrockPolicy(spec, axes);
1376
+ } else if (specUsesApi(spec, "ollama-chat")) {
1377
+ compat = resolveOllamaPolicy(spec, axes);
1341
1378
  } else if (specUsesApi(spec, "devin-agent")) {
1342
1379
  compat = resolveDevinPolicy(spec, axes);
1343
1380
  } else if (