@iola_adm/iola-cli 0.1.107 → 0.1.109

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.107",
3
+ "version": "0.1.109",
4
4
  "description": "CLI и AI-агент городского округа Йошкар-Ола.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://github.com/adm-iola/iola-cli#readme",
package/src/cli.js CHANGED
@@ -101,6 +101,19 @@ const FEATURES = {
101
101
  "mcp-management": { stage: "stable", defaultEnabled: true, description: "Команды управления MCP-интеграциями." },
102
102
  "web-search": { stage: "experimental", defaultEnabled: false, description: "Резерв под web-search режимы AI." },
103
103
  };
104
+ const MAIN_OPENROUTER_AUTHORS = [
105
+ ["openai", "OpenAI"],
106
+ ["anthropic", "Anthropic"],
107
+ ["google", "Google"],
108
+ ["qwen", "Qwen / Alibaba"],
109
+ ["deepseek", "DeepSeek"],
110
+ ["meta-llama", "Meta / Llama"],
111
+ ["mistralai", "Mistral AI"],
112
+ ["x-ai", "xAI"],
113
+ ["cohere", "Cohere"],
114
+ ["microsoft", "Microsoft"],
115
+ ["perplexity", "Perplexity"],
116
+ ];
104
117
  const SKILL_BUNDLES = {
105
118
  analyst: {
106
119
  description: "Аналитик открытых данных: поиск, карточки, отчеты и память.",
@@ -611,7 +624,7 @@ Usage:
611
624
  iola ai models iola|ollama|openai|openrouter|codex [--search TEXT]
612
625
  iola ai doctor [--json]
613
626
  iola ai setup
614
- iola ai setup iola [--yes]
627
+ iola ai setup iola [--yes] [--force]
615
628
  iola ai setup ollama [--yes] [--model MODEL]
616
629
  iola health [--json]
617
630
  iola layers [--json]
@@ -2053,7 +2066,7 @@ async function handleAi(args) {
2053
2066
  iola ai models iola|ollama|openai|openrouter|codex [--search TEXT]
2054
2067
  iola ai doctor [--json]
2055
2068
  iola ai setup
2056
- iola ai setup iola [--yes]
2069
+ iola ai setup iola [--yes] [--force]
2057
2070
  iola ai setup ollama [--yes] [--model MODEL]
2058
2071
  iola ai setup openai [--model MODEL]
2059
2072
  iola ai setup openrouter [--model MODEL]
@@ -4288,6 +4301,7 @@ async function aiModels(args) {
4288
4301
  printTable(filtered, [
4289
4302
  ["id", "Модель"],
4290
4303
  ["provider", "Провайдер"],
4304
+ ["releaseDate", "Дата"],
4291
4305
  ["note", "Примечание"],
4292
4306
  ]);
4293
4307
  }
@@ -4361,11 +4375,7 @@ async function listAiModels(provider) {
4361
4375
  if (apiKey && getAiNetworkMode(relayConfig) === "gateway") {
4362
4376
  const payload = await callAiRelayModels(relayConfig, apiKey, "OpenRouter");
4363
4377
  return (payload.data || [])
4364
- .map((model) => ({
4365
- id: model.id,
4366
- provider: "openrouter",
4367
- note: model.name || "",
4368
- }))
4378
+ .map(mapOpenRouterModel)
4369
4379
  .sort((left, right) => left.id.localeCompare(right.id));
4370
4380
  }
4371
4381
  const response = await fetch("https://openrouter.ai/api/v1/models", {
@@ -4378,11 +4388,7 @@ async function listAiModels(provider) {
4378
4388
 
4379
4389
  const payload = await response.json();
4380
4390
  return (payload.data || [])
4381
- .map((model) => ({
4382
- id: model.id,
4383
- provider: "openrouter",
4384
- note: model.name || "",
4385
- }))
4391
+ .map(mapOpenRouterModel)
4386
4392
  .sort((left, right) => left.id.localeCompare(right.id));
4387
4393
  }
4388
4394
 
@@ -4405,6 +4411,85 @@ async function getApiProviderNetworkConfig(provider) {
4405
4411
  };
4406
4412
  }
4407
4413
 
4414
+ function mapOpenRouterModel(model) {
4415
+ const id = String(model.id || "");
4416
+ const architecture = model.architecture || {};
4417
+ const created = Number(model.created || 0);
4418
+ return {
4419
+ id,
4420
+ provider: "openrouter",
4421
+ note: model.name || "",
4422
+ author: id.includes("/") ? id.split("/")[0] : "",
4423
+ created,
4424
+ releaseDate: formatUnixDate(created),
4425
+ modality: architecture.modality || "",
4426
+ inputModalities: Array.isArray(architecture.input_modalities) ? architecture.input_modalities : [],
4427
+ outputModalities: Array.isArray(architecture.output_modalities) ? architecture.output_modalities : [],
4428
+ contextLength: Number(model.context_length || model.top_provider?.context_length || 0),
4429
+ };
4430
+ }
4431
+
4432
+ function isOpenRouterTextModel(model) {
4433
+ const inputs = model.inputModalities || [];
4434
+ const outputs = model.outputModalities || [];
4435
+ if (inputs.length > 0 && !inputs.includes("text")) return false;
4436
+ if (outputs.length > 0 && !outputs.includes("text")) return false;
4437
+ const modality = String(model.modality || "").toLocaleLowerCase("en-US");
4438
+ if (modality && modality !== "text->text") return false;
4439
+ const id = String(model.id || "").toLocaleLowerCase("en-US");
4440
+ const note = String(model.note || "").toLocaleLowerCase("en-US");
4441
+ return !/\b(vl|vision|image|video|audio|tts|embed|embedding|rerank|moderation)\b/.test(`${id} ${note}`);
4442
+ }
4443
+
4444
+ function buildOpenRouterAuthorChoices(models) {
4445
+ const byAuthor = new Map();
4446
+ for (const model of models) {
4447
+ if (!model.author) continue;
4448
+ const current = byAuthor.get(model.author) || { count: 0, latestCreated: 0 };
4449
+ current.count += 1;
4450
+ current.latestCreated = Math.max(current.latestCreated, Number(model.created || 0));
4451
+ byAuthor.set(model.author, current);
4452
+ }
4453
+
4454
+ return MAIN_OPENROUTER_AUTHORS
4455
+ .map(([id, label]) => {
4456
+ const stat = byAuthor.get(id);
4457
+ if (!stat) return null;
4458
+ return {
4459
+ id,
4460
+ label,
4461
+ count: stat.count,
4462
+ latestReleaseDate: formatUnixDate(stat.latestCreated),
4463
+ };
4464
+ })
4465
+ .filter(Boolean);
4466
+ }
4467
+
4468
+ function modelMatchesSearch(model, search) {
4469
+ const needle = search.toLocaleLowerCase("ru-RU");
4470
+ return [model.id, model.note, model.author]
4471
+ .some((value) => String(value || "").toLocaleLowerCase("ru-RU").includes(needle));
4472
+ }
4473
+
4474
+ function sortModelsByFreshness(left, right) {
4475
+ return Number(right.created || 0) - Number(left.created || 0)
4476
+ || String(left.id).localeCompare(String(right.id));
4477
+ }
4478
+
4479
+ function formatUnixDate(value) {
4480
+ const seconds = Number(value || 0);
4481
+ if (!seconds) return "";
4482
+ return new Date(seconds * 1000).toISOString().slice(0, 10);
4483
+ }
4484
+
4485
+ function formatCompactNumber(value) {
4486
+ const number = Number(value || 0);
4487
+ if (!number) return "";
4488
+ if (number >= 1_000_000) return `${Math.round(number / 100_000) / 10}M`;
4489
+ if (number >= 1_000) return `${Math.round(number / 100) / 10}K`;
4490
+ return String(number);
4491
+ }
4492
+
4408
4493
  function getRecommendedOllamaModels(notePrefix = "recommended") {
4409
4494
  return [
4410
4495
  { id: IOLA_LOCAL_OLLAMA_MODEL, provider: "ollama", note: `${notePrefix} IOLA default low RAM` },
@@ -4711,8 +4796,12 @@ async function getDefaultApiProviderForModelSwitch() {
4711
4796
  }
4712
4797
 
4713
4798
  async function chooseAiModel(provider) {
4799
+ if (provider === "openrouter") {
4800
+ return chooseOpenRouterModel();
4801
+ }
4802
+
4714
4803
  let search = "";
4715
- if (provider === "openrouter" || provider === "openai") {
4804
+ if (provider === "openai") {
4716
4805
  search = (await askText("Фильтр моделей (Enter - без фильтра): ")).trim();
4717
4806
  }
4718
4807
 
@@ -4747,6 +4836,66 @@ async function chooseAiModel(provider) {
4747
4836
  return filtered[answer - 1]?.id || "";
4748
4837
  }
4749
4838
 
4839
+ async function chooseOpenRouterModel() {
4840
+ let models;
4841
+ try {
4842
+ models = await listAiModels("openrouter");
4843
+ } catch (error) {
4844
+ console.log(error instanceof Error ? error.message : String(error));
4845
+ return "";
4846
+ }
4847
+
4848
+ const textModels = models.filter(isOpenRouterTextModel);
4849
+ if (textModels.length === 0) {
4850
+ console.log("Текстовые модели OpenRouter не найдены.");
4851
+ return "";
4852
+ }
4853
+
4854
+ const authorChoices = buildOpenRouterAuthorChoices(textModels);
4855
+ console.log("Выберите автора моделей OpenRouter:");
4856
+ authorChoices.forEach((choice, index) => {
4857
+ const date = choice.latestReleaseDate ? `, свежая: ${choice.latestReleaseDate}` : "";
4858
+ console.log(` ${index + 1}. ${choice.label} (${choice.count}${date})`);
4859
+ });
4860
+ const searchIndex = authorChoices.length + 1;
4861
+ console.log(` ${searchIndex}. Поиск по всем текстовым моделям`);
4862
+ console.log(" 0. Отмена");
4863
+
4864
+ const authorAnswer = Number(await askText("Номер: "));
4865
+ if (!authorAnswer) return "";
4866
+
4867
+ let filtered;
4868
+ if (authorAnswer === searchIndex) {
4869
+ const search = (await askText("Фильтр моделей: ")).trim();
4870
+ if (!search) return "";
4871
+ filtered = textModels.filter((model) => modelMatchesSearch(model, search));
4872
+ } else {
4873
+ const selectedAuthor = authorChoices[authorAnswer - 1];
4874
+ if (!selectedAuthor) return "";
4875
+ filtered = textModels.filter((model) => model.author === selectedAuthor.id);
4876
+ }
4877
+
4878
+ filtered = filtered
4879
+ .sort(sortModelsByFreshness)
4880
+ .slice(0, 30);
4881
+
4882
+ if (filtered.length === 0) {
4883
+ console.log("Модели не найдены.");
4884
+ return "";
4885
+ }
4886
+
4887
+ console.log("Выберите текстовую модель:");
4888
+ filtered.forEach((model, index) => {
4889
+ const date = model.releaseDate || "дата неизвестна";
4890
+ const context = model.contextLength ? `, ctx ${formatCompactNumber(model.contextLength)}` : "";
4891
+ console.log(` ${index + 1}. ${model.id} (${date}${context}) - ${model.note || model.id}`);
4892
+ });
4893
+ console.log(" 0. Отмена");
4894
+
4895
+ const modelAnswer = Number(await askText("Номер: "));
4896
+ return filtered[modelAnswer - 1]?.id || "";
4897
+ }
4898
+
4750
4899
  async function chooseAndSaveApiModel(provider) {
4751
4900
  const model = await chooseAiModel(provider);
4752
4901
  if (!model) {
@@ -6239,7 +6388,7 @@ async function setupIolaLocal(args) {
6239
6388
  ggufFile,
6240
6389
  modelDir,
6241
6390
  model,
6242
- force: true,
6391
+ force: Boolean(options.force),
6243
6392
  quiet: Boolean(options.quiet),
6244
6393
  });
6245
6394
  } catch (error) {
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7
7
  const binPath = resolve(rootDir, "bin", "iola.js");
8
8
  const packageJson = JSON.parse(await readFile(resolve(rootDir, "package.json"), "utf8"));
9
+ const cliSource = await readFile(resolve(rootDir, "src", "cli.js"), "utf8");
9
10
 
10
11
  function runCli(args) {
11
12
  return new Promise((resolvePromise, reject) => {
@@ -45,6 +46,10 @@ const help = await runCli(["--help"]);
45
46
  assertIncludes(help, "iola master", "help");
46
47
  assertIncludes(help, "iola ask", "help");
47
48
 
49
+ assertIncludes(cliSource, "force: Boolean(options.force)", "IOLA setup should not force model reinstall by default");
50
+ assertIncludes(cliSource, "MAIN_OPENROUTER_AUTHORS", "OpenRouter model selection should group models by author");
51
+ assertIncludes(cliSource, "modality !== \"text->text\"", "OpenRouter model selection should prefer text models");
52
+
48
53
  const commands = await runCli(["commands"]);
49
54
  assertIncludes(commands, "iola browser status|install|open|text|html|screenshot|pdf|click|type|eval", "commands");
50
55
  assertIncludes(commands, "iola mcp list|status|install|remove|serve [--stdio]", "commands");