@iola_adm/iola-cli 0.2.1 → 0.2.3

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.2.1",
3
+ "version": "0.2.3",
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
@@ -10,6 +10,7 @@ import readline from "node:readline/promises";
10
10
  import { Readable } from "node:stream";
11
11
  import { stdin as input, stdout as output } from "node:process";
12
12
  import { DatabaseSync } from "node:sqlite";
13
+ import tls from "node:tls";
13
14
  import { fileURLToPath } from "node:url";
14
15
  import { inflateRawSync, inflateSync } from "node:zlib";
15
16
 
@@ -6695,7 +6696,7 @@ async function aiAsk(args, context = {}) {
6695
6696
  const historyEnabled = !options.bare && !options["no-history"] && isFeatureEnabled("sqlite-history");
6696
6697
  const sessionId = historyEnabled && isFeatureEnabled("sessions") ? ensureSessionForAsk(options, providerConfig, question) : null;
6697
6698
  const history = context.history || (sessionId ? getSessionAiHistory(sessionId) : []);
6698
- const directAnswer = buildDirectDataAnswer(question, dataContext);
6699
+ const directAnswer = await buildDirectDataAnswer(question, dataContext);
6699
6700
  if (directAnswer) {
6700
6701
  if (historyEnabled) {
6701
6702
  recordAskHistory({ question, answer: directAnswer, providerConfig, dataContext, error: "", sessionId });
@@ -6758,10 +6759,12 @@ async function aiAsk(args, context = {}) {
6758
6759
  return answer;
6759
6760
  }
6760
6761
 
6761
- function buildDirectDataAnswer(question, dataContext) {
6762
+ async function buildDirectDataAnswer(question, dataContext) {
6762
6763
  const normalized = question.toLocaleLowerCase("ru-RU");
6763
6764
  const requestedFields = detectDirectDataFields(normalized);
6764
6765
  if (requestedFields.length === 0) return "";
6766
+ const educationAnswer = await buildDeterministicEducationAnswer(question, requestedFields);
6767
+ if (educationAnswer) return educationAnswer;
6765
6768
  const rows = [
6766
6769
  ...dataContext.schools.map((item) => ({ layer: "schools", layerName: "школы", ...item })),
6767
6770
  ...dataContext.kindergartens.map((item) => ({ layer: "kindergartens", layerName: "детские сады", ...item })),
@@ -6791,6 +6794,140 @@ function detectDirectDataFields(normalizedQuestion) {
6791
6794
  return [...new Set(fields)];
6792
6795
  }
6793
6796
 
6797
+ async function buildDeterministicEducationAnswer(question, requestedFields) {
6798
+ const normalized = String(question || "").toLocaleLowerCase("ru-RU");
6799
+ const layer = /(сад|детсад|детск\w*\s+сад|садик)/iu.test(normalized)
6800
+ ? "kindergartens"
6801
+ : /(школ|сош|лице|гимнази)/iu.test(normalized)
6802
+ ? "schools"
6803
+ : "";
6804
+ if (!layer) return "";
6805
+
6806
+ const requests = extractEducationFactRequests(question, requestedFields, layer);
6807
+ if (requests.length === 0) return "";
6808
+
6809
+ const items = normalizeItems(await fetchAllApiItems(`${await getApiBaseUrl()}/${DATASETS[layer].endpoint}`))
6810
+ .map((item) => ({ layer, layerName: layer === "schools" ? "школы" : "детские сады", ...selectPublicSummary(item) }));
6811
+ const answers = [];
6812
+
6813
+ for (const request of requests) {
6814
+ const answer = resolveEducationFactRequest(request, items, layer);
6815
+ if (answer) answers.push(answer);
6816
+ }
6817
+
6818
+ return answers.filter(Boolean).join("\n");
6819
+ }
6820
+
6821
+ function extractEducationFactRequests(question, requestedFields, layer) {
6822
+ const normalized = String(question || "").toLocaleLowerCase("ru-RU");
6823
+ const segments = splitEducationQuestionSegments(question);
6824
+ const requests = [];
6825
+
6826
+ for (const segment of segments) {
6827
+ const segmentFields = detectDirectDataFields(segment.toLocaleLowerCase("ru-RU"));
6828
+ const fields = segmentFields.length > 0 ? segmentFields : (segments.length === 1 ? requestedFields : []);
6829
+ if (fields.length === 0) continue;
6830
+ const place = detectEducationPlace(segment) || detectEducationPlace(question);
6831
+ const number = extractEntityNumberFromQuestion(segment, layer)
6832
+ || (segments.length === 1 ? extractEntityNumberFromQuestion(question, layer) : "");
6833
+ const hasEntitySignal = Boolean(number || place || /(школ|сош|лице|гимнази|сад|детсад|детск\w*\s+сад|садик)/iu.test(segment));
6834
+ if (!hasEntitySignal) continue;
6835
+ requests.push({ segment, fields, number, place });
6836
+ }
6837
+
6838
+ if (requests.length === 0 && requestedFields.length > 0 && /(школ|сош|лице|гимнази|сад|детсад|детск\w*\s+сад|садик)/iu.test(normalized)) {
6839
+ requests.push({
6840
+ segment: question,
6841
+ fields: requestedFields,
6842
+ number: extractEntityNumberFromQuestion(question, layer),
6843
+ place: detectEducationPlace(question),
6844
+ });
6845
+ }
6846
+
6847
+ return requests;
6848
+ }
6849
+
6850
+ function splitEducationQuestionSegments(question) {
6851
+ const text = String(question || "").trim();
6852
+ if (!text) return [];
6853
+ return text
6854
+ .split(/\s+(?:и|а также|,|;)\s+(?=(?:кто|какой|какая|адрес|телефон|инн|сайт|почт|email|директ|руковод|завед|где))/iu)
6855
+ .map((part) => part.trim())
6856
+ .filter(Boolean);
6857
+ }
6858
+
6859
+ function detectEducationPlace(text) {
6860
+ const normalized = normalizeEntityText(text || "");
6861
+ if (/(козьмодемьянск|козьмодемьянск[аеуом]?|козьмодемьянск\w*)/iu.test(normalized)) {
6862
+ return { id: "kozmodemyansk", label: "Козьмодемьянск", locative: "Козьмодемьянске", supported: false, aliases: ["козьмодемьянск", "козмодемьянск"] };
6863
+ }
6864
+ if (/(семеновк|семёновк)/iu.test(normalized)) {
6865
+ return { id: "semenovka", label: "Семёновка", locative: "Семёновке", supported: true, aliases: ["семеновк", "семёновк"] };
6866
+ }
6867
+ if (/(йошкар|йошкар-ола|йошкар ола)/iu.test(normalized)) {
6868
+ return { id: "yoshkar_ola", label: "Йошкар-Ола", locative: "Йошкар-Оле", supported: true, aliases: ["йошкар", "йошкар-ола", "йошкар ола"] };
6869
+ }
6870
+ return null;
6871
+ }
6872
+
6873
+ function resolveEducationFactRequest(request, items, layer) {
6874
+ const entityLabel = layer === "schools" ? "школу" : "детский сад";
6875
+ if (request.place && !request.place.supported) {
6876
+ return `В текущих открытых данных iola-cli есть данные городского округа Йошкар-Ола. Данных по ${request.place.locative || request.place.label} в этом слое нет, поэтому ответить по этому объекту не могу.`;
6877
+ }
6878
+
6879
+ let candidates = items;
6880
+ if (request.place) {
6881
+ candidates = candidates.filter((item) => itemMatchesPlace(item, request.place));
6882
+ }
6883
+
6884
+ if (request.number) {
6885
+ const exactByNumber = candidates.filter((item) => itemNameHasNumber(item, request.number));
6886
+ if (exactByNumber.length === 0) {
6887
+ if (request.place && candidates.length > 0) {
6888
+ return [
6889
+ `Точную ${entityLabel} № ${request.number} в ${request.place.locative || request.place.label} в открытом слое не нашел.`,
6890
+ `В ${request.place.locative || request.place.label} есть:`,
6891
+ ...candidates.slice(0, 5).map((item) => `- ${getDirectDataItemName(item)}${item.address ? `; адрес: ${item.address}` : ""}${item.inn ? `; ИНН ${item.inn}` : ""}`),
6892
+ ].join("\n");
6893
+ }
6894
+ return `В открытом слое не нашел ${entityLabel} № ${request.number}.`;
6895
+ }
6896
+ candidates = exactByNumber;
6897
+ }
6898
+
6899
+ if (candidates.length === 0) {
6900
+ const placeText = request.place ? ` в ${request.place.locative || request.place.label}` : "";
6901
+ return `В открытом слое не нашел ${entityLabel}${placeText}.`;
6902
+ }
6903
+
6904
+ if (candidates.length > 1 && !request.number) {
6905
+ return [
6906
+ `Нашел несколько подходящих записей${request.place ? ` для ${request.place.locative || request.place.label}` : ""}:`,
6907
+ ...candidates.slice(0, 5).flatMap((item) => [
6908
+ `- ${getDirectDataItemName(item)}`,
6909
+ ...request.fields.map((field) => ` ${formatDirectDataField(field, item)}`).filter(Boolean),
6910
+ ` Источник: слой ${item.layer}, ИНН ${item.inn || "-"}.`,
6911
+ ]),
6912
+ ].join("\n");
6913
+ }
6914
+
6915
+ const item = candidates[0];
6916
+ const lines = request.fields.map((field) => formatDirectDataField(field, item)).filter(Boolean);
6917
+ if (lines.length === 0) return "";
6918
+ return [
6919
+ ...lines,
6920
+ `Источник: слой ${item.layer}, ${getDirectDataItemName(item)}, ИНН ${item.inn || "-"}.`,
6921
+ ].join("\n");
6922
+ }
6923
+
6924
+ function itemMatchesPlace(item, place) {
6925
+ if (!place) return true;
6926
+ const text = normalizeEntityText(`${item.name || ""} ${item.address || ""} ${item.legal_address || ""} ${item.fns_full_name || ""} ${item.fns_short_name || ""}`);
6927
+ if (place.id === "yoshkar_ola") return /йошкар|йошкар-ола|йошкар ола/u.test(text) && !/семеновк/u.test(text);
6928
+ return place.aliases.some((alias) => text.includes(normalizeEntityText(alias)));
6929
+ }
6930
+
6794
6931
  function pickDirectDataItem(question, dataContext, rows) {
6795
6932
  const patterns = dataContext.query?.patterns || extractStructuredPatterns(question);
6796
6933
  const targetLayers = patterns.targetLayers || [];
@@ -8667,18 +8804,23 @@ async function callGigaChat(config, messages) {
8667
8804
  }
8668
8805
 
8669
8806
  const token = await getGigaChatAccessToken(config, authKey);
8670
- const response = await fetch(`${String(config.baseUrl || "https://gigachat.devices.sberbank.ru/api/v1").replace(/\/+$/, "")}/chat/completions`, {
8671
- method: "POST",
8672
- headers: {
8673
- authorization: `Bearer ${token}`,
8674
- "content-type": "application/json",
8675
- },
8676
- body: JSON.stringify({
8677
- model: config.model || "GigaChat-2",
8678
- messages,
8679
- temperature: Number(config.temperature ?? 0.2),
8680
- }),
8681
- });
8807
+ let response;
8808
+ try {
8809
+ response = await fetch(`${String(config.baseUrl || "https://gigachat.devices.sberbank.ru/api/v1").replace(/\/+$/, "")}/chat/completions`, {
8810
+ method: "POST",
8811
+ headers: {
8812
+ authorization: `Bearer ${token}`,
8813
+ "content-type": "application/json",
8814
+ },
8815
+ body: JSON.stringify({
8816
+ model: config.model || "GigaChat-2",
8817
+ messages,
8818
+ temperature: Number(config.temperature ?? 0.2),
8819
+ }),
8820
+ });
8821
+ } catch (error) {
8822
+ throw new Error(formatProviderFetchError("GigaChat", error));
8823
+ }
8682
8824
 
8683
8825
  if (!response.ok) {
8684
8826
  const text = await response.text();
@@ -8690,17 +8832,23 @@ async function callGigaChat(config, messages) {
8690
8832
  }
8691
8833
 
8692
8834
  async function getGigaChatAccessToken(config, authKey) {
8835
+ enableSystemCaForGigaChat();
8693
8836
  const secrets = await loadSecrets();
8694
8837
  const scope = process.env.GIGACHAT_SCOPE || secrets.gigachat?.scope || config.scope || "GIGACHAT_API_PERS";
8695
- const response = await fetch(config.authUrl || "https://ngw.devices.sberbank.ru:9443/api/v2/oauth", {
8696
- method: "POST",
8697
- headers: {
8698
- authorization: `Basic ${authKey}`,
8699
- "content-type": "application/x-www-form-urlencoded",
8700
- RqUID: randomUUID(),
8701
- },
8702
- body: new URLSearchParams({ scope }).toString(),
8703
- });
8838
+ let response;
8839
+ try {
8840
+ response = await fetch(config.authUrl || "https://ngw.devices.sberbank.ru:9443/api/v2/oauth", {
8841
+ method: "POST",
8842
+ headers: {
8843
+ authorization: `Basic ${authKey}`,
8844
+ "content-type": "application/x-www-form-urlencoded",
8845
+ RqUID: randomUUID(),
8846
+ },
8847
+ body: new URLSearchParams({ scope }).toString(),
8848
+ });
8849
+ } catch (error) {
8850
+ throw new Error(formatProviderFetchError("GigaChat OAuth", error));
8851
+ }
8704
8852
 
8705
8853
  if (!response.ok) {
8706
8854
  const text = await response.text();
@@ -8712,6 +8860,37 @@ async function getGigaChatAccessToken(config, authKey) {
8712
8860
  return payload.access_token;
8713
8861
  }
8714
8862
 
8863
+ let systemCaForGigaChatEnabled = false;
8864
+
8865
+ function enableSystemCaForGigaChat() {
8866
+ if (systemCaForGigaChatEnabled || process.env.GIGACHAT_DISABLE_SYSTEM_CA === "1") return;
8867
+ systemCaForGigaChatEnabled = true;
8868
+
8869
+ try {
8870
+ if (typeof tls.getCACertificates !== "function" || typeof tls.setDefaultCACertificates !== "function") return;
8871
+ const certificates = [
8872
+ ...tls.getCACertificates("system"),
8873
+ ...tls.getCACertificates("bundled"),
8874
+ ...tls.getCACertificates("extra"),
8875
+ ];
8876
+ if (certificates.length > 0) tls.setDefaultCACertificates([...new Set(certificates)]);
8877
+ } catch {
8878
+ // Older Node builds may not expose system CA management. The fetch error below
8879
+ // will include the concrete TLS/network cause and the manual workaround.
8880
+ }
8881
+ }
8882
+
8883
+ function formatProviderFetchError(provider, error) {
8884
+ const cause = error?.cause;
8885
+ const causeCode = cause?.code ? `${cause.code}: ` : "";
8886
+ const causeMessage = cause?.message || "";
8887
+ const details = `${error?.message || "fetch failed"}${causeMessage ? ` (${causeCode}${causeMessage})` : ""}`;
8888
+ if (/SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_GET_ISSUER_CERT|CERT_/i.test(`${cause?.code || ""} ${causeMessage}`)) {
8889
+ return `${provider} network error: ${details}\nNode не доверяет цепочке сертификатов провайдера. CLI пробует использовать системные сертификаты ОС автоматически; если ошибка повторяется, обновите Node.js или запустите CLI с NODE_OPTIONS=--use-system-ca.`;
8890
+ }
8891
+ return `${provider} network error: ${details}`;
8892
+ }
8893
+
8715
8894
  function getAiNetworkMode(config = {}) {
8716
8895
  return validateAiNetworkMode(AI_NETWORK_MODE || config.networkMode || "gateway");
8717
8896
  }
@@ -84,4 +84,18 @@ if (deletePlan.willKeep.includes("npm package files")) {
84
84
  throw new Error("delete dry-run should not keep npm package files");
85
85
  }
86
86
 
87
+ const multiSchoolAnswer = await runCli(["ask", "Кто директор школы № 2 и адрес школы № 7?", "--profile", "yandexgpt", "--no-history"]);
88
+ assertIncludes(multiSchoolAnswer, "Адамова Наталья Васильевна", "multi school answer");
89
+ assertIncludes(multiSchoolAnswer, "улица Первомайская, дом 89", "multi school answer");
90
+ assertNotIncludes(multiSchoolAnswer, "улица Осипенко, дом 46\nИсточник: слой schools, МБОУ \"Средняя общеобразовательная школа № 7", "multi school answer");
91
+
92
+ const externalTownAnswer = await runCli(["ask", "Адрес школы № 1 Козьмодемьянска", "--profile", "yandexgpt", "--no-history"]);
93
+ assertIncludes(externalTownAnswer, "Данных по Козьмодемьянске", "external town answer");
94
+ assertNotIncludes(externalTownAnswer, "улица Петрова, дом 15", "external town answer");
95
+
96
+ const semenovkaAnswer = await runCli(["ask", "Адрес школы № 1 Семеновки", "--profile", "yandexgpt", "--no-history"]);
97
+ assertIncludes(semenovkaAnswer, "Точную школу № 1 в Семёновке", "semenovka answer");
98
+ assertIncludes(semenovkaAnswer, "село Семёновка", "semenovka answer");
99
+ assertNotIncludes(semenovkaAnswer, "улица Петрова, дом 15", "semenovka answer");
100
+
87
101
  console.log("smoke tests passed");
@@ -29,40 +29,139 @@ iola ask "найди школы на Петрова"
29
29
 
30
30
  ### YandexGPT
31
31
 
32
- Официальные страницы:
32
+ Для CLI нужны две вещи: API key и ID каталога Yandex Cloud.
33
+
34
+ Пошаговая настройка:
35
+
36
+ 1. Откройте консоль Yandex Cloud: `https://console.yandex.cloud/`.
37
+ 2. Войдите в аккаунт Яндекса.
38
+ 3. Если Yandex Cloud попросит создать облако, создайте облако. Название может быть любым, например `iola-cli`.
39
+ 4. Откройте каталог внутри облака. Если каталога нет, создайте каталог. Название может быть любым, например `default`.
40
+ 5. На верхней панели страницы каталога найдите название каталога и его идентификатор. В нашем примере рядом с `default` был показан ID вида `b1g8...`.
41
+ 6. Нажмите кнопку `Копировать` рядом с идентификатором каталога. Это значение будет `YANDEXGPT_FOLDER_ID`.
42
+ 7. В левом меню или через поиск сервисов откройте `Identity and Access Management`.
43
+ 8. Внутри `Identity and Access Management` откройте раздел `Сервисные аккаунты`.
44
+ 9. Нажмите `Создать сервисный аккаунт`.
45
+ 10. В поле `Имя` введите понятное имя, например `iola-cli`.
46
+ 11. В поле `Описание` можно написать `API-доступ iola-cli к YandexGPT`.
47
+ 12. В блоке `Роли в каталоге` нажмите `Добавить роль`.
48
+ 13. В поле поиска роли введите `ai.languageModels.user`.
49
+ 14. В найденной группе `ai.languageModels` выберите роль `user`. В форме должна появиться роль `ai.languageModels.user`.
50
+ 15. Нажмите `Создать`.
51
+ 16. После создания откройте созданный сервисный аккаунт `iola-cli`.
52
+ 17. На странице сервисного аккаунта нажмите кнопку `Создать новый ключ`.
53
+ 18. В выпадающем меню выберите `Создать API-ключ`. Не выбирайте `Создать статический ключ доступа` и не выбирайте `Создать авторизованный ключ`.
54
+ 19. В поле `Описание` напишите, например, `iola-cli YandexGPT API key`.
55
+ 20. В поле `Область действия` выберите `yc.ai.foundationModels.execute`. Если список пустой, начните вводить это значение в поле фильтра и выберите найденный вариант.
56
+ 21. Поле `Срок действия` можно оставить пустым или выбрать дату окончания действия ключа по вашей политике безопасности.
57
+ 22. Нажмите `Создать`.
58
+ 23. Yandex Cloud покажет `Идентификатор ключа` и `Ваш секретный ключ`. Секретный ключ показывается только один раз.
59
+ 24. Скопируйте `Ваш секретный ключ` и сохраните его локально. Это значение будет `YANDEXGPT_API_KEY`.
60
+ 25. Сохраните ключ в CLI:
33
61
 
34
- - документация Foundation Models: `https://yandex.cloud/ru/docs/foundation-models/`;
35
- - аутентификация API: `https://yandex.cloud/ru/docs/ai-studio/api-ref/authentication`;
36
- - тарифы: `https://yandex.cloud/ru/docs/foundation-models/pricing`.
62
+ ```bash
63
+ iola ai key set yandexgpt
64
+ ```
65
+
66
+ CLI попросит ввести:
37
67
 
38
- Для CLI нужны API key и ID каталога Yandex Cloud:
68
+ - `YANDEXGPT_API_KEY` - API-ключ сервисного аккаунта;
69
+ - `YANDEXGPT_FOLDER_ID` - ID каталога Yandex Cloud.
70
+
71
+ Важно: `Идентификатор ключа` - это не API-ключ для CLI. Для CLI нужен именно `Ваш секретный ключ`. Если закрыть окно создания ключа, секретный ключ больше нельзя посмотреть. В этом случае удалите старый API-ключ и создайте новый.
72
+
73
+ После сохранения ключа выберите профиль и модель:
39
74
 
40
75
  ```bash
41
- iola ai key set yandexgpt
42
76
  iola ai setup yandexgpt --model yandexgpt-lite/latest
43
77
  ```
44
78
 
79
+ Через интерактивное меню:
80
+
81
+ ```text
82
+ /model
83
+ 2. Российские AI (YandexGPT/GigaChat)
84
+ 1. YandexGPT API
85
+ ```
86
+
87
+ Доступные модели в CLI:
88
+
89
+ - `yandexgpt-lite/latest` - быстрый и более дешевый вариант;
90
+ - `yandexgpt/latest` - YandexGPT Pro latest;
91
+ - `yandexgpt/rc` - release candidate.
92
+
45
93
  CLI также понимает env-переменные `YANDEXGPT_API_KEY` или `YANDEX_CLOUD_API_KEY`, а для каталога - `YANDEXGPT_FOLDER_ID` или `YANDEX_CLOUD_FOLDER_ID`.
46
94
 
47
95
  ### GigaChat
48
96
 
49
- Официальные страницы:
97
+ Для CLI нужен authorization key. Это не одноразовый OAuth access token: CLI сам получает OAuth-токен перед запросом, используя сохраненный authorization key.
98
+
99
+ Пошаговая настройка:
100
+
101
+ 1. Перейдите на портал разработчиков Сбера: `https://developers.sber.ru/`.
102
+ 2. Войдите в аккаунт. Если вход еще не выполнен, портал откроет страницу авторизации: `https://developers.sber.ru/studio/login`.
103
+ 3. Откройте страницу GigaChat API. Прямой путь: `https://developers.sber.ru/studio/workspaces/my-space/get/gigachat-api`.
104
+ 4. Нажмите кнопку подключения или `Попробовать`. После входа откроется форма создания проекта.
105
+ 5. В форме `Почти готово` заполните `Название проекта`. Можно оставить предложенное название `Мой GigaChat API` или указать `iola-cli`.
106
+ 6. В блоке `Группа проекта` нажмите `Выбрать группу`.
107
+ 7. Если есть вариант `Без группы`, выберите его. Если создание проекта после этого недоступно, выберите `Создать группу`, задайте понятное имя, например `iola-cli`, и сохраните группу.
108
+ 8. Нажмите `Создать проект`.
109
+ 9. После создания проекта откроется страница GigaChat API. В верхней части будет название проекта, например `Мой GigaChat API`.
110
+ 10. На странице проекта нажмите `Настроить API`. Если кнопки не видно, откройте раздел настроек проекта GigaChat API в левом меню.
111
+ 11. На странице `Настройка API` найдите блок `Данные для авторизации запросов к API`.
112
+ 12. Проверьте поле `Scope`. Для личного Freemium-доступа обычно указан `GIGACHAT_API_PERS`. Это значение понадобится CLI.
113
+ 13. В блоке `Ключ авторизации` нажмите `Получить ключ`.
114
+ 14. Откроется окно `Сохраните ваш Authorization Key`. Не закрывайте его, пока не сохраните данные.
115
+ 15. Скопируйте поле `Authorization Key`. Это и есть значение `GIGACHAT_AUTH_KEY` для CLI.
116
+ 16. При желании также сохраните `Client ID` и `Client Secret`, но CLI они не нужны, если вы уже скопировали `Authorization Key`.
117
+ 17. Нажмите `Готово` только после сохранения ключа.
118
+ 18. Сохраните ключ локально в CLI:
119
+
120
+ ```bash
121
+ iola ai key set gigachat
122
+ ```
123
+
124
+ CLI попросит ввести:
50
125
 
51
- - документация GigaChat: `https://developers.sber.ru/docs/ru/gigachat/overview`;
52
- - получение OAuth-токена: `https://developers.sber.ru/docs/ru/gigachat/api/reference/rest/post-token`;
53
- - тарифы: `https://developers.sber.ru/docs/ru/gigachat/tariffs`.
126
+ - `GIGACHAT_AUTH_KEY` - authorization key;
127
+ - `scope` - по умолчанию `GIGACHAT_API_PERS` для персонального доступа.
54
128
 
55
- Для CLI нужен authorization key:
129
+ Важно: `Authorization Key` показывается один раз. Если закрыть окно и не сохранить ключ, его нельзя посмотреть повторно. В этом случае нажмите `Получить новый ключ` и сохраните новое значение. `Authorization Key` - это уже готовая строка для заголовка `Authorization: Basic ...`; вручную кодировать `Client ID:Client Secret` в base64 не нужно.
130
+
131
+ После сохранения ключа выберите профиль и модель:
56
132
 
57
133
  ```bash
58
- iola ai key set gigachat
59
134
  iola ai setup gigachat --model GigaChat-2
60
135
  ```
61
136
 
137
+ Через интерактивное меню:
138
+
139
+ ```text
140
+ /model
141
+ 2. Российские AI (YandexGPT/GigaChat)
142
+ 2. GigaChat API
143
+ ```
144
+
145
+ Доступные модели в CLI:
146
+
147
+ - `GigaChat-2` - основная модель;
148
+ - `GigaChat-2-Pro` - повышенное качество;
149
+ - `GigaChat-2-Max` - максимальное качество;
150
+ - `GigaChat` - legacy/fallback.
151
+
62
152
  CLI также понимает env-переменные `GIGACHAT_AUTH_KEY` или `GIGACHAT_API_KEY`. По умолчанию используется scope `GIGACHAT_API_PERS`; при необходимости его можно задать через `GIGACHAT_SCOPE`.
63
153
 
64
154
  По тарифам: у GigaChat для физических лиц есть Freemium-лимит на токены; для больших объемов используются платные пакеты. У YandexGPT тарификация идет через Yandex Cloud по токенам и квотам аккаунта, поэтому актуальные бесплатные гранты или лимиты нужно проверять в консоли Yandex Cloud.
65
155
 
156
+ Официальная документация:
157
+
158
+ - Yandex Foundation Models: `https://yandex.cloud/ru/docs/foundation-models/`;
159
+ - Yandex AI Studio authentication: `https://yandex.cloud/ru/docs/ai-studio/api-ref/authentication`;
160
+ - Yandex Foundation Models pricing: `https://yandex.cloud/ru/docs/foundation-models/pricing`;
161
+ - GigaChat overview: `https://developers.sber.ru/docs/ru/gigachat/overview`;
162
+ - GigaChat OAuth token: `https://developers.sber.ru/docs/ru/gigachat/api/reference/rest/post-token`;
163
+ - GigaChat tariffs: `https://developers.sber.ru/docs/ru/gigachat/tariffs`.
164
+
66
165
  ## OpenAI
67
166
 
68
167
  Получение ключа OpenAI Platform: