@oh-my-pi/pi-catalog 16.0.7 → 16.0.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.0.9] - 2026-06-18
6
+
7
+ ### Fixed
8
+
9
+ - Fixed GitHub Copilot's `anthropic-messages` proxy being misclassified as a non-signing reasoning endpoint (`replayUnsignedThinking: true`). It forwards to signature-enforcing Anthropic, so replaying a stripped/unsigned historical `thinking` block as `signature: ""` — most visibly an end_turn-bound checkpoint/branch-return turn whose signature the transform must strip — caused a `400 Invalid signature` that corrupted the session and re-tripped on every full history re-send (e.g. after toggling MCP servers). Copilot now degrades such blocks to text like the official API. ([#2851](https://github.com/can1357/oh-my-pi/issues/2851))
10
+ - Added a `supportsImageDetailOriginal` compat flag that resolves to `false` for GitHub Copilot, whose Responses endpoint rejects the `detail: "original"` image hint with a 400, and `true` for every other host. ([#2822](https://github.com/can1357/oh-my-pi/issues/2822))
11
+
12
+ ## [16.0.8] - 2026-06-18
13
+
14
+ ### Changed
15
+
16
+ - Refactored model family ID predicates and capability checkers to use a shared, uniform process-lifetime `memo` utility to eliminate caching boilerplate.
17
+
18
+ ### Fixed
19
+
20
+ - Fixed LM Studio dynamic discovery to use native `/api/v0/models` metadata so VLM models advertise image input. ([#2945](https://github.com/can1357/oh-my-pi/issues/2945))
21
+
5
22
  ## [16.0.7] - 2026-06-18
6
23
 
7
24
  ### Fixed
@@ -41,7 +41,6 @@ export interface UnknownModel {
41
41
  id: string;
42
42
  }
43
43
  export type ParsedModel = GeminiModel | AnthropicModel | OpenAIModel | UnknownModel;
44
- /** Strip a provider namespace prefix (`openai/gpt-5.4` → `gpt-5.4`). */
45
44
  export declare function bareModelId(modelId: string): string;
46
45
  export declare function parseKnownModel(modelId: string): ParsedModel;
47
46
  export declare const parseGeminiModel: (modelId: string) => GeminiModel | null;
@@ -7,27 +7,27 @@
7
7
  * here.
8
8
  */
9
9
  /** Kimi family ids in any namespace form (`moonshotai/kimi-*`, `kimi-k2.6`, `vendor/kimi.x`). */
10
- export declare function isKimiModelId(modelId: string): boolean;
10
+ export declare const isKimiModelId: (modelId: string) => boolean;
11
11
  /** Kimi K2.6 specifically, including router ids that spell the version `k2p6`. */
12
- export declare function isKimiK26ModelId(modelId: string): boolean;
12
+ export declare const isKimiK26ModelId: (modelId: string) => boolean;
13
13
  /** Claude ids in any namespace form (`claude-*`, `vendor/claude.x`). */
14
- export declare function isClaudeModelId(modelId: string): boolean;
14
+ export declare const isClaudeModelId: (modelId: string) => boolean;
15
15
  /** `anthropic/`-namespaced ids (aggregator catalogs like OpenRouter). */
16
- export declare function isAnthropicNamespacedModelId(modelId: string): boolean;
16
+ export declare const isAnthropicNamespacedModelId: (modelId: string) => boolean;
17
17
  /** Qwen family ids (substring match — Qwen SKUs have no stable prefix shape). */
18
- export declare function isQwenModelId(modelId: string): boolean;
18
+ export declare const isQwenModelId: (modelId: string) => boolean;
19
19
  /** Gemma open-weights family (`gemma-3-27b-it`, `google/gemma-4-E2B-it`, `gemma2-9b`). */
20
- export declare function isGemmaModelId(modelId: string): boolean;
20
+ export declare const isGemmaModelId: (modelId: string) => boolean;
21
21
  /** DeepSeek family by id or display name (proxies often rename the id but keep the name). */
22
- export declare function isDeepseekModelIdOrName(value: string): boolean;
22
+ export declare const isDeepseekModelIdOrName: (modelId: string) => boolean;
23
23
  /** Xiaomi MiMo family by id or display name. */
24
- export declare function isMimoModelIdOrName(value: string): boolean;
24
+ export declare const isMimoModelIdOrName: (modelId: string) => boolean;
25
25
  /**
26
26
  * Grok SKUs that expose the wire `reasoning.effort` dial. Other Grok reasoners
27
27
  * (e.g. `grok-build`, `grok-4.20-0309-reasoning`) think natively but reject the
28
28
  * param, so callers must omit reasoning effort for them.
29
29
  */
30
- export declare function isGrokReasoningEffortCapable(modelId: string): boolean;
30
+ export declare const isGrokReasoningEffortCapable: (modelId: string) => boolean;
31
31
  /**
32
32
  * MiniMax M2-generation family (M2, M2.1, M2.5, M2.7, including `-highspeed`/
33
33
  * `-lightning`/`-her`/`-turbo` variants, dotless aliases like `minimax-m21`,
@@ -37,18 +37,18 @@ export declare function isGrokReasoningEffortCapable(modelId: string): boolean;
37
37
  * `minimal` to `none` (Fireworks) or expects the full 5-tier scale must
38
38
  * clamp instead. Excludes M1, M3, MiniMax-Text-01, music, hailuo, voice ids.
39
39
  */
40
- export declare function isMinimaxM2FamilyModelId(modelId: string): boolean;
40
+ export declare const isMinimaxM2FamilyModelId: (modelId: string) => boolean;
41
41
  /** MiniMax M3 family ids in bundled/default and aggregator namespace forms. */
42
- export declare function isMinimaxM3FamilyModelId(modelId: string): boolean;
42
+ export declare const isMinimaxM3FamilyModelId: (modelId: string) => boolean;
43
43
  /**
44
44
  * OpenAI gpt-oss family (`gpt-oss-20b`, `gpt-oss-120b`, `gpt-oss:120b`,
45
45
  * `vendor/gpt-oss-…`). The Harmony reasoning format only accepts
46
46
  * `low|medium|high` for `reasoning_effort` and rejects `minimal`, `xhigh`,
47
47
  * and `none`.
48
48
  */
49
- export declare function isOpenAIGptOssModelId(modelId: string): boolean;
49
+ export declare const isOpenAIGptOssModelId: (modelId: string) => boolean;
50
50
  /** OpenAI model ids (gpt-*, o1-*, o3-*, o4-*, or prefixed with openai/). */
51
- export declare function isOpenAIModelId(modelId: string): boolean;
51
+ export declare const isOpenAIModelId: (modelId: string) => boolean;
52
52
  /**
53
53
  * Reasoning-capable GLM coding SKUs: glm-4.5 and up on the base / `-air` /
54
54
  * `-turbo` lines. Excludes the vision (`…v`) shape, the non-reasoning
@@ -56,11 +56,11 @@ export declare function isOpenAIModelId(modelId: string): boolean;
56
56
  * keeps newly-bumped integers (`glm-5.3`, `glm-6`, …) covered without a per-id
57
57
  * allowlist.
58
58
  */
59
- export declare function isReasoningGlmModelId(modelId: string): boolean;
59
+ export declare const isReasoningGlmModelId: (modelId: string) => boolean;
60
60
  /** GLM-5.2+ coding SKUs accept `reasoning_effort` in addition to binary thinking. */
61
- export declare function isGlm52ReasoningEffortModelId(modelId: string): boolean;
61
+ export declare const isGlm52ReasoningEffortModelId: (modelId: string) => boolean;
62
62
  /** GLM vision SKUs — the `v` that attaches to the version (`glm-4v`, `glm-4.5v`). */
63
- export declare function isGlmVisionModelId(modelId: string): boolean;
63
+ export declare const isGlmVisionModelId: (modelId: string) => boolean;
64
64
  /**
65
65
  * Coarse vendor-lineage token for "are two models the same family?" checks
66
66
  * (e.g. picking a cross-family reviewer). All Claude point releases share a token,
@@ -72,7 +72,7 @@ export declare function isGlmVisionModelId(modelId: string): boolean;
72
72
  * Vendor-only by design: a model's kind/variant (opus vs sonnet, codex vs base) is
73
73
  * collapsed onto the single vendor token; use {@link parseKnownModel} for finer breakdowns.
74
74
  */
75
- export declare function modelFamilyToken(modelId: string): string;
75
+ export declare const modelFamilyToken: (modelId: string) => string;
76
76
  /**
77
77
  * Adaptive thinking `display` is supported starting with Claude Opus 4.7 and
78
78
  * the Claude Fable/Mythos 5 generation. Older adaptive-thinking models
@@ -80,13 +80,13 @@ export declare function modelFamilyToken(modelId: string): string;
80
80
  * dashed version forms both match while bare dated ids
81
81
  * (`claude-opus-4-20250514` = Opus 4.0) stay excluded.
82
82
  */
83
- export declare function supportsAdaptiveThinkingDisplay(modelId: string): boolean;
83
+ export declare const supportsAdaptiveThinkingDisplay: (modelId: string) => boolean;
84
84
  /**
85
85
  * Returns true for Anthropic models with Opus 4.7+/Fable/Mythos API restrictions:
86
86
  * - Sampling parameters (temperature/top_p/top_k) return 400 error
87
87
  * - Thinking content is omitted by default (needs display: "summarized")
88
88
  */
89
- export declare function hasOpus47ApiRestrictions(modelId: string): boolean;
89
+ export declare const hasOpus47ApiRestrictions: (modelId: string) => boolean;
90
90
  /**
91
91
  * Mid-conversation `role: "system"` messages (system instructions appended at
92
92
  * non-first positions in the `messages` array) are supported starting with
@@ -94,8 +94,8 @@ export declare function hasOpus47ApiRestrictions(modelId: string): boolean;
94
94
  * models reject the role.
95
95
  * @see https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages
96
96
  */
97
- export declare function supportsMidConversationSystemMessages(modelId: string): boolean;
98
- export declare function isAnthropicFableOrMythosModel(modelId: string): boolean;
97
+ export declare const supportsMidConversationSystemMessages: (modelId: string) => boolean;
98
+ export declare const isAnthropicFableOrMythosModel: (modelId: string) => boolean;
99
99
  /** Thinking-variant token location inside a model id. */
100
100
  export interface ThinkingVariantToken {
101
101
  index: number;
@@ -115,4 +115,4 @@ export declare function findThinkingVariantToken(modelId: string): ThinkingVaria
115
115
  * token exists or nothing would remain. Callers MUST verify the result names
116
116
  * a live model.
117
117
  */
118
- export declare function stripThinkingVariantToken(modelId: string): string | undefined;
118
+ export declare const stripThinkingVariantToken: (modelId: string) => string | undefined;
@@ -265,6 +265,18 @@ export interface KimiCodeModelManagerConfig {
265
265
  fetch?: FetchImpl;
266
266
  }
267
267
  export declare function kimiCodeModelManagerOptions(config?: KimiCodeModelManagerConfig): ModelManagerOptions<"openai-completions">;
268
+ /** Native LM Studio metadata keyed by model id from `/api/v0/models`. */
269
+ export interface LmStudioNativeModelMetadata {
270
+ input: ("text" | "image")[];
271
+ contextWindow?: number;
272
+ }
273
+ /** Options for LM Studio's optional native metadata probe. */
274
+ export interface LmStudioNativeModelMetadataOptions {
275
+ headers?: Record<string, string>;
276
+ signal?: AbortSignal;
277
+ }
278
+ /** Fetches LM Studio native model metadata used to mark VLM models as image-capable. */
279
+ export declare function fetchLmStudioNativeModelMetadata(baseUrl: string, fetchImpl?: FetchImpl, options?: LmStudioNativeModelMetadataOptions): Promise<Map<string, LmStudioNativeModelMetadata> | null>;
268
280
  export interface LmStudioModelManagerConfig {
269
281
  apiKey?: string;
270
282
  baseUrl?: string;
@@ -247,6 +247,8 @@ export interface OpenAICompat {
247
247
  alwaysSendMaxTokens?: boolean;
248
248
  /** Whether Responses-API tool-call/result history must be strictly paired. Default: auto-detected (Azure OpenAI, GitHub Copilot). */
249
249
  strictResponsesPairing?: boolean;
250
+ /** Whether the Responses API accepts the `detail: "original"` image hint. Default: auto-detected (false for GitHub Copilot, which rejects it with a 400). */
251
+ supportsImageDetailOriginal?: boolean;
250
252
  /**
251
253
  * Append a trailing `# Juice: 0 !important` developer item when the caller
252
254
  * did not request reasoning, suppressing default reasoning on models that
@@ -416,7 +418,7 @@ export interface ResolvedOpenAISharedCompat {
416
418
  * `buildModel`; request handlers read fields and never detect, resolve, or
417
419
  * allocate.
418
420
  */
419
- export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & Required<Omit<OpenAICompat, "supportsDeveloperRole" | "supportsReasoningEffort" | "reasoningEffortMap" | "supportsReasoningParams" | "thinkingFormat" | "reasoningDisableMode" | "omitReasoningEffort" | "includeEncryptedReasoning" | "filterReasoningHistory" | "disableReasoningOnForcedToolChoice" | "disableReasoningOnToolChoice" | "supportsToolChoice" | "supportsForcedToolChoice" | "reasoningContentField" | "requiresReasoningContentForToolCalls" | "requiresReasoningContentForAllAssistantTurns" | "allowsSyntheticReasoningContentForToolCalls" | "requiresThinkingAsText" | "requiresMistralToolIds" | "requiresToolResultName" | "requiresAssistantAfterToolResult" | "requiresAssistantContentForToolCalls" | "stripDeepseekSpecialTokens" | "streamMarkupHealingPattern" | "reasoningDeltasMayBeCumulative" | "emptyLengthFinishIsContextError" | "usesOpenAIToolCallIdLimit" | "promptCacheSessionHeader" | "openRouterRouting" | "isOpenRouterHost" | "supportsStrictMode" | "supportsLongPromptCacheRetention" | "alwaysSendMaxTokens" | "wireModelIdMode" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolSchemaFlavor" | "streamIdleTimeoutMs" | "cacheControlFormat" | "thinkingKeep" | "strictResponsesPairing" | "requiresJuiceZeroHack" | "enableGeminiThinkingLoopGuard" | "whenThinking">> & {
421
+ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & Required<Omit<OpenAICompat, "supportsDeveloperRole" | "supportsReasoningEffort" | "reasoningEffortMap" | "supportsReasoningParams" | "thinkingFormat" | "reasoningDisableMode" | "omitReasoningEffort" | "includeEncryptedReasoning" | "filterReasoningHistory" | "disableReasoningOnForcedToolChoice" | "disableReasoningOnToolChoice" | "supportsToolChoice" | "supportsForcedToolChoice" | "reasoningContentField" | "requiresReasoningContentForToolCalls" | "requiresReasoningContentForAllAssistantTurns" | "allowsSyntheticReasoningContentForToolCalls" | "requiresThinkingAsText" | "requiresMistralToolIds" | "requiresToolResultName" | "requiresAssistantAfterToolResult" | "requiresAssistantContentForToolCalls" | "stripDeepseekSpecialTokens" | "streamMarkupHealingPattern" | "reasoningDeltasMayBeCumulative" | "emptyLengthFinishIsContextError" | "usesOpenAIToolCallIdLimit" | "promptCacheSessionHeader" | "openRouterRouting" | "isOpenRouterHost" | "supportsStrictMode" | "supportsLongPromptCacheRetention" | "alwaysSendMaxTokens" | "wireModelIdMode" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolSchemaFlavor" | "streamIdleTimeoutMs" | "cacheControlFormat" | "thinkingKeep" | "strictResponsesPairing" | "supportsImageDetailOriginal" | "requiresJuiceZeroHack" | "enableGeminiThinkingLoopGuard" | "whenThinking">> & {
420
422
  vercelGatewayRouting?: OpenAICompat["vercelGatewayRouting"];
421
423
  extraBody?: OpenAICompat["extraBody"];
422
424
  cacheControlFormat?: OpenAICompat["cacheControlFormat"];
@@ -434,6 +436,7 @@ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat & Required<Omit<Op
434
436
  export interface ResolvedOpenAIResponsesCompat extends ResolvedOpenAISharedCompat {
435
437
  supportsLongPromptCacheRetention: boolean;
436
438
  strictResponsesPairing: boolean;
439
+ supportsImageDetailOriginal: boolean;
437
440
  requiresJuiceZeroHack: boolean;
438
441
  supportsObfuscationOptOut: boolean;
439
442
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-catalog",
4
- "version": "16.0.7",
4
+ "version": "16.0.9",
5
5
  "description": "Model catalog for omp: bundled model database, provider discovery descriptors, model identity, classification, and equivalence",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -34,12 +34,12 @@
34
34
  },
35
35
  "dependencies": {
36
36
  "@bufbuild/protobuf": "^2.12.0",
37
- "@oh-my-pi/pi-utils": "16.0.7",
37
+ "@oh-my-pi/pi-utils": "16.0.9",
38
38
  "arktype": "^2.2.0",
39
39
  "zod": "^4"
40
40
  },
41
41
  "devDependencies": {
42
- "@oh-my-pi/pi-ai": "16.0.7",
42
+ "@oh-my-pi/pi-ai": "16.0.9",
43
43
  "@types/bun": "^1.3.14"
44
44
  },
45
45
  "engines": {
@@ -66,7 +66,16 @@ export function buildAnthropicCompat(spec: ModelSpec<"anthropic-messages">): Res
66
66
  // loses the reasoning chain and can destabilize the next tool-call
67
67
  // arguments (#2005). Known non-signing hosts (Z.AI, DeepSeek) are also
68
68
  // preserved for compatibility.
69
- replayUnsignedThinking: isZai || modelMatchesHost(spec, "deepseekFamily") || (spec.reasoning && !official),
69
+ //
70
+ // GitHub Copilot's `anthropic-messages` proxy is excluded: it forwards to
71
+ // signature-enforcing Anthropic and returns full thinking signatures, so it
72
+ // is a SIGNING endpoint. Replaying a stripped/unsigned thinking block as
73
+ // `signature: ""` there 400s the whole request ("Invalid signature") — most
74
+ // visibly when a checkpoint/branch-return turn's end_turn-bound signature is
75
+ // stripped on replay (issue #2851). Treating it like official Anthropic
76
+ // degrades such blocks to text instead, which the API accepts.
77
+ replayUnsignedThinking:
78
+ !isCopilot && (isZai || modelMatchesHost(spec, "deepseekFamily") || (spec.reasoning && !official)),
70
79
  escapeBuiltinToolNames: modelMatchesHost(spec, "umans"),
71
80
  };
72
81
  applyCompatOverrides(compat, spec.compat);
@@ -459,6 +459,12 @@ export function buildOpenAIResponsesCompat(spec: OpenAIResponsesSpecLike): Resol
459
459
  // Azure OpenAI and GitHub Copilot Responses paths require tool results
460
460
  // to strictly match prior tool calls when building Responses inputs.
461
461
  strictResponsesPairing: isAzure || spec.provider === "github-copilot",
462
+ // GitHub Copilot's Responses endpoint rejects the `detail: "original"`
463
+ // image hint with a 400; every other host preserves native-resolution
464
+ // frames (snapcompact relies on `original`). Detect Copilot by provider id
465
+ // or base-URL host (mirroring the Anthropic compat builder) so a model
466
+ // pointed at the Copilot host under a different provider id still clamps.
467
+ supportsImageDetailOriginal: !modelMatchesHost({ provider: spec.provider, baseUrl }, "githubCopilot"),
462
468
  requiresJuiceZeroHack: spec.name.toLowerCase().startsWith("gpt-5"),
463
469
  reasoningEffortMap: {},
464
470
  supportsReasoningParams: true,
@@ -514,6 +520,7 @@ function pickResponsesOnly(compat: ResolvedOpenAIResponsesCompat): ResponsesOnly
514
520
  return {
515
521
  supportsLongPromptCacheRetention: compat.supportsLongPromptCacheRetention,
516
522
  strictResponsesPairing: compat.strictResponsesPairing,
523
+ supportsImageDetailOriginal: compat.supportsImageDetailOriginal,
517
524
  requiresJuiceZeroHack: compat.requiresJuiceZeroHack,
518
525
  supportsObfuscationOptOut: compat.supportsObfuscationOptOut,
519
526
  } satisfies ResponsesOnlyCompat;
package/src/hosts.ts CHANGED
@@ -16,7 +16,7 @@ interface HostClassSpec {
16
16
  readonly providers?: readonly string[];
17
17
  /** Provider-id prefixes that imply this host class (e.g. `xiaomi-token-plan-`). */
18
18
  readonly providerPrefixes?: readonly string[];
19
- /** Case-insensitive substrings matched against the base URL. */
19
+ /** Lowercase ASCII substrings matched case-insensitively against the base URL. */
20
20
  readonly urlMarkers: readonly string[];
21
21
  // Strict hostname matching is intentionally not modeled here: the one
22
22
  // auth-sensitive consumer (Anthropic official-endpoint) parses the URL
@@ -68,9 +68,8 @@ export type KnownHost = keyof typeof KNOWN_HOSTS;
68
68
  export function hostMatchesUrl(baseUrl: string | undefined, host: KnownHost): boolean {
69
69
  if (!baseUrl) return false;
70
70
  const spec: HostClassSpec = KNOWN_HOSTS[host];
71
- const normalized = baseUrl.toLowerCase();
72
71
  for (const marker of spec.urlMarkers) {
73
- if (normalized.includes(marker)) return true;
72
+ if (includesAsciiCaseInsensitive(baseUrl, marker)) return true;
74
73
  }
75
74
  return false;
76
75
  }
@@ -91,6 +90,19 @@ export function modelMatchesHost(model: { provider: string; baseUrl: string }, h
91
90
  return hostMatchesUrl(model.baseUrl, host);
92
91
  }
93
92
 
93
+ function includesAsciiCaseInsensitive(value: string, lowerNeedle: string): boolean {
94
+ const needleLength = lowerNeedle.length;
95
+ const end = value.length - needleLength;
96
+ for (let start = 0; start <= end; start++) {
97
+ let offset = 0;
98
+ for (; offset < needleLength; offset++) {
99
+ if ((value.charCodeAt(start + offset) | 0x20) !== lowerNeedle.charCodeAt(offset)) break;
100
+ }
101
+ if (offset === needleLength) return true;
102
+ }
103
+ return false;
104
+ }
105
+
94
106
  // --- Endpoint-shape predicates (URL path/verb shapes, not vendor hosts) ---
95
107
 
96
108
  /** Vertex AI express-mode OpenAI-compatible endpoint (`…/endpoints/openapi`). */
@@ -51,9 +51,15 @@ export interface UnknownModel {
51
51
  export type ParsedModel = GeminiModel | AnthropicModel | OpenAIModel | UnknownModel;
52
52
 
53
53
  /** Strip a provider namespace prefix (`openai/gpt-5.4` → `gpt-5.4`). */
54
+ // Cache keyed by model id (a bounded set of bundled/aggregator ids), so no eviction is needed.
55
+ const bareModelIdCache = new Map<string, string>();
54
56
  export function bareModelId(modelId: string): string {
57
+ const cached = bareModelIdCache.get(modelId);
58
+ if (cached !== undefined) return cached;
55
59
  const p = modelId.lastIndexOf("/");
56
- return p !== -1 ? modelId.slice(p + 1) : modelId;
60
+ const result = p !== -1 ? modelId.slice(p + 1) : modelId;
61
+ bareModelIdCache.set(modelId, result);
62
+ return result;
57
63
  }
58
64
 
59
65
  export function parseKnownModel(modelId: string): ParsedModel {
@@ -16,45 +16,58 @@ import {
16
16
  semverGte,
17
17
  } from "./classify";
18
18
 
19
+ /** Bounded process-lifetime cache memo helper. */
20
+ function memo<T>(fn: (modelId: string) => T): (modelId: string) => T {
21
+ const cache = new Map<string, T>();
22
+ return (modelId: string) => {
23
+ if (cache.has(modelId)) {
24
+ return cache.get(modelId) as T;
25
+ }
26
+ const result = fn(modelId);
27
+ cache.set(modelId, result);
28
+ return result;
29
+ };
30
+ }
31
+
19
32
  /** Kimi family ids in any namespace form (`moonshotai/kimi-*`, `kimi-k2.6`, `vendor/kimi.x`). */
20
- export function isKimiModelId(modelId: string): boolean {
33
+ export const isKimiModelId = memo((modelId: string): boolean => {
21
34
  return modelId.includes("moonshotai/kimi") || /(^|\/)kimi[-.]/i.test(modelId);
22
- }
35
+ });
23
36
 
24
37
  /** Kimi K2.6 specifically, including router ids that spell the version `k2p6`. */
25
- export function isKimiK26ModelId(modelId: string): boolean {
38
+ export const isKimiK26ModelId = memo((modelId: string): boolean => {
26
39
  return /(^|\/)kimi-k2(?:\.6|p6)(?:[-:]|$)/i.test(modelId);
27
- }
40
+ });
28
41
 
29
42
  /** Claude ids in any namespace form (`claude-*`, `vendor/claude.x`). */
30
- export function isClaudeModelId(modelId: string): boolean {
43
+ export const isClaudeModelId = memo((modelId: string): boolean => {
31
44
  return /(^|\/)claude[-.]/i.test(modelId);
32
- }
45
+ });
33
46
 
34
47
  /** `anthropic/`-namespaced ids (aggregator catalogs like OpenRouter). */
35
- export function isAnthropicNamespacedModelId(modelId: string): boolean {
48
+ export const isAnthropicNamespacedModelId = memo((modelId: string): boolean => {
36
49
  return /(^|\/)anthropic\//i.test(modelId);
37
- }
50
+ });
38
51
 
39
52
  /** Qwen family ids (substring match — Qwen SKUs have no stable prefix shape). */
40
- export function isQwenModelId(modelId: string): boolean {
53
+ export const isQwenModelId = memo((modelId: string): boolean => {
41
54
  return modelId.toLowerCase().includes("qwen");
42
- }
55
+ });
43
56
 
44
57
  /** Gemma open-weights family (`gemma-3-27b-it`, `google/gemma-4-E2B-it`, `gemma2-9b`). */
45
- export function isGemmaModelId(modelId: string): boolean {
58
+ export const isGemmaModelId = memo((modelId: string): boolean => {
46
59
  return /(^|\/)gemma[-.]?\d/i.test(modelId);
47
- }
60
+ });
48
61
 
49
62
  /** DeepSeek family by id or display name (proxies often rename the id but keep the name). */
50
- export function isDeepseekModelIdOrName(value: string): boolean {
63
+ export const isDeepseekModelIdOrName = memo((value: string): boolean => {
51
64
  return value.toLowerCase().includes("deepseek");
52
- }
65
+ });
53
66
 
54
67
  /** Xiaomi MiMo family by id or display name. */
55
- export function isMimoModelIdOrName(value: string): boolean {
68
+ export const isMimoModelIdOrName = memo((value: string): boolean => {
56
69
  return value.toLowerCase().includes("mimo");
57
- }
70
+ });
58
71
 
59
72
  const GROK_EFFORT_CAPABLE_PREFIXES = ["grok-3-mini", "grok-4.20-multi-agent", "grok-4.3"] as const;
60
73
 
@@ -63,11 +76,11 @@ const GROK_EFFORT_CAPABLE_PREFIXES = ["grok-3-mini", "grok-4.20-multi-agent", "g
63
76
  * (e.g. `grok-build`, `grok-4.20-0309-reasoning`) think natively but reject the
64
77
  * param, so callers must omit reasoning effort for them.
65
78
  */
66
- export function isGrokReasoningEffortCapable(modelId: string): boolean {
79
+ export const isGrokReasoningEffortCapable = memo((modelId: string): boolean => {
67
80
  const bare = bareModelId(modelId).trim().toLowerCase();
68
81
  if (!bare) return false;
69
82
  return GROK_EFFORT_CAPABLE_PREFIXES.some(prefix => bare.startsWith(prefix));
70
- }
83
+ });
71
84
 
72
85
  /**
73
86
  * MiniMax M2-generation family (M2, M2.1, M2.5, M2.7, including `-highspeed`/
@@ -78,20 +91,20 @@ export function isGrokReasoningEffortCapable(modelId: string): boolean {
78
91
  * `minimal` to `none` (Fireworks) or expects the full 5-tier scale must
79
92
  * clamp instead. Excludes M1, M3, MiniMax-Text-01, music, hailuo, voice ids.
80
93
  */
81
- export function isMinimaxM2FamilyModelId(modelId: string): boolean {
94
+ export const isMinimaxM2FamilyModelId = memo((modelId: string): boolean => {
82
95
  const lower = modelId.toLowerCase();
83
96
  if (!lower.includes("minimax")) return false;
84
97
  // Boundary-delimited `m2` token followed by zero or more digits (dotless
85
98
  // variants like `m21`/`m25`/`m27`) and an optional dotted minor version.
86
99
  return /(?:^|[/.-])m2\d*(?:[.-]\d+)?(?:[-.:_]|$)/i.test(lower);
87
- }
100
+ });
88
101
 
89
102
  /** MiniMax M3 family ids in bundled/default and aggregator namespace forms. */
90
- export function isMinimaxM3FamilyModelId(modelId: string): boolean {
103
+ export const isMinimaxM3FamilyModelId = memo((modelId: string): boolean => {
91
104
  const lower = modelId.toLowerCase();
92
105
  if (!lower.includes("minimax")) return false;
93
106
  return /(?:^|[/._-])(?:minimax[/._-])?m3(?:[-.:_]|$)/i.test(lower);
94
- }
107
+ });
95
108
 
96
109
  /**
97
110
  * OpenAI gpt-oss family (`gpt-oss-20b`, `gpt-oss-120b`, `gpt-oss:120b`,
@@ -99,14 +112,14 @@ export function isMinimaxM3FamilyModelId(modelId: string): boolean {
99
112
  * `low|medium|high` for `reasoning_effort` and rejects `minimal`, `xhigh`,
100
113
  * and `none`.
101
114
  */
102
- export function isOpenAIGptOssModelId(modelId: string): boolean {
115
+ export const isOpenAIGptOssModelId = memo((modelId: string): boolean => {
103
116
  return /(^|\/)gpt-oss[-:]/i.test(modelId);
104
- }
117
+ });
105
118
 
106
119
  /** OpenAI model ids (gpt-*, o1-*, o3-*, o4-*, or prefixed with openai/). */
107
- export function isOpenAIModelId(modelId: string): boolean {
120
+ export const isOpenAIModelId = memo((modelId: string): boolean => {
108
121
  return /(^|\/)(gpt|o1|o3|o4)[-.]/i.test(modelId) || modelId.toLowerCase().includes("openai/");
109
- }
122
+ });
110
123
 
111
124
  /**
112
125
  * Reasoning-capable GLM coding SKUs: glm-4.5 and up on the base / `-air` /
@@ -115,7 +128,7 @@ export function isOpenAIModelId(modelId: string): boolean {
115
128
  * keeps newly-bumped integers (`glm-5.3`, `glm-6`, …) covered without a per-id
116
129
  * allowlist.
117
130
  */
118
- export function isReasoningGlmModelId(modelId: string): boolean {
131
+ export const isReasoningGlmModelId = memo((modelId: string): boolean => {
119
132
  const glm = parseGlmModel(bareModelId(modelId));
120
133
  if (!glm || glm.vision) {
121
134
  return false;
@@ -124,9 +137,10 @@ export function isReasoningGlmModelId(modelId: string): boolean {
124
137
  return false;
125
138
  }
126
139
  return semverGte(glm.version, "4.5");
127
- }
140
+ });
141
+
128
142
  /** GLM-5.2+ coding SKUs accept `reasoning_effort` in addition to binary thinking. */
129
- export function isGlm52ReasoningEffortModelId(modelId: string): boolean {
143
+ export const isGlm52ReasoningEffortModelId = memo((modelId: string): boolean => {
130
144
  const glm = parseGlmModel(bareModelId(modelId));
131
145
  if (!glm || glm.vision) {
132
146
  return false;
@@ -135,12 +149,13 @@ export function isGlm52ReasoningEffortModelId(modelId: string): boolean {
135
149
  return false;
136
150
  }
137
151
  return semverGte(glm.version, "5.2");
138
- }
152
+ });
139
153
 
140
154
  /** GLM vision SKUs — the `v` that attaches to the version (`glm-4v`, `glm-4.5v`). */
141
- export function isGlmVisionModelId(modelId: string): boolean {
155
+ export const isGlmVisionModelId = memo((modelId: string): boolean => {
142
156
  return parseGlmModel(bareModelId(modelId))?.vision === true;
143
- }
157
+ });
158
+
144
159
  /**
145
160
  * Coarse vendor-lineage token for "are two models the same family?" checks
146
161
  * (e.g. picking a cross-family reviewer). All Claude point releases share a token,
@@ -152,7 +167,7 @@ export function isGlmVisionModelId(modelId: string): boolean {
152
167
  * Vendor-only by design: a model's kind/variant (opus vs sonnet, codex vs base) is
153
168
  * collapsed onto the single vendor token; use {@link parseKnownModel} for finer breakdowns.
154
169
  */
155
- export function modelFamilyToken(modelId: string): string {
170
+ export const modelFamilyToken = memo((modelId: string): string => {
156
171
  const parsed = parseKnownModel(modelId);
157
172
  if (parsed.family !== "unknown") return parsed.family;
158
173
  if (isClaudeModelId(modelId) || isAnthropicNamespacedModelId(modelId)) return "anthropic";
@@ -166,7 +181,7 @@ export function modelFamilyToken(modelId: string): string {
166
181
  if (isGemmaModelId(modelId)) return "gemma";
167
182
  if (parseGlmModel(bareModelId(modelId))) return "glm";
168
183
  return "";
169
- }
184
+ });
170
185
 
171
186
  /**
172
187
  * Adaptive thinking `display` is supported starting with Claude Opus 4.7 and
@@ -175,23 +190,23 @@ export function modelFamilyToken(modelId: string): string {
175
190
  * dashed version forms both match while bare dated ids
176
191
  * (`claude-opus-4-20250514` = Opus 4.0) stay excluded.
177
192
  */
178
- export function supportsAdaptiveThinkingDisplay(modelId: string): boolean {
193
+ export const supportsAdaptiveThinkingDisplay = memo((modelId: string): boolean => {
179
194
  const parsed = parseAnthropicModel(bareModelId(modelId));
180
195
  if (!parsed) return false;
181
196
  if (isFableOrMythos(parsed.kind)) return semverGte(parsed.version, "5");
182
197
  return parsed.kind === "opus" && semverGte(parsed.version, "4.7");
183
- }
198
+ });
184
199
 
185
200
  /**
186
201
  * Returns true for Anthropic models with Opus 4.7+/Fable/Mythos API restrictions:
187
202
  * - Sampling parameters (temperature/top_p/top_k) return 400 error
188
203
  * - Thinking content is omitted by default (needs display: "summarized")
189
204
  */
190
- export function hasOpus47ApiRestrictions(modelId: string): boolean {
205
+ export const hasOpus47ApiRestrictions = memo((modelId: string): boolean => {
191
206
  const parsed = parseAnthropicModel(bareModelId(modelId));
192
207
  if (!parsed) return false;
193
208
  return (parsed.kind === "opus" && semverGte(parsed.version, "4.7")) || isFableOrMythos(parsed.kind);
194
- }
209
+ });
195
210
 
196
211
  /**
197
212
  * Mid-conversation `role: "system"` messages (system instructions appended at
@@ -200,16 +215,16 @@ export function hasOpus47ApiRestrictions(modelId: string): boolean {
200
215
  * models reject the role.
201
216
  * @see https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages
202
217
  */
203
- export function supportsMidConversationSystemMessages(modelId: string): boolean {
218
+ export const supportsMidConversationSystemMessages = memo((modelId: string): boolean => {
204
219
  const parsed = parseAnthropicModel(bareModelId(modelId));
205
220
  if (!parsed) return false;
206
221
  return (parsed.kind === "opus" && semverGte(parsed.version, "4.8")) || isFableOrMythos(parsed.kind);
207
- }
222
+ });
208
223
 
209
- export function isAnthropicFableOrMythosModel(modelId: string): boolean {
224
+ export const isAnthropicFableOrMythosModel = memo((modelId: string): boolean => {
210
225
  const parsed = parseAnthropicModel(bareModelId(modelId));
211
226
  return parsed !== null && isFableOrMythos(parsed.kind);
212
- }
227
+ });
213
228
 
214
229
  /** Thinking-variant token location inside a model id. */
215
230
  export interface ThinkingVariantToken {
@@ -245,9 +260,9 @@ export function findThinkingVariantToken(modelId: string): ThinkingVariantToken
245
260
  * token exists or nothing would remain. Callers MUST verify the result names
246
261
  * a live model.
247
262
  */
248
- export function stripThinkingVariantToken(modelId: string): string | undefined {
263
+ export const stripThinkingVariantToken = memo((modelId: string): string | undefined => {
249
264
  const token = findThinkingVariantToken(modelId);
250
265
  if (!token) return undefined;
251
266
  const stripped = modelId.slice(0, token.index) + modelId.slice(token.index + token.length);
252
267
  return stripped.length > 0 ? stripped : undefined;
253
- }
268
+ });
@@ -2184,6 +2184,86 @@ export function kimiCodeModelManagerOptions(
2184
2184
  // 12.5. LM Studio
2185
2185
  // ---------------------------------------------------------------------------
2186
2186
 
2187
+ /** Native LM Studio metadata keyed by model id from `/api/v0/models`. */
2188
+ export interface LmStudioNativeModelMetadata {
2189
+ input: ("text" | "image")[];
2190
+ contextWindow?: number;
2191
+ }
2192
+
2193
+ /** Options for LM Studio's optional native metadata probe. */
2194
+ export interface LmStudioNativeModelMetadataOptions {
2195
+ headers?: Record<string, string>;
2196
+ signal?: AbortSignal;
2197
+ }
2198
+
2199
+ const LM_STUDIO_NATIVE_METADATA_TIMEOUT_MS = 250;
2200
+
2201
+ function toLmStudioNativeBaseUrl(baseUrl: string): string {
2202
+ const trimmed = baseUrl.trim();
2203
+ const normalized = trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
2204
+ return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized;
2205
+ }
2206
+
2207
+ function getLmStudioCapabilityNames(value: unknown): string[] {
2208
+ if (!Array.isArray(value)) {
2209
+ return [];
2210
+ }
2211
+ return value.flatMap(item => (typeof item === "string" ? [item.toLowerCase()] : []));
2212
+ }
2213
+
2214
+ function getLmStudioNativeInput(entry: Record<string, unknown>): ("text" | "image")[] {
2215
+ const modelType = typeof entry.type === "string" ? entry.type.toLowerCase() : "";
2216
+ const capabilities = getLmStudioCapabilityNames(entry.capabilities);
2217
+ const supportsImage = modelType === "vlm" || capabilities.includes("vision") || capabilities.includes("image");
2218
+ return supportsImage ? ["text", "image"] : ["text"];
2219
+ }
2220
+
2221
+ function getLmStudioNativeContextWindow(entry: Record<string, unknown>): number | undefined {
2222
+ return (
2223
+ toPositiveNumber(entry.max_context_length, null) ??
2224
+ toPositiveNumber(entry.context_length, null) ??
2225
+ toPositiveNumber(entry.max_model_len, null) ??
2226
+ undefined
2227
+ );
2228
+ }
2229
+
2230
+ /** Fetches LM Studio native model metadata used to mark VLM models as image-capable. */
2231
+ export async function fetchLmStudioNativeModelMetadata(
2232
+ baseUrl: string,
2233
+ fetchImpl: FetchImpl = fetch,
2234
+ options?: LmStudioNativeModelMetadataOptions,
2235
+ ): Promise<Map<string, LmStudioNativeModelMetadata> | null> {
2236
+ const nativeBaseUrl = toLmStudioNativeBaseUrl(baseUrl);
2237
+ try {
2238
+ const response = await fetchImpl(`${nativeBaseUrl}/api/v0/models`, {
2239
+ method: "GET",
2240
+ headers: { Accept: "application/json", ...(options?.headers ?? {}) },
2241
+ signal: options?.signal ?? AbortSignal.timeout(LM_STUDIO_NATIVE_METADATA_TIMEOUT_MS),
2242
+ });
2243
+ if (!response.ok) {
2244
+ return null;
2245
+ }
2246
+ const payload = await response.json();
2247
+ if (!isRecord(payload) || !Array.isArray(payload.data)) {
2248
+ return null;
2249
+ }
2250
+ const metadata = new Map<string, LmStudioNativeModelMetadata>();
2251
+ for (const entry of payload.data) {
2252
+ if (!isRecord(entry) || typeof entry.id !== "string" || entry.id.length === 0) {
2253
+ continue;
2254
+ }
2255
+ const contextWindow = getLmStudioNativeContextWindow(entry);
2256
+ metadata.set(entry.id, {
2257
+ input: getLmStudioNativeInput(entry),
2258
+ ...(contextWindow === undefined ? {} : { contextWindow }),
2259
+ });
2260
+ }
2261
+ return metadata;
2262
+ } catch {
2263
+ return null;
2264
+ }
2265
+ }
2266
+
2187
2267
  export interface LmStudioModelManagerConfig {
2188
2268
  apiKey?: string;
2189
2269
  baseUrl?: string;
@@ -2198,8 +2278,11 @@ export function lmStudioModelManagerOptions(
2198
2278
  const references = createBundledReferenceMap<"openai-completions">("lm-studio" as any);
2199
2279
  return {
2200
2280
  providerId: "lm-studio",
2201
- fetchDynamicModels: () =>
2202
- fetchOpenAICompatibleModels({
2281
+ fetchDynamicModels: async () => {
2282
+ const nativeMetadataPromise = fetchLmStudioNativeModelMetadata(baseUrl, config?.fetch, {
2283
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined,
2284
+ });
2285
+ const models = await fetchOpenAICompatibleModels({
2203
2286
  api: "openai-completions",
2204
2287
  provider: "lm-studio",
2205
2288
  baseUrl,
@@ -2209,7 +2292,26 @@ export function lmStudioModelManagerOptions(
2209
2292
  return mapWithBundledReference(entry, defaults, reference);
2210
2293
  },
2211
2294
  fetch: config?.fetch,
2212
- }),
2295
+ });
2296
+ if (!models) {
2297
+ return models;
2298
+ }
2299
+ const nativeMetadata = await nativeMetadataPromise;
2300
+ if (!nativeMetadata) {
2301
+ return models;
2302
+ }
2303
+ return models.map(model => {
2304
+ const metadata = nativeMetadata.get(model.id);
2305
+ if (!metadata) {
2306
+ return model;
2307
+ }
2308
+ return {
2309
+ ...model,
2310
+ input: metadata.input,
2311
+ contextWindow: metadata.contextWindow ?? model.contextWindow,
2312
+ };
2313
+ });
2314
+ },
2213
2315
  };
2214
2316
  }
2215
2317
 
package/src/types.ts CHANGED
@@ -283,6 +283,8 @@ export interface OpenAICompat {
283
283
  alwaysSendMaxTokens?: boolean;
284
284
  /** Whether Responses-API tool-call/result history must be strictly paired. Default: auto-detected (Azure OpenAI, GitHub Copilot). */
285
285
  strictResponsesPairing?: boolean;
286
+ /** Whether the Responses API accepts the `detail: "original"` image hint. Default: auto-detected (false for GitHub Copilot, which rejects it with a 400). */
287
+ supportsImageDetailOriginal?: boolean;
286
288
  /**
287
289
  * Append a trailing `# Juice: 0 !important` developer item when the caller
288
290
  * did not request reasoning, suppressing default reasoning on models that
@@ -504,6 +506,7 @@ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat &
504
506
  | "cacheControlFormat"
505
507
  | "thinkingKeep"
506
508
  | "strictResponsesPairing"
509
+ | "supportsImageDetailOriginal"
507
510
  | "requiresJuiceZeroHack"
508
511
  | "enableGeminiThinkingLoopGuard"
509
512
  | "whenThinking"
@@ -527,6 +530,7 @@ export type ResolvedOpenAICompat = ResolvedOpenAISharedCompat &
527
530
  export interface ResolvedOpenAIResponsesCompat extends ResolvedOpenAISharedCompat {
528
531
  supportsLongPromptCacheRetention: boolean;
529
532
  strictResponsesPairing: boolean;
533
+ supportsImageDetailOriginal: boolean;
530
534
  requiresJuiceZeroHack: boolean;
531
535
  supportsObfuscationOptOut: boolean;
532
536
  }