mocode-ai 1.6.3 → 1.6.5

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 (69) hide show
  1. package/README.md +376 -365
  2. package/README.zh-CN.md +15 -4
  3. package/dist/agent/model-turn.js +14 -1
  4. package/dist/agent/run-coordinator.js +129 -84
  5. package/dist/agent/spawn.js +38 -5
  6. package/dist/agent/stages/model-runner.js +1 -1
  7. package/dist/agent/stages/tool-dispatcher.js +117 -79
  8. package/dist/agent/work-discipline.js +3 -1
  9. package/dist/config/index.js +61 -21
  10. package/dist/config/presets.js +40 -1
  11. package/dist/config/profiles.js +9 -1
  12. package/dist/context/clearing.js +53 -0
  13. package/dist/context/encoders/passthrough.js +2 -2
  14. package/dist/context/pipeline.js +4 -3
  15. package/dist/context/relevance.js +58 -13
  16. package/dist/context/text-search.js +113 -0
  17. package/dist/llm/index.js +35 -8
  18. package/dist/llm/providers/anthropic.js +14 -4
  19. package/dist/llm/reasoning.js +141 -0
  20. package/dist/memory/reflect.js +1 -1
  21. package/dist/memory/store.js +31 -28
  22. package/dist/models/budgets.js +11 -0
  23. package/dist/models/catalog.js +130 -0
  24. package/dist/models/map-preset.js +50 -0
  25. package/dist/models/model-panel.js +227 -0
  26. package/dist/models/protocol.js +44 -0
  27. package/dist/models/reasoning-cap.js +88 -0
  28. package/dist/models/search.js +58 -0
  29. package/dist/models/types.js +8 -0
  30. package/dist/repl/commands/effort.js +81 -0
  31. package/dist/repl/commands/model-actions.js +138 -0
  32. package/dist/repl/commands/model-catalog.js +67 -0
  33. package/dist/repl/commands/model-panel-ui.js +107 -0
  34. package/dist/repl/commands/model.js +17 -0
  35. package/dist/repl/commands/registry.js +6 -0
  36. package/dist/repl/commands/session.js +54 -0
  37. package/dist/repl/commands/stats.js +28 -0
  38. package/dist/repl/commands.js +3 -0
  39. package/dist/repl/runtime.js +5 -1
  40. package/dist/repl/status-bar.js +4 -2
  41. package/dist/rollback/store.js +17 -0
  42. package/dist/runtime/runtime.js +31 -1
  43. package/dist/session/compact.js +84 -3
  44. package/dist/session/index.js +2 -0
  45. package/dist/session/retention.js +297 -0
  46. package/dist/session/scheduler.js +30 -3
  47. package/dist/session/store.js +108 -6
  48. package/dist/session/usage-stats.js +90 -0
  49. package/dist/skills/activation.js +2 -0
  50. package/dist/skills/builtin-skills.js +153 -0
  51. package/dist/skills/discover.js +3 -1
  52. package/dist/skills/runner.js +4 -1
  53. package/dist/tools/builtins/edit-file.js +2 -4
  54. package/dist/tools/builtins/grep.js +2 -4
  55. package/dist/tools/builtins/index.js +4 -0
  56. package/dist/tools/builtins/read-file.js +21 -5
  57. package/dist/tools/builtins/session-search.js +33 -0
  58. package/dist/tools/builtins/task.js +2 -0
  59. package/dist/tools/constants.js +8 -0
  60. package/dist/tools/policy.js +1 -1
  61. package/dist/tools/read-dedup.js +62 -0
  62. package/dist/tools/tool-runtime.js +1 -0
  63. package/dist/ui/batch.js +109 -103
  64. package/dist/ui/fuzzy-picker.js +227 -0
  65. package/dist/ui/hierarchical-picker.js +302 -0
  66. package/dist/ui/intervention.js +11 -1
  67. package/dist/ui/layout-internal/statusbar.js +18 -7
  68. package/dist/ui/render.js +2 -0
  69. package/package.json +3 -2
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 各档思考预算(#model-catalog M3)。
3
+ *
4
+ * 独立小模块:reasoning.ts 与 reasoning-cap.ts 都要用,放在 models 下可避免
5
+ * reasoning-cap → reasoning 的反向依赖(reasoning 会 import reasoning-cap)。
6
+ */
7
+ export const THINK_BUDGET_BY_EFFORT = {
8
+ low: 1024,
9
+ medium: 4096,
10
+ high: 10240,
11
+ };
@@ -0,0 +1,130 @@
1
+ /**
2
+ * models.dev 目录的拉取 / ETag 协商 / 快照落盘 / 离线回退(#model-catalog M1)。
3
+ *
4
+ * 仅在用户显式命令时调用,不随启动或每轮联网。fetch 与快照路径均可注入,便于单测
5
+ * (不打真实 models.dev)。
6
+ */
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ export const CATALOG_URL = 'https://models.dev/api.json';
11
+ export const CATALOG_SNAPSHOT_PATH = path.join(os.homedir(), '.mocode', 'catalog.json');
12
+ /** 默认快照新鲜期(小时);MOCODE_CATALOG_TTL_HOURS 可覆盖。 */
13
+ export const DEFAULT_TTL_HOURS = 24;
14
+ /** 拉取超时(毫秒)。 */
15
+ export const FETCH_TIMEOUT_MS = 10_000;
16
+ function ttlHours() {
17
+ const v = Number(process.env.MOCODE_CATALOG_TTL_HOURS);
18
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_TTL_HOURS;
19
+ }
20
+ /** 读本地快照;不存在/损坏返回 null。路径可注入(测试用临时 HOME)。 */
21
+ export function readSnapshot(snapshotPath = CATALOG_SNAPSHOT_PATH) {
22
+ try {
23
+ const obj = JSON.parse(fs.readFileSync(snapshotPath, 'utf8'));
24
+ if (!obj.providers || typeof obj.providers !== 'object')
25
+ return null;
26
+ return obj;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
32
+ /** 写快照(原子:tmp + rename)。 */
33
+ export function writeSnapshot(snapshot, snapshotPath = CATALOG_SNAPSHOT_PATH) {
34
+ fs.mkdirSync(path.dirname(snapshotPath), { recursive: true });
35
+ const tmp = `${snapshotPath}.tmp-${process.pid}-${Date.now()}`;
36
+ fs.writeFileSync(tmp, JSON.stringify(snapshot), 'utf8');
37
+ fs.renameSync(tmp, snapshotPath);
38
+ }
39
+ /** 快照是否仍在 TTL 内。 */
40
+ export function isFresh(snapshot, now = new Date()) {
41
+ const fetched = Date.parse(snapshot.fetchedAt);
42
+ if (!Number.isFinite(fetched))
43
+ return false;
44
+ return now.getTime() - fetched < ttlHours() * 3600 * 1000;
45
+ }
46
+ function fetchWithTimeout(fetchImpl, init) {
47
+ const ac = new AbortController();
48
+ const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
49
+ return fetchImpl(CATALOG_URL, { ...init, signal: ac.signal }).finally(() => clearTimeout(timer));
50
+ }
51
+ function emptyCatalog(source, snapshot) {
52
+ return {
53
+ providers: snapshot?.providers ?? {},
54
+ source,
55
+ fetchedAt: snapshot?.fetchedAt,
56
+ etag: snapshot?.etag,
57
+ };
58
+ }
59
+ /**
60
+ * 强制拉取最新目录。
61
+ * - 200:解析 + 落盘,source='live'
62
+ * - 304:复用本地快照(仅刷新 fetchedAt/etag 不强制),source='cache'
63
+ * - 网络/解析失败:有快照回退快照(source='cache'),否则 source='empty'
64
+ */
65
+ export async function refreshCatalog(opts = {}) {
66
+ const fetchImpl = opts.fetch ?? globalThis.fetch.bind(globalThis);
67
+ const snapshotPath = opts.snapshotPath ?? CATALOG_SNAPSHOT_PATH;
68
+ const cached = readSnapshot(snapshotPath);
69
+ let res;
70
+ try {
71
+ const headers = { Accept: 'application/json' };
72
+ if (cached?.etag)
73
+ headers['If-None-Match'] = cached.etag;
74
+ res = await fetchWithTimeout(fetchImpl, { headers });
75
+ }
76
+ catch {
77
+ return cached ? emptyCatalog('cache', cached) : emptyCatalog('empty');
78
+ }
79
+ if (res.status === 304 && cached) {
80
+ // 内容未变:更新 fetchedAt 表示「刚确认仍最新」,保留原 providers。
81
+ const renewed = { ...cached, fetchedAt: new Date().toISOString() };
82
+ writeSnapshot(renewed, snapshotPath);
83
+ return {
84
+ providers: renewed.providers,
85
+ source: 'cache',
86
+ fetchedAt: renewed.fetchedAt,
87
+ etag: renewed.etag,
88
+ };
89
+ }
90
+ if (res.status >= 200 && res.status < 300) {
91
+ let body;
92
+ try {
93
+ body = await res.json();
94
+ }
95
+ catch {
96
+ return cached ? emptyCatalog('cache', cached) : emptyCatalog('empty');
97
+ }
98
+ const providers = body.providers;
99
+ // models.dev 直接以 provider id 为顶层键(无外层 providers 包裹);兼容两种形态。
100
+ const record = providers && typeof providers === 'object' ? providers : body;
101
+ if (!record || typeof record !== 'object') {
102
+ return cached ? emptyCatalog('cache', cached) : emptyCatalog('empty');
103
+ }
104
+ const etag = res.headers.get('etag') ?? cached?.etag ?? undefined;
105
+ const snapshot = {
106
+ fetchedAt: new Date().toISOString(),
107
+ ...(etag ? { etag } : {}),
108
+ providers: record,
109
+ };
110
+ writeSnapshot(snapshot, snapshotPath);
111
+ return {
112
+ providers: record,
113
+ source: 'live',
114
+ fetchedAt: snapshot.fetchedAt,
115
+ ...(etag ? { etag } : {}),
116
+ };
117
+ }
118
+ return cached ? emptyCatalog('cache', cached) : emptyCatalog('empty');
119
+ }
120
+ /**
121
+ * 命令入口:快照新鲜直接用快照(不联网);过期或 force 才拉取。
122
+ */
123
+ export async function loadCatalog(opts = {}) {
124
+ const snapshotPath = opts.snapshotPath ?? CATALOG_SNAPSHOT_PATH;
125
+ const cached = readSnapshot(snapshotPath);
126
+ if (!opts.force && cached && isFresh(cached, opts.now)) {
127
+ return { providers: cached.providers, source: 'cache', fetchedAt: cached.fetchedAt, etag: cached.etag };
128
+ }
129
+ return refreshCatalog({ fetch: opts.fetch, snapshotPath });
130
+ }
@@ -0,0 +1,50 @@
1
+ import { classifyProvider, resolveBaseURL, toPresetProvider } from './protocol.js';
2
+ /** 上下文窗口缺失/为 0 时的保守默认(宁可小,避免超长发出去被拒)。 */
3
+ export const FALLBACK_CONTEXT_WINDOW = 128000;
4
+ /**
5
+ * 按 provider.env 声明的变量名顺序找一个已设置的 key;都没有返回 null。
6
+ * 读 process.env 是本模块唯一触碰环境的地方,调用方可传 env 覆盖以便测试。
7
+ */
8
+ export function resolveApiKey(provider, env = process.env) {
9
+ for (const keyName of provider.env ?? []) {
10
+ const v = env[keyName];
11
+ if (typeof v === 'string' && v.trim())
12
+ return v.trim();
13
+ }
14
+ return null;
15
+ }
16
+ /** 把目录来源拼成合法预设名:`<provider>-<model>` 去非法字符。 */
17
+ export function defaultPresetName(providerId, modelId) {
18
+ const raw = `${providerId}-${modelId}`;
19
+ const sanitized = raw
20
+ .replace(/[^a-zA-Z0-9_-]+/g, '-')
21
+ .replace(/^-+|-+$/g, '')
22
+ .slice(0, 32) || 'preset';
23
+ return sanitized;
24
+ }
25
+ /**
26
+ * 产出可直接 savePreset 的对象。provider 不被现有 fetch 支持时返回 null
27
+ * (命令层据此置灰/拒绝,避免存一个必错的预设)。
28
+ */
29
+ export function buildPreset(input) {
30
+ const { provider, model } = input;
31
+ const proto = classifyProvider(provider);
32
+ const presetProvider = toPresetProvider(proto);
33
+ if (!presetProvider)
34
+ return null;
35
+ const baseURL = resolveBaseURL(provider);
36
+ if (!baseURL)
37
+ return null;
38
+ const contextWindow = typeof model.limit?.context === 'number' && model.limit.context > 0
39
+ ? Math.floor(model.limit.context)
40
+ : FALLBACK_CONTEXT_WINDOW;
41
+ return {
42
+ name: input.name ?? defaultPresetName(provider.id, model.id),
43
+ provider: presetProvider,
44
+ baseURL,
45
+ apiKey: input.apiKey,
46
+ model: input.modelOverride ?? model.id,
47
+ contextWindow,
48
+ anthropicPromptCache: presetProvider === 'anthropic',
49
+ };
50
+ }
@@ -0,0 +1,227 @@
1
+ import { classifyProvider, resolveBaseURL } from './protocol.js';
2
+ function fmtK(n) {
3
+ if (!n || n <= 0)
4
+ return '-';
5
+ return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
6
+ }
7
+ /** 该预设是否与当前生效配置一致(用于标 ★)。 */
8
+ export function isPresetCurrent(p, cur) {
9
+ return (p.provider === cur.provider &&
10
+ p.baseURL === cur.baseURL &&
11
+ p.model === cur.model &&
12
+ p.contextWindow === cur.contextWindowTokens);
13
+ }
14
+ /** 组装统一面板的全部行(顺序:当前预设 → 其他预设 → 目录模型 → 操作行)。 */
15
+ export function buildPanelEntries(input) {
16
+ const rows = [];
17
+ // 已存预设:当前的排最前。
18
+ const withCurrent = input.presets.map((p) => ({
19
+ p,
20
+ cur: isPresetCurrent(p, input.current),
21
+ }));
22
+ withCurrent.sort((a, b) => Number(b.cur) - Number(a.cur) || a.p.name.localeCompare(b.p.name));
23
+ for (const { p, cur } of withCurrent) {
24
+ rows.push({
25
+ choice: { kind: 'preset', preset: p },
26
+ searchText: `${p.name} ${p.model} ${p.baseURL} ${p.provider}`,
27
+ label: `${cur ? '★ ' : ''}${p.name}`,
28
+ detail: `${p.model} · ${p.provider}`,
29
+ });
30
+ }
31
+ // 目录模型:跳过不支持的、以及与某预设重复的。
32
+ const presetKeys = new Set(input.presets.map((p) => `${p.baseURL}|${p.model}`));
33
+ for (const e of input.catalog) {
34
+ if (!e.supported)
35
+ continue;
36
+ const provider = input.providers[e.providerId];
37
+ if (!provider)
38
+ continue;
39
+ if (classifyProvider(provider) === 'unsupported')
40
+ continue;
41
+ if (presetKeys.has(`${provider.api ?? ''}|${e.modelId}`))
42
+ continue;
43
+ const c = e.model.cost;
44
+ const caps = [e.model.reasoning ? '思考' : '', e.model.tool_call ? '工具' : '', e.model.attachment ? '多模态' : '']
45
+ .filter(Boolean)
46
+ .join('·');
47
+ rows.push({
48
+ choice: { kind: 'catalog', entry: e },
49
+ searchText: `${e.model.name ?? ''} ${e.modelId}`,
50
+ label: e.model.name || e.modelId,
51
+ detail: [
52
+ e.providerName,
53
+ `${fmtK(e.model.limit?.context)} ctx`,
54
+ c ? `$${c.input ?? 0}/$${c.output ?? 0}` : '',
55
+ caps,
56
+ ]
57
+ .filter(Boolean)
58
+ .join(' · '),
59
+ });
60
+ }
61
+ // 操作行。
62
+ rows.push({
63
+ choice: { kind: 'action', action: 'custom' },
64
+ searchText: '自定义 手动填写 custom baseURL model key',
65
+ label: '+ 自定义模型(手动填写)',
66
+ detail: '',
67
+ });
68
+ rows.push({
69
+ choice: { kind: 'action', action: 'refresh' },
70
+ searchText: '刷新模型目录 refresh catalog 更新',
71
+ label: '⟳ 刷新模型目录',
72
+ detail: '',
73
+ });
74
+ return rows;
75
+ }
76
+ /** 归一化端点为归并键:小写 host + 去尾斜杠的 path,使预设 baseURL 与目录 api 对齐。 */
77
+ function endpointKey(raw) {
78
+ const v = raw.trim();
79
+ try {
80
+ const u = new URL(v);
81
+ return `${u.host.toLowerCase()}${u.pathname.replace(/\/+$/, '')}`;
82
+ }
83
+ catch {
84
+ return v.replace(/\/+$/, '').toLowerCase();
85
+ }
86
+ }
87
+ /** 从 URL 取 host 作合成组名;失败回退整串。 */
88
+ function hostOf(raw) {
89
+ try {
90
+ return new URL(raw.trim()).host;
91
+ }
92
+ catch {
93
+ return raw.trim();
94
+ }
95
+ }
96
+ function presetLeaf(p, cur) {
97
+ return {
98
+ choice: { kind: 'preset', preset: p },
99
+ searchText: `${p.model}`,
100
+ label: `${cur ? '★ ' : ''}${p.name}`,
101
+ detail: `${p.model} · ${p.provider}`,
102
+ };
103
+ }
104
+ function catalogLeaf(e) {
105
+ const c = e.model.cost;
106
+ const caps = [e.model.reasoning ? '思考' : '', e.model.tool_call ? '工具' : '', e.model.attachment ? '多模态' : '']
107
+ .filter(Boolean)
108
+ .join('·');
109
+ return {
110
+ choice: { kind: 'catalog', entry: e },
111
+ searchText: `${e.model.name ?? ''} ${e.modelId}`,
112
+ label: e.model.name || e.modelId,
113
+ detail: [e.providerName, `${fmtK(e.model.limit?.context)} ctx`, c ? `$${c.input ?? 0}/$${c.output ?? 0}` : '', caps]
114
+ .filter(Boolean)
115
+ .join(' · '),
116
+ };
117
+ }
118
+ function actionLeaf(action) {
119
+ if (action === 'custom') {
120
+ return {
121
+ choice: { kind: 'action', action },
122
+ searchText: '自定义 手动填写 custom baseURL model key',
123
+ label: '+ 自定义模型(手动填写)',
124
+ detail: '',
125
+ };
126
+ }
127
+ return {
128
+ choice: { kind: 'action', action },
129
+ searchText: '刷新模型目录 refresh catalog 更新',
130
+ label: '⟳ 刷新模型目录',
131
+ detail: '',
132
+ };
133
+ }
134
+ /**
135
+ * 组装两级模型树:root = 厂商组 + 操作行;每个厂商组内 = 当前预设★ → 其他预设 → 目录模型。
136
+ *
137
+ * 归并规则:预设按 baseURL 归并到「端点相同」的目录厂商;目录里没有的端点按 URL host
138
+ * 合成一个组。目录模型与预设同 model 名时不重复列(预设优先)。无 I/O。
139
+ */
140
+ export function buildModelTree(input) {
141
+ const groups = [];
142
+ const byKey = new Map();
143
+ // 1) 目录厂商:仅可直发且有模型的。
144
+ for (const [providerId, provider] of Object.entries(input.providers)) {
145
+ if (classifyProvider(provider) === 'unsupported')
146
+ continue;
147
+ const endpoint = resolveBaseURL(provider);
148
+ if (!endpoint)
149
+ continue;
150
+ if (Object.keys(provider.models ?? {}).length === 0)
151
+ continue;
152
+ const label = provider.name || providerId;
153
+ const g = {
154
+ label,
155
+ key: endpointKey(endpoint),
156
+ providerId,
157
+ presetLeaves: [],
158
+ catalogLeaves: [],
159
+ seenModels: new Set(),
160
+ searchText: `${label} ${providerId}`,
161
+ };
162
+ groups.push(g);
163
+ if (!byKey.has(g.key))
164
+ byKey.set(g.key, g);
165
+ for (const e of input.catalog) {
166
+ if (!e.supported || e.providerId !== providerId)
167
+ continue;
168
+ if (g.seenModels.has(e.modelId))
169
+ continue;
170
+ g.seenModels.add(e.modelId);
171
+ g.catalogLeaves.push(catalogLeaf(e));
172
+ }
173
+ }
174
+ // 2) 预设归并:端点命中目录厂商 → 进该组;否则进/建合成组。
175
+ for (const p of input.presets) {
176
+ const cur = isPresetCurrent(p, input.current);
177
+ const key = endpointKey(p.baseURL);
178
+ let g = byKey.get(key);
179
+ if (!g) {
180
+ g = {
181
+ label: hostOf(p.baseURL) || p.baseURL,
182
+ key,
183
+ providerId: null,
184
+ presetLeaves: [],
185
+ catalogLeaves: [],
186
+ seenModels: new Set(),
187
+ searchText: `${hostOf(p.baseURL)}`,
188
+ };
189
+ groups.push(g);
190
+ byKey.set(key, g);
191
+ }
192
+ g.presetLeaves.push({ node: presetLeaf(p, cur), model: p.model, cur });
193
+ }
194
+ // 3) 组内:当前预设 → 其他预设 → 目录模型(跳过与预设同名的);空组不列。
195
+ const roots = [];
196
+ for (const g of groups) {
197
+ const presetModelNames = new Set(g.presetLeaves.map((x) => x.model));
198
+ g.presetLeaves.sort((a, b) => Number(b.cur) - Number(a.cur));
199
+ const children = [
200
+ ...g.presetLeaves.map((x) => x.node),
201
+ ...g.catalogLeaves.filter((leaf) => {
202
+ const e = leaf.choice;
203
+ return !(e?.kind === 'catalog' && presetModelNames.has(e.entry.modelId));
204
+ }),
205
+ ];
206
+ if (children.length === 0)
207
+ continue;
208
+ roots.push({
209
+ searchText: g.searchText,
210
+ label: g.label,
211
+ detail: `${children.length} 个模型`,
212
+ children,
213
+ });
214
+ }
215
+ // 目录厂商在前,合成组在后;各自按名排序。
216
+ roots.sort((a, b) => {
217
+ const ga = groups.find((g) => g.label === a.label && g.searchText === a.searchText);
218
+ const gb = groups.find((g) => g.label === b.label && g.searchText === b.searchText);
219
+ const sa = ga?.providerId ? 0 : 1;
220
+ const sb = gb?.providerId ? 0 : 1;
221
+ return sa - sb || a.label.localeCompare(b.label);
222
+ });
223
+ // 4) 操作行固定在 root 末尾。
224
+ roots.push(actionLeaf('custom'));
225
+ roots.push(actionLeaf('refresh'));
226
+ return roots;
227
+ }
@@ -0,0 +1,44 @@
1
+ /** OpenAI 官方端点(官方 provider 记录通常不带 api 字段)。 */
2
+ export const OPENAI_DEFAULT_BASE_URL = 'https://api.openai.com/v1';
3
+ /**
4
+ * Anthropic 官方端点:provider 记录同样不带 api(端点内置 SDK)。
5
+ * 不带 /v1——anthropic.ts 的 endpoint() 会在后面拼 `/v1/messages`。
6
+ */
7
+ export const ANTHROPIC_DEFAULT_BASE_URL = 'https://api.anthropic.com';
8
+ /**
9
+ * 判定该 provider 能否用 mocode 现有 fetch 直发。
10
+ * - 官方 anthropic 包 → Anthropic Messages 路径
11
+ * - openai-compatible 且带端点 → OpenAI Chat 路径
12
+ * - 官方 openai 包 → OpenAI Chat 路径(端点缺省补官方 /v1)
13
+ * - 其余(google/bedrock/azure/vertex/各 gateway…)→ 二期,UI 置灰
14
+ */
15
+ export function classifyProvider(provider) {
16
+ switch (provider.npm) {
17
+ case '@ai-sdk/anthropic':
18
+ return 'anthropic-messages';
19
+ case '@ai-sdk/openai-compatible':
20
+ return provider.api && provider.api.trim() ? 'openai-chat' : 'unsupported';
21
+ case '@ai-sdk/openai':
22
+ return 'openai-chat';
23
+ default:
24
+ return 'unsupported';
25
+ }
26
+ }
27
+ /** provider 实际要用的 baseURL:官方 openai/anthropic 缺 api 时补默认,其余原样返回。 */
28
+ export function resolveBaseURL(provider) {
29
+ if (provider.api && provider.api.trim())
30
+ return provider.api.trim();
31
+ if (provider.npm === '@ai-sdk/openai')
32
+ return OPENAI_DEFAULT_BASE_URL;
33
+ if (provider.npm === '@ai-sdk/anthropic')
34
+ return ANTHROPIC_DEFAULT_BASE_URL;
35
+ return '';
36
+ }
37
+ /** 映射到 ModelPreset.provider(presets.ts 的两值)。 */
38
+ export function toPresetProvider(proto) {
39
+ if (proto === 'openai-chat')
40
+ return 'openai';
41
+ if (proto === 'anthropic-messages')
42
+ return 'anthropic';
43
+ return null;
44
+ }
@@ -0,0 +1,88 @@
1
+ import { THINK_BUDGET_BY_EFFORT } from './budgets.js';
2
+ /**
3
+ * catalogProviderId → 方言。一家厂商一种方言(alibaba 全系 Qwen)。
4
+ * 未列出的 provider 返回 undefined → 保守不下发。
5
+ */
6
+ const PROVIDER_DIALECT = {
7
+ openai: 'openai-effort',
8
+ openrouter: 'openai-effort',
9
+ xai: 'openai-effort',
10
+ alibaba: 'qwen',
11
+ zhipuai: 'thinking-toggle',
12
+ volcengine: 'thinking-toggle',
13
+ deepseek: 'thinking-toggle',
14
+ moonshotai: 'thinking-toggle',
15
+ };
16
+ export function dialectForProvider(catalogProviderId) {
17
+ return catalogProviderId ? PROVIDER_DIALECT[catalogProviderId] : undefined;
18
+ }
19
+ /** 在 effort values 中按归一化档位挑一个合法值(按优先级,找不到回退到最低/最高档)。 */
20
+ function pickEffortValue(effort, values, off) {
21
+ const v = values.map((s) => s.toLowerCase());
22
+ const priorities = off
23
+ ? { any: ['off', 'disabled', 'minimal', 'low'] }
24
+ : effort === 'low'
25
+ ? { any: ['low', 'minimal'] }
26
+ : effort === 'medium'
27
+ ? { any: ['medium', 'mid', 'low'] }
28
+ : { any: ['high', 'xhigh', 'max', 'medium'] };
29
+ for (const cand of priorities.any) {
30
+ const hit = v.find((x) => x === cand || x.includes(cand));
31
+ if (hit)
32
+ return values[v.indexOf(hit)];
33
+ }
34
+ // 回退:off/低档取最低,高档取最高。
35
+ return off || effort === 'low' ? values[0] : values[values.length - 1];
36
+ }
37
+ /**
38
+ * 目录能力 → 请求字段;无法安全下发时返回 {}。
39
+ * 依据第一个匹配的 reasoning_option 决定形态。
40
+ */
41
+ export function paramsFromCatalog(input) {
42
+ const dialect = dialectForProvider(input.catalogProviderId);
43
+ if (!dialect)
44
+ return {};
45
+ const opts = input.reasoningOptions ?? [];
46
+ const toggle = opts.find((o) => o.type === 'toggle');
47
+ const effortOpt = opts.find((o) => o.type === 'effort');
48
+ const budgetOpt = opts.find((o) => o.type === 'budget_tokens');
49
+ const isOff = input.effort === 'off';
50
+ // ── qwen 方言:enable_thinking + thinking_budget(budget 选项优先给预算) ──
51
+ if (dialect === 'qwen') {
52
+ if (isOff)
53
+ return { enable_thinking: false };
54
+ const base = { enable_thinking: true };
55
+ if (budgetOpt) {
56
+ let budget = THINK_BUDGET_BY_EFFORT[input.effort];
57
+ if (typeof budgetOpt.min === 'number')
58
+ budget = Math.max(budgetOpt.min, budget);
59
+ base.thinking_budget = budget;
60
+ }
61
+ return base;
62
+ }
63
+ // ── openai effort 方言 ──
64
+ if (dialect === 'openai-effort') {
65
+ if (effortOpt?.values && effortOpt.values.length > 0) {
66
+ // OpenAI 官方无法真关:off 落到最低档。
67
+ return { reasoning_effort: pickEffortValue(input.effort, effortOpt.values, isOff) };
68
+ }
69
+ if (toggle) {
70
+ return isOff ? { reasoning_effort: 'low' } : { reasoning_effort: input.effort };
71
+ }
72
+ // 无 effort/toggle 元数据但已知是 OpenAI 方言:直接下发归一化档位(off→low)。
73
+ return { reasoning_effort: isOff ? 'low' : input.effort };
74
+ }
75
+ // ── thinking-toggle 方言 ──
76
+ if (dialect === 'thinking-toggle') {
77
+ if (effortOpt?.values && effortOpt.values.length > 0 && !isOff) {
78
+ // 个别厂商用 thinking 但分档(如 deepseek low/high/max):仍只开关,标 enabled。
79
+ return { thinking: { type: 'enabled' } };
80
+ }
81
+ return { thinking: { type: isOff ? 'disabled' : 'enabled' } };
82
+ }
83
+ return {};
84
+ }
85
+ /** 目录是否声明该模型支持思考(供 recognizesReasoning)。 */
86
+ export function recognizesFromCatalog(reasoning) {
87
+ return reasoning === true;
88
+ }
@@ -0,0 +1,58 @@
1
+ import { classifyProvider } from './protocol.js';
2
+ /** 把目录拍平成条目列表。 */
3
+ export function flattenCatalog(providers) {
4
+ const out = [];
5
+ for (const [providerId, provider] of Object.entries(providers)) {
6
+ const supported = classifyProvider(provider) !== 'unsupported';
7
+ const providerName = provider.name || providerId;
8
+ for (const [modelId, model] of Object.entries(provider.models ?? {})) {
9
+ out.push({ providerId, providerName, modelId, model, supported });
10
+ }
11
+ }
12
+ return out;
13
+ }
14
+ /** 关键字过滤 + 能力过滤。 */
15
+ export function searchModels(providers, filters = {}) {
16
+ const q = filters.query?.trim().toLowerCase();
17
+ return flattenCatalog(providers)
18
+ .filter((e) => {
19
+ if (!filters.includeUnsupported && !e.supported)
20
+ return false;
21
+ if (filters.reasoning && !e.model.reasoning)
22
+ return false;
23
+ if (filters.toolCall && !e.model.tool_call)
24
+ return false;
25
+ if (filters.attachment && !e.model.attachment)
26
+ return false;
27
+ if (q) {
28
+ const hay = [e.providerName, e.providerId, e.modelId, e.model.name ?? '', e.model.family ?? '']
29
+ .join(' ')
30
+ .toLowerCase();
31
+ // 支持多关键字(空格分隔),全部命中才算。
32
+ if (!q.split(/\s+/).every((tok) => hay.includes(tok)))
33
+ return false;
34
+ }
35
+ return true;
36
+ })
37
+ .sort((a, b) => {
38
+ // 可直发优先;其次厂商名;再模型名(按发布时间倒序更适合挑新模型,但纯函数保持确定性)。
39
+ if (a.supported !== b.supported)
40
+ return a.supported ? -1 : 1;
41
+ const p = a.providerName.localeCompare(b.providerName);
42
+ if (p !== 0)
43
+ return p;
44
+ return (b.model.release_date ?? '').localeCompare(a.model.release_date ?? '');
45
+ });
46
+ }
47
+ /** 按 provider 分组(保持组内顺序),供 UI 分区渲染。 */
48
+ export function groupByProvider(entries) {
49
+ const map = new Map();
50
+ for (const e of entries) {
51
+ const arr = map.get(e.providerId);
52
+ if (arr)
53
+ arr.push(e);
54
+ else
55
+ map.set(e.providerId, [e]);
56
+ }
57
+ return map;
58
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * models.dev 目录的最小类型(#model-catalog)。
3
+ *
4
+ * 只声明 mocode 实际消费的字段——上游 api.json 一个 provider 下还有很多我们用不到的
5
+ * 字段,刻意不照搬,保持类型面小、上游增字段不影响编译。所有字段按「可能缺失」处理,
6
+ * 消费方走默认值,不假设上游永远规整。
7
+ */
8
+ export {};