@oh-my-pi/pi-catalog 17.2.2 → 17.2.4

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.
@@ -9,6 +9,7 @@ import type { ModelManagerConfig, ProviderCatalogEntry, ProviderDescriptor } fro
9
9
  import { googleModelManagerOptions, googleVertexModelManagerOptions } from "./google";
10
10
  import { ollamaCloudModelManagerOptions } from "./ollama";
11
11
  import {
12
+ aiandModelManagerOptions,
12
13
  aimlApiModelManagerOptions,
13
14
  alibabaCodingPlanModelManagerOptions,
14
15
  alibabaTokenPlanModelManagerOptions,
@@ -65,6 +66,14 @@ import {
65
66
  } from "./special";
66
67
 
67
68
  export const CATALOG_PROVIDERS = [
69
+ {
70
+ id: "aiand",
71
+ defaultModel: "moonshotai/kimi-k2.7-code",
72
+ envVars: ["AIAND_API_KEY"],
73
+ createModelManagerOptions: (config: ModelManagerConfig) => aiandModelManagerOptions(config),
74
+ dynamicModelsAuthoritative: true,
75
+ catalogDiscovery: { label: "ai&" },
76
+ },
68
77
  {
69
78
  id: "aimlapi",
70
79
  defaultModel: "gpt-5.5-2026-04-23",
@@ -23,6 +23,36 @@ type OllamaShowResponse = {
23
23
  };
24
24
 
25
25
  const OLLAMA_RETRY_DELAYS_MS = [2_000, 5_000, 10_000];
26
+ /**
27
+ * Output-token ceiling that Ollama Cloud enforces for the DeepSeek V4 Pro/Flash
28
+ * deployments: `/api/chat` rejects `num_predict` above it with HTTP 400
29
+ * (`max_tokens (...) exceeds model's maximum output tokens (65536)`) even though
30
+ * the model pages advertise a 1M context / 384K output. Ollama's `/api/show`
31
+ * never reports this cap, so the catalog pins it for the affected models
32
+ * (ollama/ollama#16890, #7266). The wire layer clamps `num_predict` to the same
33
+ * value (`OLLAMA_CLOUD_NUM_PREDICT_CAP` in `packages/ai/src/providers/ollama.ts`,
34
+ * #3392/#3394).
35
+ */
36
+ export const OLLAMA_CLOUD_MAX_OUTPUT_TOKENS = 65_536;
37
+
38
+ /**
39
+ * Untagged base ids whose Ollama Cloud deployment enforces
40
+ * {@link OLLAMA_CLOUD_MAX_OUTPUT_TOKENS}. Only DeepSeek V4 Pro/Flash are known
41
+ * to cap output below their advertised window (ollama/ollama#16890); other cloud
42
+ * models keep their discovered limits.
43
+ */
44
+ const OLLAMA_CLOUD_OUTPUT_CAPPED_BASE_IDS: Record<string, true> = {
45
+ "deepseek-v4-flash": true,
46
+ "deepseek-v4-pro": true,
47
+ };
48
+
49
+ /** Whether an Ollama Cloud model id (tagged or not) enforces the 65536 output cap. */
50
+ export function isOllamaCloudOutputCapped(id: string): boolean {
51
+ const separator = id.indexOf(":");
52
+ const baseId = separator > 0 ? id.slice(0, separator) : id;
53
+ return OLLAMA_CLOUD_OUTPUT_CAPPED_BASE_IDS[baseId] === true;
54
+ }
55
+
26
56
  const OLLAMA_CLOUD_GLM_52_THINKING: ThinkingConfig = {
27
57
  mode: "effort",
28
58
  efforts: [Effort.High, Effort.Max],
@@ -133,10 +163,10 @@ export function ollamaCloudModelManagerOptions(
133
163
  }
134
164
  const capabilities = metadata?.capabilities;
135
165
  const discoveredContextWindow = getContextWindow(metadata?.model_info);
136
- // `/api/show` is the only trustworthy Ollama-owned source for size caps.
137
- // When it is unavailable (or returns only coarse capabilities), do NOT
138
- // inherit giant budgets from bundled fallback metadata sourced from a
139
- // different catalog; keep the historical safe fallback instead.
166
+ // `/api/show` reports the context length but never a per-model output
167
+ // cap. DeepSeek V4 Pro/Flash deployments enforce a 65536 output ceiling
168
+ // (ollama/ollama#16890, #7266); every other id keeps the trusted
169
+ // reference limit, falling back to the historical safe cap otherwise.
140
170
  const contextWindow = discoveredContextWindow ?? 128000;
141
171
  const reasoning = capabilities ? capabilities.includes("thinking") : (reference?.reasoning ?? false);
142
172
  const thinking = capabilities ? getThinkingConfig(id, capabilities) : reference?.thinking;
@@ -157,8 +187,9 @@ export function ollamaCloudModelManagerOptions(
157
187
  input,
158
188
  cost: reference?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
159
189
  contextWindow,
160
- maxTokens:
161
- discoveredContextWindow !== null && discoveredContextWindow !== undefined
190
+ maxTokens: isOllamaCloudOutputCapped(id)
191
+ ? Math.min(contextWindow, OLLAMA_CLOUD_MAX_OUTPUT_TOKENS)
192
+ : discoveredContextWindow !== null && discoveredContextWindow !== undefined
162
193
  ? (providerReference?.maxTokens ?? Math.min(contextWindow, 8192))
163
194
  : Math.min(contextWindow, 8192),
164
195
  omitMaxOutputTokens: true,
@@ -2465,6 +2465,24 @@ export interface OpenRouterModelManagerConfig {
2465
2465
  fetch?: FetchImpl;
2466
2466
  }
2467
2467
 
2468
+ function mapOpenRouterThinking(entry: OpenAICompatibleModelRecord): ThinkingConfig | undefined {
2469
+ const reasoning = entry.reasoning;
2470
+ if (!isRecord(reasoning)) return undefined;
2471
+ const supportedEfforts = reasoning.supported_efforts;
2472
+ if (!Array.isArray(supportedEfforts)) return undefined;
2473
+ const efforts = THINKING_EFFORTS.filter(effort => supportedEfforts.includes(effort));
2474
+ if (efforts.length === 0) return undefined;
2475
+ const defaultLevel =
2476
+ typeof reasoning.default_effort === "string"
2477
+ ? THINKING_EFFORTS.find(effort => effort === reasoning.default_effort)
2478
+ : undefined;
2479
+ return {
2480
+ mode: "effort",
2481
+ efforts,
2482
+ ...(defaultLevel !== undefined && efforts.includes(defaultLevel) ? { defaultLevel } : {}),
2483
+ };
2484
+ }
2485
+
2468
2486
  export function openrouterModelManagerOptions(
2469
2487
  config?: OpenRouterModelManagerConfig,
2470
2488
  ): ModelManagerOptions<"openrouter"> {
@@ -2496,6 +2514,7 @@ export function openrouterModelManagerOptions(
2496
2514
  const baseModel = mapWithBundledReference(entry, defaults, reference);
2497
2515
  const pricing = entry.pricing as Record<string, unknown> | undefined;
2498
2516
  const params = Array.isArray(entry.supported_parameters) ? (entry.supported_parameters as string[]) : [];
2517
+ const thinking = mapOpenRouterThinking(entry);
2499
2518
  const modality = String((entry.architecture as Record<string, unknown> | undefined)?.modality ?? "");
2500
2519
  const topProvider = entry.top_provider as Record<string, unknown> | undefined;
2501
2520
 
@@ -2504,6 +2523,7 @@ export function openrouterModelManagerOptions(
2504
2523
  return {
2505
2524
  ...baseModel,
2506
2525
  reasoning: params.includes("reasoning"),
2526
+ ...(thinking !== undefined ? { thinking } : {}),
2507
2527
  input: modality.includes("image") ? ["text", "image"] : ["text"],
2508
2528
  cost: {
2509
2529
  input: parseFloat(String(pricing?.prompt ?? "0")) * 1_000_000,
@@ -3796,6 +3816,175 @@ export function sakanaModelManagerOptions(config?: SakanaModelManagerConfig): Mo
3796
3816
  };
3797
3817
  }
3798
3818
 
3819
+ // ---------------------------------------------------------------------------
3820
+ // 16.6 ai& (aiand.com)
3821
+ // ---------------------------------------------------------------------------
3822
+
3823
+ const AIAND_DEFAULT_BASE_URL = "https://api.aiand.com/v1";
3824
+
3825
+ /** `reasoning_efforts` wire values ai& reports, mapped onto pi effort levels. */
3826
+ const AIAND_EFFORT_BY_WIRE_VALUE: Record<string, Effort> = {
3827
+ minimal: Effort.Minimal,
3828
+ low: Effort.Low,
3829
+ medium: Effort.Medium,
3830
+ high: Effort.High,
3831
+ xhigh: Effort.XHigh,
3832
+ max: Effort.Max,
3833
+ };
3834
+
3835
+ function normalizeAiandBaseUrl(baseUrl: string | undefined): string {
3836
+ const value = baseUrl?.trim() || AIAND_DEFAULT_BASE_URL;
3837
+ const normalized = value.replace(/\/+$/, "");
3838
+ return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
3839
+ }
3840
+
3841
+ function createAiandStaticModel(
3842
+ id: string,
3843
+ name: string,
3844
+ cost: { input: number; output: number },
3845
+ contextWindow: number,
3846
+ input: ModelSpec<"openai-completions">["input"],
3847
+ ): ModelSpec<"openai-completions"> {
3848
+ return {
3849
+ id,
3850
+ name,
3851
+ api: "openai-completions",
3852
+ provider: "aiand",
3853
+ baseUrl: AIAND_DEFAULT_BASE_URL,
3854
+ reasoning: true,
3855
+ input: [...input],
3856
+ cost: { input: cost.input, output: cost.output, cacheRead: 0, cacheWrite: 0 },
3857
+ contextWindow,
3858
+ maxTokens: null,
3859
+ thinking: { mode: "effort", efforts: [Effort.Low, Effort.Medium, Effort.High], defaultLevel: Effort.Medium },
3860
+ };
3861
+ }
3862
+
3863
+ /**
3864
+ * Documented ai& catalog (docs.aiand.com/models/catalog, 2026-08) bundled so
3865
+ * the provider is usable when generation and first boot have no live key.
3866
+ * The org-scoped `/v1/models` response is authoritative once discovery runs.
3867
+ */
3868
+ export const AIAND_STATIC_MODELS: readonly ModelSpec<"openai-completions">[] = [
3869
+ createAiandStaticModel("qwen/qwen3.6-27b", "Qwen3.6 27B", { input: 0, output: 0 }, 262_144, ["text"]),
3870
+ createAiandStaticModel(
3871
+ "deepseek-ai/deepseek-v4-flash",
3872
+ "DeepSeek V4 Flash",
3873
+ { input: 0.15, output: 0.25 },
3874
+ 1_000_000,
3875
+ ["text"],
3876
+ ),
3877
+ createAiandStaticModel("google/gemma-4-31b-it", "Gemma 4 31B IT", { input: 0.2, output: 0.5 }, 262_144, [
3878
+ "text",
3879
+ "image",
3880
+ ]),
3881
+ createAiandStaticModel("openai/gpt-oss-120b", "GPT OSS 120B", { input: 0.15, output: 0.6 }, 131_072, ["text"]),
3882
+ createAiandStaticModel("deepseek-ai/deepseek-v4-pro", "DeepSeek V4 Pro", { input: 1, output: 2.5 }, 1_000_000, [
3883
+ "text",
3884
+ ]),
3885
+ createAiandStaticModel("moonshotai/kimi-k2.7-code", "Kimi K2.7 Code", { input: 0.75, output: 3.5 }, 262_144, [
3886
+ "text",
3887
+ "image",
3888
+ ]),
3889
+ createAiandStaticModel("moonshotai/kimi-k2.6", "Kimi K2.6", { input: 0.85, output: 3.5 }, 262_144, [
3890
+ "text",
3891
+ "image",
3892
+ ]),
3893
+ createAiandStaticModel("zai-org/glm-5.2", "GLM 5.2", { input: 1, output: 4 }, 1_000_000, ["text"]),
3894
+ createAiandStaticModel("zai-org/glm-5.1", "GLM 5.1", { input: 1.4, output: 4.4 }, 202_752, ["text"]),
3895
+ ];
3896
+
3897
+ const AIAND_STATIC_MODEL_IDS = AIAND_STATIC_MODELS.map(model => model.id);
3898
+
3899
+ function mapAiandThinking(entry: OpenAICompatibleModelRecord): ThinkingConfig | undefined {
3900
+ const efforts = Array.isArray(entry.reasoning_efforts)
3901
+ ? entry.reasoning_efforts.flatMap(value =>
3902
+ typeof value === "string" && AIAND_EFFORT_BY_WIRE_VALUE[value] ? [AIAND_EFFORT_BY_WIRE_VALUE[value]] : [],
3903
+ )
3904
+ : [];
3905
+ if (efforts.length === 0) {
3906
+ return undefined;
3907
+ }
3908
+ const defaultLevel =
3909
+ typeof entry.reasoning_effort_default === "string"
3910
+ ? AIAND_EFFORT_BY_WIRE_VALUE[entry.reasoning_effort_default]
3911
+ : undefined;
3912
+ return {
3913
+ mode: "effort",
3914
+ efforts,
3915
+ ...(defaultLevel && efforts.includes(defaultLevel) && { defaultLevel }),
3916
+ };
3917
+ }
3918
+
3919
+ /**
3920
+ * ai& reports prices as decimal strings per 1M tokens in the org's billing
3921
+ * currency (`usd` or `jpy`). Costs are only mapped for USD orgs — JPY figures
3922
+ * would corrupt the USD-denominated cost model, so they fall back to zero.
3923
+ */
3924
+ function mapAiandCost(entry: OpenAICompatibleModelRecord): ModelSpec<"openai-completions">["cost"] {
3925
+ if (typeof entry.currency === "string" && entry.currency !== "usd") {
3926
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
3927
+ }
3928
+ return {
3929
+ input: toPositiveNumber(entry.input_per_1m, 0),
3930
+ output: toPositiveNumber(entry.output_per_1m, 0),
3931
+ cacheRead: 0,
3932
+ cacheWrite: 0,
3933
+ };
3934
+ }
3935
+
3936
+ function mapAiandModel(
3937
+ entry: OpenAICompatibleModelRecord,
3938
+ defaults: ModelSpec<"openai-completions">,
3939
+ ): ModelSpec<"openai-completions"> {
3940
+ const capabilities: unknown[] = Array.isArray(entry.capabilities) ? entry.capabilities : [];
3941
+ const reasoning = capabilities.includes("reasoning");
3942
+ const thinking = reasoning ? mapAiandThinking(entry) : undefined;
3943
+ const description =
3944
+ typeof entry.description === "string" && entry.description.trim() ? entry.description : undefined;
3945
+ return {
3946
+ ...defaults,
3947
+ name: description ?? toModelName(entry.name, defaults.name),
3948
+ reasoning,
3949
+ input: capabilities.includes("vision") ? ["text", "image"] : ["text"],
3950
+ cost: mapAiandCost(entry),
3951
+ contextWindow: toPositiveNumber(entry.context_window, null),
3952
+ ...(thinking && { thinking }),
3953
+ };
3954
+ }
3955
+
3956
+ export interface AiandModelManagerConfig {
3957
+ apiKey?: string;
3958
+ baseUrl?: string;
3959
+ fetch?: FetchImpl;
3960
+ }
3961
+
3962
+ /**
3963
+ * ai& (aiand.com) model manager: OpenAI-compatible chat completions with an
3964
+ * org-scoped `/v1/models` catalog carrying context, capability, effort, and
3965
+ * pricing metadata, so discovery is authoritative over the bundled seed.
3966
+ */
3967
+ export function aiandModelManagerOptions(config?: AiandModelManagerConfig): ModelManagerOptions<"openai-completions"> {
3968
+ const apiKey = config?.apiKey;
3969
+ const baseUrl = normalizeAiandBaseUrl(config?.baseUrl ?? Bun.env.AIAND_BASE_URL);
3970
+ return {
3971
+ providerId: "aiand",
3972
+ dynamicModelsAuthoritative: true,
3973
+ dropCachedModelIdsOnStaticMismatch: AIAND_STATIC_MODEL_IDS,
3974
+ ...(apiKey && {
3975
+ fetchDynamicModels: () =>
3976
+ fetchOpenAICompatibleModels({
3977
+ api: "openai-completions",
3978
+ provider: "aiand",
3979
+ baseUrl,
3980
+ apiKey,
3981
+ mapModel: (entry, defaults) => mapAiandModel(entry, defaults),
3982
+ fetch: config?.fetch,
3983
+ }),
3984
+ }),
3985
+ };
3986
+ }
3987
+
3799
3988
  // ---------------------------------------------------------------------------
3800
3989
  // 17. Qwen Portal
3801
3990
  // ---------------------------------------------------------------------------
package/src/types.ts CHANGED
@@ -401,6 +401,15 @@ export interface OpenAICompat {
401
401
  * that proxy gateways (Vertex AI, AWS Bedrock-style fronts, etc.) reject.
402
402
  */
403
403
  export interface AnthropicCompat {
404
+ /**
405
+ * Stream-watchdog idle-timeout fallback in ms for slow reasoning hosts.
406
+ * Set to 0 to disable the inter-event idle watchdog entirely, matching
407
+ * `OpenAICompat.streamIdleTimeoutMs`.
408
+ *
409
+ * When unset, direct Anthropic streams use `PI_STREAM_IDLE_TIMEOUT_MS`,
410
+ * then the legacy `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` alias, then 300s.
411
+ */
412
+ streamIdleTimeoutMs?: number;
404
413
  /**
405
414
  * Drop the top-level `strict: true` field on tool definitions. Vertex AI's
406
415
  * Anthropic-compatible endpoint rejects unknown tool fields with
@@ -712,7 +721,13 @@ export interface ResolvedOpenAIResponsesCompat extends ResolvedOpenAISharedCompa
712
721
  export type ResolvedOpenRouterCompat = ResolvedOpenAICompat & ResolvedOpenAIResponsesCompat;
713
722
 
714
723
  /** Fully-resolved anthropic-messages compat view (same contract as `ResolvedOpenAICompat`). */
715
- export type ResolvedAnthropicCompat = Required<AnthropicCompat> & {
724
+ export type ResolvedAnthropicCompat = Required<Omit<AnthropicCompat, "streamIdleTimeoutMs">> & {
725
+ /**
726
+ * Stream-watchdog idle-timeout fallback in ms for slow reasoning hosts; 0 disables the idle watchdog.
727
+ * Undefined defers to `PI_STREAM_IDLE_TIMEOUT_MS`, then the legacy
728
+ * `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` alias, then 300s.
729
+ */
730
+ streamIdleTimeoutMs?: number;
716
731
  /**
717
732
  * The configured endpoint is the official first-party Anthropic API
718
733
  * (https + exact `api.anthropic.com` host; a missing baseUrl counts as