@iola_adm/iola-cli 0.1.105 → 0.1.107

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +175 -31
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iola_adm/iola-cli",
3
- "version": "0.1.105",
3
+ "version": "0.1.107",
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
@@ -15,6 +15,8 @@ import { inflateRawSync, inflateSync } from "node:zlib";
15
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
16
  const API_BASE_URL = process.env.IOLA_API_BASE_URL || "https://apiiola.yasg.ru/api/v1";
17
17
  const MCP_BASE_URL = process.env.IOLA_MCP_BASE_URL || "https://apiiola.yasg.ru";
18
+ const AI_RELAY_BASE_URL = process.env.IOLA_AI_RELAY_BASE_URL || `${API_BASE_URL}/ai/relay`;
19
+ const AI_NETWORK_MODE = process.env.IOLA_AI_NETWORK_MODE || "";
18
20
  const MIN_NODE_VERSION = "22.5.0";
19
21
  const CONFIG_DIR = path.join(os.homedir(), ".iola");
20
22
  const CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
@@ -121,6 +123,7 @@ const DEFAULT_AI_CONFIG = {
121
123
  api: {
122
124
  baseUrl: "https://apiiola.yasg.ru/api/v1",
123
125
  mcpBaseUrl: "https://apiiola.yasg.ru",
126
+ aiRelayBaseUrl: "https://apiiola.yasg.ru/api/v1/ai/relay",
124
127
  },
125
128
  ai: {
126
129
  activeProfile: "local",
@@ -140,11 +143,13 @@ const DEFAULT_AI_CONFIG = {
140
143
  provider: "openai",
141
144
  model: "gpt-4.1-mini",
142
145
  baseUrl: "https://api.openai.com/v1",
146
+ networkMode: "gateway",
143
147
  },
144
148
  openrouter: {
145
149
  provider: "openrouter",
146
150
  model: "openai/gpt-4.1-mini",
147
151
  baseUrl: "https://openrouter.ai/api/v1",
152
+ networkMode: "gateway",
148
153
  },
149
154
  codex: {
150
155
  provider: "codex",
@@ -625,6 +630,8 @@ Usage:
625
630
  Environment:
626
631
  IOLA_API_BASE_URL default: ${API_BASE_URL}
627
632
  IOLA_MCP_BASE_URL default: ${MCP_BASE_URL}
633
+ IOLA_AI_RELAY_BASE_URL default: ${AI_RELAY_BASE_URL}
634
+ IOLA_AI_NETWORK_MODE direct|gateway|auto
628
635
 
629
636
  Requirements:
630
637
  Node.js >= ${MIN_NODE_VERSION}
@@ -4327,6 +4334,13 @@ async function listAiModels(provider) {
4327
4334
  if (!apiKey) {
4328
4335
  throw new Error("OpenAI API key не найден. Выполните iola ai key set openai.");
4329
4336
  }
4337
+ const relayConfig = await getApiProviderNetworkConfig("openai");
4338
+ if (getAiNetworkMode(relayConfig) === "gateway") {
4339
+ const payload = await callAiRelayModels(relayConfig, apiKey, "OpenAI");
4340
+ return (payload.data || [])
4341
+ .map((model) => ({ id: model.id, provider: "openai", note: model.owned_by || "" }))
4342
+ .sort((left, right) => left.id.localeCompare(right.id));
4343
+ }
4330
4344
  const response = await fetch("https://api.openai.com/v1/models", {
4331
4345
  headers: { authorization: `Bearer ${apiKey}` },
4332
4346
  });
@@ -4342,6 +4356,18 @@ async function listAiModels(provider) {
4342
4356
  }
4343
4357
 
4344
4358
  if (provider === "openrouter") {
4359
+ const apiKey = await getApiKey("openrouter");
4360
+ const relayConfig = await getApiProviderNetworkConfig("openrouter");
4361
+ if (apiKey && getAiNetworkMode(relayConfig) === "gateway") {
4362
+ const payload = await callAiRelayModels(relayConfig, apiKey, "OpenRouter");
4363
+ return (payload.data || [])
4364
+ .map((model) => ({
4365
+ id: model.id,
4366
+ provider: "openrouter",
4367
+ note: model.name || "",
4368
+ }))
4369
+ .sort((left, right) => left.id.localeCompare(right.id));
4370
+ }
4345
4371
  const response = await fetch("https://openrouter.ai/api/v1/models", {
4346
4372
  headers: { accept: "application/json" },
4347
4373
  });
@@ -4369,6 +4395,16 @@ async function listAiModels(provider) {
4369
4395
  ];
4370
4396
  }
4371
4397
 
4398
+ async function getApiProviderNetworkConfig(provider) {
4399
+ const config = await loadConfig();
4400
+ const profile = config.ai.profiles?.[provider] || DEFAULT_AI_CONFIG.ai.profiles[provider] || {};
4401
+ return {
4402
+ provider,
4403
+ ...profile,
4404
+ aiRelayBaseUrl: profile.aiRelayBaseUrl || config.api?.aiRelayBaseUrl || AI_RELAY_BASE_URL,
4405
+ };
4406
+ }
4407
+
4372
4408
  function getRecommendedOllamaModels(notePrefix = "recommended") {
4373
4409
  return [
4374
4410
  { id: IOLA_LOCAL_OLLAMA_MODEL, provider: "ollama", note: `${notePrefix} IOLA default low RAM` },
@@ -4389,7 +4425,9 @@ async function printAiProfiles() {
4389
4425
  provider: profile.provider,
4390
4426
  model: profile.model || "-",
4391
4427
  baseUrl: profile.baseUrl || "-",
4392
- mode: profile.provider === "codex" ? `sandbox=${profile.sandbox || "read-only"}, approval=${profile.approval || "never"}` : "-",
4428
+ mode: profile.provider === "codex"
4429
+ ? `sandbox=${profile.sandbox || "read-only"}, approval=${profile.approval || "never"}`
4430
+ : (profile.provider === "openai" || profile.provider === "openrouter" ? `network=${getAiNetworkMode(profile)}` : "-"),
4393
4431
  }));
4394
4432
 
4395
4433
  printTable(rows, [
@@ -4512,6 +4550,12 @@ function buildProfileFromOptions(provider, options) {
4512
4550
  if (options["base-url"]) {
4513
4551
  profile.baseUrl = options["base-url"];
4514
4552
  }
4553
+ if (options["network-mode"]) {
4554
+ profile.networkMode = validateAiNetworkMode(options["network-mode"]);
4555
+ }
4556
+ if (options["relay-url"]) {
4557
+ profile.aiRelayBaseUrl = options["relay-url"];
4558
+ }
4515
4559
 
4516
4560
  if (provider === "iola") {
4517
4561
  profile.runtime = options.runtime || defaults.runtime || "ollama";
@@ -4703,6 +4747,15 @@ async function chooseAiModel(provider) {
4703
4747
  return filtered[answer - 1]?.id || "";
4704
4748
  }
4705
4749
 
4750
+ async function chooseAndSaveApiModel(provider) {
4751
+ const model = await chooseAiModel(provider);
4752
+ if (!model) {
4753
+ console.log("Модель не выбрана. Оставлена модель по умолчанию.");
4754
+ return;
4755
+ }
4756
+ await switchModelTarget(provider, model);
4757
+ }
4758
+
4706
4759
  async function switchModelTarget(target, model) {
4707
4760
  const config = await loadConfig();
4708
4761
  const provider = target === "local" ? "iola" : target;
@@ -4772,6 +4825,8 @@ async function isOllamaModelInstalled(model, loadedConfig = null) {
4772
4825
 
4773
4826
  async function askText(question) {
4774
4827
  if (!process.stdin.isTTY) return "";
4828
+ if (input.isRaw) input.setRawMode(false);
4829
+ input.resume();
4775
4830
  const rl = readline.createInterface({ input, output });
4776
4831
  try {
4777
4832
  return await rl.question(question);
@@ -4806,22 +4861,16 @@ async function setAiKey(provider) {
4806
4861
  }
4807
4862
 
4808
4863
  const envName = provider === "openai" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY";
4809
- const rl = readline.createInterface({ input, output });
4864
+ const key = (await askText(`Введите ${envName}: `)).trim();
4810
4865
 
4811
- try {
4812
- const key = (await rl.question(`Введите ${envName}: `)).trim();
4813
-
4814
- if (!key) {
4815
- throw new Error("Ключ пустой, сохранение отменено.");
4816
- }
4817
-
4818
- const secrets = await loadSecrets();
4819
- secrets[provider] = { apiKey: key };
4820
- await saveSecrets(secrets);
4821
- console.log(`Ключ ${provider} сохранен локально: ${SECRETS_FILE}`);
4822
- } finally {
4823
- rl.close();
4866
+ if (!key) {
4867
+ throw new Error("Ключ пустой, сохранение отменено.");
4824
4868
  }
4869
+
4870
+ const secrets = await loadSecrets();
4871
+ secrets[provider] = { apiKey: key };
4872
+ await saveSecrets(secrets);
4873
+ console.log(`Ключ ${provider} сохранен локально: ${SECRETS_FILE}`);
4825
4874
  }
4826
4875
 
4827
4876
  async function printAiKeyStatus() {
@@ -6103,19 +6152,14 @@ async function chooseAiProvider() {
6103
6152
  console.log("4. Codex/MCP");
6104
6153
  console.log("5. Ollama");
6105
6154
 
6106
- const rl = readline.createInterface({ input, output });
6107
- try {
6108
- const answer = (await rl.question("Введите номер [1]: ")).trim() || "1";
6109
- return {
6110
- 1: "iola",
6111
- 2: "openai",
6112
- 3: "openrouter",
6113
- 4: "codex",
6114
- 5: "ollama",
6115
- }[answer] || "iola";
6116
- } finally {
6117
- rl.close();
6118
- }
6155
+ const answer = (await askText("Введите номер [1]: ")).trim() || "1";
6156
+ return {
6157
+ 1: "iola",
6158
+ 2: "openai",
6159
+ 3: "openrouter",
6160
+ 4: "codex",
6161
+ 5: "ollama",
6162
+ }[answer] || "iola";
6119
6163
  }
6120
6164
 
6121
6165
  async function setupOllama(args) {
@@ -6473,6 +6517,8 @@ function resolveAiProfile(config, options = {}) {
6473
6517
  provider,
6474
6518
  model: options.model || activeProfile.model || config.ai.model,
6475
6519
  baseUrl: options["base-url"] || activeProfile.baseUrl || config.ai.baseUrl,
6520
+ aiRelayBaseUrl: options["relay-url"] || activeProfile.aiRelayBaseUrl || config.api?.aiRelayBaseUrl || AI_RELAY_BASE_URL,
6521
+ networkMode: options["network-mode"] || activeProfile.networkMode,
6476
6522
  repo: options.repo || activeProfile.repo,
6477
6523
  modelDir: options["model-dir"] || activeProfile.modelDir,
6478
6524
  temperature: options.temperature || activeProfile.temperature,
@@ -8090,6 +8136,23 @@ async function callOpenAiCompatible(config, messages, apiKey, providerName) {
8090
8136
  throw new Error(`${providerName} API key не найден. Выполните iola ai key set ${providerName === "OpenAI" ? "openai" : "openrouter"} или задайте ${providerName === "OpenAI" ? "OPENAI_API_KEY" : "OPENROUTER_API_KEY"}.`);
8091
8137
  }
8092
8138
 
8139
+ const networkMode = getAiNetworkMode(config);
8140
+ if (networkMode === "gateway") {
8141
+ return callAiRelay(config, messages, apiKey, providerName);
8142
+ }
8143
+ if (networkMode === "auto") {
8144
+ try {
8145
+ return await callOpenAiDirect(config, messages, apiKey, providerName);
8146
+ } catch (error) {
8147
+ if (!isNetworkFallbackError(error)) throw error;
8148
+ return callAiRelay(config, messages, apiKey, providerName);
8149
+ }
8150
+ }
8151
+
8152
+ return callOpenAiDirect(config, messages, apiKey, providerName);
8153
+ }
8154
+
8155
+ async function callOpenAiDirect(config, messages, apiKey, providerName) {
8093
8156
  const response = await fetch(`${config.baseUrl}/chat/completions`, {
8094
8157
  method: "POST",
8095
8158
  headers: {
@@ -8107,13 +8170,80 @@ async function callOpenAiCompatible(config, messages, apiKey, providerName) {
8107
8170
 
8108
8171
  if (!response.ok) {
8109
8172
  const text = await response.text();
8110
- throw new Error(`${providerName} request failed: ${response.status} ${response.statusText}\n${text}`);
8173
+ throw new Error(`${providerName} request failed: ${response.status} ${response.statusText}\n${sanitizeSecretFromText(text, apiKey)}`);
8111
8174
  }
8112
8175
 
8113
8176
  const payload = await response.json();
8114
8177
  return payload.choices?.[0]?.message?.content || "";
8115
8178
  }
8116
8179
 
8180
+ async function callAiRelay(config, messages, apiKey, providerName) {
8181
+ const relayBaseUrl = String(config.aiRelayBaseUrl || AI_RELAY_BASE_URL).replace(/\/+$/, "");
8182
+ const response = await fetch(`${relayBaseUrl}/chat`, {
8183
+ method: "POST",
8184
+ headers: {
8185
+ "content-type": "application/json",
8186
+ },
8187
+ body: JSON.stringify({
8188
+ provider: providerName === "OpenAI" ? "openai" : "openrouter",
8189
+ api_key: apiKey,
8190
+ model: config.model,
8191
+ messages,
8192
+ temperature: Number(config.temperature ?? 0.2),
8193
+ }),
8194
+ });
8195
+
8196
+ if (!response.ok) {
8197
+ const text = await response.text();
8198
+ throw new Error(`${providerName} relay request failed: ${response.status} ${response.statusText}\n${sanitizeSecretFromText(text, apiKey)}`);
8199
+ }
8200
+
8201
+ const payload = await response.json();
8202
+ return payload.choices?.[0]?.message?.content || "";
8203
+ }
8204
+
8205
+ async function callAiRelayModels(config, apiKey, providerName) {
8206
+ const relayBaseUrl = String(config.aiRelayBaseUrl || AI_RELAY_BASE_URL).replace(/\/+$/, "");
8207
+ const response = await fetch(`${relayBaseUrl}/models`, {
8208
+ method: "POST",
8209
+ headers: {
8210
+ "content-type": "application/json",
8211
+ },
8212
+ body: JSON.stringify({
8213
+ provider: providerName === "OpenAI" ? "openai" : "openrouter",
8214
+ api_key: apiKey,
8215
+ }),
8216
+ });
8217
+
8218
+ if (!response.ok) {
8219
+ const text = await response.text();
8220
+ throw new Error(`${providerName} relay models request failed: ${response.status} ${response.statusText}\n${sanitizeSecretFromText(text, apiKey)}`);
8221
+ }
8222
+
8223
+ return response.json();
8224
+ }
8225
+
8226
+ function getAiNetworkMode(config = {}) {
8227
+ return validateAiNetworkMode(AI_NETWORK_MODE || config.networkMode || "gateway");
8228
+ }
8229
+
8230
+ function validateAiNetworkMode(value) {
8231
+ const mode = String(value || "").trim().toLocaleLowerCase("en-US");
8232
+ if (mode === "direct" || mode === "gateway" || mode === "auto") return mode;
8233
+ throw new Error("AI network mode должен быть direct, gateway или auto.");
8234
+ }
8235
+
8236
+ function isNetworkFallbackError(error) {
8237
+ const message = String(error?.message || "");
8238
+ return /fetch failed|ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket|TLS|proxy|network/i.test(message);
8239
+ }
8240
+
8241
+ function sanitizeSecretFromText(text, secret) {
8242
+ const source = String(text || "");
8243
+ if (!secret) return source;
8244
+ return source.split(secret).join("[redacted]");
8245
+ }
8246
+
8117
8247
  async function getApiKey(provider) {
8118
8248
  if (provider === "openai" && process.env.OPENAI_API_KEY) {
8119
8249
  return process.env.OPENAI_API_KEY;
@@ -8372,11 +8502,17 @@ async function onboard(args = []) {
8372
8502
  }
8373
8503
  if (components.includes("openai")) {
8374
8504
  await aiSetup(["openai"]);
8375
- if (process.stdin.isTTY) await setAiKey("openai");
8505
+ if (process.stdin.isTTY) {
8506
+ await setAiKey("openai");
8507
+ await chooseAndSaveApiModel("openai");
8508
+ }
8376
8509
  }
8377
8510
  if (components.includes("openrouter")) {
8378
8511
  await aiSetup(["openrouter"]);
8379
- if (process.stdin.isTTY) await setAiKey("openrouter");
8512
+ if (process.stdin.isTTY) {
8513
+ await setAiKey("openrouter");
8514
+ await chooseAndSaveApiModel("openrouter");
8515
+ }
8380
8516
  }
8381
8517
  if (components.includes("codex")) {
8382
8518
  await installCodexIfMissing();
@@ -10433,6 +10569,8 @@ function mergeConfig(base, override) {
10433
10569
 
10434
10570
  function sanitizeConfig(config) {
10435
10571
  const next = JSON.parse(JSON.stringify(config || {}));
10572
+ next.api = next.api || {};
10573
+ next.api.aiRelayBaseUrl = next.api.aiRelayBaseUrl || AI_RELAY_BASE_URL;
10436
10574
  if (next.permissions?.localTools && typeof next.permissions.localTools === "object") {
10437
10575
  for (const tool of Object.keys(next.permissions.localTools)) {
10438
10576
  if (!ALL_TOOL_ALIASES.includes(tool)) {
@@ -10458,6 +10596,11 @@ function sanitizeConfig(config) {
10458
10596
  next.ai.model = IOLA_LOCAL_MODEL;
10459
10597
  next.ai.baseUrl = next.ai.baseUrl || "http://127.0.0.1:11434";
10460
10598
  }
10599
+ for (const profile of Object.values(next.ai?.profiles || {})) {
10600
+ if (profile?.provider === "openai" || profile?.provider === "openrouter") {
10601
+ profile.networkMode = profile.networkMode || "gateway";
10602
+ }
10603
+ }
10461
10604
  return next;
10462
10605
  }
10463
10606
 
@@ -10471,6 +10614,7 @@ function validateConfig(config) {
10471
10614
  for (const [name, profile] of Object.entries(config.ai?.profiles || {})) {
10472
10615
  if (!["iola", "ollama", "openai", "openrouter", "codex"].includes(profile.provider)) errors.push(`ai.profiles.${name}.provider неизвестен`);
10473
10616
  if (profile.provider !== "codex" && profile.provider !== "iola" && !profile.baseUrl) errors.push(`ai.profiles.${name}.baseUrl обязателен`);
10617
+ if (profile.networkMode && !["direct", "gateway", "auto"].includes(profile.networkMode)) errors.push(`ai.profiles.${name}.networkMode должен быть direct, gateway или auto`);
10474
10618
  }
10475
10619
  for (const tool of Object.keys(config.permissions?.localTools || {})) {
10476
10620
  if (!ALL_TOOL_ALIASES.includes(tool)) errors.push(`permissions.localTools.${tool} неизвестен`);