@armadra/agent 0.2.1 → 0.3.0

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 (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +55 -11
  3. package/dist/agent/session-cache.js +5 -2
  4. package/dist/agent/session-state.js +2 -1
  5. package/dist/agent/session-sync.js +9 -2
  6. package/dist/agent/session.js +8 -7
  7. package/dist/ai/apis/openai-responses.js +2 -1
  8. package/dist/ai/cache/fingerprint.d.ts +1 -1
  9. package/dist/ai/cache/fingerprint.js +1 -1
  10. package/dist/ai/cache/reporting.js +2 -1
  11. package/dist/ai/providers/channels.d.ts +51 -0
  12. package/dist/ai/providers/channels.js +96 -0
  13. package/dist/ai/providers/enrich.d.ts +34 -0
  14. package/dist/ai/providers/enrich.js +86 -0
  15. package/dist/ai/providers/models-dev-cache.d.ts +53 -0
  16. package/dist/ai/providers/models-dev-cache.js +147 -0
  17. package/dist/ai/providers/models-dev.d.ts +99 -0
  18. package/dist/ai/providers/models-dev.js +315 -0
  19. package/dist/ai/providers/registry.d.ts +35 -4
  20. package/dist/ai/providers/registry.js +177 -24
  21. package/dist/ai/types.d.ts +21 -1
  22. package/dist/bundle/ama.cjs +4075 -2008
  23. package/dist/cli/args.d.ts +6 -3
  24. package/dist/cli/args.js +30 -3
  25. package/dist/cli/bootstrap.js +1 -0
  26. package/dist/cli/compose-providers.js +3 -0
  27. package/dist/cli/compose-session.d.ts +3 -0
  28. package/dist/cli/compose.d.ts +3 -1
  29. package/dist/cli/compose.js +24 -9
  30. package/dist/cli/deps.d.ts +2 -0
  31. package/dist/cli/main.d.ts +2 -1
  32. package/dist/cli/main.js +13 -1
  33. package/dist/cli/startup-steps.js +6 -1
  34. package/dist/cli/subcommands/config.d.ts +11 -2
  35. package/dist/cli/subcommands/config.js +80 -4
  36. package/dist/cli/subcommands/context.js +1 -0
  37. package/dist/cli/subcommands/init.d.ts +6 -0
  38. package/dist/cli/subcommands/init.js +22 -0
  39. package/dist/cli/subcommands/model-meta.d.ts +16 -0
  40. package/dist/cli/subcommands/model-meta.js +54 -0
  41. package/dist/cli/subcommands/models-cache-probe.js +1 -1
  42. package/dist/cli/subcommands/models-discover.d.ts +6 -0
  43. package/dist/cli/subcommands/models-discover.js +37 -9
  44. package/dist/cli/subcommands/models.js +26 -18
  45. package/dist/cli/subcommands/providers-list.d.ts +8 -0
  46. package/dist/cli/subcommands/providers-list.js +97 -0
  47. package/dist/cli/subcommands/providers-plan.d.ts +79 -0
  48. package/dist/cli/subcommands/providers-plan.js +215 -0
  49. package/dist/cli/subcommands/providers.d.ts +27 -0
  50. package/dist/cli/subcommands/providers.js +433 -0
  51. package/dist/config/init.d.ts +30 -0
  52. package/dist/config/init.js +92 -0
  53. package/dist/config/json-schema.d.ts +14 -0
  54. package/dist/config/json-schema.js +182 -0
  55. package/dist/config/schema.d.ts +1 -1
  56. package/dist/config/schema.js +74 -11
  57. package/dist/config/types.d.ts +23 -5
  58. package/dist/config/types.js +2 -0
  59. package/dist/modes/image-input.d.ts +27 -0
  60. package/dist/modes/image-input.js +78 -0
  61. package/dist/modes/interactive/commands.js +2 -3
  62. package/dist/modes/interactive/interactive-mode.js +5 -1
  63. package/dist/modes/interactive/line/line-mode.js +3 -1
  64. package/dist/modes/interactive/startup-ui.d.ts +7 -2
  65. package/dist/modes/interactive/startup-ui.js +41 -11
  66. package/dist/modes/print/print-mode.d.ts +2 -0
  67. package/dist/modes/print/print-mode.js +12 -1
  68. package/dist/modes/rpc/commands.js +7 -2
  69. package/dist/rpc.d.ts +2 -0
  70. package/dist/session/projection.js +5 -1
  71. package/dist/session/types.d.ts +2 -0
  72. package/dist/tools/image-file.d.ts +31 -0
  73. package/dist/tools/image-file.js +114 -0
  74. package/dist/tools/read.d.ts +4 -8
  75. package/dist/tools/read.js +15 -64
  76. package/docs/providers.md +216 -1
  77. package/docs/rpc.md +3 -3
  78. package/docs/session-format.md +1 -1
  79. package/docs/tui.md +2 -0
  80. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # 更新记录
2
2
 
3
+ ## 0.3.0(2026-10-02)
4
+
5
+ 自定义供应商与多渠道、models.dev 模型元数据、图像输入、默认配置目录。
6
+
7
+ - **一键接入**:`ama providers add <id> --base-url <url>` 只要 baseUrl 与 key——列出中转的模型、按提示或 `--probe` 逐渠道
8
+ 探测、写进配置;`list` / `channels` / `remove` / `refresh`。
9
+ - **渠道**:一个供应商可挂多个渠道(协议 + 地址 + 可选 key / headers / compat),模型声明 `channels`,
10
+ `provider/model@channel` 指定渠道;旧配置按隐式 `default` 渠道处理,不用改。
11
+ - **models.dev 元数据**:上下文、输出上限、图像输入、推理、价格缺省从 models.dev 补(数据目录缓存,启动不联网),
12
+ `ama models refresh-catalog` 刷新;`models list` / `config show` 标出每个字段的来源。
13
+ - **图像输入**:`-p --image`、界面里 `@图片路径`;与 read 工具共用 MIME 检测与 5 MB 上限;模型不收图片时拒绝。
14
+ - **配置目录**:首次运行自动建 `~/.config/ama/` 与最小 `config.json`、`config.schema.json`;`ama init`、
15
+ `ama config path`、`ama config edit`。
16
+ - **修复**:Responses 的 `incomplete_details.reason: "length"` 按输出截断处理(中转转发 DeepSeek 时出现)。
17
+
3
18
  ## 0.2.1(2026-10-02)
4
19
 
5
20
  npm 首发:`npm i -g @armadra/agent`。功能与 0.2.0 相同。
package/README.md CHANGED
@@ -41,7 +41,9 @@ ama
41
41
  | 方面 | 内容 |
42
42
  | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
43
43
  | 多协议与供应商 | 4 条协议线、13 家内置供应商(Anthropic、OpenAI、Google、DeepSeek、Moonshot、智谱、通义、OpenRouter、Groq、xAI、Mistral、Ollama、LM Studio)、自定义供应商、模型级协议 |
44
- | 零配置与中转站 | 有 key 就选第一个可用的供应商;识别 `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL`;`ama models discover` 从中转站探测协议并写回配置 |
44
+ | 零配置与中转站 | 有 key 就选第一个可用的供应商;识别 `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL`;`ama providers add` 只给 baseUrl 与 key 一键接入:列模型、探测渠道、写回配置 |
45
+ | 模型元数据 | 上下文、输出上限、图像输入、推理、价格缺省从 models.dev 补(本地缓存,启动不联网);一个供应商可挂多个渠道(Chat / Responses / Messages),`provider/model@渠道` |
46
+ | 图像输入 | `-p --image`、界面里 `@图片路径`;四条协议都映射;模型不收图片时直接拒绝并提示换模型 |
45
47
  | 工具与预设 | read / edit / write / bash / grep / glob,另有 ls、todo、task(子 Agent)、codemode;四个预设 `default` / `minimal` / `codemode` / `coordinator` |
46
48
  | codemode | 模型写一段 JS,在受 Node 权限模型约束的子进程里编排多次工具调用,只有输出回到模型 |
47
49
  | Skill | `SKILL.md` 目录,模型按索引自行读取,用户用 `/skill:<名字>` 调用;另有提示模板 |
@@ -133,10 +135,13 @@ ama -p "列出 TODO" --model deepseek/deepseek-v4-pro --output-format json
133
135
 
134
136
  ## 配置
135
137
 
136
- 一个文件 `~/.config/ama/config.json`。常用的只有五个键:
138
+ 一个文件 `~/.config/ama/config.json`。第一次运行 ama 时自动建好目录(0700)、最小的 `config.json` 与给编辑器用的
139
+ `config.schema.json`;也可以 `ama init` 手动建(已有文件不覆盖)。`ama config path` 打印各文件位置,`ama config edit`
140
+ 用 `$VISUAL` / `$EDITOR` 打开。常用的只有五个键:
137
141
 
138
142
  ```json
139
143
  {
144
+ "$schema": "./config.schema.json",
140
145
  "version": 1,
141
146
  "defaultModel": "anthropic/<model-id>",
142
147
  "thinkingLevel": "medium",
@@ -150,13 +155,13 @@ ama -p "列出 TODO" --model deepseek/deepseek-v4-pro --output-format json
150
155
 
151
156
  ### 文件位置与层级
152
157
 
153
- | 位置 | 内容 |
154
- | --------------------- | ---------------------------------------------------------------------------------------------------------- |
155
- | `~/.config/ama/` | 用户级:`config.json`、`auth.json`、`hooks.json`、`keybindings.json`、`trust.json`、`AGENTS.md`、`skills/` |
156
- | `~/.local/share/ama/` | 数据:`sessions/`(会话 JSONL)、输入历史 |
157
- | `<项目>/.ama/` | 项目级:`config.json`(只能收紧)、`hooks.json` / `skills/` / `prompts/`(需信任) |
158
- | `<项目>/AGENTS.md` | 项目约定,从 cwd 向上查找,自动进系统提示 |
159
- | `--profile <文件>` | 宿主 profile(嵌入方用,见「嵌入 Armadra」) |
158
+ | 位置 | 内容 |
159
+ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
160
+ | `~/.config/ama/` | 用户级:`config.json`、`config.schema.json`(ama 生成)、`auth.json`(0600)、`hooks.json`、`keybindings.json`、`trust.json`、`AGENTS.md`、`skills/` |
161
+ | `~/.local/share/ama/` | 数据:`sessions/`(会话 JSONL)、`models-dev.json`(模型元数据缓存)、输入历史 |
162
+ | `<项目>/.ama/` | 项目级:`config.json`(只能收紧)、`hooks.json` / `skills/` / `prompts/`(需信任) |
163
+ | `<项目>/AGENTS.md` | 项目约定,从 cwd 向上查找,自动进系统提示 |
164
+ | `--profile <文件>` | 宿主 profile(嵌入方用,见「嵌入 Armadra」) |
160
165
 
161
166
  `AMA_CONFIG_DIR` / `AMA_DATA_DIR` 可改两个目录;也遵循 `XDG_CONFIG_HOME` / `XDG_DATA_HOME`,Windows 下是 `%APPDATA%\ama` 与 `%LOCALAPPDATA%\ama`。
162
167
 
@@ -172,6 +177,41 @@ ama doctor # 配置层级、项目信任、key 来源、Hook、终
172
177
 
173
178
  ## 接入中转站
174
179
 
180
+ **一键接入**:只给 baseUrl 与 key。
181
+
182
+ ```sh
183
+ export PACKY_API_KEY=sk-...
184
+ ama providers add packy --base-url https://proxy.example/v1 --key-env PACKY_API_KEY --probe --limit 8 --yes
185
+ ama -p "hi" --model packy/kimi-k2.5 # 首选渠道
186
+ ama -p "hi" --model packy/kimi-k2.5@messages # 指定渠道(Anthropic Messages)
187
+ ama -p "图里有什么颜色" --image shot.png --model packy/kimi-k2.5
188
+ ama providers list # 供应商 → 渠道 → 模型数、key 来源
189
+ ```
190
+
191
+ `add` 列出 `GET {baseUrl}/models` 的模型,从 baseUrl 推出 chat / responses / messages 三个候选渠道,`--probe` 逐渠道发最小
192
+ 请求,把能用的渠道写进每个模型的 `channels`;上下文、输出上限、图像、推理与价格不写进配置,运行时从 models.dev 缓存补
193
+ (`ama models list` 标出每个字段的来源)。不给 `--key-env` 时 key 从 stdin 读(不回显)存进 `auth.json`。写入后的配置:
194
+
195
+ ```json
196
+ {
197
+ "providers": {
198
+ "packy": {
199
+ "apiKey": "$PACKY_API_KEY",
200
+ "channels": {
201
+ "chat": { "api": "openai-completions", "baseUrl": "https://proxy.example/v1" },
202
+ "responses": { "api": "openai-responses", "baseUrl": "https://proxy.example/v1" },
203
+ "messages": { "api": "anthropic-messages", "baseUrl": "https://proxy.example" }
204
+ },
205
+ "defaultChannel": "chat",
206
+ "models": [
207
+ { "id": "kimi-k2.5", "channels": ["chat", "messages"] },
208
+ { "id": "grok-4.7", "channels": ["responses"] }
209
+ ]
210
+ }
211
+ }
212
+ }
213
+ ```
214
+
175
215
  **零配置**:内置的 `openai` / `anthropic` 识别 `OPENAI_BASE_URL` / `ANTHROPIC_BASE_URL`。baseUrl 不在官方主机时接受目录外的 model id,缓存相关字段按保守缺省。
176
216
 
177
217
  ```sh
@@ -200,7 +240,8 @@ OPENAI_BASE_URL=https://proxy.example/v1 OPENAI_API_KEY=$PACKY_API_KEY \
200
240
 
201
241
  - `api` 缺省 `openai-completions`;可选 `openai-responses`、`anthropic-messages`、`google-generative-ai`。
202
242
  - `apiKey` 支持 `$ENV` / `${ENV}`(读环境变量)与 `!command`(执行命令取值),不要把 key 明文写进配置。
203
- - 自定义模型不猜 `contextWindow`,没写时自动压缩关闭;需要时在模型条目里补上。
243
+ - 自定义模型的元数据缺省从 models.dev 补(`ama models refresh-catalog` 刷新缓存);匹配不到时不猜 `contextWindow`,自动
244
+ 压缩关闭,需要时在模型条目里补上或写 `"modelsDev": "provider/model"` 指定条目。
204
245
 
205
246
  **不想手写模型表**:让 ama 去问中转站。
206
247
 
@@ -310,7 +351,7 @@ anthropic/<model-id> · think:medium · ↑412k ↓8.1k · cache 83% ♨ · $0.8
310
351
  | Ctrl+C | 清空输入;输入为空时 1.5 秒内再按一次退出 |
311
352
  | Tab | 补全:`/` 命令、模板与 Skill,`@` 文件路径 |
312
353
 
313
- 常用命令:`/model`、`/thinking`、`/permission`、`/tools`、`/compact`、`/tree`(回到某条消息之前重新分支)、`/fork`、`/resume`、`/new`、`/session`、`/cache`、`/hooks`、`/skill:<名字>`、`/help`。按键可在 `~/.config/ama/keybindings.json` 覆盖。见 [docs/tui.md](docs/tui.md)。
354
+ 常用命令:`/model`、`/thinking`、`/permission`、`/tools`、`/compact`、`/tree`(回到某条消息之前重新分支)、`/fork`、`/resume`、`/new`、`/session`、`/cache`、`/hooks`、`/skill:<名字>`、`/help`。输入里的 `@图片路径`(或粘贴 / 拖入的图片路径)作为图片附件发给模型;`/model` 按「供应商 · 渠道」分组,标出上下文与 `img`。按键可在 `~/.config/ama/keybindings.json` 覆盖。见 [docs/tui.md](docs/tui.md)。
314
355
 
315
356
  `--no-tui`(或 stdin / stdout 不是 TTY、`TERM=dumb`)进入行式界面:readline + 括号粘贴,命令相同。
316
357
 
@@ -322,6 +363,9 @@ anthropic/<model-id> · think:medium · ↑412k ↓8.1k · cache 83% ♨ · $0.8
322
363
  | `json` | 一个 `result` 对象:会话 id、模型、`stopReason`、`text`、用量、费用、缓存统计 |
323
364
  | `stream-json` | 每行一个事件,与 RPC 事件同形状 |
324
365
 
366
+ `--image <文件>` 可重复,随提示发送图片(PNG / JPEG / GIF / WebP,单张 ≤ 5 MB);提示里的 `@图片路径` 同样作为附件。当前
367
+ 模型不收图片时直接退出 2,不发请求。
368
+
325
369
  退出码:0 正常 · 1 运行期错误 · 2 用法错误 · 3 配置错误 · 4 无可用模型或 key · 5 会话错误 · 6 宿主 / Hook 启动失败 · 78 宿主 API 版本不匹配 · 130 / 143 信号。
326
370
 
327
371
  ### RPC
@@ -7,6 +7,7 @@
7
7
  * 之后的首个请求是重置点。子会话(depth > 0)有自己的控制器,`warmSubagents` 为 false 时不保温。
8
8
  * fork 出的会话沿用根会话 id 作 `prompt_cache_key`(只是路由提示);task 子会话不沿用。
9
9
  */
10
+ import { modelRefOf } from "../ai/providers/channels.js";
10
11
  import { readFileSync } from "node:fs";
11
12
  import { evaluateWarm } from "../ai/cache/economics.js";
12
13
  import { fingerprintContext } from "../ai/cache/fingerprint.js";
@@ -183,7 +184,7 @@ export class SessionCacheController {
183
184
  const record = {
184
185
  at,
185
186
  purpose,
186
- model: { provider: model.provider, id: model.id },
187
+ model: modelRefOf(model),
187
188
  api: model.api,
188
189
  baseUrl: model.baseUrl ?? "",
189
190
  fingerprint: fingerprintContext(context, model),
@@ -305,7 +306,9 @@ export class SessionCacheController {
305
306
  const model = this.core.model();
306
307
  if (record === undefined || this.disposed)
307
308
  return undefined;
308
- if (model.provider !== record.model.provider || model.id !== record.model.id)
309
+ if (model.provider !== record.model.provider ||
310
+ model.id !== record.model.id ||
311
+ model.channel !== record.model.channel)
309
312
  return undefined;
310
313
  const messages = convertToLlm(this.core.agent.messages, {
311
314
  provider: model.provider,
@@ -5,13 +5,14 @@
5
5
  * 压缩与分支摘要请求的 usage 也计入;`usage` 条目(缓存保温等不进上下文的请求,第三波 §1.7)
6
6
  * 计入 token 与费用但不算消息。上下文 % 用投影感知估算(§9)。
7
7
  */
8
+ import { modelRefOf } from "../ai/providers/channels.js";
8
9
  export function buildSessionState(input) {
9
10
  const { agent, manager, model } = input;
10
11
  return {
11
12
  isStreaming: agent.isRunning,
12
13
  isCompacting: input.isCompacting,
13
14
  isRetrying: input.isRetrying,
14
- model: { provider: model.provider, id: model.id },
15
+ model: modelRefOf(model),
15
16
  thinkingLevel: input.thinkingLevel,
16
17
  permissionMode: input.permissionMode,
17
18
  sessionId: manager.id,
@@ -28,17 +28,24 @@ export function persistMessage(core, message) {
28
28
  export function recordModelState(core, model, thinkingLevel) {
29
29
  let provider;
30
30
  let modelId;
31
+ let channel;
31
32
  let thinking;
32
33
  for (const entry of core.manager.branch()) {
33
34
  if (entry.type === "model_change") {
34
35
  provider = entry.provider;
35
36
  modelId = entry.modelId;
37
+ channel = entry.channel;
36
38
  }
37
39
  else if (entry.type === "thinking_level_change")
38
40
  thinking = entry.thinkingLevel;
39
41
  }
40
- if (provider !== model.provider || modelId !== model.id) {
41
- core.appendEntry({ type: "model_change", provider: model.provider, modelId: model.id });
42
+ if (provider !== model.provider || modelId !== model.id || channel !== model.channel) {
43
+ core.appendEntry({
44
+ type: "model_change",
45
+ provider: model.provider,
46
+ modelId: model.id,
47
+ ...(model.channel !== undefined ? { channel: model.channel } : {}),
48
+ });
42
49
  }
43
50
  if (thinking !== thinkingLevel) {
44
51
  core.appendEntry({ type: "thinking_level_change", thinkingLevel });
@@ -10,6 +10,7 @@
10
10
  * - `steer()` / `followUp()`:运行中入队(返回 "queued");空闲时直接以该消息开始一个周期("handled")。
11
11
  * - `abort()`:中断供应商流、工具、重试等待与压缩;回到 idle 后 resolve;不清队列。
12
12
  */
13
+ import { modelRefOf } from "../ai/providers/channels.js";
13
14
  import { join } from "node:path";
14
15
  import { AmaError } from "../errors.js";
15
16
  import { buildProjection } from "../session/projection.js";
@@ -141,7 +142,8 @@ export class AgentSessionImpl {
141
142
  }
142
143
  async resolveApiKey() {
143
144
  try {
144
- return (await this.options.providers.resolveApiKey(this.currentModel.provider)).apiKey;
145
+ const { provider, channel } = this.currentModel;
146
+ return (await this.options.providers.resolveApiKey(provider, channel)).apiKey;
145
147
  }
146
148
  catch {
147
149
  return undefined;
@@ -358,15 +360,14 @@ export class AgentSessionImpl {
358
360
  }
359
361
  this.currentModel = lookup.model;
360
362
  this.compaction.refresh();
363
+ const next = modelRefOf(lookup.model);
361
364
  this.appendEntry({
362
365
  type: "model_change",
363
- provider: lookup.model.provider,
364
- modelId: lookup.model.id,
365
- });
366
- this.emit({
367
- type: "model_changed",
368
- model: { provider: lookup.model.provider, id: lookup.model.id },
366
+ provider: next.provider,
367
+ modelId: next.id,
368
+ ...(next.channel !== undefined ? { channel: next.channel } : {}),
369
369
  });
370
+ this.emit({ type: "model_changed", model: next });
370
371
  }
371
372
  setThinkingLevel(level) {
372
373
  this.currentThinking = level;
@@ -50,7 +50,8 @@ export function parseResponsesUsage(raw) {
50
50
  }
51
51
  export function mapResponsesStatus(status, incomplete) {
52
52
  if (status === "incomplete") {
53
- if (incomplete === "max_output_tokens")
53
+ // 中转转发的 DeepSeek 等上游写 `length`(实测 2026-10-02),与 `max_output_tokens` 同义。
54
+ if (incomplete === "max_output_tokens" || incomplete === "length")
54
55
  return { reason: "length" };
55
56
  return {
56
57
  reason: "error",
@@ -11,7 +11,7 @@ import type { Model, ModelRef, TranscriptContext } from "../types.js";
11
11
  import type { PrefixFingerprint } from "./types.js";
12
12
  /** sha256 的前 16 位 hex(64 bit)。 */
13
13
  export declare function hash16(text: string): string;
14
- export declare function modelKey(model: Pick<Model, "provider" | "id"> | ModelRef): string;
14
+ export declare function modelKey(model: Pick<Model, "provider" | "id" | "channel"> | ModelRef): string;
15
15
  export declare function fingerprintContext(context: TranscriptContext, model: Pick<Model, "provider" | "id">): PrefixFingerprint;
16
16
  /** 两个指纹的差异(按归因顺序:system → tools → model);相同返回 undefined。 */
17
17
  export declare function fingerprintChange(prev: PrefixFingerprint, cur: PrefixFingerprint): "system" | "tools" | "model" | undefined;
@@ -14,7 +14,7 @@ export function hash16(text) {
14
14
  return createHash("sha256").update(text).digest("hex").slice(0, 16);
15
15
  }
16
16
  export function modelKey(model) {
17
- return `${model.provider}/${model.id}`;
17
+ return `${model.provider}/${model.id}${model.channel !== undefined ? `@${model.channel}` : ""}`;
18
18
  }
19
19
  function sortedTools(tools) {
20
20
  return [...tools].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
@@ -41,7 +41,8 @@ function hostOf(baseUrl) {
41
41
  }
42
42
  }
43
43
  export function endpointKey(record) {
44
- return `${record.model.provider}|${hostOf(record.baseUrl)}|${record.model.id}`;
44
+ const channel = record.model.channel !== undefined ? `@${record.model.channel}` : "";
45
+ return `${record.model.provider}|${hostOf(record.baseUrl)}|${record.model.id}${channel}`;
45
46
  }
46
47
  function sameFingerprint(a, b) {
47
48
  return a.system === b.system && a.tools === b.tools && a.model === b.model;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 渠道(docs/providers.md「渠道」):一个供应商下的多种接口(协议 + 地址 + 可选 key / headers / compat)。
3
+ *
4
+ * - 没有 `channels` 的供应商按单渠道处理:供应商级 `api` + `baseUrl` 就是隐式的 `default` 渠道,
5
+ * 模型上不出现 `channel` / `channels` 字段,行为与引入渠道之前完全相同。
6
+ * - 有 `channels` 时:模型 `channels[0]` 是首选,缺省 `defaultChannel`;模型级 `api` / `baseUrl`
7
+ * 覆盖在所选渠道之上(等价于匿名渠道)。
8
+ * - 模型引用 `provider/model@channel`:`@` 之后是该供应商的渠道名才当渠道,否则整串仍是 model id。
9
+ * - 渠道自己的 key 以 `<provider>@<channel>` 为键进 key 解析(config 的 `channels.<c>.apiKey` 或
10
+ * auth.json 的同名条目),没有时用供应商的 key。
11
+ */
12
+ import type { ProviderConfig } from "../../config/types.js";
13
+ import type { Api, ProviderChannel } from "../types.js";
14
+ /** 隐式渠道名(单渠道供应商)。 */
15
+ export declare const DEFAULT_CHANNEL = "default";
16
+ /** 渠道 key 在 key 解析里的键。 */
17
+ export declare function channelKeyId(providerId: string, channel: string): string;
18
+ /** config 的 `channels` → 物化渠道表(跳过名字非法或缺 api / baseUrl 的,返回警告)。 */
19
+ export declare function parseChannels(providerId: string, config: ProviderConfig): {
20
+ channels: ProviderChannel[];
21
+ defaultChannel: string | undefined;
22
+ warnings: string[];
23
+ };
24
+ /** 模型的渠道表:去掉不存在的;空 → [defaultChannel]。 */
25
+ export declare function modelChannels(wanted: readonly string[] | undefined, known: readonly ProviderChannel[], defaultChannel: string, onUnknown: (name: string) => void): string[];
26
+ /**
27
+ * 拆 `provider/model@channel` 末尾的渠道:只看最后一个 `/` 之后的部分;渠道名不合法时返回 undefined。
28
+ * 是否真的是渠道由调用方结合供应商的渠道表判断。
29
+ */
30
+ export declare function splitChannelRef(ref: string): {
31
+ base: string;
32
+ channel: string;
33
+ } | undefined;
34
+ /** `provider/model` 或 `provider/model@channel`(channel 为空时不带后缀)。 */
35
+ export declare function formatModelRef(ref: {
36
+ provider: string;
37
+ id: string;
38
+ channel?: string;
39
+ }): string;
40
+ /** Model / ModelRef → ModelRef(带上渠道,没有渠道时不出现该键)。 */
41
+ export declare function modelRefOf(model: {
42
+ provider: string;
43
+ id: string;
44
+ channel?: string | undefined;
45
+ }): {
46
+ provider: string;
47
+ id: string;
48
+ channel?: string;
49
+ };
50
+ /** 协议的短名(表格用)。 */
51
+ export declare function apiShortName(api: Api): string;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * 渠道(docs/providers.md「渠道」):一个供应商下的多种接口(协议 + 地址 + 可选 key / headers / compat)。
3
+ *
4
+ * - 没有 `channels` 的供应商按单渠道处理:供应商级 `api` + `baseUrl` 就是隐式的 `default` 渠道,
5
+ * 模型上不出现 `channel` / `channels` 字段,行为与引入渠道之前完全相同。
6
+ * - 有 `channels` 时:模型 `channels[0]` 是首选,缺省 `defaultChannel`;模型级 `api` / `baseUrl`
7
+ * 覆盖在所选渠道之上(等价于匿名渠道)。
8
+ * - 模型引用 `provider/model@channel`:`@` 之后是该供应商的渠道名才当渠道,否则整串仍是 model id。
9
+ * - 渠道自己的 key 以 `<provider>@<channel>` 为键进 key 解析(config 的 `channels.<c>.apiKey` 或
10
+ * auth.json 的同名条目),没有时用供应商的 key。
11
+ */
12
+ import { CHANNEL_NAME_PATTERN } from "../../config/types.js";
13
+ /** 隐式渠道名(单渠道供应商)。 */
14
+ export const DEFAULT_CHANNEL = "default";
15
+ /** 渠道 key 在 key 解析里的键。 */
16
+ export function channelKeyId(providerId, channel) {
17
+ return `${providerId}@${channel}`;
18
+ }
19
+ /** config 的 `channels` → 物化渠道表(跳过名字非法或缺 api / baseUrl 的,返回警告)。 */
20
+ export function parseChannels(providerId, config) {
21
+ const warnings = [];
22
+ const channels = [];
23
+ for (const [name, raw] of Object.entries(config.channels ?? {})) {
24
+ const c = raw;
25
+ if (!CHANNEL_NAME_PATTERN.test(name) || !c || !c.api || !c.baseUrl) {
26
+ warnings.push(`provider "${providerId}" channel "${name}" is invalid; ignored`);
27
+ continue;
28
+ }
29
+ const channel = { name, api: c.api, baseUrl: c.baseUrl };
30
+ if (c.authHeader !== undefined)
31
+ channel.authHeader = c.authHeader;
32
+ if (c.headers !== undefined)
33
+ channel.headers = { ...c.headers };
34
+ if (c.compat !== undefined)
35
+ channel.compat = { ...c.compat };
36
+ channels.push(channel);
37
+ }
38
+ let defaultChannel = config.defaultChannel;
39
+ if (defaultChannel !== undefined && !channels.some((c) => c.name === defaultChannel)) {
40
+ warnings.push(`provider "${providerId}" defaultChannel "${defaultChannel}" not found`);
41
+ defaultChannel = undefined;
42
+ }
43
+ return { channels, defaultChannel: defaultChannel ?? channels[0]?.name, warnings };
44
+ }
45
+ /** 模型的渠道表:去掉不存在的;空 → [defaultChannel]。 */
46
+ export function modelChannels(wanted, known, defaultChannel, onUnknown) {
47
+ const names = new Set(known.map((c) => c.name));
48
+ const out = [];
49
+ for (const name of wanted ?? []) {
50
+ if (!names.has(name))
51
+ onUnknown(name);
52
+ else if (!out.includes(name))
53
+ out.push(name);
54
+ }
55
+ return out.length > 0 ? out : [defaultChannel];
56
+ }
57
+ /**
58
+ * 拆 `provider/model@channel` 末尾的渠道:只看最后一个 `/` 之后的部分;渠道名不合法时返回 undefined。
59
+ * 是否真的是渠道由调用方结合供应商的渠道表判断。
60
+ */
61
+ export function splitChannelRef(ref) {
62
+ const at = ref.lastIndexOf("@");
63
+ if (at <= 0 || at < ref.lastIndexOf("/"))
64
+ return undefined;
65
+ const channel = ref.slice(at + 1);
66
+ if (!CHANNEL_NAME_PATTERN.test(channel))
67
+ return undefined;
68
+ return { base: ref.slice(0, at), channel };
69
+ }
70
+ /** `provider/model` 或 `provider/model@channel`(channel 为空时不带后缀)。 */
71
+ export function formatModelRef(ref) {
72
+ return `${ref.provider}/${ref.id}${ref.channel !== undefined ? `@${ref.channel}` : ""}`;
73
+ }
74
+ /** Model / ModelRef → ModelRef(带上渠道,没有渠道时不出现该键)。 */
75
+ export function modelRefOf(model) {
76
+ return {
77
+ provider: model.provider,
78
+ id: model.id,
79
+ ...(model.channel !== undefined ? { channel: model.channel } : {}),
80
+ };
81
+ }
82
+ /** 协议的短名(表格用)。 */
83
+ export function apiShortName(api) {
84
+ switch (api) {
85
+ case "openai-completions":
86
+ return "chat";
87
+ case "openai-responses":
88
+ return "responses";
89
+ case "anthropic-messages":
90
+ return "messages";
91
+ case "google-generative-ai":
92
+ return "gemini";
93
+ default:
94
+ return api;
95
+ }
96
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * 用 models.dev 缓存给自定义模型补元数据(docs/providers.md「模型元数据:models.dev」)。
3
+ *
4
+ * 优先级:用户配置写了的字段 > models.dev > 自定义缺省(maxTokens 8192、input ["text"]、
5
+ * reasoning false、不猜 contextWindow)。内置目录的模型不经过这里(目录本身就是人工校对的值)。
6
+ * 只读传入的索引,不联网。
7
+ */
8
+ import type { ModelConfig } from "../../config/types.js";
9
+ import type { Model } from "../types.js";
10
+ import { type EnrichableField, type ModelsDevIndex, type ModelsDevMatch } from "./models-dev.js";
11
+ export type FieldSource = "config" | "catalog" | "models.dev" | "default";
12
+ export interface ModelMetadata {
13
+ sources: Record<EnrichableField, FieldSource>;
14
+ /** models.dev 匹配结果;未查(没有缓存或 `modelsDev: false`)为 undefined。 */
15
+ match?: ModelsDevMatch | undefined;
16
+ /** 是否查过 models.dev(有缓存且没关闭)。 */
17
+ looked: boolean;
18
+ /** models.dev 的 tool_call。 */
19
+ toolCall?: boolean;
20
+ }
21
+ export type ModelsDevSource = ModelsDevIndex | (() => ModelsDevIndex | undefined) | undefined;
22
+ /** 惰性取索引:只有真的要补字段时才读缓存(启动时没有自定义模型就不读约 2 MB 的缓存)。 */
23
+ export declare function lazyIndex(source: ModelsDevSource): () => ModelsDevIndex | undefined;
24
+ /**
25
+ * 补全一个自定义模型条目(config 的 `models[]` 或合成的模型)。返回补过字段的条目(不含
26
+ * `modelsDev` / `channels` 这些配置专用键)与每个字段的来源。
27
+ */
28
+ export declare function enrichEntry(entry: ModelConfig, index: () => ModelsDevIndex | undefined): {
29
+ entry: Omit<ModelConfig, "modelsDev" | "channels">;
30
+ metadata: ModelMetadata;
31
+ };
32
+ /** 内置目录模型的来源(全部 catalog;目录没写 contextWindow / cost 的为缺省)。 */
33
+ export declare function catalogMetadata(model: Model): ModelMetadata;
34
+ export declare function sourceText(source: FieldSource): string;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * 用 models.dev 缓存给自定义模型补元数据(docs/providers.md「模型元数据:models.dev」)。
3
+ *
4
+ * 优先级:用户配置写了的字段 > models.dev > 自定义缺省(maxTokens 8192、input ["text"]、
5
+ * reasoning false、不猜 contextWindow)。内置目录的模型不经过这里(目录本身就是人工校对的值)。
6
+ * 只读传入的索引,不联网。
7
+ */
8
+ import { ENRICHABLE_FIELDS, modelsDevFields, } from "./models-dev.js";
9
+ /** 惰性取索引:只有真的要补字段时才读缓存(启动时没有自定义模型就不读约 2 MB 的缓存)。 */
10
+ export function lazyIndex(source) {
11
+ if (typeof source !== "function")
12
+ return () => source;
13
+ let loaded = false;
14
+ let index;
15
+ return () => {
16
+ if (!loaded) {
17
+ loaded = true;
18
+ try {
19
+ index = source();
20
+ }
21
+ catch {
22
+ index = undefined;
23
+ }
24
+ }
25
+ return index;
26
+ };
27
+ }
28
+ function needsLookup(entry) {
29
+ return ENRICHABLE_FIELDS.some((field) => entry[field] === undefined);
30
+ }
31
+ /**
32
+ * 补全一个自定义模型条目(config 的 `models[]` 或合成的模型)。返回补过字段的条目(不含
33
+ * `modelsDev` / `channels` 这些配置专用键)与每个字段的来源。
34
+ */
35
+ export function enrichEntry(entry, index) {
36
+ const { modelsDev, channels: _channels, ...rest } = entry;
37
+ const sources = {};
38
+ for (const field of ENRICHABLE_FIELDS) {
39
+ sources[field] = rest[field] !== undefined ? "config" : "default";
40
+ }
41
+ const metadata = { sources, looked: false };
42
+ if (modelsDev === false || (!needsLookup(rest) && modelsDev === undefined)) {
43
+ return { entry: rest, metadata };
44
+ }
45
+ const idx = index();
46
+ if (idx === undefined)
47
+ return { entry: rest, metadata };
48
+ metadata.looked = true;
49
+ const match = idx.match(entry.id, typeof modelsDev === "string" ? modelsDev : undefined);
50
+ metadata.match = match;
51
+ if (match === undefined)
52
+ return { entry: rest, metadata };
53
+ const fields = modelsDevFields(match.model);
54
+ if (fields.toolCall !== undefined)
55
+ metadata.toolCall = fields.toolCall;
56
+ const out = { ...rest };
57
+ for (const field of ENRICHABLE_FIELDS) {
58
+ if (out[field] !== undefined)
59
+ continue;
60
+ const value = fields[field];
61
+ if (value === undefined)
62
+ continue;
63
+ out[field] = structuredClone(value);
64
+ sources[field] = "models.dev";
65
+ }
66
+ if (out.name === undefined && fields.name !== undefined)
67
+ out.name = fields.name;
68
+ return { entry: out, metadata };
69
+ }
70
+ /** 内置目录模型的来源(全部 catalog;目录没写 contextWindow / cost 的为缺省)。 */
71
+ export function catalogMetadata(model) {
72
+ const sources = {};
73
+ for (const field of ENRICHABLE_FIELDS) {
74
+ sources[field] = model[field] !== undefined ? "catalog" : "default";
75
+ }
76
+ return { sources, looked: false };
77
+ }
78
+ const SOURCE_TEXT = {
79
+ config: "config",
80
+ catalog: "目录",
81
+ "models.dev": "models.dev",
82
+ default: "缺省",
83
+ };
84
+ export function sourceText(source) {
85
+ return SOURCE_TEXT[source];
86
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * models.dev 缓存(docs/providers.md「模型元数据:models.dev」)。
3
+ *
4
+ * - 缓存文件 `<dataDir>/models-dev.json`:`{ version, url, fetchedAt, etag?, providers }`,providers 是
5
+ * `trimModelsDev()` 裁剪后的数据(约 2.3 MB,原始约 5 MB)。写入走同目录临时文件 + rename。
6
+ * - **启动不联网**:`loadModelsDevIndex()` 只读缓存(按路径 + mtime 记忆,同一进程只解析一次)。
7
+ * - 联网只在 `ama providers add|refresh`、`ama models discover`、`ama models refresh-catalog` 调
8
+ * `refreshModelsDev()` 时发生:缓存 24 小时内不重拉(`force` 例外),带 `If-None-Match`;304 只刷新时间;
9
+ * 失败时返回旧缓存与 warning。`AMA_MODELS_DEV_URL` 换数据源。
10
+ */
11
+ import { ModelsDevIndex, type ModelsDevData } from "./models-dev.js";
12
+ export declare const MODELS_DEV_URL = "https://models.dev/api.json";
13
+ export declare const MODELS_DEV_URL_ENV = "AMA_MODELS_DEV_URL";
14
+ export declare const MODELS_DEV_FILE = "models-dev.json";
15
+ export declare const MODELS_DEV_TTL_MS: number;
16
+ export declare const MODELS_DEV_TIMEOUT_MS = 30000;
17
+ export interface ModelsDevCacheFile {
18
+ version: 1;
19
+ url: string;
20
+ /** ISO 8601。 */
21
+ fetchedAt: string;
22
+ etag?: string;
23
+ providers: ModelsDevData;
24
+ }
25
+ export declare function modelsDevCachePath(dataDir: string): string;
26
+ export declare function modelsDevUrl(env?: Readonly<Record<string, string | undefined>>): string;
27
+ /** 读缓存;不存在或损坏返回 undefined(损坏不报错:下次拉取会覆盖)。 */
28
+ export declare function readModelsDevCache(dataDir: string): ModelsDevCacheFile | undefined;
29
+ export declare function writeModelsDevCache(dataDir: string, file: ModelsDevCacheFile): void;
30
+ /** 只读缓存构造索引(不联网);没有缓存返回 undefined。 */
31
+ export declare function loadModelsDevIndex(dataDir: string): ModelsDevIndex | undefined;
32
+ export type RefreshStatus = "fresh" | "updated" | "not-modified" | "stale" | "unavailable";
33
+ export interface RefreshResult {
34
+ status: RefreshStatus;
35
+ index?: ModelsDevIndex;
36
+ fetchedAt?: string;
37
+ url: string;
38
+ warning?: string;
39
+ }
40
+ export interface RefreshOptions {
41
+ dataDir: string;
42
+ env?: Readonly<Record<string, string | undefined>>;
43
+ /** 忽略 TTL(`ama models refresh-catalog`)。 */
44
+ force?: boolean;
45
+ ttlMs?: number;
46
+ timeoutMs?: number;
47
+ now?: () => number;
48
+ fetch?: typeof fetch;
49
+ }
50
+ /** 按需拉取 models.dev 并更新缓存;失败时回落旧缓存(status `stale`)或无数据(`unavailable`)。 */
51
+ export declare function refreshModelsDev(options: RefreshOptions): Promise<RefreshResult>;
52
+ /** 一行状态说明(命令输出用)。 */
53
+ export declare function describeRefresh(result: RefreshResult): string;