@nvae/llmswitch 0.8.0 → 1.0.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.
@@ -15,6 +15,20 @@ export function envKeyName(profileName) {
15
15
  const key = providerKey(profileName).toUpperCase();
16
16
  return `LLM_SWITCH_${key}_API_KEY`;
17
17
  }
18
+ /**
19
+ * Model-level metadata Codex accepts in config.toml. Only `model_context_window`
20
+ * is a stable model-info key (schema is deny_unknown_fields — never write
21
+ * removed keys like model_max_output_tokens).
22
+ */
23
+ function applyModelInfo(config, profile) {
24
+ const context = profile.models.meta?.[profile.models.default]?.context;
25
+ if (typeof context === "number" && context > 0) {
26
+ config.model_context_window = Math.round(context);
27
+ }
28
+ else {
29
+ delete config.model_context_window;
30
+ }
31
+ }
18
32
  export function readCodexConfig(path = getCodexConfigPath()) {
19
33
  if (!existsSync(path))
20
34
  return {};
@@ -41,12 +55,14 @@ export function buildCodexConfig(existing, profile, effectiveBaseUrl) {
41
55
  }
42
56
  }
43
57
  providers[id] = providerBlock;
44
- return {
58
+ const next = {
45
59
  ...existing,
46
60
  model: profile.models.default,
47
61
  model_provider: id,
48
62
  model_providers: providers,
49
63
  };
64
+ applyModelInfo(next, profile);
65
+ return next;
50
66
  }
51
67
  export function buildCodexEnvFile(existingContent, profile, effectiveApiKey = profile.apiKey || "llm-switch-bridge") {
52
68
  const lines = existingContent ? existingContent.split(/\r?\n/) : [];
@@ -172,11 +188,13 @@ export async function deactivateCodexProfile(profileName) {
172
188
  if (next.model_provider === id) {
173
189
  delete next.model_provider;
174
190
  delete next.model;
191
+ delete next.model_context_window;
175
192
  }
176
193
  }
177
194
  else if (next.model_provider) {
178
195
  delete next.model_provider;
179
196
  delete next.model;
197
+ delete next.model_context_window;
180
198
  }
181
199
  next.model_providers = providers;
182
200
  atomicWriteFile(configPath, stringify(next) + "\n");
@@ -31,13 +31,55 @@ export function readOpenCodeAuth(path = getOpenCodeAuthPath()) {
31
31
  return {};
32
32
  return JSON.parse(readFileSync(path, "utf8"));
33
33
  }
34
+ /**
35
+ * Build one model entry for the OpenCode provider block, filling in every
36
+ * per-model field the OpenCode schema supports when metadata from
37
+ * models.lonae.com is available:
38
+ * { name, family, release_date, limit, cost, modalities, attachment,
39
+ * reasoning, temperature, tool_call }
40
+ */
41
+ function buildOpenCodeModelEntry(id, meta) {
42
+ const entry = { name: meta?.name || id };
43
+ if (meta?.family) {
44
+ entry.family = meta.family;
45
+ }
46
+ if (meta?.releaseDate) {
47
+ entry.release_date = meta.releaseDate;
48
+ }
49
+ if (typeof meta?.context === "number" && typeof meta?.maxOutput === "number") {
50
+ entry.limit = { context: meta.context, output: meta.maxOutput };
51
+ }
52
+ if (meta?.cost) {
53
+ entry.cost = { input: meta.cost.input, output: meta.cost.output };
54
+ }
55
+ if (meta?.modalities) {
56
+ entry.modalities = {
57
+ input: meta.modalities.input?.length ? meta.modalities.input : ["text"],
58
+ output: meta.modalities.output?.length ? meta.modalities.output : ["text"],
59
+ };
60
+ }
61
+ if (typeof meta?.attachment === "boolean") {
62
+ entry.attachment = meta.attachment;
63
+ }
64
+ if (typeof meta?.reasoning === "boolean") {
65
+ entry.reasoning = meta.reasoning;
66
+ }
67
+ if (typeof meta?.temperature === "boolean") {
68
+ entry.temperature = meta.temperature;
69
+ }
70
+ if (typeof meta?.toolCall === "boolean") {
71
+ entry.tool_call = meta.toolCall;
72
+ }
73
+ return entry;
74
+ }
34
75
  export function buildOpenCodeProviderBlock(profile, overrides) {
76
+ const metaById = profile.models.meta || {};
35
77
  const models = {};
36
78
  for (const id of profile.models.list) {
37
- models[id] = { name: id };
79
+ models[id] = buildOpenCodeModelEntry(id, metaById[id]);
38
80
  }
39
81
  if (!models[profile.models.default]) {
40
- models[profile.models.default] = { name: profile.models.default };
82
+ models[profile.models.default] = buildOpenCodeModelEntry(profile.models.default, metaById[profile.models.default]);
41
83
  }
42
84
  const options = {
43
85
  baseURL: overrides?.baseURL ||
@@ -6,6 +6,7 @@ import { getPreset, presetsForTool } from "../presets/index.js";
6
6
  import { detectApiFormat } from "../utils/detect-format.js";
7
7
  import { assertValidProfileName, listProfiles, profileExists, saveProfile, } from "../store/profiles.js";
8
8
  import { fetchModelList, preferResolvedBaseUrl, } from "../utils/fetch-models.js";
9
+ import { collectModelMeta, fetchModelMetadata, lookupModelMeta, } from "../utils/model-metadata.js";
9
10
  import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
10
11
  import { maskSecret } from "../utils/fs.js";
11
12
  export function isCancel(value) {
@@ -391,6 +392,7 @@ export async function promptProfileDraft(tool, partial = {}) {
391
392
  models: {
392
393
  default: defaultModel,
393
394
  list: Array.from(new Set(modelList)),
395
+ meta: resolved.modelMeta,
394
396
  },
395
397
  proxy,
396
398
  bridgeMode: tool === "codex" && apiFormat === "openai-chat"
@@ -475,37 +477,76 @@ export async function promptEditProfile(tool, current) {
475
477
  /**
476
478
  * Fetch models from the provider API (when possible), then let the user
477
479
  * pick a default + a saved list. Falls back to manual text entry.
480
+ * Model metadata (modalities/attachment) is fetched from models.lonae.com
481
+ * while the model list is loading and attached to the final selection.
478
482
  */
479
483
  export async function resolveModelsInteractive(input) {
484
+ // Best-effort: fetch metadata in the background so it overlaps with /models.
485
+ const metaPromise = tryFetchModelMetadata(input);
480
486
  if (input.fixedDefault && input.fixedList?.length) {
481
487
  const list = [...input.fixedList];
482
488
  if (!list.includes(input.fixedDefault))
483
489
  list.unshift(input.fixedDefault);
484
- return { defaultModel: input.fixedDefault, modelList: list };
490
+ return {
491
+ defaultModel: input.fixedDefault,
492
+ modelList: list,
493
+ modelMeta: collectModelMeta(await metaPromise, list),
494
+ };
485
495
  }
486
496
  if (input.fixedDefault && !input.fixedList) {
487
497
  const fetched = await tryFetchModels(input);
488
- if (fetched?.models.length) {
489
- const list = Array.from(new Set([input.fixedDefault, ...fetched.models]));
490
- return {
491
- defaultModel: input.fixedDefault,
492
- modelList: list,
493
- resolvedBaseUrl: fetched.resolvedBaseUrl,
494
- };
495
- }
498
+ const list = fetched?.models.length
499
+ ? Array.from(new Set([input.fixedDefault, ...fetched.models]))
500
+ : [input.fixedDefault];
496
501
  return {
497
502
  defaultModel: input.fixedDefault,
498
- modelList: [input.fixedDefault],
503
+ modelList: list,
504
+ resolvedBaseUrl: fetched?.resolvedBaseUrl,
505
+ modelMeta: collectModelMeta(await metaPromise, list),
499
506
  };
500
507
  }
508
+ const catalog = await metaPromise;
501
509
  const fetched = await tryFetchModels(input);
502
510
  if (fetched && fetched.models.length > 0) {
503
- const selected = await selectModelsFromFetched(fetched.models, input);
511
+ const selected = await selectModelsFromFetched(fetched.models, input, catalog);
504
512
  return { ...selected, resolvedBaseUrl: fetched.resolvedBaseUrl };
505
513
  }
506
- return manualModelsEntry(input);
514
+ return manualModelsEntry(input, catalog);
507
515
  }
508
- async function selectModelsFromFetched(fetched, input) {
516
+ /**
517
+ * Best-effort metadata fetch from models.lonae.com.
518
+ * Returns undefined (with a warning) when unreachable.
519
+ */
520
+ async function tryFetchModelMetadata(input) {
521
+ try {
522
+ const catalog = await fetchModelMetadata({ proxy: input.proxy });
523
+ p.log.info(`已从 models.lonae.com 获取 ${Object.keys(catalog.full).length} 个模型的元数据`);
524
+ return catalog;
525
+ }
526
+ catch (err) {
527
+ const msg = err instanceof Error ? err.message : String(err);
528
+ p.log.warn(`获取模型元数据失败(models.lonae.com):${msg}`);
529
+ return undefined;
530
+ }
531
+ }
532
+ /** Human-readable modality hint for a model, e.g. "text/image → text · 支持附件". */
533
+ function metadataHint(modelId, catalog) {
534
+ const meta = catalog ? lookupModelMeta(catalog, modelId) : undefined;
535
+ if (!meta)
536
+ return undefined;
537
+ const parts = [];
538
+ if (meta.modalities) {
539
+ parts.push(`${meta.modalities.input.join("/")} → ${meta.modalities.output.join("/")}`);
540
+ }
541
+ if (meta.attachment === true)
542
+ parts.push("支持附件");
543
+ return parts.length > 0 ? parts.join(" · ") : undefined;
544
+ }
545
+ function joinHints(...hints) {
546
+ const joined = hints.filter(Boolean).join(" · ");
547
+ return joined || undefined;
548
+ }
549
+ async function selectModelsFromFetched(fetched, input, catalog) {
509
550
  const preferredDefault = (input.preferredDefault && fetched.includes(input.preferredDefault)
510
551
  ? input.preferredDefault
511
552
  : undefined) ||
@@ -518,7 +559,7 @@ async function selectModelsFromFetched(fetched, input) {
518
559
  options: fetched.map((id) => ({
519
560
  value: id,
520
561
  label: id,
521
- hint: id === input.preferredDefault ? "当前" : undefined,
562
+ hint: joinHints(id === input.preferredDefault ? "当前" : undefined, metadataHint(id, catalog)),
522
563
  })),
523
564
  initialValue: preferredDefault,
524
565
  });
@@ -530,7 +571,7 @@ async function selectModelsFromFetched(fetched, input) {
530
571
  options: fetched.map((id) => ({
531
572
  value: id,
532
573
  label: id,
533
- hint: (input.preferredList || []).includes(id) ? "当前" : undefined,
574
+ hint: joinHints((input.preferredList || []).includes(id) ? "当前" : undefined, metadataHint(id, catalog)),
534
575
  })),
535
576
  initialValues: Array.from(new Set([defaultModel, ...preferredList, ...presetList])),
536
577
  required: true,
@@ -540,9 +581,9 @@ async function selectModelsFromFetched(fetched, input) {
540
581
  process.exit(0);
541
582
  }
542
583
  const modelList = Array.from(new Set([defaultModel, ...picked]));
543
- return { defaultModel, modelList };
584
+ return { defaultModel, modelList, modelMeta: collectModelMeta(catalog, modelList) };
544
585
  }
545
- async function manualModelsEntry(input) {
586
+ async function manualModelsEntry(input, catalog) {
546
587
  p.log.warn("未能自动获取模型列表,改为手动输入。");
547
588
  let defaultModel = input.fixedDefault || input.preferredDefault;
548
589
  if (!defaultModel) {
@@ -571,7 +612,11 @@ async function manualModelsEntry(input) {
571
612
  if (!modelList.includes(defaultModel)) {
572
613
  modelList = [defaultModel, ...modelList];
573
614
  }
574
- return { defaultModel, modelList };
615
+ return {
616
+ defaultModel,
617
+ modelList,
618
+ modelMeta: collectModelMeta(catalog, modelList),
619
+ };
575
620
  }
576
621
  export async function tryFetchModels(input) {
577
622
  const spin = p.spinner();
@@ -376,6 +376,7 @@ async function configureProfileModels(tool, profile) {
376
376
  });
377
377
  profile.models.default = resolved.defaultModel;
378
378
  profile.models.list = resolved.modelList;
379
+ profile.models.meta = resolved.modelMeta;
379
380
  if (resolved.resolvedBaseUrl &&
380
381
  resolved.resolvedBaseUrl !== profile.baseUrl) {
381
382
  p.log.info(`已根据可用接口将 Base URL 规范为 ${resolved.resolvedBaseUrl}(原:${profile.baseUrl})`);
@@ -5,6 +5,13 @@ import { normalizeBaseUrlForFormat } from "../utils/base-url.js";
5
5
  import { atomicWriteFile, ensureDir, maskSecret } from "../utils/fs.js";
6
6
  import { getProfilePath, getProfilesDir, getStatePath, getToolStoreDir, } from "../utils/paths.js";
7
7
  const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
8
+ /** Keep only metadata entries whose model id is still in the list. */
9
+ function filterModelMeta(meta, list) {
10
+ if (!meta)
11
+ return undefined;
12
+ const filtered = Object.fromEntries(Object.entries(meta).filter(([id]) => list.includes(id)));
13
+ return Object.keys(filtered).length > 0 ? filtered : undefined;
14
+ }
8
15
  export function assertValidProfileName(name) {
9
16
  if (!NAME_RE.test(name)) {
10
17
  throw new Error(`无效的 profile 名称「${name}」。仅允许字母、数字、下划线、连字符,且以字母或数字开头。`);
@@ -115,6 +122,7 @@ export function saveProfile(tool, profile) {
115
122
  const list = Array.from(new Set([profile.models.default, profile.models.fast, ...(profile.models.list || [])]
116
123
  .filter(Boolean)
117
124
  .map((m) => m.trim())));
125
+ const meta = filterModelMeta(profile.models.meta, list);
118
126
  const next = {
119
127
  ...profile,
120
128
  displayName: profile.displayName || profile.name,
@@ -124,6 +132,7 @@ export function saveProfile(tool, profile) {
124
132
  default: profile.models.default.trim(),
125
133
  fast: profile.models.fast?.trim() || undefined,
126
134
  list,
135
+ meta,
127
136
  },
128
137
  headers: profile.headers || {},
129
138
  updatedAt: new Date().toISOString(),
@@ -228,6 +237,7 @@ function normalizeProfile(raw, fallbackName) {
228
237
  default: raw.models?.default || list[0] || "",
229
238
  fast: raw.models?.fast || undefined,
230
239
  list: list.length ? list : raw.models?.default ? [raw.models.default] : [],
240
+ meta: filterModelMeta(raw.models?.meta, list),
231
241
  },
232
242
  proxy: normalizeProxyValue(raw.proxy),
233
243
  bridgeMode: raw.bridgeMode,
@@ -0,0 +1,201 @@
1
+ import { requestWithNodeTransport } from "../bridge/transport.js";
2
+ export const MODEL_METADATA_SOURCE = "https://models.lonae.com";
3
+ const DEFAULT_ENDPOINT = `${MODEL_METADATA_SOURCE}/api/v1/models`;
4
+ const PAGE_SIZE = 1000;
5
+ /** Trailing date stamp, e.g. "-20250219" in "claude-3-7-sonnet-20250219". */
6
+ const DATE_SUFFIX_RE = /-?\d{8}$/;
7
+ /**
8
+ * Normalize a model id for fuzzy matching: lowercase, drop separators, and
9
+ * align dotted version segments ("claude-3.7-sonnet" ↔ "claude-3-7-sonnet").
10
+ */
11
+ export function normalizeModelKey(value) {
12
+ return value
13
+ .toLowerCase()
14
+ .replace(/\./g, "-")
15
+ .replace(/[^a-z0-9]/g, "");
16
+ }
17
+ /** Normalize a model id ignoring a trailing date stamp. */
18
+ export function normalizeModelKeyLoose(value) {
19
+ const bare = value.replace(DATE_SUFFIX_RE, "");
20
+ return normalizeModelKey(bare) || normalizeModelKey(value);
21
+ }
22
+ function asModalityList(value) {
23
+ if (!Array.isArray(value))
24
+ return undefined;
25
+ const list = value.filter((item) => typeof item === "string" && !!item.trim());
26
+ return list.length > 0 ? list : undefined;
27
+ }
28
+ function parseModalities(row) {
29
+ const nested = row.modalities;
30
+ const input = (nested && typeof nested === "object"
31
+ ? asModalityList(nested.input)
32
+ : undefined) ?? asModalityList(row.inputModalities);
33
+ const output = (nested && typeof nested === "object"
34
+ ? asModalityList(nested.output)
35
+ : undefined) ?? asModalityList(row.outputModalities);
36
+ if (!input && !output)
37
+ return undefined;
38
+ return {
39
+ input: input ?? ["text"],
40
+ output: output ?? ["text"],
41
+ };
42
+ }
43
+ function asNumber(value) {
44
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
45
+ }
46
+ function parseCost(row) {
47
+ const input = asNumber(row.priceInput);
48
+ const output = asNumber(row.priceOutput);
49
+ return input !== undefined && output !== undefined
50
+ ? { input, output }
51
+ : undefined;
52
+ }
53
+ /** Insert into a normalized bucket, preferring undated ids as canonical. */
54
+ function indexNorm(bucket, datedMetas, key, meta, dated) {
55
+ if (!key)
56
+ return;
57
+ const existing = bucket[key];
58
+ if (!existing) {
59
+ bucket[key] = meta;
60
+ return;
61
+ }
62
+ if (datedMetas.has(existing) && !dated)
63
+ bucket[key] = meta;
64
+ }
65
+ /**
66
+ * Build the lookup catalog from a /api/v1/models payload.
67
+ * Indexes: full id ("lab/model"), bare id ("model"), plus normalized variants
68
+ * (date-stamp tolerant) so "claude-3.7-sonnet" can find
69
+ * "claude-3-7-sonnet-20250219".
70
+ */
71
+ export function parseModelMetadata(payload) {
72
+ const catalog = { full: {}, bare: {}, norm: {} };
73
+ const rows = payload && typeof payload === "object"
74
+ ? payload.data
75
+ : null;
76
+ if (!Array.isArray(rows))
77
+ return catalog;
78
+ const datedMetas = new Set();
79
+ for (const item of rows) {
80
+ if (!item || typeof item !== "object")
81
+ continue;
82
+ const row = item;
83
+ if (typeof row.id !== "string" || !row.id.trim())
84
+ continue;
85
+ const id = row.id.trim();
86
+ const meta = {
87
+ id,
88
+ name: typeof row.name === "string" && row.name.trim() ? row.name.trim() : undefined,
89
+ family: typeof row.family === "string" && row.family.trim()
90
+ ? row.family.trim()
91
+ : undefined,
92
+ releaseDate: typeof row.releaseDate === "string" && row.releaseDate.trim()
93
+ ? row.releaseDate.trim()
94
+ : undefined,
95
+ context: asNumber(row.context),
96
+ maxOutput: asNumber(row.output),
97
+ reasoning: typeof row.reasoning === "boolean" ? row.reasoning : undefined,
98
+ toolCall: typeof row.toolCall === "boolean" ? row.toolCall : undefined,
99
+ temperature: typeof row.temperature === "boolean" ? row.temperature : undefined,
100
+ modalities: parseModalities(row),
101
+ attachment: typeof row.attachment === "boolean" ? row.attachment : undefined,
102
+ cost: parseCost(row),
103
+ };
104
+ const bareIndex = id.indexOf("/");
105
+ const bare = bareIndex >= 0 ? id.slice(bareIndex + 1).trim() : id;
106
+ const dated = DATE_SUFFIX_RE.test(bare);
107
+ if (dated)
108
+ datedMetas.add(meta);
109
+ const fullKey = id.toLowerCase();
110
+ const bareKey = bare.toLowerCase();
111
+ const normKey = normalizeModelKey(bare);
112
+ const looseKey = normalizeModelKeyLoose(bare);
113
+ if (!catalog.full[fullKey])
114
+ catalog.full[fullKey] = meta;
115
+ if (bare && !catalog.bare[bareKey])
116
+ catalog.bare[bareKey] = meta;
117
+ indexNorm(catalog.norm, datedMetas, normKey, meta, dated);
118
+ if (looseKey !== normKey)
119
+ indexNorm(catalog.norm, datedMetas, looseKey, meta, dated);
120
+ }
121
+ return catalog;
122
+ }
123
+ /** Look up metadata for a profile model id (exact → bare → normalized). */
124
+ export function lookupModelMeta(catalog, modelId) {
125
+ const trimmed = modelId.trim();
126
+ if (!trimmed)
127
+ return undefined;
128
+ const bareIndex = trimmed.indexOf("/");
129
+ const bare = bareIndex >= 0 ? trimmed.slice(bareIndex + 1).trim() : trimmed;
130
+ return (catalog.full[trimmed.toLowerCase()] ??
131
+ catalog.bare[bare.toLowerCase()] ??
132
+ catalog.norm[normalizeModelKey(bare)] ??
133
+ catalog.norm[normalizeModelKeyLoose(bare)]);
134
+ }
135
+ /** Collect metadata for a set of profile model ids. */
136
+ export function collectModelMeta(catalog, models) {
137
+ if (!catalog)
138
+ return undefined;
139
+ const meta = {};
140
+ for (const id of models) {
141
+ const found = lookupModelMeta(catalog, id);
142
+ if (found)
143
+ meta[id] = found;
144
+ }
145
+ return Object.keys(meta).length > 0 ? meta : undefined;
146
+ }
147
+ /**
148
+ * Fetch the model metadata catalog from models.lonae.com.
149
+ * Paginates automatically (page_size capped at 1000 per request).
150
+ */
151
+ export async function fetchModelMetadata(options = {}) {
152
+ const endpoint = options.endpoint || DEFAULT_ENDPOINT;
153
+ const timeoutMs = options.timeoutMs ?? 15_000;
154
+ const rows = [];
155
+ let total = Infinity;
156
+ let page = 1;
157
+ while (rows.length < total) {
158
+ const url = `${endpoint}${endpoint.includes("?") ? "&" : "?"}page_size=${PAGE_SIZE}&page=${page}`;
159
+ const payload = await requestJson(url, options.proxy, timeoutMs);
160
+ const batch = payload && typeof payload === "object"
161
+ ? payload.data
162
+ : null;
163
+ if (!Array.isArray(batch) || batch.length === 0)
164
+ break;
165
+ rows.push(...batch);
166
+ const totalRaw = payload && typeof payload === "object"
167
+ ? payload.meta?.total
168
+ : undefined;
169
+ total = typeof totalRaw === "number" && totalRaw > 0 ? totalRaw : rows.length;
170
+ page += 1;
171
+ }
172
+ return parseModelMetadata({ data: rows });
173
+ }
174
+ async function requestJson(url, proxy, timeoutMs) {
175
+ const controller = new AbortController();
176
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
177
+ try {
178
+ const res = await requestWithNodeTransport({
179
+ url,
180
+ method: "GET",
181
+ headers: { Accept: "application/json" },
182
+ proxy,
183
+ signal: controller.signal,
184
+ totalTimeoutMs: timeoutMs,
185
+ });
186
+ if (!res.ok) {
187
+ const body = (await res.text().catch(() => "")).slice(0, 200);
188
+ throw new Error(`HTTP ${res.status}${body ? `: ${body}` : ""}`);
189
+ }
190
+ return await res.json();
191
+ }
192
+ catch (err) {
193
+ if (err instanceof Error && err.name === "AbortError") {
194
+ throw new Error(`请求超时(${timeoutMs}ms)`);
195
+ }
196
+ throw err;
197
+ }
198
+ finally {
199
+ clearTimeout(timer);
200
+ }
201
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nvae/llmswitch",
3
- "version": "0.8.0",
3
+ "version": "1.0.0",
4
4
  "description": "CLI to switch LLM providers and models for Claude Code, Codex, and OpenCode",
5
5
  "type": "module",
6
6
  "bin": {