@oh-my-pi/pi-catalog 18.2.6 → 18.2.7

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 (56) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/THIRD-PARTY-NOTICES.txt +0 -37
  3. package/dist/types/build.d.ts +7 -1
  4. package/dist/types/compat/auth-ids.d.ts +1 -1
  5. package/dist/types/compat/axes.d.ts +1 -1
  6. package/dist/types/compat/cascade.d.ts +3 -2
  7. package/dist/types/compat/provider-ids.d.ts +1 -1
  8. package/dist/types/compat/resolve.d.ts +2 -0
  9. package/dist/types/compat/taxonomy.d.ts +2 -2
  10. package/dist/types/compat/types.d.ts +18 -7
  11. package/dist/types/discovery/index.d.ts +1 -0
  12. package/dist/types/discovery/openai-compatible.d.ts +2 -0
  13. package/dist/types/discovery/typesafe.d.ts +26 -0
  14. package/dist/types/model-manager.d.ts +1 -1
  15. package/dist/types/provider-models/openai-compat.d.ts +6 -4
  16. package/dist/types/provider-models/special.d.ts +10 -0
  17. package/dist/types/types.d.ts +20 -0
  18. package/package.json +4 -4
  19. package/src/build.ts +27 -5
  20. package/src/compat/auth-ids.ts +2 -0
  21. package/src/compat/axes.ts +9 -1
  22. package/src/compat/cascade.ts +45 -23
  23. package/src/compat/provider-ids.ts +3 -0
  24. package/src/compat/resolve.ts +24 -14
  25. package/src/compat/rules/README.md +5 -3
  26. package/src/compat/rules/auth/local.kdl +4 -0
  27. package/src/compat/rules/auth/typesafe.kdl +3 -6
  28. package/src/compat/rules/auth/web.kdl +4 -0
  29. package/src/compat/rules/classes/qwen.kdl +6 -5
  30. package/src/compat/rules/providers/anthropic.kdl +1 -0
  31. package/src/compat/rules/providers/deepinfra.kdl +28 -0
  32. package/src/compat/rules/providers/google-antigravity.kdl +17 -0
  33. package/src/compat/rules/providers/google.kdl +9 -0
  34. package/src/compat/rules/providers/llama.cpp.kdl +23 -0
  35. package/src/compat/rules/providers/local.kdl +110 -0
  36. package/src/compat/rules/providers/openai-codex.kdl +18 -0
  37. package/src/compat/rules/providers/openai.kdl +3 -0
  38. package/src/compat/rules/providers/openrouter.kdl +24 -0
  39. package/src/compat/rules/providers/typesafe.kdl +21 -0
  40. package/src/compat/rules/providers/web.kdl +153 -0
  41. package/src/compat/rules/providers/xai-oauth.kdl +26 -0
  42. package/src/compat/rules/providers/xai.kdl +22 -0
  43. package/src/compat/rules/runtime/behavior.kdl +2 -1
  44. package/src/compat/rules/taxonomy/qwen.kdl +2 -0
  45. package/src/compat/rules.json +1 -1
  46. package/src/compat/taxonomy.ts +92 -31
  47. package/src/compat/types.ts +22 -7
  48. package/src/discovery/index.ts +1 -0
  49. package/src/discovery/openai-compatible.ts +4 -1
  50. package/src/discovery/typesafe.ts +118 -0
  51. package/src/model-manager.ts +44 -15
  52. package/src/models.json +1 -1
  53. package/src/provider-models/descriptors.ts +6 -0
  54. package/src/provider-models/openai-compat.ts +246 -79
  55. package/src/provider-models/special.ts +53 -0
  56. package/src/types.ts +33 -0
@@ -77,6 +77,9 @@ import {
77
77
  cursorModelManagerOptions,
78
78
  devinModelManagerOptions,
79
79
  gitLabDuoWorkflowModelManagerOptions,
80
+ localModelManagerOptions,
81
+ typesafeModelManagerOptions,
82
+ webModelManagerOptions,
80
83
  zaiModelManagerOptions,
81
84
  } from "./special";
82
85
 
@@ -114,6 +117,7 @@ const MODEL_MANAGER_FACTORIES: Readonly<Partial<Record<KnownProvider, ModelManag
114
117
  kilo: config => kiloModelManagerOptions(config),
115
118
  "kimi-code": config => kimiCodeModelManagerOptions(config),
116
119
  litellm: config => litellmModelManagerOptions(config),
120
+ local: () => localModelManagerOptions(),
117
121
  "lm-studio": config => lmStudioModelManagerOptions(config),
118
122
  mistral: config => mistralModelManagerOptions(config),
119
123
  "muse-code": config => museCodeModelManagerOptions(config),
@@ -135,11 +139,13 @@ const MODEL_MANAGER_FACTORIES: Readonly<Partial<Record<KnownProvider, ModelManag
135
139
  "siliconflow-cn": config => siliconflowCnModelManagerOptions(config),
136
140
  synthetic: config => syntheticModelManagerOptions(config),
137
141
  together: config => togetherModelManagerOptions(config),
142
+ typesafe: config => typesafeModelManagerOptions(config),
138
143
  umans: config => umansModelManagerOptions(config),
139
144
  venice: config => veniceModelManagerOptions(config),
140
145
  "vercel-ai-gateway": config => vercelAiGatewayModelManagerOptions(config),
141
146
  vllm: config => vllmModelManagerOptions(config),
142
147
  "wafer-serverless": config => waferServerlessModelManagerOptions(config),
148
+ web: () => webModelManagerOptions(),
143
149
  coreweave: config => coreWeaveModelManagerOptions(config),
144
150
  xai: config => xaiModelManagerOptions(config),
145
151
  "xai-oauth": config => xaiOAuthModelManagerOptions(config),
@@ -14,7 +14,7 @@ import {
14
14
  import { xaiResponsesReasoningEffortMap } from "../compat/openai";
15
15
  import { hasModelScopedEffortLadder, resolveModelPolicy } from "../compat/resolve";
16
16
  import { compareRevision, parseRevision } from "../compat/revision";
17
- import { seedModels } from "../compat/providers";
17
+ import { providerEntries, seedModels } from "../compat/providers";
18
18
  import { billingVariantPlain, classifyModel, discoveryVocabulary } from "../compat/taxonomy";
19
19
  import {
20
20
  DEFAULT_OPENAI_COMPATIBLE_DISCOVERY_TIMEOUT_MS,
@@ -28,7 +28,17 @@ import { getBundledModelReferenceIndex } from "../identity/bundled";
28
28
  import { resolveModelReference } from "../identity/reference";
29
29
  import type { ModelManagerOptions, ModelsDevFallback } from "../model-manager";
30
30
  import { type GeneratedProvider, getBundledModels } from "../models";
31
- import type { Api, FetchImpl, Model, ModelSpec, OpenAICompat, Provider, ThinkingConfig } from "../types";
31
+ import {
32
+ MODEL_KINDS,
33
+ type Api,
34
+ type FetchImpl,
35
+ type Model,
36
+ type ModelKind,
37
+ type ModelSpec,
38
+ type OpenAICompat,
39
+ type Provider,
40
+ type ThinkingConfig,
41
+ } from "../types";
32
42
  import { discoveryFetch, isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils";
33
43
  import { ALIBABA_TOKEN_PLAN_BASE_URL, parseAlibabaTokenPlanCredential } from "../wire/alibaba-token-plan";
34
44
  import { normalizeCharmHyperBaseUrl } from "../wire/charm-hyper";
@@ -96,6 +106,7 @@ const ANTHROPIC_OAUTH_BETA =
96
106
  export interface ModelsDevModel {
97
107
  id?: string;
98
108
  name?: string;
109
+ kind?: string;
99
110
  tool_call?: boolean;
100
111
  reasoning?: boolean;
101
112
  reasoning_options?: Array<{ type?: string; values?: string[]; min?: number; max?: number }>;
@@ -111,6 +122,7 @@ export interface ModelsDevModel {
111
122
  };
112
123
  modalities?: {
113
124
  input?: string[];
125
+ output?: string[];
114
126
  };
115
127
  status?: string;
116
128
  provider?: { npm?: string };
@@ -517,8 +529,11 @@ function mapWithBundledReference<TApi extends Api>(
517
529
  name,
518
530
  };
519
531
  }
532
+ // Generic `/models` rows describe the chat roster. Do not make a bundled
533
+ // runner kind look endpoint-authored merely because its metadata is reused.
534
+ const { kind: _inheritedKind, ...chatReference } = reference;
520
535
  return {
521
- ...reference,
536
+ ...chatReference,
522
537
  id: defaults.id,
523
538
  name,
524
539
  api: defaults.api,
@@ -1434,8 +1449,11 @@ function mapDeepinfraModel(
1434
1449
  : referenceMaxTokens !== null && contextWindow !== null
1435
1450
  ? Math.min(referenceMaxTokens, contextWindow)
1436
1451
  : referenceMaxTokens;
1452
+ // This endpoint is filtered to `chat`; a same-id runner reference may lend
1453
+ // metadata, but its kind is not evidence that chat discovery advertised it.
1454
+ const { kind: _inheritedKind, ...chatReference } = reference ?? {};
1437
1455
  return {
1438
- ...reference,
1456
+ ...chatReference,
1439
1457
  id,
1440
1458
  name: reference?.name ?? id,
1441
1459
  api: "openai-completions",
@@ -1691,8 +1709,7 @@ function mergeCuratedIntoModel(
1691
1709
  * window, reasoning flags, or the effort-dial allowlist.
1692
1710
  *
1693
1711
  * Three passes:
1694
- * 1. Filter `XAI_NON_CHAT_PREFIXES` (picker pollution defense for tool
1695
- * surfaces routed through dedicated tools — generate_image, tts).
1712
+ * 1. Filter KDL exclusions and runner seed ids out of the chat roster.
1696
1713
  * 2. Overlay curated metadata onto dynamic-fetch matches. xAI's /v1/models
1697
1714
  * does not return context_window or reasoning metadata, so without
1698
1715
  * this overlay the runtime falls back to the bundled-reference default
@@ -1708,8 +1725,13 @@ function mergeCuratedIntoModel(
1708
1725
  * in original order.
1709
1726
  */
1710
1727
  function applyXAIOAuthCuration(dynamic: readonly ModelSpec<"openai-responses">[]): ModelSpec<"openai-responses">[] {
1711
- const filtered = dynamic.filter(e => !isExcludedModel("xai-oauth", e.id));
1712
- const curatedModels = seedModels<"openai-responses">("xai-oauth");
1728
+ const curatedModels: ModelSpec<"openai-responses">[] = [];
1729
+ const runnerIds = new Set<string>();
1730
+ for (const seed of seedModels("xai-oauth")) {
1731
+ if (isResponsesSeed(seed)) curatedModels.push(seed);
1732
+ else runnerIds.add(seed.id);
1733
+ }
1734
+ const filtered = dynamic.filter(e => !runnerIds.has(e.id) && !isExcludedModel("xai-oauth", e.id));
1713
1735
 
1714
1736
  const byId = new Map<string, ModelSpec<"openai-responses">>(filtered.map(e => [e.id, e]));
1715
1737
  for (const curated of curatedModels) {
@@ -1737,13 +1759,20 @@ function applyXAIOAuthCuration(dynamic: readonly ModelSpec<"openai-responses">[]
1737
1759
  return [...curatedFirst, ...rest];
1738
1760
  }
1739
1761
 
1762
+ function isResponsesSeed(seed: ModelSpec<Api>): seed is ModelSpec<"openai-responses"> {
1763
+ return seed.api === "openai-responses";
1764
+ }
1765
+
1740
1766
  /**
1741
1767
  * Render the xai-oauth KDL seed as the static runtime fallback consumed by
1742
1768
  * {@link xaiOAuthModelManagerOptions}.
1743
1769
  */
1744
- export function buildXaiOAuthStaticSeed(baseUrl?: string): ModelSpec<"openai-responses">[] {
1770
+ export function buildXaiOAuthStaticSeed(baseUrl?: string): ModelSpec<Api>[] {
1745
1771
  const resolvedBaseUrl = baseUrl ?? "https://api.x.ai/v1";
1746
- return seedModels<"openai-responses">("xai-oauth").map(seed => {
1772
+ return seedModels("xai-oauth").map(seed => {
1773
+ if (!isResponsesSeed(seed)) {
1774
+ return { ...seed, baseUrl: resolvedBaseUrl };
1775
+ }
1747
1776
  const base: ModelSpec<"openai-responses"> = {
1748
1777
  ...seed,
1749
1778
  baseUrl: resolvedBaseUrl,
@@ -1753,9 +1782,7 @@ export function buildXaiOAuthStaticSeed(baseUrl?: string): ModelSpec<"openai-res
1753
1782
  });
1754
1783
  }
1755
1784
 
1756
- export function xaiOAuthModelManagerOptions(
1757
- config?: XaiOAuthModelManagerConfig,
1758
- ): ModelManagerOptions<"openai-responses"> {
1785
+ export function xaiOAuthModelManagerOptions(config?: XaiOAuthModelManagerConfig): ModelManagerOptions<Api> {
1759
1786
  const defaultBaseUrl = "https://api.x.ai/v1";
1760
1787
  const resolvedBaseUrl = config?.baseUrl ?? defaultBaseUrl;
1761
1788
  const base = createOpenAICompatibleModelManagerOptions({
@@ -3161,6 +3188,14 @@ export interface OpenRouterModelManagerConfig {
3161
3188
  fetch?: FetchImpl;
3162
3189
  }
3163
3190
 
3191
+ /**
3192
+ * OpenRouter's Decisions API lives at `/api/alpha`, a sibling of the `/api/v1`
3193
+ * chat root; derive it so a custom gateway base URL keeps both aligned.
3194
+ */
3195
+ function openrouterDecisionsBaseUrl(chatBaseUrl: string): string {
3196
+ return chatBaseUrl.endsWith("/v1") ? `${chatBaseUrl.slice(0, -"/v1".length)}/alpha` : `${chatBaseUrl}/alpha`;
3197
+ }
3198
+
3164
3199
  function mapOpenRouterThinking(entry: OpenAICompatibleModelRecord): ThinkingConfig | undefined {
3165
3200
  const reasoning = entry.reasoning;
3166
3201
  if (!isRecord(reasoning)) return undefined;
@@ -3180,11 +3215,10 @@ function mapOpenRouterThinking(entry: OpenAICompatibleModelRecord): ThinkingConf
3180
3215
  };
3181
3216
  }
3182
3217
 
3183
- export function openrouterModelManagerOptions(
3184
- config?: OpenRouterModelManagerConfig,
3185
- ): ModelManagerOptions<"openrouter"> {
3218
+ export function openrouterModelManagerOptions(config?: OpenRouterModelManagerConfig): ModelManagerOptions<Api> {
3186
3219
  const apiKey = config?.apiKey;
3187
- const baseUrl = config?.baseUrl ?? "https://openrouter.ai/api/v1";
3220
+ const baseUrl = (config?.baseUrl ?? "https://openrouter.ai/api/v1").replace(/\/+$/g, "");
3221
+ const decisionsBaseUrl = openrouterDecisionsBaseUrl(baseUrl);
3188
3222
  const references = createBundledReferenceMap<"openrouter">("openrouter");
3189
3223
  return {
3190
3224
  providerId: "openrouter",
@@ -3192,55 +3226,147 @@ export function openrouterModelManagerOptions(
3192
3226
  // Namespace the refreshed pseudo-API cache separately so those rows cannot
3193
3227
  // override bundled `api: "openrouter"` models during online-if-uncached startup.
3194
3228
  cacheProviderId: resolveModelCacheProviderId("openrouter"),
3195
- fetchDynamicModels: () =>
3196
- fetchOpenAICompatibleModels({
3197
- api: "openrouter",
3198
- provider: "openrouter",
3199
- baseUrl,
3200
- apiKey,
3201
- filterModel: (entry: OpenAICompatibleModelRecord) => {
3202
- const params = entry.supported_parameters;
3203
- return Array.isArray(params) && params.includes("tools");
3204
- },
3205
- mapModel: (
3206
- entry: OpenAICompatibleModelRecord,
3207
- defaults: ModelSpec<"openrouter">,
3208
- _context: OpenAICompatibleModelMapperContext<"openrouter">,
3209
- ): ModelSpec<"openrouter"> => {
3210
- const reference = references.get(defaults.id);
3211
- const baseModel = mapWithBundledReference(entry, defaults, reference);
3212
- const pricing = entry.pricing as Record<string, unknown> | undefined;
3213
- const params = Array.isArray(entry.supported_parameters) ? (entry.supported_parameters as string[]) : [];
3214
- const thinking = mapOpenRouterThinking(entry);
3215
- const modality = String((entry.architecture as Record<string, unknown> | undefined)?.modality ?? "");
3216
- const topProvider = entry.top_provider as Record<string, unknown> | undefined;
3229
+ fetchDynamicModels: async () => {
3230
+ const [chatModels, imageModels, decisionModels] = await Promise.all([
3231
+ fetchOpenAICompatibleModels({
3232
+ api: "openrouter",
3233
+ provider: "openrouter",
3234
+ baseUrl,
3235
+ apiKey,
3236
+ filterModel: (entry: OpenAICompatibleModelRecord) => {
3237
+ const params = entry.supported_parameters;
3238
+ return Array.isArray(params) && params.includes("tools");
3239
+ },
3240
+ mapModel: (
3241
+ entry: OpenAICompatibleModelRecord,
3242
+ defaults: ModelSpec<"openrouter">,
3243
+ _context: OpenAICompatibleModelMapperContext<"openrouter">,
3244
+ ): ModelSpec<"openrouter"> => {
3245
+ const reference = references.get(defaults.id);
3246
+ const baseModel = mapWithBundledReference(entry, defaults, reference);
3247
+ const pricing = isRecord(entry.pricing) ? entry.pricing : undefined;
3248
+ const params = Array.isArray(entry.supported_parameters)
3249
+ ? entry.supported_parameters.filter((value): value is string => typeof value === "string")
3250
+ : [];
3251
+ const thinking = mapOpenRouterThinking(entry);
3252
+ const architecture = isRecord(entry.architecture) ? entry.architecture : undefined;
3253
+ const input: ("text" | "image")[] = Array.isArray(architecture?.input_modalities)
3254
+ ? toInputCapabilities(architecture.input_modalities)
3255
+ : String(architecture?.modality ?? "").includes("image")
3256
+ ? ["text", "image"]
3257
+ : ["text"];
3258
+ const topProvider = isRecord(entry.top_provider) ? entry.top_provider : undefined;
3217
3259
 
3218
- const supportsToolChoice = params.includes("tool_choice");
3260
+ const supportsToolChoice = params.includes("tool_choice");
3219
3261
 
3220
- return {
3221
- ...baseModel,
3222
- reasoning: params.includes("reasoning"),
3223
- ...(thinking !== undefined ? { thinking } : {}),
3224
- input: modality.includes("image") ? ["text", "image"] : ["text"],
3225
- cost: {
3226
- input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000,
3227
- output: parseFloat(String(pricing?.completion ?? "0")) * 1_000_000,
3228
- cacheRead: parseFloat(String(pricing?.input_cache_read ?? "0")) * 1_000_000,
3229
- cacheWrite: parseFloat(String(pricing?.input_cache_write ?? "0")) * 1_000_000,
3230
- },
3231
- contextWindow:
3232
- typeof entry.context_length === "number" ? entry.context_length : baseModel.contextWindow,
3233
- maxTokens:
3234
- typeof topProvider?.max_completion_tokens === "number"
3235
- ? topProvider.max_completion_tokens
3236
- : baseModel.maxTokens,
3237
- ...(!supportsToolChoice && {
3238
- compat: { ...baseModel.compat, supportsToolChoice: false },
3239
- }),
3240
- };
3241
- },
3242
- fetch: config?.fetch,
3243
- }),
3262
+ return {
3263
+ ...baseModel,
3264
+ reasoning: params.includes("reasoning"),
3265
+ ...(thinking !== undefined ? { thinking } : {}),
3266
+ input,
3267
+ cost: {
3268
+ input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000,
3269
+ output: parseFloat(String(pricing?.completion ?? "0")) * 1_000_000,
3270
+ cacheRead: parseFloat(String(pricing?.input_cache_read ?? "0")) * 1_000_000,
3271
+ cacheWrite: parseFloat(String(pricing?.input_cache_write ?? "0")) * 1_000_000,
3272
+ },
3273
+ contextWindow:
3274
+ typeof entry.context_length === "number" ? entry.context_length : baseModel.contextWindow,
3275
+ maxTokens:
3276
+ typeof topProvider?.max_completion_tokens === "number"
3277
+ ? topProvider.max_completion_tokens
3278
+ : baseModel.maxTokens,
3279
+ ...(!supportsToolChoice && {
3280
+ compat: { ...baseModel.compat, supportsToolChoice: false },
3281
+ }),
3282
+ };
3283
+ },
3284
+ fetch: config?.fetch,
3285
+ }),
3286
+ fetchOpenAICompatibleModels({
3287
+ api: "openrouter-images",
3288
+ provider: "openrouter",
3289
+ baseUrl: `${baseUrl}/images`,
3290
+ apiKey,
3291
+ filterModel: entry => {
3292
+ const architecture = isRecord(entry.architecture) ? entry.architecture : undefined;
3293
+ return (
3294
+ Array.isArray(architecture?.output_modalities) && architecture.output_modalities.includes("image")
3295
+ );
3296
+ },
3297
+ mapModel: (_entry, defaults): ModelSpec<"openrouter-images"> => ({
3298
+ ...defaults,
3299
+ baseUrl,
3300
+ kind: "image",
3301
+ reasoning: false,
3302
+ input: ["text", "image"],
3303
+ supportsTools: false,
3304
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3305
+ contextWindow: null,
3306
+ maxTokens: null,
3307
+ }),
3308
+ fetch: config?.fetch,
3309
+ }),
3310
+ // Decision models (`text->decisions`) are absent from the default roster
3311
+ // and answer only through the Decisions API, outside the `/v1` prefix.
3312
+ fetchOpenAICompatibleModels({
3313
+ api: "openrouter-decisions",
3314
+ provider: "openrouter",
3315
+ baseUrl,
3316
+ apiKey,
3317
+ query: { output_modalities: "decisions" },
3318
+ filterModel: entry => {
3319
+ const architecture = isRecord(entry.architecture) ? entry.architecture : undefined;
3320
+ return (
3321
+ Array.isArray(architecture?.output_modalities) &&
3322
+ architecture.output_modalities.includes("decisions")
3323
+ );
3324
+ },
3325
+ mapModel: (entry, defaults): ModelSpec<"openrouter-decisions"> => {
3326
+ const pricing = isRecord(entry.pricing) ? entry.pricing : undefined;
3327
+ const topProvider = isRecord(entry.top_provider) ? entry.top_provider : undefined;
3328
+ return {
3329
+ ...defaults,
3330
+ baseUrl: decisionsBaseUrl,
3331
+ kind: "judge",
3332
+ reasoning: false,
3333
+ input: ["text"],
3334
+ supportsTools: false,
3335
+ cost: {
3336
+ input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000,
3337
+ output: parseFloat(String(pricing?.completion ?? "0")) * 1_000_000,
3338
+ cacheRead: 0,
3339
+ cacheWrite: 0,
3340
+ },
3341
+ contextWindow: typeof entry.context_length === "number" ? entry.context_length : null,
3342
+ maxTokens:
3343
+ typeof topProvider?.max_completion_tokens === "number"
3344
+ ? topProvider.max_completion_tokens
3345
+ : null,
3346
+ };
3347
+ },
3348
+ fetch: config?.fetch,
3349
+ }),
3350
+ ]);
3351
+
3352
+ if (imageModels === null) {
3353
+ logger.warn("OpenRouter image model discovery unavailable; preserving chat model discovery", {
3354
+ endpoint: `${baseUrl}/images/models`,
3355
+ });
3356
+ }
3357
+ if (decisionModels === null) {
3358
+ logger.warn("OpenRouter decision model discovery unavailable; preserving chat model discovery", {
3359
+ endpoint: `${baseUrl}/models?output_modalities=decisions`,
3360
+ });
3361
+ }
3362
+ if (chatModels === null && imageModels === null && decisionModels === null) return null;
3363
+
3364
+ const models = new Map<string, ModelSpec<Api>>();
3365
+ for (const model of chatModels ?? []) models.set(model.id, model);
3366
+ for (const model of imageModels ?? []) models.set(model.id, model);
3367
+ for (const model of decisionModels ?? []) models.set(model.id, model);
3368
+ return Array.from(models.values()).sort((left, right) => left.id.localeCompare(right.id));
3369
+ },
3244
3370
  };
3245
3371
  }
3246
3372
 
@@ -6174,32 +6300,69 @@ export function mapModelsDevToModels(
6174
6300
  descriptors: readonly ModelsDevProviderDescriptor[],
6175
6301
  ): ModelSpec<Api>[] {
6176
6302
  const models: ModelSpec<Api>[] = [];
6303
+ const providers = providerEntries();
6177
6304
  for (const desc of descriptors) {
6178
- const providerData = (data as Record<string, Record<string, unknown>>)[desc.modelsDevKey];
6305
+ const providerData = data[desc.modelsDevKey];
6179
6306
  if (!isRecord(providerData) || !isRecord(providerData.models)) continue;
6180
6307
 
6181
- for (const [modelId, rawModel] of Object.entries(providerData.models)) {
6308
+ for (const modelId in providerData.models) {
6309
+ const rawModel = providerData.models[modelId];
6182
6310
  if (!isRecord(rawModel)) continue;
6183
6311
  const m = rawModel as ModelsDevModel;
6312
+ const name = toModelName(m.name, modelId);
6313
+ let kind: ModelKind | undefined;
6314
+ let kindApi: Api | undefined;
6315
+
6316
+ if (m.kind !== undefined) {
6317
+ const policy = resolveModelPolicy({
6318
+ id: modelId,
6319
+ name,
6320
+ api: desc.api,
6321
+ provider: desc.providerId,
6322
+ baseUrl: desc.baseUrl,
6323
+ reasoning: false,
6324
+ input: ["text"],
6325
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
6326
+ contextWindow: null,
6327
+ maxTokens: null,
6328
+ });
6329
+ const normalizedKind = policy.catalog.kind ?? m.kind;
6330
+ if (typeof normalizedKind !== "string" || !MODEL_KINDS.some(value => value === normalizedKind)) continue;
6331
+ if (normalizedKind !== "chat") {
6332
+ if (normalizedKind !== "image" && normalizedKind !== "tts" && normalizedKind !== "stt") continue;
6333
+ kindApi = providers[desc.providerId]?.kindApis?.[normalizedKind];
6334
+ if (kindApi === undefined) continue;
6335
+ kind = normalizedKind;
6336
+ }
6337
+ }
6184
6338
 
6185
- // Default filter: tool_call must be true
6186
- if (desc.filterModel) {
6187
- if (!desc.filterModel(modelId, m)) continue;
6188
- } else {
6189
- if (m.tool_call !== true) continue;
6339
+ if (kind === undefined) {
6340
+ // Ordinary chat rows retain the provider-specific/default tool filter.
6341
+ if (desc.filterModel) {
6342
+ if (!desc.filterModel(modelId, m)) continue;
6343
+ } else if (m.tool_call !== true) {
6344
+ continue;
6345
+ }
6190
6346
  }
6191
6347
 
6192
- // Resolve API and baseUrl (may be per-model for providers like OpenCode)
6193
- const resolved = desc.resolveApi?.(modelId, m) ?? { api: desc.api, baseUrl: desc.baseUrl };
6348
+ // Non-chat rows use the provider-authored runner API; chat rows retain
6349
+ // per-model API/base URL resolution (for example OpenCode route pins).
6350
+ let resolved: { api: Api; baseUrl: string } | null;
6351
+ if (kind === undefined) {
6352
+ resolved = desc.resolveApi?.(modelId, m) ?? { api: desc.api, baseUrl: desc.baseUrl };
6353
+ } else {
6354
+ if (kindApi === undefined) continue;
6355
+ resolved = { api: kindApi, baseUrl: desc.baseUrl };
6356
+ }
6194
6357
  if (!resolved) continue;
6195
6358
 
6196
6359
  const mapped: ModelSpec<Api> = {
6197
6360
  id: modelId,
6198
- name: toModelName(m.name, modelId),
6361
+ name,
6199
6362
  api: resolved.api,
6200
- provider: desc.providerId as ModelSpec<Api>["provider"],
6363
+ provider: desc.providerId,
6201
6364
  baseUrl: resolved.baseUrl,
6202
- reasoning: m.reasoning === true,
6365
+ reasoning: kind === undefined && m.reasoning === true,
6203
6366
  input: toInputCapabilities(m.modalities?.input),
6204
6367
  cost: {
6205
6368
  input: toNumber(m.cost?.input) ?? 0,
@@ -6209,15 +6372,19 @@ export function mapModelsDevToModels(
6209
6372
  },
6210
6373
  contextWindow: toPositiveNumber(m.limit?.context, desc.defaultContextWindow ?? null),
6211
6374
  maxTokens: toPositiveNumber(m.limit?.output, desc.defaultMaxTokens ?? null),
6375
+ ...(kind !== undefined ? { kind, supportsTools: false } : {}),
6212
6376
  ...(m.int != null ? { int: m.int } : {}),
6213
6377
  ...(m.tps != null ? { tps: m.tps } : {}),
6214
- ...(m.tool_call === false ? { supportsTools: false } : {}),
6378
+ ...(kind === undefined && m.tool_call === false ? { supportsTools: false } : {}),
6215
6379
  ...(desc.compat && { compat: desc.compat }),
6216
6380
  ...(desc.headers && { headers: { ...desc.headers } }),
6217
6381
  };
6218
6382
 
6219
- // Apply per-model transform
6220
- if (desc.transformModel) {
6383
+ // Provider transforms are chat-specific. Normalized non-chat rows are
6384
+ // complete once their authored runner API has been assigned.
6385
+ if (kind !== undefined) {
6386
+ models.push(mapped);
6387
+ } else if (desc.transformModel) {
6221
6388
  const result = desc.transformModel(mapped, modelId, m);
6222
6389
  if (result === null) continue;
6223
6390
  if (Array.isArray(result)) {
@@ -4,6 +4,7 @@ import { apiRouteFor } from "../compat/behavior";
4
4
  import { seedModels } from "../compat/providers";
5
5
  import { type CodexModelDiscoveryResult, fetchCodexModels } from "../discovery/codex";
6
6
  import type { DevinModelDiscoveryOptions } from "../discovery/devin";
7
+ import { fetchTypeSafeModels, TYPESAFE_DEFAULT_BASE_URL } from "../discovery/typesafe";
7
8
  import { buildGitLabDuoWorkflowFallbackModel, fetchGitLabDuoWorkflowModels } from "../discovery/gitlab-duo-workflow";
8
9
  import type { ModelManagerOptions } from "../model-manager";
9
10
  import { getBundledModel } from "../models";
@@ -352,6 +353,58 @@ export function devinModelManagerOptions(config: DevinModelManagerConfig = {}):
352
353
  }
353
354
 
354
355
  const devinDiscovery = once(() => import("../discovery/devin"));
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Synthetic role providers
359
+ // ---------------------------------------------------------------------------
360
+
361
+ export function localModelManagerOptions(): ModelManagerOptions<"local-inference"> {
362
+ return {
363
+ providerId: "local",
364
+ cacheProviderId: resolveModelCacheProviderId("local"),
365
+ staticModels: seedModels<"local-inference">("local"),
366
+ };
367
+ }
368
+
369
+ export function webModelManagerOptions(): ModelManagerOptions<"web-search"> {
370
+ return {
371
+ providerId: "web",
372
+ cacheProviderId: resolveModelCacheProviderId("web"),
373
+ staticModels: seedModels<"web-search">("web"),
374
+ };
375
+ }
376
+
377
+ /** Credentials and endpoint overrides for the TypeSafe catalog manager. */
378
+ export interface TypeSafeModelManagerConfig {
379
+ apiKey?: string;
380
+ baseUrl?: string;
381
+ fetch?: FetchImpl;
382
+ }
383
+
384
+ /** Discover account-visible judge models while keeping the bundled offline seed. */
385
+ export function typesafeModelManagerOptions(config: TypeSafeModelManagerConfig = {}): ModelManagerOptions<"typesafe"> {
386
+ const { apiKey } = config;
387
+ const envBaseUrl = Bun.env.TYPESAFE_BASE_URL?.trim();
388
+ const baseUrl = (config.baseUrl ?? (envBaseUrl || TYPESAFE_DEFAULT_BASE_URL)).replace(/\/+$/, "");
389
+ const staticModels = seedModels<"typesafe">("typesafe");
390
+ return {
391
+ providerId: "typesafe",
392
+ cacheProviderId: resolveModelCacheProviderId("typesafe"),
393
+ staticModels: staticModels.map(model => ({ ...model, baseUrl })),
394
+ ...(apiKey ? { dynamicModelsAuthoritative: true } : undefined),
395
+ ...(apiKey
396
+ ? {
397
+ fetchDynamicModels: () =>
398
+ fetchTypeSafeModels({
399
+ apiKey,
400
+ baseUrl,
401
+ fetch: config.fetch,
402
+ }),
403
+ }
404
+ : undefined),
405
+ };
406
+ }
407
+
355
408
  // ---------------------------------------------------------------------------
356
409
  // Zai
357
410
  // ---------------------------------------------------------------------------
package/src/types.ts CHANGED
@@ -23,6 +23,29 @@ export type KnownApi =
23
23
  | "devin-agent";
24
24
  export type Api = KnownApi | (string & {});
25
25
 
26
+ /** Catalog kinds used to isolate role-specific runners from session chat models. */
27
+ export const MODEL_KINDS = ["chat", "tiny", "image", "tts", "stt", "search", "judge"] as const;
28
+ /** Technical capability of a catalog model; absent model kinds mean chat. */
29
+ export type ModelKind = (typeof MODEL_KINDS)[number];
30
+ /** Grounding transport available to chat models selected by the web role. */
31
+ export type WebSearchGrounding = "gemini" | "anthropic" | "codex" | "xai" | "openrouter";
32
+ /** Non-chat runner protocols accepted by catalog seeds, outside the chat dispatch union. */
33
+ export const RUNNER_APIS = [
34
+ "local-inference",
35
+ "web-search",
36
+ "typesafe",
37
+ "openrouter-decisions",
38
+ "openai-images",
39
+ "openrouter-images",
40
+ "xai-tts",
41
+ "openai-speech",
42
+ ] as const;
43
+
44
+ /** Resolve a model's kind while preserving chat semantics for existing catalog rows. */
45
+ export function modelKind(model: Pick<Model, "kind">): ModelKind {
46
+ return model.kind ?? "chat";
47
+ }
48
+
26
49
  /** Canonical thinking transport used by a model. */
27
50
  export type ThinkingControlMode =
28
51
  | "effort"
@@ -1125,6 +1148,10 @@ export type ModelTokenizer =
1125
1148
  // Model interface for the unified model system
1126
1149
  export interface Model<TApi extends Api = Api> {
1127
1150
  id: string;
1151
+ /** Role-specific runner capability; omitted for ordinary chat models. */
1152
+ kind?: ModelKind;
1153
+ /** Grounding transport supported by this chat model. */
1154
+ webSearch?: WebSearchGrounding;
1128
1155
  /**
1129
1156
  * Structured model identity resolved by the compat engine: vendor lineage
1130
1157
  * class, product family, and revision. Baked into models.json rows and
@@ -1167,6 +1194,12 @@ export interface Model<TApi extends Api = Api> {
1167
1194
  name: string;
1168
1195
  api: TApi;
1169
1196
  provider: Provider;
1197
+ /**
1198
+ * Discovery backend whose catalog policy applies when it differs from the
1199
+ * credential-bearing provider id. Persisted so cached and rebuilt custom
1200
+ * providers retain their transport backend's policy.
1201
+ */
1202
+ providerType?: string;
1170
1203
  baseUrl: string;
1171
1204
  reasoning: boolean;
1172
1205
  /**