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

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 (57) hide show
  1. package/CHANGELOG.md +26 -3
  2. package/README.md +18 -18
  3. package/dist/types/compat/auth-ids.d.ts +2 -2
  4. package/dist/types/compat/axes.d.ts +2 -2
  5. package/dist/types/compat/catalog-policy.d.ts +7 -0
  6. package/dist/types/compat/output-limits.d.ts +3 -0
  7. package/dist/types/compat/provider-ids.d.ts +1 -1
  8. package/dist/types/compat/tools.d.ts +5 -0
  9. package/dist/types/compat/types.d.ts +2 -2
  10. package/dist/types/provider-models/openai-compat.d.ts +26 -0
  11. package/dist/types/types.d.ts +5 -2
  12. package/dist/types/wire/singularityapi.d.ts +29 -0
  13. package/package.json +38 -38
  14. package/src/compat/auth-ids.ts +4 -0
  15. package/src/compat/axes.ts +21 -2
  16. package/src/compat/catalog-policy.ts +28 -0
  17. package/src/compat/output-limits.ts +12 -0
  18. package/src/compat/provider-ids.ts +2 -0
  19. package/src/compat/resolve.ts +4 -2
  20. package/src/compat/rules/README.md +34 -33
  21. package/src/compat/rules/auth/_order.kdl +1 -1
  22. package/src/compat/rules/auth/singularityapi-dev.kdl +22 -0
  23. package/src/compat/rules/auth/singularityapi-tech.kdl +23 -0
  24. package/src/compat/rules/classes/gpt-oss.kdl +3 -0
  25. package/src/compat/rules/classes/xai.kdl +3 -3
  26. package/src/compat/rules/providers/amazon-bedrock.kdl +1 -1
  27. package/src/compat/rules/providers/anthropic.kdl +1 -1
  28. package/src/compat/rules/providers/cloudflare-ai-gateway.kdl +1 -1
  29. package/src/compat/rules/providers/commandcode.kdl +1 -1
  30. package/src/compat/rules/providers/cursor.kdl +1 -1
  31. package/src/compat/rules/providers/google-antigravity.kdl +5 -0
  32. package/src/compat/rules/providers/kilo.kdl +1 -1
  33. package/src/compat/rules/providers/litellm.kdl +1 -1
  34. package/src/compat/rules/providers/native-tools.kdl +4 -0
  35. package/src/compat/rules/providers/openai.kdl +58 -0
  36. package/src/compat/rules/providers/opencode-zen.kdl +1 -1
  37. package/src/compat/rules/providers/openrouter.kdl +108 -0
  38. package/src/compat/rules/providers/output-limits.kdl +4 -0
  39. package/src/compat/rules/providers/singularityapi-dev.kdl +92 -0
  40. package/src/compat/rules/providers/singularityapi-tech.kdl +72 -0
  41. package/src/compat/rules/providers/tool-free-history.kdl +6 -0
  42. package/src/compat/rules/providers/vercel-ai-gateway.kdl +1 -1
  43. package/src/compat/rules/providers/xai-oauth.kdl +8 -1
  44. package/src/compat/rules/providers/xai.kdl +4 -4
  45. package/src/compat/rules/providers/xiaomi-token-plan-cn.kdl +23 -0
  46. package/src/compat/rules/providers/zenmux.kdl +1 -1
  47. package/src/compat/rules/runtime/behavior.kdl +2 -0
  48. package/src/compat/rules.json +1 -1
  49. package/src/compat/tools.ts +12 -0
  50. package/src/compat/types.ts +2 -2
  51. package/src/discovery/devin.ts +59 -34
  52. package/src/models.json +1 -1
  53. package/src/provider-models/cache-provider-id.ts +31 -0
  54. package/src/provider-models/descriptors.ts +4 -0
  55. package/src/provider-models/openai-compat.ts +390 -119
  56. package/src/types.ts +19 -1
  57. package/src/wire/singularityapi.ts +34 -0
@@ -1,5 +1,10 @@
1
1
  import { CHARM_HYPER_API_BASE_URL, normalizeCharmHyperBaseUrl } from "../wire/charm-hyper";
2
2
  import { PERSONAL_GITHUB_COPILOT_BASE_URL } from "../wire/github-copilot";
3
+ import {
4
+ SINGULARITYAPI_DEV_API_BASE_URL,
5
+ SINGULARITYAPI_TECH_API_BASE_URL,
6
+ normalizeSingularityApiBaseUrl,
7
+ } from "../wire/singularityapi";
3
8
 
4
9
  export interface ModelCacheProviderIdOptions {
5
10
  apiKey?: string;
@@ -11,6 +16,11 @@ const CREDENTIAL_SCOPED_MODEL_CACHE_PROVIDERS: Readonly<Record<string, true>> =
11
16
  "opencode-zen": true,
12
17
  "github-copilot": true,
13
18
  "muse-code": true,
19
+ // Both SingularityAPI rosters are issued per key, so the namespace must be
20
+ // resolved with the credential (`hydrateCredentialScopedModelCaches`) rather
21
+ // than from the synchronous, credential-less startup read.
22
+ "singularityapi-dev": true,
23
+ "singularityapi-tech": true,
14
24
  };
15
25
 
16
26
  /** Whether a provider's model-cache namespace requires its resolved credential. */
@@ -87,6 +97,27 @@ export function resolveModelCacheProviderId(providerId: string, options: ModelCa
87
97
  const scope = `${options.apiKey ?? ""}\u0000${baseUrl}`;
88
98
  return `muse-code:models-v1:${Bun.hash(scope).toString(36)}`;
89
99
  }
100
+ case "singularityapi-dev":
101
+ case "singularityapi-tech": {
102
+ // Both products issue their roster per key, and a configured proxy
103
+ // publishes its own. Discovery is authoritative, so a shared namespace
104
+ // would serve the previous key's roster for the full 24h TTL — including
105
+ // ids the current key cannot call. Hashing the pair means switching
106
+ // either re-runs discovery instead, and the provider-id prefix keeps the
107
+ // two products from ever reading each other's rows behind one proxy.
108
+ //
109
+ // Both call paths must land on one namespace: `ModelRegistry` resolves
110
+ // this provider through the credential-scoped hydration pass (it is in
111
+ // CREDENTIAL_SCOPED_MODEL_CACHE_PROVIDERS), while discovery hashes the
112
+ // `/v1`-suffixed endpoint the matching `singularityApi*ModelManagerOptions`
113
+ // passes — which is why both normalize through
114
+ // `normalizeSingularityApiBaseUrl` against their own canonical host.
115
+ const canonical =
116
+ providerId === "singularityapi-tech" ? SINGULARITYAPI_TECH_API_BASE_URL : SINGULARITYAPI_DEV_API_BASE_URL;
117
+ const baseUrl = normalizeSingularityApiBaseUrl(options.baseUrl, canonical);
118
+ const scope = `${options.apiKey ?? ""}\u0000${baseUrl}`;
119
+ return `${providerId}:models-v1:${Bun.hash(scope).toString(36)}`;
120
+ }
90
121
  case "litellm": {
91
122
  const baseUrl = options.baseUrl ?? getDefaultModelDiscoveryBaseUrl(providerId)!;
92
123
  // rich-v11 invalidates rows that inherited ClinePass gateway metadata
@@ -59,6 +59,8 @@ import {
59
59
  sakanaModelManagerOptions,
60
60
  siliconflowCnModelManagerOptions,
61
61
  siliconflowModelManagerOptions,
62
+ singularityApiDevModelManagerOptions,
63
+ singularityApiTechModelManagerOptions,
62
64
  syntheticModelManagerOptions,
63
65
  togetherModelManagerOptions,
64
66
  umansModelManagerOptions,
@@ -137,6 +139,8 @@ const MODEL_MANAGER_FACTORIES: Readonly<Partial<Record<KnownProvider, ModelManag
137
139
  sakana: config => sakanaModelManagerOptions(config),
138
140
  siliconflow: config => siliconflowModelManagerOptions(config),
139
141
  "siliconflow-cn": config => siliconflowCnModelManagerOptions(config),
142
+ "singularityapi-dev": config => singularityApiDevModelManagerOptions(config),
143
+ "singularityapi-tech": config => singularityApiTechModelManagerOptions(config),
140
144
  synthetic: config => syntheticModelManagerOptions(config),
141
145
  together: config => togetherModelManagerOptions(config),
142
146
  typesafe: config => typesafeModelManagerOptions(config),
@@ -29,6 +29,7 @@ import { resolveModelReference } from "../identity/reference";
29
29
  import type { ModelManagerOptions, ModelsDevFallback } from "../model-manager";
30
30
  import { type GeneratedProvider, getBundledModels } from "../models";
31
31
  import {
32
+ KIND_API_KINDS,
32
33
  MODEL_KINDS,
33
34
  type Api,
34
35
  type FetchImpl,
@@ -54,6 +55,11 @@ import {
54
55
  mergeCopilotApiHeaders,
55
56
  parseGitHubCopilotApiKey,
56
57
  } from "../wire/github-copilot";
58
+ import {
59
+ SINGULARITYAPI_DEV_API_BASE_URL,
60
+ SINGULARITYAPI_TECH_API_BASE_URL,
61
+ normalizeSingularityApiBaseUrl,
62
+ } from "../wire/singularityapi";
57
63
  import { createBundledReferenceMap, createReferenceResolver, toModelSpec } from "./bundled-references";
58
64
  import { getDefaultModelDiscoveryBaseUrl, resolveModelCacheProviderId } from "./cache-provider-id";
59
65
  import { getClinePassModelMetadata } from "./cline-pass";
@@ -3227,127 +3233,210 @@ export function openrouterModelManagerOptions(config?: OpenRouterModelManagerCon
3227
3233
  // override bundled `api: "openrouter"` models during online-if-uncached startup.
3228
3234
  cacheProviderId: resolveModelCacheProviderId("openrouter"),
3229
3235
  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;
3259
-
3260
- const supportsToolChoice = params.includes("tool_choice");
3236
+ const [chatModels, imageModels, decisionModels, rerankModels, videoModels, embeddingModels] =
3237
+ await Promise.all([
3238
+ fetchOpenAICompatibleModels({
3239
+ api: "openrouter",
3240
+ provider: "openrouter",
3241
+ baseUrl,
3242
+ apiKey,
3243
+ filterModel: (entry: OpenAICompatibleModelRecord) => {
3244
+ const params = entry.supported_parameters;
3245
+ return Array.isArray(params) && params.includes("tools");
3246
+ },
3247
+ mapModel: (
3248
+ entry: OpenAICompatibleModelRecord,
3249
+ defaults: ModelSpec<"openrouter">,
3250
+ _context: OpenAICompatibleModelMapperContext<"openrouter">,
3251
+ ): ModelSpec<"openrouter"> => {
3252
+ const reference = references.get(defaults.id);
3253
+ const baseModel = mapWithBundledReference(entry, defaults, reference);
3254
+ const pricing = isRecord(entry.pricing) ? entry.pricing : undefined;
3255
+ const params = Array.isArray(entry.supported_parameters)
3256
+ ? entry.supported_parameters.filter((value): value is string => typeof value === "string")
3257
+ : [];
3258
+ const thinking = mapOpenRouterThinking(entry);
3259
+ const architecture = isRecord(entry.architecture) ? entry.architecture : undefined;
3260
+ const input: ("text" | "image")[] = Array.isArray(architecture?.input_modalities)
3261
+ ? toInputCapabilities(architecture.input_modalities)
3262
+ : String(architecture?.modality ?? "").includes("image")
3263
+ ? ["text", "image"]
3264
+ : ["text"];
3265
+ const topProvider = isRecord(entry.top_provider) ? entry.top_provider : undefined;
3266
+
3267
+ const supportsToolChoice = params.includes("tool_choice");
3261
3268
 
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,
3269
+ return {
3270
+ ...baseModel,
3271
+ reasoning: params.includes("reasoning"),
3272
+ ...(thinking !== undefined ? { thinking } : {}),
3273
+ input,
3274
+ cost: {
3275
+ input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000,
3276
+ output: parseFloat(String(pricing?.completion ?? "0")) * 1_000_000,
3277
+ cacheRead: parseFloat(String(pricing?.input_cache_read ?? "0")) * 1_000_000,
3278
+ cacheWrite: parseFloat(String(pricing?.input_cache_write ?? "0")) * 1_000_000,
3279
+ },
3280
+ contextWindow:
3281
+ typeof entry.context_length === "number" ? entry.context_length : baseModel.contextWindow,
3282
+ maxTokens:
3283
+ typeof topProvider?.max_completion_tokens === "number"
3284
+ ? topProvider.max_completion_tokens
3285
+ : baseModel.maxTokens,
3286
+ ...(!supportsToolChoice && {
3287
+ compat: { ...baseModel.compat, supportsToolChoice: false },
3288
+ }),
3289
+ };
3290
+ },
3291
+ fetch: config?.fetch,
3292
+ }),
3293
+ fetchOpenAICompatibleModels({
3294
+ api: "openrouter-images",
3295
+ provider: "openrouter",
3296
+ baseUrl: `${baseUrl}/images`,
3297
+ apiKey,
3298
+ filterModel: entry => {
3299
+ const architecture = isRecord(entry.architecture) ? entry.architecture : undefined;
3300
+ return (
3301
+ Array.isArray(architecture?.output_modalities) &&
3302
+ architecture.output_modalities.includes("image")
3303
+ );
3304
+ },
3305
+ mapModel: (_entry, defaults): ModelSpec<"openrouter-images"> => ({
3306
+ ...defaults,
3307
+ baseUrl,
3308
+ kind: "image",
3309
+ reasoning: false,
3310
+ input: ["text", "image"],
3311
+ supportsTools: false,
3312
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3313
+ contextWindow: null,
3314
+ maxTokens: null,
3315
+ }),
3316
+ fetch: config?.fetch,
3317
+ }),
3318
+ // Decision models (`text->decisions`) are absent from the default roster
3319
+ // and answer only through the Decisions API, outside the `/v1` prefix.
3320
+ fetchOpenAICompatibleModels({
3321
+ api: "openrouter-decisions",
3322
+ provider: "openrouter",
3299
3323
  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,
3324
+ apiKey,
3325
+ query: { output_modalities: "decisions" },
3326
+ filterModel: entry => {
3327
+ const architecture = isRecord(entry.architecture) ? entry.architecture : undefined;
3328
+ return (
3329
+ Array.isArray(architecture?.output_modalities) &&
3330
+ architecture.output_modalities.includes("decisions")
3331
+ );
3332
+ },
3333
+ mapModel: (entry, defaults): ModelSpec<"openrouter-decisions"> => {
3334
+ const pricing = isRecord(entry.pricing) ? entry.pricing : undefined;
3335
+ const topProvider = isRecord(entry.top_provider) ? entry.top_provider : undefined;
3336
+ return {
3337
+ ...defaults,
3338
+ baseUrl: decisionsBaseUrl,
3339
+ kind: "judge",
3340
+ reasoning: false,
3341
+ input: ["text"],
3342
+ supportsTools: false,
3343
+ cost: {
3344
+ input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000,
3345
+ output: parseFloat(String(pricing?.completion ?? "0")) * 1_000_000,
3346
+ cacheRead: 0,
3347
+ cacheWrite: 0,
3348
+ },
3349
+ contextWindow: typeof entry.context_length === "number" ? entry.context_length : null,
3350
+ maxTokens:
3351
+ typeof topProvider?.max_completion_tokens === "number"
3352
+ ? topProvider.max_completion_tokens
3353
+ : null,
3354
+ };
3355
+ },
3356
+ fetch: config?.fetch,
3307
3357
  }),
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 {
3358
+ fetchOpenAICompatibleModels({
3359
+ api: "openrouter-rerank",
3360
+ provider: "openrouter",
3361
+ baseUrl,
3362
+ apiKey,
3363
+ query: { output_modalities: "rerank" },
3364
+ filterModel: entry => {
3365
+ const architecture = isRecord(entry.architecture) ? entry.architecture : undefined;
3366
+ return (
3367
+ Array.isArray(architecture?.output_modalities) &&
3368
+ architecture.output_modalities.includes("rerank")
3369
+ );
3370
+ },
3371
+ mapModel: (entry, defaults): ModelSpec<"openrouter-rerank"> => {
3372
+ const architecture = isRecord(entry.architecture) ? entry.architecture : undefined;
3373
+ const topProvider = isRecord(entry.top_provider) ? entry.top_provider : undefined;
3374
+ return {
3375
+ ...defaults,
3376
+ baseUrl,
3377
+ kind: "rerank",
3378
+ reasoning: false,
3379
+ input: Array.isArray(architecture?.input_modalities)
3380
+ ? toInputCapabilities(architecture.input_modalities)
3381
+ : ["text"],
3382
+ supportsTools: false,
3383
+ // OpenRouter bills reranking per search; ModelCost has no search-unit axis.
3384
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3385
+ contextWindow: typeof entry.context_length === "number" ? entry.context_length : null,
3386
+ maxTokens:
3387
+ typeof topProvider?.max_completion_tokens === "number"
3388
+ ? topProvider.max_completion_tokens
3389
+ : null,
3390
+ };
3391
+ },
3392
+ fetch: config?.fetch,
3393
+ }),
3394
+ fetchOpenAICompatibleModels({
3395
+ api: "openrouter-video",
3396
+ provider: "openrouter",
3397
+ baseUrl: `${baseUrl}/videos`,
3398
+ apiKey,
3399
+ mapModel: (_entry, defaults): ModelSpec<"openrouter-video"> => ({
3329
3400
  ...defaults,
3330
- baseUrl: decisionsBaseUrl,
3331
- kind: "judge",
3401
+ baseUrl,
3402
+ kind: "video",
3332
3403
  reasoning: false,
3333
- input: ["text"],
3404
+ input: ["text", "image"],
3334
3405
  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
- ]);
3406
+ // OpenRouter bills video by output second/SKU; ModelCost has no duration axis.
3407
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3408
+ contextWindow: null,
3409
+ maxTokens: null,
3410
+ }),
3411
+ fetch: config?.fetch,
3412
+ }),
3413
+ fetchOpenAICompatibleModels({
3414
+ api: "openai-embeddings",
3415
+ provider: "openrouter",
3416
+ baseUrl: `${baseUrl}/embeddings`,
3417
+ apiKey,
3418
+ mapModel: (entry, defaults): ModelSpec<"openai-embeddings"> => {
3419
+ const pricing = isRecord(entry.pricing) ? entry.pricing : undefined;
3420
+ return {
3421
+ ...defaults,
3422
+ baseUrl,
3423
+ kind: "embedding",
3424
+ reasoning: false,
3425
+ input: ["text"],
3426
+ supportsTools: false,
3427
+ cost: {
3428
+ input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000,
3429
+ output: 0,
3430
+ cacheRead: 0,
3431
+ cacheWrite: 0,
3432
+ },
3433
+ contextWindow: typeof entry.context_length === "number" ? entry.context_length : null,
3434
+ maxTokens: null,
3435
+ };
3436
+ },
3437
+ fetch: config?.fetch,
3438
+ }),
3439
+ ]);
3351
3440
 
3352
3441
  if (imageModels === null) {
3353
3442
  logger.warn("OpenRouter image model discovery unavailable; preserving chat model discovery", {
@@ -3359,12 +3448,39 @@ export function openrouterModelManagerOptions(config?: OpenRouterModelManagerCon
3359
3448
  endpoint: `${baseUrl}/models?output_modalities=decisions`,
3360
3449
  });
3361
3450
  }
3362
- if (chatModels === null && imageModels === null && decisionModels === null) return null;
3451
+ if (rerankModels === null) {
3452
+ logger.warn("OpenRouter rerank model discovery unavailable; preserving other model discovery", {
3453
+ endpoint: `${baseUrl}/models?output_modalities=rerank`,
3454
+ });
3455
+ }
3456
+ if (videoModels === null) {
3457
+ logger.warn("OpenRouter video model discovery unavailable; preserving other model discovery", {
3458
+ endpoint: `${baseUrl}/videos/models`,
3459
+ });
3460
+ }
3461
+ if (embeddingModels === null) {
3462
+ logger.warn("OpenRouter embedding model discovery unavailable; preserving other model discovery", {
3463
+ endpoint: `${baseUrl}/embeddings/models`,
3464
+ });
3465
+ }
3466
+ if (
3467
+ chatModels === null &&
3468
+ imageModels === null &&
3469
+ decisionModels === null &&
3470
+ rerankModels === null &&
3471
+ videoModels === null &&
3472
+ embeddingModels === null
3473
+ ) {
3474
+ return null;
3475
+ }
3363
3476
 
3364
3477
  const models = new Map<string, ModelSpec<Api>>();
3365
3478
  for (const model of chatModels ?? []) models.set(model.id, model);
3366
3479
  for (const model of imageModels ?? []) models.set(model.id, model);
3367
3480
  for (const model of decisionModels ?? []) models.set(model.id, model);
3481
+ for (const model of rerankModels ?? []) models.set(model.id, model);
3482
+ for (const model of videoModels ?? []) models.set(model.id, model);
3483
+ for (const model of embeddingModels ?? []) models.set(model.id, model);
3368
3484
  return Array.from(models.values()).sort((left, right) => left.id.localeCompare(right.id));
3369
3485
  },
3370
3486
  };
@@ -4937,6 +5053,9 @@ export function xiaomiModelManagerOptions(
4937
5053
  // would incorrectly pin to the standard endpoint (api.xiaomimimo.com).
4938
5054
  const baseUrl = isTokenPlanKey ? tokenPlanBaseUrls[0] : (config?.baseUrl ?? XIAOMI_STANDARD_BASE_URL);
4939
5055
  const references = createBundledReferenceMap<"openai-completions">("xiaomi");
5056
+ for (const seed of seedModels<"openai-completions">(providerId)) {
5057
+ references.set(seed.id, seed);
5058
+ }
4940
5059
  const fetchModels = (url: string) =>
4941
5060
  fetchOpenAICompatibleModels({
4942
5061
  api: "openai-completions",
@@ -6329,10 +6448,11 @@ export function mapModelsDevToModels(
6329
6448
  const normalizedKind = policy.catalog.kind ?? m.kind;
6330
6449
  if (typeof normalizedKind !== "string" || !MODEL_KINDS.some(value => value === normalizedKind)) continue;
6331
6450
  if (normalizedKind !== "chat") {
6332
- if (normalizedKind !== "image" && normalizedKind !== "tts" && normalizedKind !== "stt") continue;
6333
- kindApi = providers[desc.providerId]?.kindApis?.[normalizedKind];
6451
+ const kindApiKind = KIND_API_KINDS.find(value => value === normalizedKind);
6452
+ if (kindApiKind === undefined) continue;
6453
+ kindApi = providers[desc.providerId]?.kindApis?.[kindApiKind];
6334
6454
  if (kindApi === undefined) continue;
6335
- kind = normalizedKind;
6455
+ kind = kindApiKind;
6336
6456
  }
6337
6457
  }
6338
6458
 
@@ -7288,3 +7408,154 @@ export function charmHyperModelManagerOptions(
7288
7408
  }),
7289
7409
  };
7290
7410
  }
7411
+
7412
+ // ---------------------------------------------------------------------------
7413
+ // SingularityAPI
7414
+ // ---------------------------------------------------------------------------
7415
+
7416
+ export interface SingularityApiModelManagerConfig {
7417
+ apiKey?: string;
7418
+ baseUrl?: string;
7419
+ fetch?: FetchImpl;
7420
+ }
7421
+
7422
+ interface SingularityApiCapability extends Record<string, unknown> {
7423
+ endpoint?: unknown;
7424
+ context_window_tokens?: unknown;
7425
+ maximum_output_tokens?: unknown;
7426
+ default_output_tokens?: unknown;
7427
+ pricing?: unknown;
7428
+ }
7429
+
7430
+ /** Endpoints that decide which transport serves a `/v1/models` row. */
7431
+ const SINGULARITYAPI_CHAT_ENDPOINT = "/v1/chat/completions";
7432
+ const SINGULARITYAPI_IMAGE_ENDPOINT = "/v1/images/generations";
7433
+
7434
+ function singularityApiCapabilities(entry: OpenAICompatibleModelRecord): readonly SingularityApiCapability[] {
7435
+ const capabilities = entry.capabilities;
7436
+ if (!Array.isArray(capabilities)) return [];
7437
+ return capabilities.filter((capability): capability is SingularityApiCapability => isRecord(capability));
7438
+ }
7439
+
7440
+ function toSingularityApiRate(value: unknown): number {
7441
+ const parsed = toNumber(value);
7442
+ return parsed !== undefined && parsed >= 0 ? parsed : 0;
7443
+ }
7444
+
7445
+ function resolveSingularityApiCost(capability: SingularityApiCapability | undefined): ModelSpec<Api>["cost"] {
7446
+ const pricing = capability !== undefined && isRecord(capability.pricing) ? capability.pricing : undefined;
7447
+ if (!pricing) return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
7448
+ return {
7449
+ input: toSingularityApiRate(pricing.input_per_million_usd),
7450
+ output: toSingularityApiRate(pricing.output_per_million_usd),
7451
+ cacheRead: 0,
7452
+ cacheWrite: 0,
7453
+ };
7454
+ }
7455
+
7456
+ /**
7457
+ * Map one `/v1/models` row onto its serving transport.
7458
+ *
7459
+ * The wire's own `capabilities` list decides the transport: a row that serves
7460
+ * chat completions is a chat model, and a row whose only surface is
7461
+ * `/v1/images/generations` is routed to `openai-images` so
7462
+ * `generateImage`-style dispatch can reach it. Without that assignment the row
7463
+ * kept the discovery default (`openai-completions`) while still being marked
7464
+ * as an image model, so it was offered as an image target and then rejected by
7465
+ * every image client. The gateway bills image requests per request, never by
7466
+ * tokens, so those rows carry no token tariff.
7467
+ */
7468
+ function mapSingularityApiModel(entry: OpenAICompatibleModelRecord, defaults: ModelSpec<Api>): ModelSpec<Api> {
7469
+ const capabilities = singularityApiCapabilities(entry);
7470
+ const capability = capabilities.find(candidate => candidate.endpoint === SINGULARITYAPI_CHAT_ENDPOINT);
7471
+ if (
7472
+ capability === undefined &&
7473
+ capabilities.some(candidate => candidate.endpoint === SINGULARITYAPI_IMAGE_ENDPOINT)
7474
+ ) {
7475
+ return {
7476
+ ...defaults,
7477
+ api: "openai-images",
7478
+ name: toModelName(entry.name, defaults.name),
7479
+ kind: "image",
7480
+ reasoning: false,
7481
+ input: ["text", "image"],
7482
+ supportsTools: false,
7483
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
7484
+ contextWindow: null,
7485
+ maxTokens: null,
7486
+ };
7487
+ }
7488
+ return {
7489
+ ...defaults,
7490
+ name: toModelName(entry.name, defaults.name),
7491
+ contextWindow: toPositiveNumber(capability?.context_window_tokens, defaults.contextWindow),
7492
+ maxTokens: toPositiveNumber(capability?.maximum_output_tokens, defaults.maxTokens),
7493
+ cost: resolveSingularityApiCost(capability),
7494
+ };
7495
+ }
7496
+ /**
7497
+ * Core options shared by both SingularityAPI products. `mapModel` is supplied
7498
+ * only by the universal gateway, whose rows publish capability metadata; the
7499
+ * lane roster answers with bare ids and keeps the discovery defaults.
7500
+ */
7501
+ function singularityApiModelManagerOptions(
7502
+ providerId: "singularityapi-dev" | "singularityapi-tech",
7503
+ canonical: string,
7504
+ config: SingularityApiModelManagerConfig | undefined,
7505
+ mapModel?: (entry: OpenAICompatibleModelRecord, defaults: ModelSpec<Api>) => ModelSpec<Api>,
7506
+ ): ModelManagerOptions<Api> {
7507
+ const apiKey = config?.apiKey;
7508
+ const baseUrl = normalizeSingularityApiBaseUrl(config?.baseUrl, canonical);
7509
+ return {
7510
+ providerId,
7511
+ cacheProviderId: resolveModelCacheProviderId(providerId, { apiKey, baseUrl }),
7512
+ dynamicModelsAuthoritative: true,
7513
+ ...(apiKey && {
7514
+ fetchDynamicModels: () =>
7515
+ fetchOpenAICompatibleModels<Api>({
7516
+ api: "openai-completions",
7517
+ provider: providerId,
7518
+ baseUrl,
7519
+ apiKey,
7520
+ ...(mapModel && { mapModel }),
7521
+ fetch: config?.fetch,
7522
+ }),
7523
+ }),
7524
+ };
7525
+ }
7526
+
7527
+ /**
7528
+ * `singularityapi-dev` — SingularityAPI's pay-as-you-go universal gateway
7529
+ * (`api.singularityapi.dev`): chat completions over a 300+ model catalog,
7530
+ * plus image generation for the rows that advertise it.
7531
+ * `GET /v1/models` publishes each row's per-endpoint capabilities — context
7532
+ * window, max output tokens, and per-million pricing as 12-decimal strings —
7533
+ * with `cache-control: no-store`, so discovery reads limits and tariffs
7534
+ * straight off the wire and the endpoint list picks each row's transport.
7535
+ * Rows without a reasoning vocabulary stay non-reasoning; reviewed KDL rules
7536
+ * own the ladders the gateway leaves implicit (DeepSeek Flash/Pro, GPT-5.6
7537
+ * flagships), because a model discovered as non-reasoning never sends a
7538
+ * `reasoning_effort` and the gateway requires one alongside tools.
7539
+ */
7540
+ export function singularityApiDevModelManagerOptions(
7541
+ config?: SingularityApiModelManagerConfig,
7542
+ ): ModelManagerOptions<Api> {
7543
+ return singularityApiModelManagerOptions(
7544
+ "singularityapi-dev",
7545
+ SINGULARITYAPI_DEV_API_BASE_URL,
7546
+ config,
7547
+ mapSingularityApiModel,
7548
+ );
7549
+ }
7550
+
7551
+ /**
7552
+ * `singularityapi-tech` — SingularityAPI's slot-reserved DeepSeek lanes
7553
+ * (`api.singularityapi.tech`). `/v1/models` answers with bare `{id}` rows and
7554
+ * no capability metadata, so rows keep the discovery defaults and the
7555
+ * reviewed KDL rules own the wire shape, limits patch, and effort ladder.
7556
+ */
7557
+ export function singularityApiTechModelManagerOptions(
7558
+ config?: SingularityApiModelManagerConfig,
7559
+ ): ModelManagerOptions<Api> {
7560
+ return singularityApiModelManagerOptions("singularityapi-tech", SINGULARITYAPI_TECH_API_BASE_URL, config);
7561
+ }