@heventure/model-provider-x 0.2.4 → 0.2.5-beta.0

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/README.md CHANGED
@@ -135,6 +135,51 @@ node dist/cli/index.js setup --target codex --provider lmstudio --proxy
135
135
  node dist/cli/index.js setup --target opencode --provider lmstudio --direct
136
136
  ```
137
137
 
138
+ ## Model Metadata Registry
139
+
140
+ Some OpenAI-compatible providers only return minimal `/v1/models` entries with `id`, `object`, and `owned_by`.
141
+ For those providers, `model-provider-x` can enrich model capabilities from registries.
142
+
143
+ The merge order is:
144
+
145
+ 1. User overrides such as `--modalities`.
146
+ 2. Provider or native runtime metadata.
147
+ 3. Project-local registry overrides.
148
+ 4. Built-in models.dev-shaped metadata.
149
+ 5. Conservative model-name heuristics.
150
+
151
+ By default, the CLI reads `model-provider-x.models.jsonc` from the current working directory when it exists.
152
+ You can also pass one or more registry files explicitly:
153
+
154
+ ```bash
155
+ node dist/cli/index.js setup \
156
+ --provider custom \
157
+ --model-registry ./model-provider-x.models.jsonc
158
+ ```
159
+
160
+ Example registry:
161
+
162
+ ```jsonc
163
+ {
164
+ "providers": {
165
+ "mimo-pool": {
166
+ "models": {
167
+ "mimo-v2.5-tts": {
168
+ "type": "tts",
169
+ "modalities": {
170
+ "input": ["text"],
171
+ "output": ["audio"]
172
+ },
173
+ "limit": {
174
+ "context": 8192
175
+ }
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ ```
182
+
138
183
  ## Commands
139
184
 
140
185
  ```bash
package/dist/cli/args.js CHANGED
@@ -29,6 +29,12 @@ export function parseCliArgs(argv) {
29
29
  .map((model) => model.trim())
30
30
  .filter(Boolean);
31
31
  break;
32
+ case "--model-registry":
33
+ if (!options.modelRegistryPaths) {
34
+ options.modelRegistryPaths = [];
35
+ }
36
+ options.modelRegistryPaths.push(next());
37
+ break;
32
38
  case "--modalities":
33
39
  if (!options.modalities) {
34
40
  options.modalities = [];
@@ -113,6 +119,8 @@ Options:
113
119
  --proxy Write agent config through the local compatibility proxy.
114
120
  --direct Write agent config directly to the upstream provider.
115
121
  --models <list> Comma-separated model ids. Skips interactive model selection.
122
+ --model-registry <path>
123
+ JSONC model metadata override. Can be repeated.
116
124
  --modalities <spec> Model modalities in format <model>:<input>:<output>.
117
125
  Example: --modalities qwen-vl:image,text:text
118
126
  --config <path> OpenCode config path to write when targeting OpenCode.
package/dist/cli/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { createInterface } from "node:readline/promises";
3
+ import { existsSync } from "node:fs";
3
4
  import { stdin as input, stdout as output } from "node:process";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import { readProxyStatus, startProxyProcess, stopProxyProcess } from "../core/proxy-process.js";
6
7
  import { createProxyAuthToken, getDefaultToolConfigPath, readToolConfig, upsertProviderProfile, writeToolConfig } from "../core/tool-config.js";
7
8
  import { discoverOpenCodeConfigs, getDefaultConfigPath, writeProviderToConfig } from "../core/config.js";
8
9
  import { buildProviderConfig, detectProviderCapabilities, recommendProxyMode, validateAndFetchModels } from "../core/provider.js";
10
+ import { DEFAULT_MODEL_REGISTRY_FILE } from "../core/model-registry.js";
9
11
  import { isKnownModel } from "../core/model-capabilities.js";
10
12
  import { startProxyServer } from "../proxy/server.js";
11
13
  import { defaultClaudeModelMapping, getDefaultClaudeSettingsPath, writeClaudeCodeSettings } from "../targets/claude-code.js";
@@ -72,12 +74,17 @@ export async function runCli(options) {
72
74
  const baseURL = await requiredOption(rl, options.baseURL ?? providerDefaults.baseURL, "API base URL");
73
75
  const apiKey = await resolveApiKey(rl, options.apiKey, providerDefaults.preset);
74
76
  output.write("Fetching models...\n");
75
- const fetched = await validateAndFetchModels({ baseURL, apiKey });
77
+ const fetched = await validateAndFetchModels({
78
+ baseURL,
79
+ apiKey,
80
+ providerId,
81
+ modelRegistryPaths: resolveModelRegistryPaths(options)
82
+ });
76
83
  const selectedModels = options.models ??
77
84
  (canUseTui()
78
- ? await multiSelectChoices("Select models", createModelChoices(fetched.models))
85
+ ? await multiSelectChoices("Select models", createModelChoices(fetched.modelDetails))
79
86
  : parseModelSelection(await rl.question(formatModelPrompt(fetched.models)), fetched.models));
80
- const modelDetails = fetched.modelDetails.filter((m) => selectedModels.includes(m.id));
87
+ const modelDetails = applyModelModalityOverrides(fetched.modelDetails.filter((m) => selectedModels.includes(m.id)), options);
81
88
  const fragment = buildProviderConfig({
82
89
  providerId,
83
90
  providerName,
@@ -145,10 +152,15 @@ async function collectProviderInput(rl, command) {
145
152
  }
146
153
  async function collectProviderSelection(rl, command, providerInput) {
147
154
  output.write("Fetching models...\n");
148
- const fetched = await validateAndFetchModels({ baseURL: providerInput.baseURL, apiKey: providerInput.apiKey });
155
+ const fetched = await validateAndFetchModels({
156
+ baseURL: providerInput.baseURL,
157
+ apiKey: providerInput.apiKey,
158
+ providerId: providerInput.providerId,
159
+ modelRegistryPaths: resolveModelRegistryPaths(command.options)
160
+ });
149
161
  const selectedModels = command.options.models ??
150
162
  (canUseTui()
151
- ? await multiSelectChoices("Select models", createModelChoices(fetched.models))
163
+ ? await multiSelectChoices("Select models", createModelChoices(fetched.modelDetails))
152
164
  : parseModelSelection(await rl.question(formatModelPrompt(fetched.models)), fetched.models));
153
165
  const defaultModel = await resolveDefaultModel(rl, command, selectedModels);
154
166
  if (!defaultModel) {
@@ -372,6 +384,30 @@ function tokenHint(token) {
372
384
  }
373
385
  return `${token.slice(0, 8)}...${token.slice(-4)}`;
374
386
  }
387
+ function resolveModelRegistryPaths(options) {
388
+ const paths = [...(options.modelRegistryPaths ?? [])];
389
+ if (existsSync(DEFAULT_MODEL_REGISTRY_FILE) && !paths.includes(DEFAULT_MODEL_REGISTRY_FILE)) {
390
+ paths.push(DEFAULT_MODEL_REGISTRY_FILE);
391
+ }
392
+ return paths;
393
+ }
394
+ function applyModelModalityOverrides(modelDetails, options) {
395
+ if (!options.modalities?.length) {
396
+ return modelDetails;
397
+ }
398
+ const overrides = new Map(options.modalities.map((override) => [override.modelId, override.modalities]));
399
+ return modelDetails.map((model) => {
400
+ const modalities = overrides.get(model.id);
401
+ if (!modalities) {
402
+ return model;
403
+ }
404
+ return {
405
+ ...model,
406
+ modalities,
407
+ metadataSources: [...new Set([...(model.metadataSources ?? []), "user"])]
408
+ };
409
+ });
410
+ }
375
411
  async function requiredOption(rl, value, label) {
376
412
  const answer = value ?? (await rl.question(`${label}: `));
377
413
  if (!answer.trim()) {
@@ -671,14 +707,14 @@ async function resolveModelModalities(rl, command, selectedModels, fetchedModelD
671
707
  async function promptModelModalities(rl, modelId) {
672
708
  output.write(`\nModel: ${modelId} - Capabilities unknown\n`);
673
709
  if (canUseTui()) {
674
- const inputModalities = await multiSelectChoices("Input modalities", [
710
+ const inputModalities = await multiSelectChoices(`Input modalities: ${modelId}`, [
675
711
  { label: "text", value: "text", hint: "default" },
676
712
  { label: "image", value: "image" },
677
713
  { label: "audio", value: "audio" },
678
714
  { label: "video", value: "video" },
679
715
  { label: "pdf", value: "pdf" },
680
716
  ]);
681
- const outputModalities = await multiSelectChoices("Output modalities", [
717
+ const outputModalities = await multiSelectChoices(`Output modalities: ${modelId}`, [
682
718
  { label: "text", value: "text", hint: "default" },
683
719
  { label: "image", value: "image" },
684
720
  { label: "audio", value: "audio" },
@@ -1,17 +1,49 @@
1
1
  const likelyUnsupportedModelName = /\b(?:embed|embedding|embeddings|bge|e5|nomic-embed)\b/i;
2
2
  export function createModelChoices(models) {
3
3
  return models.map((model) => {
4
- if (!isLikelyUnsupportedModelName(model)) {
5
- return { label: model, value: model, selected: true };
4
+ const info = typeof model === "string" ? { id: model } : model;
5
+ const unsupported = isLikelyUnsupportedModel(info);
6
+ const hint = modelHint(info, unsupported);
7
+ if (!unsupported) {
8
+ return modelChoice(info.id, hint, true);
6
9
  }
7
- return {
8
- label: model,
9
- value: model,
10
- hint: "suspected unsupported model",
11
- selected: false
12
- };
10
+ return modelChoice(info.id, hint, false);
13
11
  });
14
12
  }
15
13
  export function isLikelyUnsupportedModelName(model) {
16
14
  return likelyUnsupportedModelName.test(model);
17
15
  }
16
+ function isLikelyUnsupportedModel(model) {
17
+ return model.type === "embedding" || isLikelyUnsupportedModelName(model.id);
18
+ }
19
+ function modelHint(model, unsupported) {
20
+ const hints = [
21
+ model.type,
22
+ model.architecture,
23
+ model.quantization,
24
+ model.contextLength ? `${formatContextLength(model.contextLength)} ctx` : undefined,
25
+ model.modalities?.input?.includes("image") ? "vision" : undefined,
26
+ model.capabilities?.toolCall ? "tools" : undefined,
27
+ model.capabilities?.reasoning ? "reasoning" : undefined,
28
+ model.state && model.state !== "loaded" ? model.state : undefined,
29
+ unsupported ? "suspected unsupported model" : undefined
30
+ ].filter((hint) => Boolean(hint));
31
+ return hints.length ? hints.join(", ") : undefined;
32
+ }
33
+ function formatContextLength(value) {
34
+ if (value >= 1000 && value % 1000 === 0) {
35
+ return `${value / 1000}k`;
36
+ }
37
+ if (value >= 1024 && value % 1024 === 0) {
38
+ return `${value / 1024}k`;
39
+ }
40
+ return String(value);
41
+ }
42
+ function modelChoice(modelId, hint, selected) {
43
+ return {
44
+ label: modelId,
45
+ value: modelId,
46
+ ...(hint ? { hint } : {}),
47
+ selected
48
+ };
49
+ }
@@ -113,12 +113,15 @@ export function parseCapabilitiesFromApi(model) {
113
113
  if (model.capabilities && typeof model.capabilities === "object") {
114
114
  const caps = model.capabilities;
115
115
  const input = ["text"];
116
- if (caps.vision || caps.image || caps.multimodal) {
116
+ if (caps.vision || caps.image || caps.multimodal || caps.image_input) {
117
117
  input.push("image");
118
118
  }
119
119
  if (caps.audio || caps.speech) {
120
120
  input.push("audio");
121
121
  }
122
+ if (caps.video) {
123
+ input.push("video");
124
+ }
122
125
  if (input.length > 1) {
123
126
  return { input: input, output: ["text"] };
124
127
  }
@@ -0,0 +1,191 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { parse } from "jsonc-parser";
3
+ export const DEFAULT_MODEL_REGISTRY_FILE = "model-provider-x.models.jsonc";
4
+ const BUILT_IN_MODELS_DEV_REGISTRY = {
5
+ source: "models-dev",
6
+ providers: {
7
+ openai: {
8
+ models: {
9
+ "gpt-oss-20b": {
10
+ type: "llm",
11
+ contextLength: 131072,
12
+ capabilities: {
13
+ reasoning: true,
14
+ toolCall: true
15
+ }
16
+ }
17
+ }
18
+ }
19
+ }
20
+ };
21
+ export async function loadModelRegistryFile(path) {
22
+ const text = await readFile(path, "utf8");
23
+ const value = parse(text);
24
+ if (!isObject(value)) {
25
+ throw new Error(`Model registry must be an object: ${path}`);
26
+ }
27
+ return normalizeRegistry(value, "local-registry");
28
+ }
29
+ export async function resolveModelRegistryMetadata(input) {
30
+ const registries = [
31
+ ...(input.includeBuiltIn === false ? [] : [BUILT_IN_MODELS_DEV_REGISTRY]),
32
+ ...(input.registries ?? [])
33
+ ];
34
+ let result;
35
+ for (const registry of registries) {
36
+ const model = lookupRegistryModel(registry, input.providerId, input.modelId);
37
+ if (!model) {
38
+ continue;
39
+ }
40
+ const info = normalizeRegistryModel(input.modelId, model, registry.source);
41
+ result = result ? mergeRegistryInfo(result, info) : info;
42
+ }
43
+ return result;
44
+ }
45
+ function normalizeRegistry(value, fallbackSource) {
46
+ return {
47
+ source: stringValue(value.source) ?? fallbackSource,
48
+ providers: normalizeProviders(value.providers),
49
+ models: normalizeModels(value.models)
50
+ };
51
+ }
52
+ function lookupRegistryModel(registry, providerId, modelId) {
53
+ const aliases = modelAliases(modelId);
54
+ if (providerId) {
55
+ const providerModels = registry.providers?.[providerId]?.models;
56
+ const match = lookupModel(providerModels, aliases);
57
+ if (match) {
58
+ return match;
59
+ }
60
+ const modelsDevProvider = registry[providerId];
61
+ if (isObject(modelsDevProvider)) {
62
+ const provider = normalizeProviders({ [providerId]: modelsDevProvider })?.[providerId];
63
+ const providerMatch = lookupModel(provider?.models, aliases);
64
+ if (providerMatch) {
65
+ return providerMatch;
66
+ }
67
+ }
68
+ }
69
+ return lookupModel(registry.models, aliases);
70
+ }
71
+ function lookupModel(models, aliases) {
72
+ if (!models) {
73
+ return undefined;
74
+ }
75
+ for (const alias of aliases) {
76
+ if (models[alias]) {
77
+ return models[alias];
78
+ }
79
+ }
80
+ return undefined;
81
+ }
82
+ function normalizeRegistryModel(modelId, model, source) {
83
+ const modalities = normalizeModalities(model);
84
+ const capabilities = model.capabilities;
85
+ const toolCall = booleanValue(capabilities?.toolCall ?? capabilities?.tool_call ?? model.tool_call);
86
+ const reasoning = booleanValue(capabilities?.reasoning ?? model.reasoning);
87
+ return cleanModelInfo({
88
+ id: model.id ?? modelId,
89
+ type: model.type,
90
+ architecture: model.architecture,
91
+ quantization: model.quantization,
92
+ parameterSize: model.parameterSize,
93
+ state: model.state,
94
+ contextLength: model.contextLength ?? model.limit?.context,
95
+ modalities,
96
+ capabilities: toolCall || reasoning ? { toolCall, reasoning } : undefined,
97
+ metadataSources: [source]
98
+ });
99
+ }
100
+ function normalizeModalities(model) {
101
+ if (model.modalities) {
102
+ return model.modalities;
103
+ }
104
+ if (model.input_modalities || model.output_modalities) {
105
+ return {
106
+ ...(model.input_modalities ? { input: model.input_modalities } : {}),
107
+ ...(model.output_modalities ? { output: model.output_modalities } : {})
108
+ };
109
+ }
110
+ return undefined;
111
+ }
112
+ function mergeRegistryInfo(base, next) {
113
+ return cleanModelInfo({
114
+ ...base,
115
+ ...next,
116
+ id: base.id || next.id,
117
+ type: next.type ?? base.type,
118
+ architecture: next.architecture ?? base.architecture,
119
+ quantization: next.quantization ?? base.quantization,
120
+ parameterSize: next.parameterSize ?? base.parameterSize,
121
+ state: next.state ?? base.state,
122
+ contextLength: next.contextLength ?? base.contextLength,
123
+ modalities: next.modalities ?? base.modalities,
124
+ capabilities: base.capabilities || next.capabilities
125
+ ? {
126
+ ...base.capabilities,
127
+ ...next.capabilities
128
+ }
129
+ : undefined,
130
+ metadataSources: [...new Set([...(base.metadataSources ?? []), ...(next.metadataSources ?? [])])]
131
+ });
132
+ }
133
+ function normalizeProviders(value) {
134
+ if (!isObject(value)) {
135
+ return undefined;
136
+ }
137
+ const providers = {};
138
+ for (const [providerId, providerValue] of Object.entries(value)) {
139
+ if (!isObject(providerValue)) {
140
+ continue;
141
+ }
142
+ const models = normalizeModels(providerValue.models);
143
+ if (models) {
144
+ providers[providerId] = { models };
145
+ }
146
+ }
147
+ return Object.keys(providers).length ? providers : undefined;
148
+ }
149
+ function normalizeModels(value) {
150
+ if (!isObject(value)) {
151
+ return undefined;
152
+ }
153
+ const models = {};
154
+ for (const [modelId, modelValue] of Object.entries(value)) {
155
+ if (isObject(modelValue)) {
156
+ models[modelId] = modelValue;
157
+ }
158
+ }
159
+ return Object.keys(models).length ? models : undefined;
160
+ }
161
+ function modelAliases(modelId) {
162
+ const normalized = modelId.trim();
163
+ const withoutPrefix = normalized.includes("/") ? normalized.split("/").pop() : normalized;
164
+ return [...new Set([normalized, withoutPrefix])];
165
+ }
166
+ function cleanModelInfo(model) {
167
+ const capabilities = model.capabilities?.toolCall || model.capabilities?.reasoning
168
+ ? model.capabilities
169
+ : undefined;
170
+ return {
171
+ id: model.id,
172
+ ...(model.type ? { type: model.type } : {}),
173
+ ...(model.architecture ? { architecture: model.architecture } : {}),
174
+ ...(model.quantization ? { quantization: model.quantization } : {}),
175
+ ...(model.parameterSize ? { parameterSize: model.parameterSize } : {}),
176
+ ...(model.state ? { state: model.state } : {}),
177
+ ...(model.contextLength ? { contextLength: model.contextLength } : {}),
178
+ ...(model.modalities ? { modalities: model.modalities } : {}),
179
+ ...(capabilities ? { capabilities } : {}),
180
+ ...(model.metadataSources?.length ? { metadataSources: model.metadataSources } : {})
181
+ };
182
+ }
183
+ function isObject(value) {
184
+ return typeof value === "object" && value !== null;
185
+ }
186
+ function stringValue(value) {
187
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
188
+ }
189
+ function booleanValue(value) {
190
+ return typeof value === "boolean" ? value : undefined;
191
+ }
@@ -1,4 +1,5 @@
1
1
  import { parseCapabilitiesFromApi, mergeModelCapabilities } from "./model-capabilities.js";
2
+ import { loadModelRegistryFile, resolveModelRegistryMetadata } from "./model-registry.js";
2
3
  export function normalizeBaseUrl(baseURL) {
3
4
  const normalized = baseURL.trim().replace(/\/+$/, "");
4
5
  if (!normalized) {
@@ -12,7 +13,7 @@ export function normalizeBaseUrl(baseURL) {
12
13
  }
13
14
  return normalized;
14
15
  }
15
- export async function validateAndFetchModels(input, fetchImpl = globalThis.fetch) {
16
+ export async function validateAndFetchModels(input, fetchImpl = globalThis.fetch, lmStudioSdkLoader = loadLmStudioSdkModels) {
16
17
  const baseURL = normalizeBaseUrl(input.baseURL);
17
18
  const apiKey = input.apiKey?.trim() ?? "";
18
19
  const headers = {};
@@ -27,26 +28,35 @@ export async function validateAndFetchModels(input, fetchImpl = globalThis.fetch
27
28
  if (!isModelListResponse(body)) {
28
29
  throw new Error("Expected /models to return an object with a data array");
29
30
  }
30
- const compatibleModels = body.data.filter(isOpenCodeCompatibleModel);
31
- const models = [
32
- ...new Set(compatibleModels
33
- .map((model) => model.id.trim())
34
- .filter(Boolean))
35
- ];
31
+ const sourceModels = body.data.map((model) => normalizeModelInfo(model, "openai-models"));
32
+ const nativeModels = await fetchNativeRestModelDetails(baseURL, headers, fetchImpl);
33
+ const sdkModels = nativeModels.length > 0 && (lmStudioSdkLoader !== loadLmStudioSdkModels || fetchImpl === globalThis.fetch)
34
+ ? await safeLoadLmStudioSdkModels(lmStudioSdkLoader, baseURL)
35
+ : [];
36
+ const registries = await loadRegistries(input);
37
+ const registryModels = await Promise.all(sourceModels.map((model) => resolveModelRegistryMetadata({
38
+ providerId: input.providerId,
39
+ modelId: model.id,
40
+ registries
41
+ })));
42
+ const modelDetailsById = mergeModelDetails([...sourceModels, ...registryModels.filter(isModelInfo), ...nativeModels, ...sdkModels]);
43
+ const compatibleModels = sourceModels
44
+ .map((model) => mergeModelInfo(model, modelDetailsForId(model.id, modelDetailsById)))
45
+ .filter(isOpenCodeCompatibleModel);
46
+ const models = [...new Set(compatibleModels.map((model) => model.id.trim()).filter(Boolean))];
36
47
  if (models.length === 0) {
37
48
  throw new Error("Provider returned no OpenCode-compatible model ids");
38
49
  }
39
- const modelDetails = models.map((modelId) => {
40
- const rawModel = compatibleModels.find((m) => m.id.trim() === modelId);
41
- const apiCapabilities = rawModel ? parseCapabilitiesFromApi(rawModel) : undefined;
42
- const mergedCapabilities = mergeModelCapabilities(modelId, apiCapabilities);
43
- return {
44
- id: modelId,
45
- modalities: mergedCapabilities
46
- };
47
- });
50
+ const modelDetails = models.map((modelId) => withMergedModalities(modelDetailsForId(modelId, modelDetailsById)));
48
51
  return { baseURL, models, modelDetails };
49
52
  }
53
+ async function loadRegistries(input) {
54
+ const loaded = await Promise.all((input.modelRegistryPaths ?? []).map((path) => loadModelRegistryFile(path)));
55
+ return [...(input.registries ?? []), ...loaded];
56
+ }
57
+ function isModelInfo(value) {
58
+ return Boolean(value);
59
+ }
50
60
  export async function detectProviderCapabilities(input, fetchImpl = globalThis.fetch) {
51
61
  const baseURL = normalizeBaseUrl(input.baseURL);
52
62
  const apiKey = input.apiKey?.trim() ?? "";
@@ -111,6 +121,15 @@ function buildModelConfig(modelId, modelInfo) {
111
121
  if (modelInfo?.modalities) {
112
122
  config.modalities = modelInfo.modalities;
113
123
  }
124
+ if (modelInfo?.capabilities?.reasoning) {
125
+ config.reasoning = true;
126
+ }
127
+ if (modelInfo?.capabilities?.toolCall) {
128
+ config.tool_call = true;
129
+ }
130
+ if (modelInfo?.contextLength) {
131
+ config.limit = { context: modelInfo.contextLength };
132
+ }
114
133
  return config;
115
134
  }
116
135
  export function npmPackageForOpenCodeApiType(apiType) {
@@ -132,7 +151,249 @@ function isOpenCodeCompatibleModel(model) {
132
151
  if (typeof model.type !== "string") {
133
152
  return true;
134
153
  }
135
- return ["llm", "chat", "completion", "text-generation"].includes(model.type.trim().toLowerCase());
154
+ return !["embedding", "embed", "rerank", "reranker"].includes(model.type.trim().toLowerCase());
155
+ }
156
+ async function fetchNativeRestModelDetails(baseURL, headers, fetchImpl) {
157
+ if (!isLikelyLmStudioBaseUrl(baseURL)) {
158
+ return [];
159
+ }
160
+ for (const endpoint of nativeLmStudioModelEndpoints(baseURL)) {
161
+ try {
162
+ const response = await fetchImpl(endpoint, { headers });
163
+ if (!response.ok) {
164
+ continue;
165
+ }
166
+ const body = await response.json();
167
+ const rawModels = modelListItems(body);
168
+ if (!rawModels) {
169
+ continue;
170
+ }
171
+ return rawModels.map((model) => normalizeModelInfo(model, "lmstudio-rest"));
172
+ }
173
+ catch {
174
+ continue;
175
+ }
176
+ }
177
+ return [];
178
+ }
179
+ function nativeLmStudioModelEndpoints(baseURL) {
180
+ const url = new URL(baseURL);
181
+ url.pathname = "/api/v1/models";
182
+ url.search = "";
183
+ url.hash = "";
184
+ const v1 = url.toString();
185
+ url.pathname = "/api/v0/models";
186
+ const v0 = url.toString();
187
+ return [v1, v0];
188
+ }
189
+ function isLikelyLmStudioBaseUrl(baseURL) {
190
+ const url = new URL(baseURL);
191
+ return ["localhost", "127.0.0.1", "::1"].includes(url.hostname) && url.port === "1234";
192
+ }
193
+ function normalizeModelInfo(raw, source) {
194
+ const id = String(raw.id ?? raw.key ?? raw.model ?? "").trim();
195
+ const capabilitiesObject = objectValue(raw.capabilities);
196
+ const modalities = parseCapabilitiesFromApi(raw);
197
+ const type = stringValue(raw.type);
198
+ const contextLength = numberValue(raw.max_context_length ?? raw.context_length ?? raw.contextLength);
199
+ const toolCall = capabilityFlag(raw.tool_call ??
200
+ raw.tool_calls ??
201
+ raw.toolCall ??
202
+ raw.supports_tool_calls ??
203
+ raw.supportsToolCalls ??
204
+ capabilitiesObject?.toolUse ??
205
+ capabilitiesObject?.tool_use ??
206
+ capabilitiesObject?.toolCall ??
207
+ capabilitiesObject?.tool_call ??
208
+ capabilitiesObject?.trainedForToolUse ??
209
+ capabilitiesObject?.trained_for_tool_use);
210
+ const reasoning = capabilityFlag(raw.reasoning ?? raw.supports_reasoning ?? capabilitiesObject?.reasoning);
211
+ return cleanModelInfo({
212
+ id,
213
+ type,
214
+ architecture: stringValue(raw.arch ?? raw.architecture),
215
+ quantization: stringValue(raw.quantization) ?? stringValue(objectValue(raw.quantization)?.name),
216
+ parameterSize: stringValue(raw.params_string ?? raw.paramsString ?? raw.parameterSize),
217
+ state: stringValue(raw.state) ?? (Array.isArray(raw.loaded_instances) && raw.loaded_instances.length > 0 ? "loaded" : undefined),
218
+ contextLength,
219
+ modalities,
220
+ capabilities: toolCall || reasoning ? { toolCall, reasoning } : undefined,
221
+ metadataSources: [source]
222
+ });
223
+ }
224
+ function mergeModelDetails(models) {
225
+ const result = new Map();
226
+ for (const model of models) {
227
+ if (!model.id) {
228
+ continue;
229
+ }
230
+ for (const alias of modelAliases(model.id)) {
231
+ const current = result.get(alias);
232
+ result.set(alias, current ? mergeModelInfo(current, model) : model);
233
+ }
234
+ }
235
+ return result;
236
+ }
237
+ function modelDetailsForId(modelId, models) {
238
+ return modelAliases(modelId).reduce((merged, alias) => {
239
+ const next = models.get(alias);
240
+ return next ? mergeModelInfo(merged, next) : merged;
241
+ }, { id: modelId });
242
+ }
243
+ function mergeModelInfo(base, next) {
244
+ return cleanModelInfo({
245
+ ...base,
246
+ ...next,
247
+ id: base.id || next.id,
248
+ type: next.type ?? base.type,
249
+ architecture: next.architecture ?? base.architecture,
250
+ quantization: next.quantization ?? base.quantization,
251
+ parameterSize: next.parameterSize ?? base.parameterSize,
252
+ state: next.state ?? base.state,
253
+ contextLength: next.contextLength ?? base.contextLength,
254
+ modalities: next.modalities ?? base.modalities,
255
+ capabilities: base.capabilities || next.capabilities
256
+ ? {
257
+ ...base.capabilities,
258
+ ...next.capabilities
259
+ }
260
+ : undefined,
261
+ metadataSources: [...new Set([...(base.metadataSources ?? []), ...(next.metadataSources ?? [])])]
262
+ });
263
+ }
264
+ function withMergedModalities(model) {
265
+ const modalities = mergeModelCapabilities(model.id, model.modalities);
266
+ return cleanModelInfo({
267
+ ...model,
268
+ modalities,
269
+ metadataSources: modalities
270
+ ? [...new Set([...(model.metadataSources ?? []), model.modalities ? undefined : "heuristics"].filter((v) => Boolean(v)))]
271
+ : model.metadataSources
272
+ });
273
+ }
274
+ function modelAliases(modelId) {
275
+ const normalized = modelId.trim();
276
+ const withoutPrefix = normalized.includes("/") ? normalized.split("/").pop() : normalized;
277
+ return [...new Set([normalized, withoutPrefix])];
278
+ }
279
+ function cleanModelInfo(model) {
280
+ const capabilities = model.capabilities?.toolCall || model.capabilities?.reasoning
281
+ ? model.capabilities
282
+ : undefined;
283
+ return {
284
+ id: model.id,
285
+ ...(model.type ? { type: model.type } : {}),
286
+ ...(model.architecture ? { architecture: model.architecture } : {}),
287
+ ...(model.quantization ? { quantization: model.quantization } : {}),
288
+ ...(model.parameterSize ? { parameterSize: model.parameterSize } : {}),
289
+ ...(model.state ? { state: model.state } : {}),
290
+ ...(model.contextLength ? { contextLength: model.contextLength } : {}),
291
+ ...(model.modalities ? { modalities: model.modalities } : {}),
292
+ ...(capabilities ? { capabilities } : {}),
293
+ ...(model.metadataSources?.length ? { metadataSources: model.metadataSources } : {})
294
+ };
295
+ }
296
+ function objectValue(value) {
297
+ return typeof value === "object" && value !== null ? value : undefined;
298
+ }
299
+ function stringValue(value) {
300
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
301
+ }
302
+ function numberValue(value) {
303
+ const number = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
304
+ return Number.isFinite(number) && number > 0 ? number : undefined;
305
+ }
306
+ function booleanValue(value) {
307
+ if (typeof value === "boolean") {
308
+ return value;
309
+ }
310
+ if (typeof value === "string") {
311
+ if (["true", "yes", "1"].includes(value.toLowerCase())) {
312
+ return true;
313
+ }
314
+ if (["false", "no", "0"].includes(value.toLowerCase())) {
315
+ return false;
316
+ }
317
+ }
318
+ return undefined;
319
+ }
320
+ function capabilityFlag(value) {
321
+ if (typeof value === "object" && value !== null) {
322
+ return true;
323
+ }
324
+ return booleanValue(value);
325
+ }
326
+ function modelListItems(body) {
327
+ if (typeof body !== "object" || body === null) {
328
+ return undefined;
329
+ }
330
+ const data = body.data;
331
+ const models = body.models;
332
+ const items = Array.isArray(data) ? data : Array.isArray(models) ? models : undefined;
333
+ if (!items) {
334
+ return undefined;
335
+ }
336
+ if (!items.every((model) => typeof model === "object" && model !== null)) {
337
+ return undefined;
338
+ }
339
+ return items;
340
+ }
341
+ async function safeLoadLmStudioSdkModels(loader, baseURL) {
342
+ try {
343
+ return await loader(baseURL);
344
+ }
345
+ catch {
346
+ return [];
347
+ }
348
+ }
349
+ async function loadLmStudioSdkModels(baseURL) {
350
+ const sdk = await import("@lmstudio/sdk");
351
+ const Client = sdk.LMStudioClient;
352
+ if (!Client) {
353
+ return [];
354
+ }
355
+ const wsBaseURL = httpBaseUrlToWs(baseURL);
356
+ const connectedClient = new Client({ baseUrl: wsBaseURL, logger: silentLogger });
357
+ try {
358
+ const models = await connectedClient.system?.listDownloadedModels?.();
359
+ if (!Array.isArray(models)) {
360
+ return [];
361
+ }
362
+ return models.map((model) => {
363
+ const raw = model;
364
+ const id = stringValue(raw.modelKey ?? raw.path ?? raw.id ?? raw.displayName) ?? "";
365
+ const vision = booleanValue(raw.vision);
366
+ const trainedForToolUse = booleanValue(raw.trainedForToolUse ?? raw.trained_for_tool_use);
367
+ return cleanModelInfo({
368
+ id,
369
+ type: stringValue(raw.type) ?? "llm",
370
+ architecture: stringValue(raw.architecture),
371
+ quantization: stringValue(raw.quantization) ?? stringValue(objectValue(raw.quantization)?.name),
372
+ parameterSize: stringValue(raw.paramsString),
373
+ contextLength: numberValue(raw.maxContextLength ?? raw.max_context_length),
374
+ modalities: vision ? { input: ["text", "image"], output: ["text"] } : undefined,
375
+ capabilities: trainedForToolUse ? { toolCall: true } : undefined,
376
+ metadataSources: ["lmstudio-sdk"]
377
+ });
378
+ });
379
+ }
380
+ finally {
381
+ await connectedClient[Symbol.asyncDispose]?.();
382
+ }
383
+ }
384
+ const silentLogger = {
385
+ debug() { },
386
+ info() { },
387
+ warn() { },
388
+ error() { }
389
+ };
390
+ function httpBaseUrlToWs(baseURL) {
391
+ const url = new URL(baseURL);
392
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
393
+ url.pathname = "";
394
+ url.search = "";
395
+ url.hash = "";
396
+ return url.toString().replace(/\/$/, "");
136
397
  }
137
398
  async function probe(input, init, fetchImpl) {
138
399
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@heventure/model-provider-x",
3
- "version": "0.2.4",
3
+ "version": "0.2.5-beta.0",
4
4
  "description": "TUI configurator and local API proxy for wiring custom model providers into OpenCode and Claude Code.",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -46,5 +46,8 @@
46
46
  "typescript": "^5.9.0",
47
47
  "typescript-eslint": "^8.0.0",
48
48
  "vitest": "^3.0.0"
49
+ },
50
+ "optionalDependencies": {
51
+ "@lmstudio/sdk": "^1.5.0"
49
52
  }
50
53
  }