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

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 (58) hide show
  1. package/CHANGELOG.md +24 -3
  2. package/README.md +18 -18
  3. package/THIRD-PARTY-NOTICES.txt +0 -37
  4. package/dist/types/build.d.ts +7 -1
  5. package/dist/types/compat/auth-ids.d.ts +1 -1
  6. package/dist/types/compat/axes.d.ts +1 -1
  7. package/dist/types/compat/cascade.d.ts +3 -2
  8. package/dist/types/compat/provider-ids.d.ts +1 -1
  9. package/dist/types/compat/resolve.d.ts +2 -0
  10. package/dist/types/compat/taxonomy.d.ts +2 -2
  11. package/dist/types/compat/types.d.ts +18 -7
  12. package/dist/types/discovery/index.d.ts +1 -0
  13. package/dist/types/discovery/openai-compatible.d.ts +2 -0
  14. package/dist/types/discovery/typesafe.d.ts +26 -0
  15. package/dist/types/model-manager.d.ts +1 -1
  16. package/dist/types/provider-models/openai-compat.d.ts +6 -4
  17. package/dist/types/provider-models/special.d.ts +10 -0
  18. package/dist/types/types.d.ts +23 -0
  19. package/package.json +38 -38
  20. package/src/build.ts +27 -5
  21. package/src/compat/auth-ids.ts +2 -0
  22. package/src/compat/axes.ts +9 -1
  23. package/src/compat/cascade.ts +45 -23
  24. package/src/compat/provider-ids.ts +3 -0
  25. package/src/compat/resolve.ts +24 -14
  26. package/src/compat/rules/README.md +38 -35
  27. package/src/compat/rules/auth/local.kdl +4 -0
  28. package/src/compat/rules/auth/typesafe.kdl +3 -6
  29. package/src/compat/rules/auth/web.kdl +4 -0
  30. package/src/compat/rules/classes/qwen.kdl +6 -5
  31. package/src/compat/rules/providers/anthropic.kdl +1 -0
  32. package/src/compat/rules/providers/deepinfra.kdl +28 -0
  33. package/src/compat/rules/providers/google-antigravity.kdl +17 -0
  34. package/src/compat/rules/providers/google.kdl +9 -0
  35. package/src/compat/rules/providers/llama.cpp.kdl +23 -0
  36. package/src/compat/rules/providers/local.kdl +110 -0
  37. package/src/compat/rules/providers/openai-codex.kdl +18 -0
  38. package/src/compat/rules/providers/openai.kdl +61 -0
  39. package/src/compat/rules/providers/openrouter.kdl +132 -0
  40. package/src/compat/rules/providers/typesafe.kdl +21 -0
  41. package/src/compat/rules/providers/web.kdl +153 -0
  42. package/src/compat/rules/providers/xai-oauth.kdl +26 -0
  43. package/src/compat/rules/providers/xai.kdl +22 -0
  44. package/src/compat/rules/runtime/behavior.kdl +2 -1
  45. package/src/compat/rules/taxonomy/qwen.kdl +2 -0
  46. package/src/compat/rules.json +12526 -1
  47. package/src/compat/taxonomy.ts +92 -31
  48. package/src/compat/types.ts +22 -7
  49. package/src/discovery/devin.ts +3 -1
  50. package/src/discovery/index.ts +1 -0
  51. package/src/discovery/openai-compatible.ts +4 -1
  52. package/src/discovery/typesafe.ts +118 -0
  53. package/src/model-manager.ts +44 -15
  54. package/src/models.json +1 -1
  55. package/src/provider-models/descriptors.ts +6 -0
  56. package/src/provider-models/openai-compat.ts +357 -78
  57. package/src/provider-models/special.ts +53 -0
  58. package/src/types.ts +51 -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,18 @@ 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
+ KIND_API_KINDS,
33
+ MODEL_KINDS,
34
+ type Api,
35
+ type FetchImpl,
36
+ type Model,
37
+ type ModelKind,
38
+ type ModelSpec,
39
+ type OpenAICompat,
40
+ type Provider,
41
+ type ThinkingConfig,
42
+ } from "../types";
32
43
  import { discoveryFetch, isAnthropicOAuthToken, isRecord, toBoolean, toNumber, toPositiveNumber } from "../utils";
33
44
  import { ALIBABA_TOKEN_PLAN_BASE_URL, parseAlibabaTokenPlanCredential } from "../wire/alibaba-token-plan";
34
45
  import { normalizeCharmHyperBaseUrl } from "../wire/charm-hyper";
@@ -96,6 +107,7 @@ const ANTHROPIC_OAUTH_BETA =
96
107
  export interface ModelsDevModel {
97
108
  id?: string;
98
109
  name?: string;
110
+ kind?: string;
99
111
  tool_call?: boolean;
100
112
  reasoning?: boolean;
101
113
  reasoning_options?: Array<{ type?: string; values?: string[]; min?: number; max?: number }>;
@@ -111,6 +123,7 @@ export interface ModelsDevModel {
111
123
  };
112
124
  modalities?: {
113
125
  input?: string[];
126
+ output?: string[];
114
127
  };
115
128
  status?: string;
116
129
  provider?: { npm?: string };
@@ -517,8 +530,11 @@ function mapWithBundledReference<TApi extends Api>(
517
530
  name,
518
531
  };
519
532
  }
533
+ // Generic `/models` rows describe the chat roster. Do not make a bundled
534
+ // runner kind look endpoint-authored merely because its metadata is reused.
535
+ const { kind: _inheritedKind, ...chatReference } = reference;
520
536
  return {
521
- ...reference,
537
+ ...chatReference,
522
538
  id: defaults.id,
523
539
  name,
524
540
  api: defaults.api,
@@ -1434,8 +1450,11 @@ function mapDeepinfraModel(
1434
1450
  : referenceMaxTokens !== null && contextWindow !== null
1435
1451
  ? Math.min(referenceMaxTokens, contextWindow)
1436
1452
  : referenceMaxTokens;
1453
+ // This endpoint is filtered to `chat`; a same-id runner reference may lend
1454
+ // metadata, but its kind is not evidence that chat discovery advertised it.
1455
+ const { kind: _inheritedKind, ...chatReference } = reference ?? {};
1437
1456
  return {
1438
- ...reference,
1457
+ ...chatReference,
1439
1458
  id,
1440
1459
  name: reference?.name ?? id,
1441
1460
  api: "openai-completions",
@@ -1691,8 +1710,7 @@ function mergeCuratedIntoModel(
1691
1710
  * window, reasoning flags, or the effort-dial allowlist.
1692
1711
  *
1693
1712
  * Three passes:
1694
- * 1. Filter `XAI_NON_CHAT_PREFIXES` (picker pollution defense for tool
1695
- * surfaces routed through dedicated tools — generate_image, tts).
1713
+ * 1. Filter KDL exclusions and runner seed ids out of the chat roster.
1696
1714
  * 2. Overlay curated metadata onto dynamic-fetch matches. xAI's /v1/models
1697
1715
  * does not return context_window or reasoning metadata, so without
1698
1716
  * this overlay the runtime falls back to the bundled-reference default
@@ -1708,8 +1726,13 @@ function mergeCuratedIntoModel(
1708
1726
  * in original order.
1709
1727
  */
1710
1728
  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");
1729
+ const curatedModels: ModelSpec<"openai-responses">[] = [];
1730
+ const runnerIds = new Set<string>();
1731
+ for (const seed of seedModels("xai-oauth")) {
1732
+ if (isResponsesSeed(seed)) curatedModels.push(seed);
1733
+ else runnerIds.add(seed.id);
1734
+ }
1735
+ const filtered = dynamic.filter(e => !runnerIds.has(e.id) && !isExcludedModel("xai-oauth", e.id));
1713
1736
 
1714
1737
  const byId = new Map<string, ModelSpec<"openai-responses">>(filtered.map(e => [e.id, e]));
1715
1738
  for (const curated of curatedModels) {
@@ -1737,13 +1760,20 @@ function applyXAIOAuthCuration(dynamic: readonly ModelSpec<"openai-responses">[]
1737
1760
  return [...curatedFirst, ...rest];
1738
1761
  }
1739
1762
 
1763
+ function isResponsesSeed(seed: ModelSpec<Api>): seed is ModelSpec<"openai-responses"> {
1764
+ return seed.api === "openai-responses";
1765
+ }
1766
+
1740
1767
  /**
1741
1768
  * Render the xai-oauth KDL seed as the static runtime fallback consumed by
1742
1769
  * {@link xaiOAuthModelManagerOptions}.
1743
1770
  */
1744
- export function buildXaiOAuthStaticSeed(baseUrl?: string): ModelSpec<"openai-responses">[] {
1771
+ export function buildXaiOAuthStaticSeed(baseUrl?: string): ModelSpec<Api>[] {
1745
1772
  const resolvedBaseUrl = baseUrl ?? "https://api.x.ai/v1";
1746
- return seedModels<"openai-responses">("xai-oauth").map(seed => {
1773
+ return seedModels("xai-oauth").map(seed => {
1774
+ if (!isResponsesSeed(seed)) {
1775
+ return { ...seed, baseUrl: resolvedBaseUrl };
1776
+ }
1747
1777
  const base: ModelSpec<"openai-responses"> = {
1748
1778
  ...seed,
1749
1779
  baseUrl: resolvedBaseUrl,
@@ -1753,9 +1783,7 @@ export function buildXaiOAuthStaticSeed(baseUrl?: string): ModelSpec<"openai-res
1753
1783
  });
1754
1784
  }
1755
1785
 
1756
- export function xaiOAuthModelManagerOptions(
1757
- config?: XaiOAuthModelManagerConfig,
1758
- ): ModelManagerOptions<"openai-responses"> {
1786
+ export function xaiOAuthModelManagerOptions(config?: XaiOAuthModelManagerConfig): ModelManagerOptions<Api> {
1759
1787
  const defaultBaseUrl = "https://api.x.ai/v1";
1760
1788
  const resolvedBaseUrl = config?.baseUrl ?? defaultBaseUrl;
1761
1789
  const base = createOpenAICompatibleModelManagerOptions({
@@ -3161,6 +3189,14 @@ export interface OpenRouterModelManagerConfig {
3161
3189
  fetch?: FetchImpl;
3162
3190
  }
3163
3191
 
3192
+ /**
3193
+ * OpenRouter's Decisions API lives at `/api/alpha`, a sibling of the `/api/v1`
3194
+ * chat root; derive it so a custom gateway base URL keeps both aligned.
3195
+ */
3196
+ function openrouterDecisionsBaseUrl(chatBaseUrl: string): string {
3197
+ return chatBaseUrl.endsWith("/v1") ? `${chatBaseUrl.slice(0, -"/v1".length)}/alpha` : `${chatBaseUrl}/alpha`;
3198
+ }
3199
+
3164
3200
  function mapOpenRouterThinking(entry: OpenAICompatibleModelRecord): ThinkingConfig | undefined {
3165
3201
  const reasoning = entry.reasoning;
3166
3202
  if (!isRecord(reasoning)) return undefined;
@@ -3180,11 +3216,10 @@ function mapOpenRouterThinking(entry: OpenAICompatibleModelRecord): ThinkingConf
3180
3216
  };
3181
3217
  }
3182
3218
 
3183
- export function openrouterModelManagerOptions(
3184
- config?: OpenRouterModelManagerConfig,
3185
- ): ModelManagerOptions<"openrouter"> {
3219
+ export function openrouterModelManagerOptions(config?: OpenRouterModelManagerConfig): ModelManagerOptions<Api> {
3186
3220
  const apiKey = config?.apiKey;
3187
- const baseUrl = config?.baseUrl ?? "https://openrouter.ai/api/v1";
3221
+ const baseUrl = (config?.baseUrl ?? "https://openrouter.ai/api/v1").replace(/\/+$/g, "");
3222
+ const decisionsBaseUrl = openrouterDecisionsBaseUrl(baseUrl);
3188
3223
  const references = createBundledReferenceMap<"openrouter">("openrouter");
3189
3224
  return {
3190
3225
  providerId: "openrouter",
@@ -3192,55 +3227,257 @@ export function openrouterModelManagerOptions(
3192
3227
  // Namespace the refreshed pseudo-API cache separately so those rows cannot
3193
3228
  // override bundled `api: "openrouter"` models during online-if-uncached startup.
3194
3229
  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;
3217
-
3218
- const supportsToolChoice = params.includes("tool_choice");
3230
+ fetchDynamicModels: async () => {
3231
+ const [chatModels, imageModels, decisionModels, rerankModels, videoModels, embeddingModels] =
3232
+ await Promise.all([
3233
+ fetchOpenAICompatibleModels({
3234
+ api: "openrouter",
3235
+ provider: "openrouter",
3236
+ baseUrl,
3237
+ apiKey,
3238
+ filterModel: (entry: OpenAICompatibleModelRecord) => {
3239
+ const params = entry.supported_parameters;
3240
+ return Array.isArray(params) && params.includes("tools");
3241
+ },
3242
+ mapModel: (
3243
+ entry: OpenAICompatibleModelRecord,
3244
+ defaults: ModelSpec<"openrouter">,
3245
+ _context: OpenAICompatibleModelMapperContext<"openrouter">,
3246
+ ): ModelSpec<"openrouter"> => {
3247
+ const reference = references.get(defaults.id);
3248
+ const baseModel = mapWithBundledReference(entry, defaults, reference);
3249
+ const pricing = isRecord(entry.pricing) ? entry.pricing : undefined;
3250
+ const params = Array.isArray(entry.supported_parameters)
3251
+ ? entry.supported_parameters.filter((value): value is string => typeof value === "string")
3252
+ : [];
3253
+ const thinking = mapOpenRouterThinking(entry);
3254
+ const architecture = isRecord(entry.architecture) ? entry.architecture : undefined;
3255
+ const input: ("text" | "image")[] = Array.isArray(architecture?.input_modalities)
3256
+ ? toInputCapabilities(architecture.input_modalities)
3257
+ : String(architecture?.modality ?? "").includes("image")
3258
+ ? ["text", "image"]
3259
+ : ["text"];
3260
+ const topProvider = isRecord(entry.top_provider) ? entry.top_provider : undefined;
3261
+
3262
+ const supportsToolChoice = params.includes("tool_choice");
3219
3263
 
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,
3264
+ return {
3265
+ ...baseModel,
3266
+ reasoning: params.includes("reasoning"),
3267
+ ...(thinking !== undefined ? { thinking } : {}),
3268
+ input,
3269
+ cost: {
3270
+ input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000,
3271
+ output: parseFloat(String(pricing?.completion ?? "0")) * 1_000_000,
3272
+ cacheRead: parseFloat(String(pricing?.input_cache_read ?? "0")) * 1_000_000,
3273
+ cacheWrite: parseFloat(String(pricing?.input_cache_write ?? "0")) * 1_000_000,
3274
+ },
3275
+ contextWindow:
3276
+ typeof entry.context_length === "number" ? entry.context_length : baseModel.contextWindow,
3277
+ maxTokens:
3278
+ typeof topProvider?.max_completion_tokens === "number"
3279
+ ? topProvider.max_completion_tokens
3280
+ : baseModel.maxTokens,
3281
+ ...(!supportsToolChoice && {
3282
+ compat: { ...baseModel.compat, supportsToolChoice: false },
3283
+ }),
3284
+ };
3230
3285
  },
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 },
3286
+ fetch: config?.fetch,
3287
+ }),
3288
+ fetchOpenAICompatibleModels({
3289
+ api: "openrouter-images",
3290
+ provider: "openrouter",
3291
+ baseUrl: `${baseUrl}/images`,
3292
+ apiKey,
3293
+ filterModel: entry => {
3294
+ const architecture = isRecord(entry.architecture) ? entry.architecture : undefined;
3295
+ return (
3296
+ Array.isArray(architecture?.output_modalities) &&
3297
+ architecture.output_modalities.includes("image")
3298
+ );
3299
+ },
3300
+ mapModel: (_entry, defaults): ModelSpec<"openrouter-images"> => ({
3301
+ ...defaults,
3302
+ baseUrl,
3303
+ kind: "image",
3304
+ reasoning: false,
3305
+ input: ["text", "image"],
3306
+ supportsTools: false,
3307
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3308
+ contextWindow: null,
3309
+ maxTokens: null,
3239
3310
  }),
3240
- };
3241
- },
3242
- fetch: config?.fetch,
3243
- }),
3311
+ fetch: config?.fetch,
3312
+ }),
3313
+ // Decision models (`text->decisions`) are absent from the default roster
3314
+ // and answer only through the Decisions API, outside the `/v1` prefix.
3315
+ fetchOpenAICompatibleModels({
3316
+ api: "openrouter-decisions",
3317
+ provider: "openrouter",
3318
+ baseUrl,
3319
+ apiKey,
3320
+ query: { output_modalities: "decisions" },
3321
+ filterModel: entry => {
3322
+ const architecture = isRecord(entry.architecture) ? entry.architecture : undefined;
3323
+ return (
3324
+ Array.isArray(architecture?.output_modalities) &&
3325
+ architecture.output_modalities.includes("decisions")
3326
+ );
3327
+ },
3328
+ mapModel: (entry, defaults): ModelSpec<"openrouter-decisions"> => {
3329
+ const pricing = isRecord(entry.pricing) ? entry.pricing : undefined;
3330
+ const topProvider = isRecord(entry.top_provider) ? entry.top_provider : undefined;
3331
+ return {
3332
+ ...defaults,
3333
+ baseUrl: decisionsBaseUrl,
3334
+ kind: "judge",
3335
+ reasoning: false,
3336
+ input: ["text"],
3337
+ supportsTools: false,
3338
+ cost: {
3339
+ input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000,
3340
+ output: parseFloat(String(pricing?.completion ?? "0")) * 1_000_000,
3341
+ cacheRead: 0,
3342
+ cacheWrite: 0,
3343
+ },
3344
+ contextWindow: typeof entry.context_length === "number" ? entry.context_length : null,
3345
+ maxTokens:
3346
+ typeof topProvider?.max_completion_tokens === "number"
3347
+ ? topProvider.max_completion_tokens
3348
+ : null,
3349
+ };
3350
+ },
3351
+ fetch: config?.fetch,
3352
+ }),
3353
+ fetchOpenAICompatibleModels({
3354
+ api: "openrouter-rerank",
3355
+ provider: "openrouter",
3356
+ baseUrl,
3357
+ apiKey,
3358
+ query: { output_modalities: "rerank" },
3359
+ filterModel: entry => {
3360
+ const architecture = isRecord(entry.architecture) ? entry.architecture : undefined;
3361
+ return (
3362
+ Array.isArray(architecture?.output_modalities) &&
3363
+ architecture.output_modalities.includes("rerank")
3364
+ );
3365
+ },
3366
+ mapModel: (entry, defaults): ModelSpec<"openrouter-rerank"> => {
3367
+ const architecture = isRecord(entry.architecture) ? entry.architecture : undefined;
3368
+ const topProvider = isRecord(entry.top_provider) ? entry.top_provider : undefined;
3369
+ return {
3370
+ ...defaults,
3371
+ baseUrl,
3372
+ kind: "rerank",
3373
+ reasoning: false,
3374
+ input: Array.isArray(architecture?.input_modalities)
3375
+ ? toInputCapabilities(architecture.input_modalities)
3376
+ : ["text"],
3377
+ supportsTools: false,
3378
+ // OpenRouter bills reranking per search; ModelCost has no search-unit axis.
3379
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3380
+ contextWindow: typeof entry.context_length === "number" ? entry.context_length : null,
3381
+ maxTokens:
3382
+ typeof topProvider?.max_completion_tokens === "number"
3383
+ ? topProvider.max_completion_tokens
3384
+ : null,
3385
+ };
3386
+ },
3387
+ fetch: config?.fetch,
3388
+ }),
3389
+ fetchOpenAICompatibleModels({
3390
+ api: "openrouter-video",
3391
+ provider: "openrouter",
3392
+ baseUrl: `${baseUrl}/videos`,
3393
+ apiKey,
3394
+ mapModel: (_entry, defaults): ModelSpec<"openrouter-video"> => ({
3395
+ ...defaults,
3396
+ baseUrl,
3397
+ kind: "video",
3398
+ reasoning: false,
3399
+ input: ["text", "image"],
3400
+ supportsTools: false,
3401
+ // OpenRouter bills video by output second/SKU; ModelCost has no duration axis.
3402
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3403
+ contextWindow: null,
3404
+ maxTokens: null,
3405
+ }),
3406
+ fetch: config?.fetch,
3407
+ }),
3408
+ fetchOpenAICompatibleModels({
3409
+ api: "openai-embeddings",
3410
+ provider: "openrouter",
3411
+ baseUrl: `${baseUrl}/embeddings`,
3412
+ apiKey,
3413
+ mapModel: (entry, defaults): ModelSpec<"openai-embeddings"> => {
3414
+ const pricing = isRecord(entry.pricing) ? entry.pricing : undefined;
3415
+ return {
3416
+ ...defaults,
3417
+ baseUrl,
3418
+ kind: "embedding",
3419
+ reasoning: false,
3420
+ input: ["text"],
3421
+ supportsTools: false,
3422
+ cost: {
3423
+ input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000,
3424
+ output: 0,
3425
+ cacheRead: 0,
3426
+ cacheWrite: 0,
3427
+ },
3428
+ contextWindow: typeof entry.context_length === "number" ? entry.context_length : null,
3429
+ maxTokens: null,
3430
+ };
3431
+ },
3432
+ fetch: config?.fetch,
3433
+ }),
3434
+ ]);
3435
+
3436
+ if (imageModels === null) {
3437
+ logger.warn("OpenRouter image model discovery unavailable; preserving chat model discovery", {
3438
+ endpoint: `${baseUrl}/images/models`,
3439
+ });
3440
+ }
3441
+ if (decisionModels === null) {
3442
+ logger.warn("OpenRouter decision model discovery unavailable; preserving chat model discovery", {
3443
+ endpoint: `${baseUrl}/models?output_modalities=decisions`,
3444
+ });
3445
+ }
3446
+ if (rerankModels === null) {
3447
+ logger.warn("OpenRouter rerank model discovery unavailable; preserving other model discovery", {
3448
+ endpoint: `${baseUrl}/models?output_modalities=rerank`,
3449
+ });
3450
+ }
3451
+ if (videoModels === null) {
3452
+ logger.warn("OpenRouter video model discovery unavailable; preserving other model discovery", {
3453
+ endpoint: `${baseUrl}/videos/models`,
3454
+ });
3455
+ }
3456
+ if (embeddingModels === null) {
3457
+ logger.warn("OpenRouter embedding model discovery unavailable; preserving other model discovery", {
3458
+ endpoint: `${baseUrl}/embeddings/models`,
3459
+ });
3460
+ }
3461
+ if (
3462
+ chatModels === null &&
3463
+ imageModels === null &&
3464
+ decisionModels === null &&
3465
+ rerankModels === null &&
3466
+ videoModels === null &&
3467
+ embeddingModels === null
3468
+ ) {
3469
+ return null;
3470
+ }
3471
+
3472
+ const models = new Map<string, ModelSpec<Api>>();
3473
+ for (const model of chatModels ?? []) models.set(model.id, model);
3474
+ for (const model of imageModels ?? []) models.set(model.id, model);
3475
+ for (const model of decisionModels ?? []) models.set(model.id, model);
3476
+ for (const model of rerankModels ?? []) models.set(model.id, model);
3477
+ for (const model of videoModels ?? []) models.set(model.id, model);
3478
+ for (const model of embeddingModels ?? []) models.set(model.id, model);
3479
+ return Array.from(models.values()).sort((left, right) => left.id.localeCompare(right.id));
3480
+ },
3244
3481
  };
3245
3482
  }
3246
3483
 
@@ -6174,32 +6411,70 @@ export function mapModelsDevToModels(
6174
6411
  descriptors: readonly ModelsDevProviderDescriptor[],
6175
6412
  ): ModelSpec<Api>[] {
6176
6413
  const models: ModelSpec<Api>[] = [];
6414
+ const providers = providerEntries();
6177
6415
  for (const desc of descriptors) {
6178
- const providerData = (data as Record<string, Record<string, unknown>>)[desc.modelsDevKey];
6416
+ const providerData = data[desc.modelsDevKey];
6179
6417
  if (!isRecord(providerData) || !isRecord(providerData.models)) continue;
6180
6418
 
6181
- for (const [modelId, rawModel] of Object.entries(providerData.models)) {
6419
+ for (const modelId in providerData.models) {
6420
+ const rawModel = providerData.models[modelId];
6182
6421
  if (!isRecord(rawModel)) continue;
6183
6422
  const m = rawModel as ModelsDevModel;
6423
+ const name = toModelName(m.name, modelId);
6424
+ let kind: ModelKind | undefined;
6425
+ let kindApi: Api | undefined;
6426
+
6427
+ if (m.kind !== undefined) {
6428
+ const policy = resolveModelPolicy({
6429
+ id: modelId,
6430
+ name,
6431
+ api: desc.api,
6432
+ provider: desc.providerId,
6433
+ baseUrl: desc.baseUrl,
6434
+ reasoning: false,
6435
+ input: ["text"],
6436
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
6437
+ contextWindow: null,
6438
+ maxTokens: null,
6439
+ });
6440
+ const normalizedKind = policy.catalog.kind ?? m.kind;
6441
+ if (typeof normalizedKind !== "string" || !MODEL_KINDS.some(value => value === normalizedKind)) continue;
6442
+ if (normalizedKind !== "chat") {
6443
+ const kindApiKind = KIND_API_KINDS.find(value => value === normalizedKind);
6444
+ if (kindApiKind === undefined) continue;
6445
+ kindApi = providers[desc.providerId]?.kindApis?.[kindApiKind];
6446
+ if (kindApi === undefined) continue;
6447
+ kind = kindApiKind;
6448
+ }
6449
+ }
6184
6450
 
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;
6451
+ if (kind === undefined) {
6452
+ // Ordinary chat rows retain the provider-specific/default tool filter.
6453
+ if (desc.filterModel) {
6454
+ if (!desc.filterModel(modelId, m)) continue;
6455
+ } else if (m.tool_call !== true) {
6456
+ continue;
6457
+ }
6190
6458
  }
6191
6459
 
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 };
6460
+ // Non-chat rows use the provider-authored runner API; chat rows retain
6461
+ // per-model API/base URL resolution (for example OpenCode route pins).
6462
+ let resolved: { api: Api; baseUrl: string } | null;
6463
+ if (kind === undefined) {
6464
+ resolved = desc.resolveApi?.(modelId, m) ?? { api: desc.api, baseUrl: desc.baseUrl };
6465
+ } else {
6466
+ if (kindApi === undefined) continue;
6467
+ resolved = { api: kindApi, baseUrl: desc.baseUrl };
6468
+ }
6194
6469
  if (!resolved) continue;
6195
6470
 
6196
6471
  const mapped: ModelSpec<Api> = {
6197
6472
  id: modelId,
6198
- name: toModelName(m.name, modelId),
6473
+ name,
6199
6474
  api: resolved.api,
6200
- provider: desc.providerId as ModelSpec<Api>["provider"],
6475
+ provider: desc.providerId,
6201
6476
  baseUrl: resolved.baseUrl,
6202
- reasoning: m.reasoning === true,
6477
+ reasoning: kind === undefined && m.reasoning === true,
6203
6478
  input: toInputCapabilities(m.modalities?.input),
6204
6479
  cost: {
6205
6480
  input: toNumber(m.cost?.input) ?? 0,
@@ -6209,15 +6484,19 @@ export function mapModelsDevToModels(
6209
6484
  },
6210
6485
  contextWindow: toPositiveNumber(m.limit?.context, desc.defaultContextWindow ?? null),
6211
6486
  maxTokens: toPositiveNumber(m.limit?.output, desc.defaultMaxTokens ?? null),
6487
+ ...(kind !== undefined ? { kind, supportsTools: false } : {}),
6212
6488
  ...(m.int != null ? { int: m.int } : {}),
6213
6489
  ...(m.tps != null ? { tps: m.tps } : {}),
6214
- ...(m.tool_call === false ? { supportsTools: false } : {}),
6490
+ ...(kind === undefined && m.tool_call === false ? { supportsTools: false } : {}),
6215
6491
  ...(desc.compat && { compat: desc.compat }),
6216
6492
  ...(desc.headers && { headers: { ...desc.headers } }),
6217
6493
  };
6218
6494
 
6219
- // Apply per-model transform
6220
- if (desc.transformModel) {
6495
+ // Provider transforms are chat-specific. Normalized non-chat rows are
6496
+ // complete once their authored runner API has been assigned.
6497
+ if (kind !== undefined) {
6498
+ models.push(mapped);
6499
+ } else if (desc.transformModel) {
6221
6500
  const result = desc.transformModel(mapped, modelId, m);
6222
6501
  if (result === null) continue;
6223
6502
  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
  // ---------------------------------------------------------------------------