mocode-ai 1.2.6 → 1.2.7

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.
@@ -0,0 +1,370 @@
1
+ import { config, getActiveModel } from '../../config/index.js';
2
+ let fetchImplOverride = null;
3
+ /** 仅供单测注入;生产路径使用 Node 18+ 全局 fetch。 */
4
+ export function __setAnthropicFetchImpl(impl) {
5
+ fetchImplOverride = impl;
6
+ }
7
+ function parseJsonObject(value) {
8
+ if (!value.trim())
9
+ return {};
10
+ try {
11
+ const parsed = JSON.parse(value);
12
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
13
+ ? parsed
14
+ : {};
15
+ }
16
+ catch {
17
+ return {};
18
+ }
19
+ }
20
+ function textBlocks(content) {
21
+ if (content == null)
22
+ return [];
23
+ if (typeof content === 'string')
24
+ return content ? [{ type: 'text', text: content }] : [];
25
+ if (!Array.isArray(content))
26
+ return [{ type: 'text', text: String(content) }];
27
+ const blocks = [];
28
+ for (const raw of content) {
29
+ if (!raw || typeof raw !== 'object')
30
+ continue;
31
+ const part = raw;
32
+ if (part.type === 'text' && typeof part.text === 'string') {
33
+ blocks.push({ type: 'text', text: part.text });
34
+ continue;
35
+ }
36
+ if (part.type !== 'image_url')
37
+ continue;
38
+ const image = part.image_url;
39
+ if (typeof image?.url !== 'string')
40
+ continue;
41
+ const match = /^data:(image\/(?:jpeg|png|gif|webp));base64,(.+)$/s.exec(image.url);
42
+ if (!match)
43
+ continue;
44
+ blocks.push({
45
+ type: 'image',
46
+ source: { type: 'base64', media_type: match[1], data: match[2] },
47
+ });
48
+ }
49
+ return blocks;
50
+ }
51
+ function appendMessage(messages, role, blocks, cacheCandidates, cacheable = true) {
52
+ if (blocks.length === 0)
53
+ return;
54
+ const previous = messages[messages.length - 1];
55
+ if (previous?.role === role)
56
+ previous.content.push(...blocks);
57
+ else
58
+ messages.push({ role, content: [...blocks] });
59
+ if (cacheable)
60
+ cacheCandidates.push(...blocks);
61
+ }
62
+ /**
63
+ * 把会话落盘使用的 OpenAI 兼容消息转换为 Anthropic Messages 内容块。
64
+ * history 保持旧格式以兼容已有 session;provider 边界负责 tool_use/tool_result 编码。
65
+ */
66
+ export function encodeAnthropicMessages(input, promptCache = config.anthropicPromptCache) {
67
+ const system = [];
68
+ const messages = [];
69
+ const cacheCandidates = [];
70
+ let dialogStarted = false;
71
+ for (const raw of input) {
72
+ const message = raw;
73
+ if (message.role === 'system' && !dialogStarted) {
74
+ system.push(...textBlocks(message.content));
75
+ continue;
76
+ }
77
+ dialogStarted = true;
78
+ if (message.role === 'system') {
79
+ const blocks = textBlocks(message.content).map((block) => ({
80
+ ...block,
81
+ text: `[System reminder]\n${String(block.text ?? '')}`,
82
+ }));
83
+ // 动态尾部 reminder 每步可能变化,不把 cache breakpoint 放在它之后。
84
+ appendMessage(messages, 'user', blocks, cacheCandidates, false);
85
+ continue;
86
+ }
87
+ if (message.role === 'user') {
88
+ appendMessage(messages, 'user', textBlocks(message.content), cacheCandidates);
89
+ continue;
90
+ }
91
+ if (message.role === 'assistant') {
92
+ const blocks = textBlocks(message.content);
93
+ for (const call of message.tool_calls ?? []) {
94
+ blocks.push({
95
+ type: 'tool_use',
96
+ id: call.id ?? '',
97
+ name: call.function?.name ?? '',
98
+ input: parseJsonObject(call.function?.arguments ?? ''),
99
+ });
100
+ }
101
+ appendMessage(messages, 'assistant', blocks, cacheCandidates);
102
+ continue;
103
+ }
104
+ if (message.role === 'tool') {
105
+ appendMessage(messages, 'user', [{
106
+ type: 'tool_result',
107
+ tool_use_id: message.tool_call_id ?? '',
108
+ content: typeof message.content === 'string'
109
+ ? message.content
110
+ : JSON.stringify(message.content ?? ''),
111
+ }], cacheCandidates);
112
+ }
113
+ }
114
+ if (promptCache) {
115
+ // 最后一个稳定消息块形成会话前缀断点;动态 session reminder 明确不参与。
116
+ const last = cacheCandidates[cacheCandidates.length - 1];
117
+ if (last)
118
+ last.cache_control = { type: 'ephemeral' };
119
+ // 没有对话时仍缓存稳定 system prompt。
120
+ else if (system.length > 0)
121
+ system[system.length - 1].cache_control = { type: 'ephemeral' };
122
+ }
123
+ return { system, messages };
124
+ }
125
+ export function encodeAnthropicTools(tools, promptCache = config.anthropicPromptCache) {
126
+ const encoded = tools.map((tool) => ({
127
+ name: tool.function.name,
128
+ description: tool.function.description,
129
+ input_schema: tool.function.parameters ?? { type: 'object', properties: {} },
130
+ }));
131
+ if (promptCache && encoded.length > 0) {
132
+ encoded[encoded.length - 1] = {
133
+ ...encoded[encoded.length - 1],
134
+ cache_control: { type: 'ephemeral' },
135
+ };
136
+ }
137
+ return encoded;
138
+ }
139
+ function endpoint(baseURL) {
140
+ const base = baseURL.replace(/\/+$/, '');
141
+ if (/\/v1$/i.test(base))
142
+ return `${base}/messages`;
143
+ if (/\/v1\/messages$/i.test(base))
144
+ return base;
145
+ return `${base}/v1/messages`;
146
+ }
147
+ export function buildAnthropicRequest(messages, tools) {
148
+ const encoded = encodeAnthropicMessages(messages);
149
+ const anthropicTools = encodeAnthropicTools(tools);
150
+ return {
151
+ model: getActiveModel(),
152
+ max_tokens: config.maxTokens ?? 8192,
153
+ stream: true,
154
+ system: encoded.system,
155
+ messages: encoded.messages,
156
+ ...(anthropicTools.length > 0 ? { tools: anthropicTools } : {}),
157
+ };
158
+ }
159
+ class AnthropicHttpError extends Error {
160
+ status;
161
+ code;
162
+ headers;
163
+ constructor(status, message, headers, code) {
164
+ super(message);
165
+ this.name = 'AnthropicAPIError';
166
+ this.status = status;
167
+ this.code = code;
168
+ this.headers = headers;
169
+ }
170
+ }
171
+ async function throwResponseError(response) {
172
+ let message = `Anthropic API HTTP ${response.status}`;
173
+ let code;
174
+ try {
175
+ const body = JSON.parse(await response.text());
176
+ if (typeof body.error?.message === 'string')
177
+ message = body.error.message;
178
+ if (typeof body.error?.type === 'string')
179
+ code = body.error.type;
180
+ }
181
+ catch {
182
+ // 非 JSON 错误页只保留状态码,避免把代理 HTML 大段写进 TUI。
183
+ }
184
+ throw new AnthropicHttpError(response.status, message, response.headers, code);
185
+ }
186
+ function decodeSseRecord(record) {
187
+ let event = '';
188
+ const data = [];
189
+ for (const line of record.split(/\r?\n/)) {
190
+ if (line.startsWith('event:'))
191
+ event = line.slice(6).trim();
192
+ else if (line.startsWith('data:'))
193
+ data.push(line.slice(5).trimStart());
194
+ }
195
+ if (data.length === 0)
196
+ return null;
197
+ return { event, data: data.join('\n') };
198
+ }
199
+ async function* readSse(response) {
200
+ if (!response.body)
201
+ throw new Error('Anthropic 流式响应缺少 body');
202
+ const reader = response.body.getReader();
203
+ const decoder = new TextDecoder();
204
+ let buffer = '';
205
+ while (true) {
206
+ const { value, done } = await reader.read();
207
+ buffer += decoder.decode(value, { stream: !done });
208
+ let match;
209
+ while ((match = /\r?\n\r?\n/.exec(buffer)) !== null) {
210
+ const record = buffer.slice(0, match.index);
211
+ buffer = buffer.slice(match.index + match[0].length);
212
+ const parsed = decodeSseRecord(record);
213
+ if (parsed)
214
+ yield parsed;
215
+ }
216
+ if (done)
217
+ break;
218
+ }
219
+ const parsed = decodeSseRecord(buffer);
220
+ if (parsed)
221
+ yield parsed;
222
+ }
223
+ function finiteToken(value) {
224
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0;
225
+ }
226
+ function usageFromAnthropic(raw) {
227
+ const direct = finiteToken(raw.input_tokens);
228
+ const cacheRead = finiteToken(raw.cache_read_input_tokens);
229
+ const cacheCreation = finiteToken(raw.cache_creation_input_tokens);
230
+ const output = finiteToken(raw.output_tokens);
231
+ const prompt = direct + cacheRead + cacheCreation;
232
+ return {
233
+ promptTokens: prompt,
234
+ completionTokens: output,
235
+ totalTokens: prompt + output,
236
+ cachedTokens: cacheRead,
237
+ cacheCreationTokens: cacheCreation,
238
+ reasoningTokens: 0,
239
+ };
240
+ }
241
+ function addLiveCount(text, state) {
242
+ for (const ch of text) {
243
+ const cp = ch.codePointAt(0) ?? 0;
244
+ if ((cp >= 0x4e00 && cp <= 0x9fff)
245
+ || (cp >= 0x3400 && cp <= 0x4dbf)
246
+ || (cp >= 0x3040 && cp <= 0x30ff)
247
+ || (cp >= 0xac00 && cp <= 0xd7a3))
248
+ state.cjk++;
249
+ else
250
+ state.other++;
251
+ }
252
+ }
253
+ /** Anthropic Messages API 单次请求;外层 chat() 继续统一负责重试。 */
254
+ export async function anthropicChatOnce(messages, handlers, signal, tools) {
255
+ const fetchImpl = fetchImplOverride ?? fetch;
256
+ const response = await fetchImpl(endpoint(config.baseURL), {
257
+ method: 'POST',
258
+ headers: {
259
+ 'content-type': 'application/json',
260
+ accept: 'text/event-stream',
261
+ 'x-api-key': config.apiKey,
262
+ 'anthropic-version': process.env.ANTHROPIC_VERSION || '2023-06-01',
263
+ },
264
+ body: JSON.stringify(buildAnthropicRequest(messages, tools)),
265
+ signal,
266
+ });
267
+ if (!response.ok)
268
+ await throwResponseError(response);
269
+ let content = '';
270
+ let usage;
271
+ const toolAcc = new Map();
272
+ const live = { cjk: 0, other: 0 };
273
+ const reportLive = () => {
274
+ handlers.onProgress?.({ completionTokens: Math.ceil(live.cjk + live.other / 4) });
275
+ };
276
+ for await (const record of readSse(response)) {
277
+ if (record.data === '[DONE]')
278
+ continue;
279
+ let event;
280
+ try {
281
+ event = JSON.parse(record.data);
282
+ }
283
+ catch {
284
+ continue;
285
+ }
286
+ const type = typeof event.type === 'string' ? event.type : record.event;
287
+ if (type === 'error') {
288
+ const error = event.error;
289
+ const thrown = new Error(typeof error?.message === 'string' ? error.message : 'Anthropic stream error');
290
+ thrown.name = typeof error?.type === 'string' ? error.type : 'AnthropicStreamError';
291
+ throw thrown;
292
+ }
293
+ if (type === 'message_start') {
294
+ const message = event.message;
295
+ if (message?.usage)
296
+ usage = usageFromAnthropic(message.usage);
297
+ continue;
298
+ }
299
+ if (type === 'content_block_start') {
300
+ const index = finiteToken(event.index);
301
+ const block = event.content_block;
302
+ if (block?.type === 'text' && typeof block.text === 'string' && block.text) {
303
+ content += block.text;
304
+ addLiveCount(block.text, live);
305
+ handlers.onText?.(block.text);
306
+ reportLive();
307
+ }
308
+ else if (block?.type === 'tool_use') {
309
+ const initialInput = block.input && typeof block.input === 'object'
310
+ ? block.input
311
+ : undefined;
312
+ const call = {
313
+ id: typeof block.id === 'string' ? block.id : '',
314
+ name: typeof block.name === 'string' ? block.name : '',
315
+ // 流式 tool_use 通常以 input:{} 开始,真正 JSON 紧随 input_json_delta;
316
+ // 只有非空 input 才直接采用,避免累加成 `{}{...}`。
317
+ arguments: initialInput && Object.keys(initialInput).length > 0
318
+ ? JSON.stringify(initialInput)
319
+ : '',
320
+ };
321
+ toolAcc.set(index, call);
322
+ if (call.name)
323
+ handlers.onToolCall?.(call.name);
324
+ }
325
+ continue;
326
+ }
327
+ if (type === 'content_block_delta') {
328
+ const index = finiteToken(event.index);
329
+ const delta = event.delta;
330
+ if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
331
+ content += delta.text;
332
+ addLiveCount(delta.text, live);
333
+ handlers.onText?.(delta.text);
334
+ reportLive();
335
+ }
336
+ else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
337
+ const call = toolAcc.get(index) ?? { id: '', name: '', arguments: '' };
338
+ call.arguments += delta.partial_json;
339
+ toolAcc.set(index, call);
340
+ addLiveCount(delta.partial_json, live);
341
+ reportLive();
342
+ }
343
+ continue;
344
+ }
345
+ if (type === 'message_delta') {
346
+ const raw = event.usage;
347
+ if (raw) {
348
+ const previous = usage ?? usageFromAnthropic({});
349
+ const output = finiteToken(raw.output_tokens);
350
+ usage = {
351
+ ...previous,
352
+ completionTokens: output,
353
+ totalTokens: previous.promptTokens + output,
354
+ };
355
+ }
356
+ }
357
+ }
358
+ if (usage) {
359
+ handlers.onProgress?.({
360
+ completionTokens: usage.completionTokens,
361
+ promptTokens: usage.promptTokens,
362
+ cachedTokens: usage.cachedTokens,
363
+ cacheCreationTokens: usage.cacheCreationTokens,
364
+ });
365
+ }
366
+ const toolCalls = [...toolAcc.entries()]
367
+ .sort((a, b) => a[0] - b[0])
368
+ .map(([, call]) => ({ ...call, arguments: call.arguments || '{}' }));
369
+ return { content: content || null, toolCalls, usage };
370
+ }
@@ -1,7 +1,7 @@
1
1
  import readline from 'node:readline/promises';
2
2
  import { emitKeypressEvents } from 'node:readline';
3
3
  import { stdin, stdout } from 'node:process';
4
- import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isSubAgentEnabled, updateSubAgentConfig, isFrontendToolsEnabled, updateFrontendToolsConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, hasCodegraphIndex, reinjectSessionStateIntoSystem, DEFAULT_CONTEXT_WINDOW_TOKENS, } from '../config/index.js';
4
+ import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isSubAgentEnabled, updateSubAgentConfig, isFrontendToolsEnabled, updateFrontendToolsConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, hasCodegraphIndex, DEFAULT_CONTEXT_WINDOW_TOKENS, pinSessionModel, } from '../config/index.js';
5
5
  import { getLanguage, normalizeLanguage, t, } from '../i18n/index.js';
6
6
  import { DEFAULT_BUDGET_POLICY } from '../context/budget.js';
7
7
  import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
@@ -152,17 +152,18 @@ function themeDescription(name) {
152
152
  rose: 1, emerald: 1, amber: 1, lavender: 1, sunset: 1,
153
153
  } ? t(key) : '';
154
154
  }
155
- /** /model 预设后端:选一个预填 baseURL,仍可逐项改。base_url 取自 README 常见表。 */
155
+ /** /model 预设后端:同时声明原生协议;旧服务继续走 OpenAI-compatible。 */
156
156
  const MODEL_PRESETS = [
157
- { label: 'GLM(智谱)', baseURL: 'https://open.bigmodel.cn/api/v3', model: 'glm-4.6', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
158
- { label: 'DeepSeek', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
159
- { label: 'Qwen(阿里)', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
157
+ { label: 'Anthropic Claude', provider: 'anthropic', baseURL: 'https://api.anthropic.com', model: 'claude-sonnet-4-5', window: 200000, anthropicPromptCache: true },
158
+ { label: 'GLM(智谱)', provider: 'openai', baseURL: 'https://open.bigmodel.cn/api/v3', model: 'glm-4.6', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
159
+ { label: 'DeepSeek', provider: 'openai', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
160
+ { label: 'Qwen(阿里)', provider: 'openai', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
160
161
  // MiniMax OpenAI 兼容端点(https://platform.minimax.io/docs/api-reference/text-openai-api)。
161
162
  // MiniMax-M3 为唯一支持图片/视频输入的模型;M2 系列纯文本(见 llm/capabilities.ts KNOWN_TEXT_ONLY_PREFIXES)。
162
- { label: 'MiniMax', baseURL: 'https://api.minimax.io/v1', model: 'MiniMax-M3', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
163
- { label: '本地 Ollama', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
164
- { label: '本地 vLLM', baseURL: 'http://localhost:8000/v1', model: 'default', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
165
- { label: '自定义 base_url', baseURL: '', model: '', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
163
+ { label: 'MiniMax', provider: 'openai', baseURL: 'https://api.minimax.io/v1', model: 'MiniMax-M3', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
164
+ { label: '本地 Ollama', provider: 'openai', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
165
+ { label: '本地 vLLM', provider: 'openai', baseURL: 'http://localhost:8000/v1', model: 'default', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
166
+ { label: '自定义 base_url', provider: 'openai', baseURL: '', model: '', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
166
167
  ];
167
168
  /** apiKey 脱敏:只露末 4 位,前面打星号(显示用,绝不把明文 key 写进内容区)。 */
168
169
  function maskKey(k) {
@@ -750,6 +751,8 @@ export function renderHistory(history) {
750
751
  export async function startRepl(initialHistory, sessionId, sandboxRootOverride, initialQueryHistory) {
751
752
  // 模式重置:agentMode 不落盘,每个 REPL 会话从 auto 开始(/resume / --resume 亦重置)。
752
753
  setAgentMode('auto');
754
+ // 钉死本会话模型:之后运行中 agent 一律用此值,其它窗口的 /model switch 不会影响本窗口。
755
+ pinSessionModel();
753
756
  // 沙箱根:文件操作边界。优先级 --sandbox-root > SANDBOX_ROOT env > process.cwd()。
754
757
  // 纯边界记录(不 chdir),jail.ts 内部 resolve。子 agent 同进程继承全局 root。
755
758
  setSandboxRoot(sandboxRootOverride ?? config.sandboxRoot ?? process.cwd());
@@ -834,10 +837,12 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
834
837
  if (listPresets().length === 0) {
835
838
  try {
836
839
  const migrated = migrateCurrentToPreset({
840
+ provider: config.provider,
837
841
  baseURL: config.baseURL,
838
842
  apiKey: config.apiKey,
839
843
  model: config.model,
840
844
  contextWindow: config.contextWindowTokens,
845
+ anthropicPromptCache: config.anthropicPromptCache,
841
846
  });
842
847
  if (migrated) {
843
848
  layout.contentWrite(`${ui.dim} ↳ 检测到老配置,已自动迁为预设 “${migrated}”(${ui.cyan}/model list${ui.dim} 查看 · /model switch 切换)${ui.reset}\n`);
@@ -1545,9 +1550,9 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1545
1550
  // focus 透传到 compact_history action 的 LLM 摘要 prompt。
1546
1551
  // 返回 SchedulerRunLog 给 UI 显示决策;退化路径(开关关时)在 manualCompact 内部走 compactHistory。
1547
1552
  const log = await manualCompact(history, focus, { force });
1548
- // ② compact 后把会话状态(plan + 笔记段)重注入系统提示(history[0]),避免 agent 因上下文压缩丢失计划与笔记。
1549
- if (log.compactHistoryCalled)
1550
- reinjectSessionStateIntoSystem(history);
1553
+ // 会话状态(plan + 笔记段)不在此处回写 history[0]:agent/core 每步都在 requestHistory
1554
+ // 末尾注入最新副本(buildSessionStateReminder),压缩后下一步自然恢复,且系统提示保持
1555
+ // 逐字节稳定以命中 prompt 缓存。
1551
1556
  const d = log.compactDetail;
1552
1557
  appendCurrentSessionRuntimeEvent('compact', {
1553
1558
  source: 'manual',
@@ -1716,16 +1721,20 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1716
1721
  // 共用:apply 一个预设到 config + 持久化 + 重建 client + 重显横幅。无参 /model 选菜单和 /model use 都走这里。
1717
1722
  const applyPresetAndPersist = (target) => {
1718
1723
  updateModelConfig({
1724
+ provider: target.provider,
1719
1725
  model: target.model,
1720
1726
  baseURL: target.baseURL,
1721
1727
  apiKey: target.apiKey,
1722
1728
  contextWindowTokens: target.contextWindow,
1729
+ anthropicPromptCache: target.anthropicPromptCache,
1723
1730
  });
1724
1731
  writeConfigKeys({
1732
+ LLM_PROVIDER: target.provider,
1725
1733
  LLM_BASE_URL: target.baseURL,
1726
1734
  LLM_API_KEY: target.apiKey,
1727
1735
  LLM_MODEL: target.model,
1728
1736
  CONTEXT_WINDOW_TOKENS: String(target.contextWindow),
1737
+ ANTHROPIC_PROMPT_CACHE: target.anthropicPromptCache ? 'true' : 'false',
1729
1738
  });
1730
1739
  reconfigureClient();
1731
1740
  refreshStatusBase(history);
@@ -1736,20 +1745,22 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1736
1745
  else {
1737
1746
  layout.writeBanner(bannerLines(banner()));
1738
1747
  }
1739
- layout.contentWrite(`${ui.dim}(已切换到预设 “${target.name}” ${target.model} @ ${target.baseURL})${ui.reset}\n`);
1748
+ const cacheLabel = target.provider === 'anthropic'
1749
+ ? ` · Prompt Cache ${target.anthropicPromptCache ? 'on' : 'off'}`
1750
+ : '';
1751
+ layout.contentWrite(`${ui.dim}(已切换到预设 “${target.name}” → ${target.model} · ${target.provider}${cacheLabel} @ ${target.baseURL})${ui.reset}\n`);
1740
1752
  if (config.llmKeysFromShell.length > 0) {
1741
1753
  layout.contentWrite(`${ui.dim}(shell 环境变量已设 ${config.llmKeysFromShell.join(' / ')},文件写入下次启动被其覆盖)${ui.reset}\n`);
1742
1754
  }
1743
1755
  };
1744
- // 决定自动存的预设名:用 desired(model 字段),若与已有预设四元组完全相同则不重复存(返 null);
1745
- // 否则若 desired 已存在则追加 -2/-3/...。desired 含非法字符(如 glm-4.6 '.')时先 sanitize(. → -),
1746
- // sanitize 后仍空才退化到 'preset'。
1747
- const uniquePresetName = (desired, baseURL, apiKey, model, contextWindow) => {
1756
+ // 决定自动存的预设名:协议、缓存配置和连接四元组都一致时不重复存。
1757
+ const uniquePresetName = (desired, provider, baseURL, apiKey, model, contextWindow, anthropicPromptCache) => {
1748
1758
  const existing = listPresets();
1749
- const sameEntry = existing.find((p) => p.baseURL === baseURL && p.apiKey === apiKey && p.model === model && p.contextWindow === contextWindow);
1759
+ const sameEntry = existing.find((p) => p.provider === provider && p.baseURL === baseURL && p.apiKey === apiKey
1760
+ && p.model === model && p.contextWindow === contextWindow
1761
+ && p.anthropicPromptCache === anthropicPromptCache);
1750
1762
  if (sameEntry)
1751
- return null; // 完全相同,不重复存
1752
- // sanitize:把非 [a-zA-Z0-9_-] 字符(如 glm-4.6 的 '.')替换为 -,压缩两端 -,裁 1-32。
1763
+ return null;
1753
1764
  const sanitized = desired
1754
1765
  .replace(/[^a-zA-Z0-9_-]+/g, '-')
1755
1766
  .replace(/^-+|-+$/g, '')
@@ -1773,14 +1784,17 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1773
1784
  layout.contentWrite(`${ui.dim}(还没有预设;先跑 /model 添加一个)${ui.reset}\n`);
1774
1785
  continue;
1775
1786
  }
1776
- const isCurrent = (p) => p.baseURL === config.baseURL &&
1787
+ const isCurrent = (p) => p.provider === config.provider &&
1788
+ p.baseURL === config.baseURL &&
1777
1789
  p.apiKey === config.apiKey &&
1778
1790
  p.model === config.model &&
1779
- p.contextWindow === config.contextWindowTokens;
1791
+ p.contextWindow === config.contextWindowTokens &&
1792
+ p.anthropicPromptCache === (config.provider === 'anthropic' && config.anthropicPromptCache);
1780
1793
  const cols = layout.getGeo().cols;
1781
1794
  const labelFor = (p) => {
1782
1795
  const tag = isCurrent(p) ? ' ★current' : '';
1783
- const right = `${p.model} @ ${p.baseURL}`;
1796
+ const cache = p.provider === 'anthropic' ? ` · cache ${p.anthropicPromptCache ? 'on' : 'off'}` : '';
1797
+ const right = `${p.provider}${cache} · ${p.model} @ ${p.baseURL}`;
1784
1798
  const left = `${p.name}${tag}`;
1785
1799
  const sep = left.length + 1 + right.length;
1786
1800
  if (sep <= cols - 2)
@@ -1790,7 +1804,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1790
1804
  const choice = await promptIntervention({
1791
1805
  type: 'choice',
1792
1806
  title: '切换模型预设',
1793
- detail: `当前: ${config.model} @ ${config.baseURL}(★ = 已匹配)`,
1807
+ detail: `当前: ${config.provider} · ${config.model} @ ${config.baseURL}(★ = 已匹配)`,
1794
1808
  options: presets.map(labelFor),
1795
1809
  allowCustom: false, // 纯切换,不需要「其他」干扰
1796
1810
  });
@@ -1812,19 +1826,30 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1812
1826
  }
1813
1827
  layout.contentWrite(`${ui.dim}已配置 ${ps.length} 个预设:${ui.reset}\n`);
1814
1828
  for (const p of ps) {
1815
- const star = p.baseURL === config.baseURL && p.apiKey === config.apiKey && p.model === config.model ? ' ★' : '';
1816
- layout.contentWrite(` ${ui.accent}${p.name}${ui.reset}${star} ${ui.dim}${p.model} @ ${p.baseURL}${ui.reset}\n`);
1829
+ const current = p.provider === config.provider
1830
+ && p.baseURL === config.baseURL
1831
+ && p.apiKey === config.apiKey
1832
+ && p.model === config.model
1833
+ && p.contextWindow === config.contextWindowTokens
1834
+ && p.anthropicPromptCache === (config.provider === 'anthropic' && config.anthropicPromptCache);
1835
+ const star = current ? ' ★' : '';
1836
+ const cache = p.provider === 'anthropic' ? ` · cache ${p.anthropicPromptCache ? 'on' : 'off'}` : '';
1837
+ layout.contentWrite(` ${ui.accent}${p.name}${ui.reset}${star} ${ui.dim}${p.provider}${cache} · ${p.model} @ ${p.baseURL}${ui.reset}\n`);
1817
1838
  }
1818
- layout.contentWrite(`${ui.dim}(★ = 与当前一致;切换用 /model switch)${ui.reset}\n`);
1839
+ layout.contentWrite(`${ui.dim}(★ = 与当前协议及缓存配置一致;切换用 /model switch)${ui.reset}\n`);
1819
1840
  continue;
1820
1841
  }
1821
- // /model show:显示当前四项配置(apiKey 脱敏)。
1842
+ // /model show:显示当前协议、连接与缓存配置(apiKey 脱敏)。
1822
1843
  if (arg === 'show') {
1823
1844
  layout.contentWrite(`${ui.dim}当前模型配置:${ui.reset}\n`);
1824
- layout.contentWrite(` ${ui.accent}baseURL${ui.reset} ${config.baseURL}\n`);
1825
- layout.contentWrite(` ${ui.accent}apiKey ${ui.reset} ${maskKey(config.apiKey)}\n`);
1826
- layout.contentWrite(` ${ui.accent}model ${ui.reset} ${config.model}\n`);
1827
- layout.contentWrite(` ${ui.accent}窗口 ${ui.reset} ${config.contextWindowTokens} tokens\n`);
1845
+ layout.contentWrite(` ${ui.accent}provider${ui.reset} ${config.provider}\n`);
1846
+ layout.contentWrite(` ${ui.accent}baseURL ${ui.reset} ${config.baseURL}\n`);
1847
+ layout.contentWrite(` ${ui.accent}apiKey ${ui.reset} ${maskKey(config.apiKey)}\n`);
1848
+ layout.contentWrite(` ${ui.accent}model ${ui.reset} ${config.model}\n`);
1849
+ layout.contentWrite(` ${ui.accent}窗口 ${ui.reset} ${config.contextWindowTokens} tokens\n`);
1850
+ if (config.provider === 'anthropic') {
1851
+ layout.contentWrite(` ${ui.accent}缓存 ${ui.reset} Prompt Cache ${config.anthropicPromptCache ? 'on' : 'off'}\n`);
1852
+ }
1828
1853
  layout.contentWrite(`${ui.dim}(配置文件: ${CONFIG_PATH})${ui.reset}\n`);
1829
1854
  continue;
1830
1855
  }
@@ -2017,25 +2042,42 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
2017
2042
  }
2018
2043
  }
2019
2044
  }
2020
- // 3) 应用:内存 config + env(updateModelConfig)→ 持久化(writeConfigKeys)→ 重建 client(reconfigureClient)。
2021
- updateModelConfig({ model, baseURL, apiKey, contextWindowTokens: window });
2045
+ // 3) 应用协议、连接与缓存配置;Anthropic 原生协议无需重建 OpenAI client,但统一刷新无害。
2046
+ const provider = preset.provider;
2047
+ const anthropicPromptCache = provider === 'anthropic' && preset.anthropicPromptCache;
2048
+ updateModelConfig({
2049
+ provider,
2050
+ model,
2051
+ baseURL,
2052
+ apiKey,
2053
+ contextWindowTokens: window,
2054
+ anthropicPromptCache,
2055
+ });
2022
2056
  writeConfigKeys({
2057
+ LLM_PROVIDER: provider,
2023
2058
  LLM_BASE_URL: baseURL,
2024
2059
  LLM_API_KEY: apiKey,
2025
2060
  LLM_MODEL: model,
2026
2061
  CONTEXT_WINDOW_TOKENS: String(window),
2062
+ ANTHROPIC_PROMPT_CACHE: anthropicPromptCache ? 'true' : 'false',
2027
2063
  });
2028
2064
  reconfigureClient();
2029
- // 3.5) 自动存为命名预设:用 model 字段,重名追加 -2/-3,完全相同的四元组不重复存。
2030
- // 让 /model 跑一次就多一份可切换的预设,/model switch 切回去。
2065
+ // 3.5) 自动存为命名预设;协议与缓存策略也是去重键的一部分。
2031
2066
  let savedName = null;
2032
2067
  try {
2033
- const finalName = uniquePresetName(model, baseURL, apiKey, model, window);
2068
+ const finalName = uniquePresetName(model, provider, baseURL, apiKey, model, window, anthropicPromptCache);
2034
2069
  if (finalName) {
2035
- savePreset({ name: finalName, baseURL, apiKey, model, contextWindow: window });
2070
+ savePreset({
2071
+ name: finalName,
2072
+ provider,
2073
+ baseURL,
2074
+ apiKey,
2075
+ model,
2076
+ contextWindow: window,
2077
+ anthropicPromptCache,
2078
+ });
2036
2079
  savedName = finalName;
2037
2080
  }
2038
- // finalName === null 表示与某个已存在预设完全一致,不再重复保存。
2039
2081
  }
2040
2082
  catch (e) {
2041
2083
  layout.contentWrite(`${ui.red}保存预设失败: ${e.message}${ui.reset}\n`);
@@ -2049,7 +2091,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
2049
2091
  else {
2050
2092
  layout.writeBanner(bannerLines(banner()));
2051
2093
  }
2052
- layout.contentWrite(`${ui.dim}(已切换模型 ${model} @ ${baseURL})${ui.reset}\n`);
2094
+ const cacheLabel = provider === 'anthropic'
2095
+ ? ` · Prompt Cache ${anthropicPromptCache ? 'on' : 'off'}`
2096
+ : '';
2097
+ layout.contentWrite(`${ui.dim}(已切换模型 → ${model} · ${provider}${cacheLabel} @ ${baseURL})${ui.reset}\n`);
2053
2098
  if (savedName) {
2054
2099
  layout.contentWrite(`${ui.dim}(已保存为预设 “${savedName}”,下次 /model use ${savedName} 一键切回)${ui.reset}\n`);
2055
2100
  }