min-agent 0.5.0 → 0.5.1

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/README.md CHANGED
@@ -76,6 +76,7 @@ Type `/` to open a command menu. Use **↑ / ↓** to recall previous messages.
76
76
  | `/mcp` | Show connected MCP servers |
77
77
  | `/skills` | Manage skills |
78
78
  | `/tokens` | Usage and estimated cost |
79
+ | `/ctx` | Choose local-model context window |
79
80
  | `/help` | Show all commands |
80
81
  | `/exit` | Exit |
81
82
 
@@ -117,6 +118,12 @@ In chat, `/memory on` or `/memory off` toggles it; `/memory` lists notes; `/memo
117
118
 
118
119
  Run `min-agent setup` for a guided setup — this is the easiest way to add or switch AI providers.
119
120
 
121
+ ```bash
122
+ min-agent setup --type ollama
123
+ min-agent setup --type openai-compatible --url https://api.example.com/v1 --api-key "$KEY" --name groq --default-model llama-3.1-70b
124
+ min-agent setup --switch ollama
125
+ ```
126
+
120
127
  Settings live in `~/.min-agent/config.json` (global). A project can have its own `.min-agent/config.json` that only applies there.
121
128
 
122
129
  ## Extending It
@@ -194,6 +201,7 @@ min-agent --provider anthropic "你的消息"
194
201
  | `/mcp` | 查看已连接的 MCP 服务 |
195
202
  | `/skills` | 管理技能 |
196
203
  | `/tokens` | 查看用量与预估费用 |
204
+ | `/ctx` | 选择本地模型上下文窗口 |
197
205
  | `/help` | 查看全部命令 |
198
206
  | `/exit` | 退出 |
199
207
 
@@ -235,6 +243,12 @@ min-agent memory add "prefer bun"
235
243
 
236
244
  运行 `min-agent setup` 即可通过引导式流程完成配置,这是添加或切换 AI 服务商最简单的方式。
237
245
 
246
+ ```bash
247
+ min-agent setup --type ollama
248
+ min-agent setup --type openai-compatible --url https://api.example.com/v1 --api-key "$KEY" --name groq --default-model llama-3.1-70b
249
+ min-agent setup --switch ollama
250
+ ```
251
+
238
252
  配置保存在 `~/.min-agent/config.json`(全局生效)。也可以在某个项目下单独创建 `.min-agent/config.json`,仅对该项目生效。
239
253
 
240
254
  ## 扩展能力
package/dist/agent.js CHANGED
@@ -21,6 +21,7 @@ import { XmlSearchSplitter } from "./xml-search.js";
21
21
  import { printHeader, printDivider, printToolCall, printToolResult, printDone } from "./output.js";
22
22
  import { createTodoTool, captureGoal, copyTaskState, emptyTaskState, formatTaskStatePrompt, } from "./tools/todo.js";
23
23
  import { getContextWindowInfo } from "./context-window.js";
24
+ import { formatTokenCount } from "./token-display.js";
24
25
  import { log, logToolCall, logToolResult, startRunLog, nextRunPass, endRunLog } from "./logger.js";
25
26
  import { markSyntheticMessage, isSyntheticMessage } from "./synthetic.js";
26
27
  import { runWithInstructionTracker, resetActiveInstructionTracker } from "./instructions.js";
@@ -269,14 +270,34 @@ function sessionStateMessage(volatile) {
269
270
  return null;
270
271
  return { role: "system", content: [SESSION_STATE_HEADER, ...volatile].join("\n\n") };
271
272
  }
273
+ export function parseContextOverflow(msg) {
274
+ if (!isContextOverflowError(msg))
275
+ return null;
276
+ const prompt = msg.match(/n_prompt_tokens["\s:=]+(\d+)/i)?.[1] ?? msg.match(/request\s*\((\d+)\s*tokens?\)/i)?.[1];
277
+ const ctx = msg.match(/n_ctx["\s:=]+(\d+)/i)?.[1] ?? msg.match(/context size\s*\((\d+)\s*tokens?\)/i)?.[1];
278
+ return {
279
+ ...(prompt ? { promptTokens: Number(prompt) } : {}),
280
+ ...(ctx ? { ctxTokens: Number(ctx) } : {}),
281
+ };
282
+ }
283
+ function formatContextOverflowMessage(msg) {
284
+ const info = parseContextOverflow(msg);
285
+ const prompt = info?.promptTokens != null ? formatTokenCount(info.promptTokens) : null;
286
+ const ctx = info?.ctxTokens != null ? formatTokenCount(info.ctxTokens) : null;
287
+ if (prompt && ctx)
288
+ return `请求内容约 ${prompt} token,超过当前上下文窗口 ${ctx}。请把窗口调大后再试。`;
289
+ return "请求内容超过当前上下文窗口。请把窗口调大后再试。";
290
+ }
272
291
  function formatErrorMessage(msg) {
292
+ if (isContextOverflowError(msg))
293
+ return formatContextOverflowMessage(msg);
273
294
  if (msg.includes("API key") || msg.includes("Unauthorized") || msg.includes("Forbidden")) {
274
295
  return "Authentication error: Check your API key.";
275
296
  }
276
297
  if (msg.includes("429") || msg.includes("rate limit") || msg.includes("Rate limit")) {
277
298
  return "Rate limited after retries. Please wait and try again.";
278
299
  }
279
- if (msg.includes("timeout") || msg.includes("ETIMEDOUT") || msg.includes("ECONNRESET")) {
300
+ if (msg.includes("timeout") || msg.includes("ETIMEDOUT")) {
280
301
  return `Network error (retries exhausted): ${msg}`;
281
302
  }
282
303
  return msg;
@@ -292,7 +313,7 @@ function safeText(value) {
292
313
  }
293
314
  }
294
315
  function isContextOverflowError(msg) {
295
- return /context[\s_-]*(length|window|size)|context_length_exceeded|maximum context|too many tokens|prompt (is )?too long|reduce the (prompt )?length|token limit exceeded/i.test(msg);
316
+ return /exceed_context_size_error|context[\s_-]*(length|window|size)|context_length_exceeded|maximum context|too many tokens|prompt (is )?too long|reduce the (prompt )?length|token limit exceeded/i.test(msg);
296
317
  }
297
318
  function invokeCallback(fn, ...args) {
298
319
  if (!fn)
@@ -606,7 +627,7 @@ async function runOnceCoreLoop(messages, systemPrompt, modelId, abortSignal, cal
606
627
  log("error", msg);
607
628
  hasError = true;
608
629
  if (cbs.onStreamError)
609
- invokeCallback(cbs.onStreamError, msg);
630
+ invokeCallback(cbs.onStreamError, formatErrorMessage(msg));
610
631
  else
611
632
  console.error(`\x1b[31m${formatErrorMessage(msg)}\x1b[0m`);
612
633
  return finish({ aborted: Boolean(abortSignal?.aborted), maxStepsReached: false });
@@ -630,6 +651,25 @@ async function runOnceCoreLoop(messages, systemPrompt, modelId, abortSignal, cal
630
651
  applyToolPrune(messages, pruneBudget());
631
652
  continue;
632
653
  }
654
+ // Prompt (system + tools + messages) does not fit the loaded window.
655
+ // Retrying the same request cannot help; compaction only helps if history is long.
656
+ if (inner.contextPressure && inner.stepCount === 0 && !inner.lastStepHadTools) {
657
+ const compacted = await applyCompaction(messages, model, tracker, cbs, callbacks, { ...compactCfg, force: true }, pruneBudget());
658
+ if (compacted) {
659
+ continues++;
660
+ if (continues > maxContinues) {
661
+ return finish({ aborted: false, maxStepsReached: true, incomplete: true });
662
+ }
663
+ continue;
664
+ }
665
+ hasError = true;
666
+ const display = formatErrorMessage(inner.overflowError ?? "");
667
+ if (cbs.onStreamError)
668
+ invokeCallback(cbs.onStreamError, display);
669
+ else
670
+ console.error(`\x1b[31m${display}\x1b[0m`);
671
+ return finish({ aborted: false, maxStepsReached: false });
672
+ }
633
673
  // The provider produced nothing at all (no text, no reasoning-backed reply,
634
674
  // no tool call): a transport / gateway hiccup rather than model intent.
635
675
  // Re-send the same request with backoff instead of nudging the model.
@@ -794,6 +834,7 @@ class StreamRenderer {
794
834
  thinkingSplit = new ThinkingBodySplitter();
795
835
  xmlSplit = new XmlSearchSplitter();
796
836
  rawText = "";
837
+ hadThinking = false;
797
838
  thinkingOpen = false;
798
839
  constructor(cbs, interactive) {
799
840
  this.cbs = cbs;
@@ -802,6 +843,7 @@ class StreamRenderer {
802
843
  emitThinking(t) {
803
844
  if (!t)
804
845
  return;
846
+ this.hadThinking = true;
805
847
  if (this.cbs.onThinkingDelta) {
806
848
  invokeCallback(this.cbs.onThinkingDelta, t);
807
849
  return;
@@ -926,6 +968,7 @@ class InnerStreamMachine {
926
968
  stepFinishSeen = false;
927
969
  stepUsage;
928
970
  totalUsage;
971
+ overflowError;
929
972
  constructor(params) {
930
973
  this.params = params;
931
974
  this.assembler = new TurnAssembler(params.messages, (info) => {
@@ -1200,6 +1243,7 @@ class InnerStreamMachine {
1200
1243
  }
1201
1244
  if (isContextOverflowError(errorMsg)) {
1202
1245
  this.stop.context = true;
1246
+ this.overflowError = errorMsg;
1203
1247
  log("warn", `context overflow: ${errorMsg}`);
1204
1248
  await this.flushOutputAndStep();
1205
1249
  this.innerController.abort();
@@ -1208,7 +1252,7 @@ class InnerStreamMachine {
1208
1252
  this.hasError = true;
1209
1253
  log("error", `stream error: ${errorMsg}`);
1210
1254
  if (cbs.onStreamError)
1211
- invokeCallback(cbs.onStreamError, errorMsg);
1255
+ invokeCallback(cbs.onStreamError, formatErrorMessage(errorMsg));
1212
1256
  else
1213
1257
  console.error(`\x1b[31m${formatErrorMessage(errorMsg)}\x1b[0m`);
1214
1258
  return false;
@@ -1230,7 +1274,8 @@ class InnerStreamMachine {
1230
1274
  wrapUp: this.stop.wrapUp,
1231
1275
  lastStepHadTools: this.assembler.lastStepHadTools,
1232
1276
  xmlToolFollowUp: this.xmlSearchRecovered && !this.answerAfterXml && !this.stop.doom,
1233
- emptyCompletion: !this.assembler.hadAssistantText && !this.assembler.hadTools && !this.xmlSearchRecovered,
1277
+ emptyCompletion: !this.stop.context && !this.assembler.hadAssistantText && !this.assembler.hadTools && !this.xmlSearchRecovered,
1278
+ overflowError: this.overflowError,
1234
1279
  providerStall: this.stop.stall,
1235
1280
  stepCount: this.assembler.innerSteps,
1236
1281
  usage,
@@ -1255,7 +1300,7 @@ class InnerStreamMachine {
1255
1300
  // The provider went quiet and we cut the request. Nothing was produced →
1256
1301
  // let the empty-reply retry path re-send it; otherwise keep the partial
1257
1302
  // turn and continue like any other tool-only step.
1258
- const produced = this.assembler.hadAssistantText || this.assembler.hadTools;
1303
+ const produced = this.assembler.hadAssistantText || this.assembler.hadTools || this.renderer.hadThinking;
1259
1304
  log("warn", `provider stall: ${describeError(err)}`);
1260
1305
  this.stop.stall = true;
1261
1306
  return this.snapshot(usage, { hasError: false, userAborted: false, emptyCompletion: !produced });
@@ -1267,12 +1312,13 @@ class InnerStreamMachine {
1267
1312
  log("error", msg);
1268
1313
  if (isContextOverflowError(msg)) {
1269
1314
  this.stop.context = true;
1315
+ this.overflowError = msg;
1270
1316
  return this.snapshot(usage, { hasError: false, contextPressure: true, userAborted: false });
1271
1317
  }
1272
1318
  this.hasError = true;
1273
1319
  const display = formatErrorMessage(msg);
1274
1320
  if (this.params.cbs.onStreamError)
1275
- invokeCallback(this.params.cbs.onStreamError, msg);
1321
+ invokeCallback(this.params.cbs.onStreamError, display);
1276
1322
  else if (display !== msg)
1277
1323
  console.error(`\x1b[31m${display}\x1b[0m`);
1278
1324
  else
@@ -0,0 +1,7 @@
1
+ import { runCtxCli } from "../../ctx-cli.js";
2
+ export async function runCtxCommand(args) {
3
+ const result = runCtxCli({ positionals: args });
4
+ console.log(result.lines.join("\n"));
5
+ if (!result.ok)
6
+ process.exitCode = 1;
7
+ }
@@ -1,6 +1,6 @@
1
1
  import { Option } from "commander";
2
2
  import { resolveScope, parsePositiveIntArg } from "../option-helpers.js";
3
- import { runSetupCommand } from "./setup.js";
3
+ import { registerSetupCommand } from "./setup.js";
4
4
  import { runModelsCommand } from "./models.js";
5
5
  import { runChat } from "./chat.js";
6
6
  import { runExec } from "./exec.js";
@@ -8,6 +8,7 @@ import { runServeCommand } from "./serve.js";
8
8
  import { runSandboxCommand } from "./sandbox.js";
9
9
  import { runPermissionCommand } from "./permission.js";
10
10
  import { runThinkCommand } from "./think.js";
11
+ import { runCtxCommand } from "./ctx.js";
11
12
  import { runInitCommand } from "./init.js";
12
13
  import { runRulesShow, runRulesEdit } from "./rules.js";
13
14
  import { runUpdateCommand } from "./update.js";
@@ -45,7 +46,7 @@ export function registerCommands(program) {
45
46
  await runExec(message, rootOpts());
46
47
  });
47
48
  // --- Setup & config ----------------------------------------------------
48
- program.command("setup").description("Configure providers (interactively)").action(runSetupCommand);
49
+ registerSetupCommand(program, rootOpts);
49
50
  program.command("init").description("Initialize .min-agent/ in current directory").action(runInitCommand);
50
51
  program
51
52
  .command("models")
@@ -125,6 +126,13 @@ export function registerCommands(program) {
125
126
  const opts = rootOpts();
126
127
  await runThinkCommand(level ? [level] : [], opts, opts);
127
128
  });
129
+ program
130
+ .command("ctx")
131
+ .description("Show or set the Ollama context window level")
132
+ .argument("[level]", "2k, 4k, 8k, 12k, 16k, 32k, 64k, 128k, 256k, or auto")
133
+ .action(async (level) => {
134
+ await runCtxCommand(level ? [level] : []);
135
+ });
128
136
  // --- Integrations --------------------------------------------------------
129
137
  const mcp = program.command("mcp").description("Manage MCP servers");
130
138
  mcp
@@ -1,4 +1,56 @@
1
- import { runSetup } from "../../config.js";
2
- export async function runSetupCommand() {
3
- await runSetup();
1
+ import path from "path";
2
+ import { Option } from "commander";
3
+ import { fetchModelsLive, getConfigDir, loadConfig, saveConfig } from "../../config.js";
4
+ import { parsePositiveIntArg } from "../option-helpers.js";
5
+ import { parseProviderType } from "../setup/flags.js";
6
+ import { runSetup } from "../setup/flow.js";
7
+ export function registerSetupCommand(program, rootOpts) {
8
+ program
9
+ .command("setup")
10
+ .description("Configure providers (interactively, or with flags)")
11
+ .addOption(new Option("--type <type>", "openai-compatible, openai, or ollama").argParser(parseProviderType))
12
+ .option("--url <url>", "API base URL")
13
+ .option("--api-key <key>", "API key")
14
+ .option("--name <name>", "Provider name")
15
+ .option("--default-model <id>", "Default model to save")
16
+ .addOption(new Option("--context-window <n>", "Context window in tokens").argParser(parsePositiveIntArg("--context-window")))
17
+ .option("--switch <name>", "Set the active provider")
18
+ .option("--remove <name>", "Remove a provider")
19
+ .option("-y, --yes", "Skip overwrite and delete confirmation")
20
+ .action(async (opts) => {
21
+ await runSetupCommand(opts, { yes: Boolean(opts.yes || rootOpts().yes) });
22
+ });
23
+ }
24
+ function createSetupDeps() {
25
+ return {
26
+ isTty: Boolean(process.stdin.isTTY && process.stdout.isTTY),
27
+ load: loadConfig,
28
+ save: saveConfig,
29
+ fetchLive: fetchModelsLive,
30
+ log: (message) => {
31
+ console.log(message);
32
+ },
33
+ configPath: () => path.join(getConfigDir(), "config.json"),
34
+ runWizard: async (wizardMode) => {
35
+ const { renderSetupWizard } = await import("../setup/ui.js");
36
+ return renderSetupWizard(wizardMode);
37
+ },
38
+ };
39
+ }
40
+ export async function offerInteractiveSetup(mode) {
41
+ await runSetup({}, { ...createSetupDeps(), isTty: true }, mode);
42
+ }
43
+ export async function runSetupCommand(opts, session) {
44
+ const flags = {
45
+ type: opts.type,
46
+ url: opts.url,
47
+ apiKey: opts.apiKey,
48
+ name: opts.name,
49
+ defaultModel: opts.defaultModel,
50
+ contextWindow: opts.contextWindow,
51
+ switch: opts.switch,
52
+ remove: opts.remove,
53
+ yes: Boolean(session.yes || opts.yes),
54
+ };
55
+ await runSetup(flags, createSetupDeps());
4
56
  }
@@ -1,8 +1,17 @@
1
1
  import { isConfigured } from "../../config.js";
2
2
  import { CliError } from "../errors.js";
3
+ import { shouldOfferSetupWizard } from "../setup/flow.js";
3
4
  export async function startTuiSession(input) {
4
5
  if (!isConfigured()) {
5
- throw new CliError("Not configured.", { hint: "Run: min-agent setup" });
6
+ const tty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
7
+ if (!shouldOfferSetupWizard(false, tty)) {
8
+ throw new CliError("Not configured.", { hint: "Run: min-agent setup" });
9
+ }
10
+ const { offerInteractiveSetup } = await import("./setup.js");
11
+ await offerInteractiveSetup("session-gate");
12
+ if (!isConfigured()) {
13
+ throw new CliError("Not configured.", { hint: "Run: min-agent setup" });
14
+ }
6
15
  }
7
16
  const { runTui } = await import("../../tui-chat.js");
8
17
  const prompt = input.positionals.join(" ").trim();
@@ -47,7 +47,7 @@ Session:
47
47
  min-agent exec --resume <id> <msg> Continue a saved exec session
48
48
 
49
49
  Setup & config:
50
- min-agent setup Configure providers (interactively)
50
+ min-agent setup [--type T] Configure providers (interactively, or with flags)
51
51
  min-agent init Initialize .min-agent/ in current directory
52
52
  min-agent models List available models
53
53
  min-agent rules [edit] Show or edit instruction rules
@@ -57,6 +57,7 @@ Setup & config:
57
57
  min-agent sandbox network <allow|deny> Show or set network policy
58
58
  min-agent permission [ask|accept-edits|allow-all] Show or set confirmation mode
59
59
  min-agent think [off|low|medium|high|max] Show or set thinking intensity
60
+ min-agent ctx [2k|4k|8k|12k|16k|32k|64k|128k|256k|auto] Show or set Ollama context window
60
61
 
61
62
  Integrations:
62
63
  min-agent mcp <list|add|remove|info|check|enable|disable> Manage MCP servers
@@ -79,6 +80,10 @@ Thinking intensity (default: medium):
79
80
  high Strong reasoning
80
81
  max Highest reasoning
81
82
 
83
+ Ollama context window:
84
+ 2k … 256k Loaded window size
85
+ auto Use the model's default
86
+
82
87
  Memory (default: off):
83
88
  off Do not inject memories or expose memory tools
84
89
  on Remember facts across sessions
@@ -96,6 +101,7 @@ Rules (loaded as system instructions):
96
101
 
97
102
  Examples:
98
103
  min-agent setup
104
+ min-agent setup --type ollama
99
105
  min-agent
100
106
  min-agent "hello"
101
107
  min-agent exec "hello"
@@ -0,0 +1,17 @@
1
+ import { normalizeOllamaBaseURL } from "../../config.js";
2
+ const DEFAULT_TAGS_URL = "http://localhost:11434/api/tags";
3
+ const DEFAULT_TIMEOUT_MS = 800;
4
+ const DEFAULT_BASE = "http://localhost:11434/v1";
5
+ export async function detectLocalOllama(opts) {
6
+ const fetchImpl = opts?.fetchImpl ?? fetch;
7
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
8
+ try {
9
+ const response = await fetchImpl(DEFAULT_TAGS_URL, { signal: AbortSignal.timeout(timeoutMs) });
10
+ if (!response.ok)
11
+ return { found: false };
12
+ return { found: true, baseURL: normalizeOllamaBaseURL(DEFAULT_BASE) };
13
+ }
14
+ catch {
15
+ return { found: false };
16
+ }
17
+ }
@@ -0,0 +1,12 @@
1
+ import { userError } from "../errors.js";
2
+ export function parseProviderType(value) {
3
+ if (value === "openai-compatible" || value === "openai" || value === "ollama")
4
+ return value;
5
+ throw userError(`Invalid --type value: "${value}"`, "use one of: openai-compatible, openai, ollama");
6
+ }
7
+ export function isNonInteractive(flags) {
8
+ return Boolean(flags.type || flags.switch || flags.remove);
9
+ }
10
+ export function exclusiveActionCount(flags) {
11
+ return [flags.type, flags.switch, flags.remove].filter(Boolean).length;
12
+ }
@@ -0,0 +1,108 @@
1
+ import { getActiveProvider, normalizeOllamaBaseURL } from "../../config.js";
2
+ import { CliError, runtimeError } from "../errors.js";
3
+ import { applyAddProvider, applyRemoveProvider, applySwitchProvider, suggestName, } from "./provider-form.js";
4
+ import { exclusiveActionCount } from "./flags.js";
5
+ const OPENAI_URL = "https://api.openai.com/v1";
6
+ const OLLAMA_URL = "http://localhost:11434/v1";
7
+ const NON_INTERACTIVE_HINT = "使用 --type、--switch 或 --remove。例如: min-agent setup --type ollama";
8
+ export function shouldOfferSetupWizard(configured, isTty) {
9
+ return !configured && isTty;
10
+ }
11
+ export async function runSetup(flags, deps, wizardMode) {
12
+ if (exclusiveActionCount(flags) > 1) {
13
+ throw new CliError("一次只能执行一种操作。");
14
+ }
15
+ if (flags.remove) {
16
+ await runRemove(flags.remove, flags.yes === true, deps);
17
+ return;
18
+ }
19
+ if (flags.switch) {
20
+ persist(applySwitchProvider(deps.load(), flags.switch), deps);
21
+ return;
22
+ }
23
+ if (flags.type) {
24
+ await runAdd(flags, deps);
25
+ return;
26
+ }
27
+ if (!deps.isTty) {
28
+ throw new CliError("无法在非交互环境完成配置。", { hint: NON_INTERACTIVE_HINT });
29
+ }
30
+ const config = deps.load();
31
+ const mode = wizardMode ?? (hasUsableProvider(config) ? "hub" : "first-run");
32
+ await deps.runWizard(mode);
33
+ }
34
+ function hasUsableProvider(config) {
35
+ const provider = getActiveProvider(config);
36
+ return Boolean(provider?.baseURL && provider?.apiKey);
37
+ }
38
+ async function runRemove(name, yes, deps) {
39
+ if (!yes) {
40
+ throw new CliError("删除需要确认。", { hint: "加上 --yes" });
41
+ }
42
+ persist(applyRemoveProvider(deps.load(), name), deps);
43
+ }
44
+ async function runAdd(flags, deps) {
45
+ const type = flags.type;
46
+ const baseURL = resolveBaseURL(type, flags.url);
47
+ const apiKey = resolveApiKey(type, flags.apiKey);
48
+ const defaultModel = await resolveDefaultModel(type, baseURL, apiKey, flags.defaultModel, deps.fetchLive);
49
+ const config = deps.load();
50
+ const existing = (config.providers ?? []).map((p) => p.name).filter((name) => Boolean(name));
51
+ const name = (flags.name?.trim() || suggestName(type, baseURL, existing)).trim();
52
+ const draft = {
53
+ name,
54
+ type,
55
+ baseURL,
56
+ apiKey,
57
+ defaultModel,
58
+ ...(flags.contextWindow != null ? { contextWindow: flags.contextWindow } : {}),
59
+ };
60
+ const overwrite = flags.yes === true;
61
+ const result = applyAddProvider(config, draft, { overwrite });
62
+ if (isApplyError(result) && result.error.startsWith("已存在同名服务商") && !overwrite) {
63
+ throw new CliError(result.error, { hint: "加上 --yes 以覆盖" });
64
+ }
65
+ persist(result, deps);
66
+ }
67
+ function resolveBaseURL(type, url) {
68
+ if (type === "openai")
69
+ return OPENAI_URL;
70
+ if (type === "ollama")
71
+ return normalizeOllamaBaseURL(url?.trim() || OLLAMA_URL);
72
+ const trimmed = url?.trim() ?? "";
73
+ if (!trimmed)
74
+ throw new CliError("API 地址不能为空。");
75
+ return trimmed.replace(/\/$/, "");
76
+ }
77
+ function resolveApiKey(type, apiKey) {
78
+ if (type === "ollama")
79
+ return apiKey?.trim() || "ollama";
80
+ const trimmed = apiKey?.trim() ?? "";
81
+ if (!trimmed)
82
+ throw new CliError("API 密钥不能为空。");
83
+ return trimmed;
84
+ }
85
+ async function resolveDefaultModel(type, baseURL, apiKey, explicit, fetchLive) {
86
+ const given = explicit?.trim() ?? "";
87
+ if (given)
88
+ return given;
89
+ const live = await fetchLive(baseURL, apiKey);
90
+ if (live.models[0])
91
+ return live.models[0];
92
+ if (type === "ollama")
93
+ return "llama3";
94
+ if (!live.ok && live.status == null) {
95
+ throw runtimeError("无法连接到该服务商。");
96
+ }
97
+ throw new CliError("默认模型不能为空。");
98
+ }
99
+ function persist(result, deps) {
100
+ if (isApplyError(result))
101
+ throw new CliError(result.error);
102
+ deps.save(result);
103
+ deps.log(`已保存到 ${deps.configPath()}`);
104
+ }
105
+ function isApplyError(result) {
106
+ return Object.keys(result).length === 1 && "error" in result;
107
+ }
108
+ export { isNonInteractive } from "./flags.js";
@@ -0,0 +1,102 @@
1
+ export function suggestName(type, baseURL, existing) {
2
+ const base = type === "openai" ? "openai" : type === "ollama" ? "ollama" : (hostSlug(baseURL) ?? "provider");
3
+ return uniqueName(base, existing);
4
+ }
5
+ export function validateProviderName(name) {
6
+ const trimmed = name.trim();
7
+ if (!trimmed || trimmed.includes("/") || trimmed.includes(":")) {
8
+ return "名称不能为空,且不能包含 / 或 :。";
9
+ }
10
+ return null;
11
+ }
12
+ export function maskApiKey(key) {
13
+ if (key.length < 4)
14
+ return "****";
15
+ return `…${key.slice(-4)}`;
16
+ }
17
+ export function filterChoices(query, items) {
18
+ const q = query.trim().toLowerCase();
19
+ if (!q)
20
+ return [...items];
21
+ return items.filter((item) => item.toLowerCase().includes(q));
22
+ }
23
+ export function applyAddProvider(config, draft, opts) {
24
+ const nameError = validateProviderName(draft.name);
25
+ if (nameError)
26
+ return { error: nameError };
27
+ const name = draft.name.trim();
28
+ const providers = [...(config.providers ?? [])];
29
+ const idx = providers.findIndex((p) => p.name === name);
30
+ if (idx >= 0 && !opts.overwrite)
31
+ return { error: `已存在同名服务商 "${name}"。` };
32
+ const entry = toProvider(draft, name);
33
+ if (idx >= 0)
34
+ providers[idx] = entry;
35
+ else
36
+ providers.push(entry);
37
+ return { ...config, providers, activeProvider: name };
38
+ }
39
+ export function applySwitchProvider(config, name) {
40
+ const target = (config.providers ?? []).find((p) => p.name === name);
41
+ if (!target)
42
+ return { error: `找不到服务商 "${name}"。` };
43
+ return { ...config, activeProvider: name };
44
+ }
45
+ export function applyUpdateProvider(config, oldName, draft) {
46
+ const nameError = validateProviderName(draft.name);
47
+ if (nameError)
48
+ return { error: nameError };
49
+ const name = draft.name.trim();
50
+ const providers = [...(config.providers ?? [])];
51
+ const idx = providers.findIndex((p) => p.name === oldName);
52
+ if (idx < 0)
53
+ return { error: `找不到服务商 "${oldName}"。` };
54
+ if (name !== oldName && providers.some((p) => p.name === name)) {
55
+ return { error: `已存在同名服务商 "${name}"。` };
56
+ }
57
+ providers[idx] = toProvider(draft, name);
58
+ const activeProvider = config.activeProvider === oldName ? name : config.activeProvider;
59
+ return { ...config, providers, activeProvider };
60
+ }
61
+ export function applyRemoveProvider(config, name) {
62
+ const providers = config.providers ?? [];
63
+ if (!providers.some((p) => p.name === name))
64
+ return { error: `找不到服务商 "${name}"。` };
65
+ const remaining = providers.filter((p) => p.name !== name);
66
+ if (remaining.length === 0) {
67
+ const { activeProvider: _dropped, ...rest } = config;
68
+ return { ...rest, providers: [] };
69
+ }
70
+ const activeProvider = config.activeProvider === name ? remaining[0]?.name : config.activeProvider;
71
+ return { ...config, providers: remaining, activeProvider };
72
+ }
73
+ function toProvider(draft, name) {
74
+ return {
75
+ name,
76
+ type: draft.type,
77
+ baseURL: draft.baseURL,
78
+ apiKey: draft.apiKey,
79
+ defaultModel: draft.defaultModel,
80
+ ...(draft.contextWindow != null ? { contextWindow: draft.contextWindow } : {}),
81
+ };
82
+ }
83
+ function hostSlug(baseURL) {
84
+ try {
85
+ const host = new URL(baseURL).hostname.toLowerCase();
86
+ const trimmed = host.startsWith("www.") ? host.slice(4) : host;
87
+ if (!trimmed)
88
+ return undefined;
89
+ return trimmed.replace(/\./g, "-");
90
+ }
91
+ catch {
92
+ return undefined;
93
+ }
94
+ }
95
+ function uniqueName(base, existing) {
96
+ if (!existing.includes(base))
97
+ return base;
98
+ let n = 2;
99
+ while (existing.includes(`${base}-${n}`))
100
+ n += 1;
101
+ return `${base}-${n}`;
102
+ }