@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.
- package/package.json +1 -1
- package/src/cli.js +175 -31
package/package.json
CHANGED
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"
|
|
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
|
|
4864
|
+
const key = (await askText(`Введите ${envName}: `)).trim();
|
|
4810
4865
|
|
|
4811
|
-
|
|
4812
|
-
|
|
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
|
|
6107
|
-
|
|
6108
|
-
|
|
6109
|
-
|
|
6110
|
-
|
|
6111
|
-
|
|
6112
|
-
|
|
6113
|
-
|
|
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)
|
|
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)
|
|
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} неизвестен`);
|