diff --git a/.env.example b/.env.example index 90252cb5..758609bb 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,20 @@ SERVICE_PORT=3000 # retry regenerates from scratch and is the longest such window. # ANTHROPIC_PING_INTERVAL_MS=15000 +# Anthropic (/v1/messages) 与 OpenAI (/v1/chat/completions) 两条路径共用:一轮 Agent +# 回合里文本通道工具调用的上限(默认 24,钳在 4..256)。 +# 模型在叙述的 [TOOL CALL] 之后失控(同一调用重复上百次、幻想整段 agent 会话)时, +# 第 N 个已放行的调用一到就终止上游,已放行的调用照常交付 +# (stop_reason=tool_use / finish_reason=tool_calls)。更早的 delta 里已放行过调用之后, +# 再出现重复调用 / 被拒绝的调用 / 正文或思考同样立刻截断,不受此上限影响。 +# Both agent paths (/v1/messages and /v1/chat/completions): cap on text-channel tool +# calls per agent turn (default 24, clamped to 4..256). When the model runs away after a +# narrated [TOOL CALL] (repeats the same call hundreds of times, hallucinates a whole +# agentic session), the upstream is cut right after the N-th admitted call and the +# admitted calls are still delivered (stop_reason=tool_use / finish_reason=tool_calls). +# Once a call was admitted in an earlier delta, a duplicate, a rejected call, or +# prose/thinking after it also cuts the turn, regardless of this cap. +# AGENT_TURN_MAX_TOOL_CALLS=24 # 监听地址(非必填) # Listen address (optional) @@ -169,15 +183,45 @@ QWEN_CLI_PROXY_URL= # 示例 / Example: PROXY_URL=socks5://127.0.0.1:1080 PROXY_URL= -# ========== Claude Code 兼容配置 / Claude Code Compatibility ========== - -# Claude-to-Qwen 模型映射(可选,内置默认映射已覆盖主流 Claude 模型) -# 格式: claude-model-name=qwen-model-id,claude-model-name2=qwen-model-id2 -# 未匹配的 claude-* 模型自动回退到 qwen3-coder-plus -# Claude-to-Qwen model mapping (optional; built-in defaults cover mainstream Claude models) -# Format: claude-model-name=qwen-model-id,claude-model-name2=qwen-model-id2 -# Unmatched claude-* models fall back to qwen3-coder-plus -# CLAUDE_MODEL_MAP=claude-sonnet-5=qwen3-coder-plus,claude-opus-4=qwen3-max +# ========== 入站模型名映射 / Incoming model name mapping ========== + +# 把客户端发来的模型名映射成 Qwen 模型 id,只作用于 /v1/chat/completions 和 /v1/messages +# (图片、视频、CLI 端点不走这里)。Claude Code 的子代理会发 claude-opus-5 / claude-haiku-* +# (除非客户端自己设置了 ANTHROPIC_DEFAULT_OPUS/SONNET/HAIKU_MODEL 或 CLAUDE_CODE_SUBAGENT_MODEL; +# 服务端映射不需要任何客户端配置),OpenAI 风格客户端会发 gpt-*;不映射时上游返回 "Model not found"。 +# 规则(两个端点相同): +# 1. 精确匹配优先,不区分大小写,末尾的 [..] 后缀先去掉(claude-opus-5[1m] 按 claude-opus-5 处理) +# 2. 上游已存在的 Qwen id(含 -thinking 等变体)原样透传,不受 * 影响 +# 3. 其余名字用 * 条目;没有 * 时用上游第一个 t2t 模型并打印一条 warn。 +# 仅在上游模型列表可用时生效:列表取不到时名字原样转发(打印一条 warn),不套用 * +# 目标 id 可带 -thinking 等后缀,后缀照常生效(会打开思考)。响应里的 model 字段回显解析后的 +# Qwen id,不是别名。别名不需要出现在 /v1/models 里。落到回退目标的名字记录在进程内存中 +# (每个 PM2 worker 一份,最多 100 个)。 +# Maps incoming model names to Qwen model ids; applies to /v1/chat/completions and /v1/messages +# only (not images/videos/cli). Claude Code subagents send claude-opus-5 / claude-haiku-* (unless +# the client sets ANTHROPIC_DEFAULT_OPUS/SONNET/HAIKU_MODEL or CLAUDE_CODE_SUBAGENT_MODEL; the +# server-side map needs no client config), OpenAI-style clients send gpt-*; without a map the +# upstream answers "Model not found". Rule (same on both endpoints): +# 1. exact entry wins, case-insensitive; a trailing [..] suffix is stripped first (claude-opus-5[1m] = claude-opus-5) +# 2. names that already exist upstream (incl. -thinking variants) pass through, even with * +# 3. everything else uses the * entry; with no * the first upstream t2t model is used with a warn. +# Only while the upstream model list is available: if it cannot be fetched the name is +# forwarded unchanged (one warn) and * is not applied +# Targets may carry suffixes such as -thinking; they apply as usual (thinking switches on). The +# response `model` field echoes the resolved Qwen id, not the alias. Aliases work without being +# listed in /v1/models. Names that fell to the fallback are recorded in process memory (one list +# per PM2 worker, 100 max). +# Dashboard:系统设置里的「模型映射」卡片可在线编辑。dashboard 保存过的映射优先于本变量(重启后仍生效); +# DATA_SAVE_MODE=none 时 dashboard 的修改只在内存里生效,重启即丢;「恢复 env 映射」会清掉保存的映射, +# 本变量重新生效。PM2 多 worker 时,保存的映射只在处理请求的 worker 立即生效,其他 worker 重启后才读到。 +# Dashboard: the "Model mapping" card in Settings edits this at runtime. A dashboard-saved map takes +# precedence over this variable (and survives restarts); with DATA_SAVE_MODE=none dashboard changes +# live in memory only and are lost on restart; "Restore env map" clears the saved map so this variable +# applies again. With several PM2 workers a saved map applies at once only in the worker that handled +# the save; the others pick it up at their next restart. +# 格式 / Format: alias=qwen-model-id,alias2=qwen-model-id2,*=fallback-qwen-model-id +# 示例 / Example: MODEL_MAP=claude-opus-5=qwen3.8-max,*=qwen3.8-max-thinking +MODEL_MAP= # ========== CLI 配置 / CLI Configuration ========== diff --git a/README-en.md b/README-en.md index 81f43031..a7637e61 100644 --- a/README-en.md +++ b/README-en.md @@ -102,6 +102,7 @@ OUTPUT_THINK=true # Whether to output thinking process (true/false) LEGACY_REASONING_IN_CONTENT=false # Reasoning format, false=reasoning_content field, true=legacy inside content (true/false) SIMPLE_MODEL_MAP=false # Simplify model mapping (true/false) MODELS_CACHE_TTL=3600 # Model list cache TTL in seconds, 0=never expires +AGENT_TURN_MAX_TOOL_CALLS=24 # Anthropic path: text-channel tool_use cap per agent turn (4-256), upstream cut after it # 🌐 Proxy and Reverse Proxy Configuration QWEN_CHAT_PROXY_URL= # Custom Chat API reverse proxy URL (default: https://chat.qwen.ai) @@ -130,7 +131,9 @@ CACHE_MODE=default # Image cache mode (default/file) | `OUTPUT_THINK` | Whether to show AI thinking process | `true` or `false` | | `LEGACY_REASONING_IN_CONTENT` | Reasoning output format. Default `false` = reasoning goes to a separate `reasoning_content` field; `true` = legacy behavior (`` inside `content`) | `true` or `false` | | `SIMPLE_MODEL_MAP` | Simplify model mapping, return basic models without variants only | `true` or `false` | +| `MODEL_MAP` | Incoming model name mapping: `alias=qwen-id,...,*=fallback`. Exact entry wins (trailing `[..]` stripped, case-insensitive), existing Qwen ids pass through, everything else uses `*`; applies to `/v1/chat/completions` and `/v1/messages` only. Also editable at runtime in the dashboard (Settings → Model mapping); a dashboard-saved map overrides this variable, see `.env.example` | `*=qwen3.8-max-thinking` | | `MODELS_CACHE_TTL` | Model list cache TTL in seconds; after expiry the next request refreshes it from upstream; `0` = never expires | `3600` | +| `AGENT_TURN_MAX_TOOL_CALLS` | Anthropic path: cap on text-channel `tool_use` blocks per agent turn (4–256). When the model runs away after a narrated `[TOOL CALL]` (repeats the same call hundreds of times, hallucinates a whole session), the upstream is cut right after the N-th admitted call and the admitted calls are delivered with `stop_reason=tool_use`; once a call was admitted in an earlier delta, a duplicate, a rejected call or prose/thinking also cuts the turn | `24` | | `AGENT_CONTEXT_FILE_THRESHOLD_BYTES` | Externalize complete Agent tool definitions and history as a Qwen text document when the request body exceeds this size, avoiding the roughly 128 KiB WAF limit | `92160` (90 KiB) | | `AGENT_CONTEXT_LIVE_PROMPT_BYTES` | Maximum size of the tool protocol and current turn kept in the live request after context externalization | `49152` (48 KiB) | | `QWEN_CHAT_PROXY_URL` | Custom Chat API reverse proxy address | `https://your-proxy.com` | diff --git a/README-ru.md b/README-ru.md index 8289d741..85b13e20 100644 --- a/README-ru.md +++ b/README-ru.md @@ -122,6 +122,7 @@ OUTPUT_THINK=true # Выводить ли процесс размы LEGACY_REASONING_IN_CONTENT=false # Формат рассуждений: false=поле reasoning_content, true=старый режим внутри content (true/false) SIMPLE_MODEL_MAP=false # Упрощенное сопоставление моделей (true/false) MODELS_CACHE_TTL=3600 # Срок жизни кэша списка моделей (сек), 0=бессрочно +AGENT_TURN_MAX_TOOL_CALLS=24 # Anthropic: лимит tool_use по текстовому каналу за ход агента (4-256), дальше обрыв upstream # 🌐 Прокси и обратный прокси QWEN_CHAT_PROXY_URL= # Пользовательский URL обратного прокси Chat API (по умолчанию: https://chat.qwen.ai) @@ -150,7 +151,9 @@ CACHE_MODE=default # Режим кэширования изображ | `OUTPUT_THINK` | Отображать ли процесс размышления AI | `true` или `false` | | `LEGACY_REASONING_IN_CONTENT` | Формат вывода рассуждений. По умолчанию `false` = рассуждения в отдельном поле `reasoning_content`; `true` = старый режим (`` внутри `content`) | `true` или `false` | | `SIMPLE_MODEL_MAP` | Упрощенное сопоставление моделей, возвращает только базовые модели без вариантов | `true` или `false` | +| `MODEL_MAP` | Сопоставление входящих имён моделей: `alias=qwen-id,...,*=fallback`. Точное совпадение в приоритете (хвостовой `[..]` отбрасывается, без учёта регистра), существующие id Qwen проходят без изменений, остальное идёт в `*`; действует только для `/v1/chat/completions` и `/v1/messages`. Редактируется и в панели (Настройки → Сопоставление моделей); сохранённое в панели сопоставление имеет приоритет над этой переменной, см. `.env.example` | `*=qwen3.8-max-thinking` | | `MODELS_CACHE_TTL` | Срок жизни кэша списка моделей (в секундах); по истечении следующий запрос обновит список; `0` = бессрочный кэш | `3600` | +| `AGENT_TURN_MAX_TOOL_CALLS` | Путь Anthropic: лимит блоков `tool_use` по текстовому каналу за один ход агента (4–256). Если модель «идёт вразнос» после нарративного `[TOOL CALL]` (сотни повторов одного вызова, выдуманная сессия целиком), upstream обрывается сразу после N-го принятого вызова, а принятые вызовы отдаются со `stop_reason=tool_use`; после уже принятого в более раннем delta вызова дубликат, отклонённый вызов или проза/размышление также обрывают ход | `24` | | `QWEN_CHAT_PROXY_URL` | Пользовательский адрес обратного прокси Chat API | `https://your-proxy.com` | | `QWEN_CLI_PROXY_URL` | Пользовательский адрес обратного прокси CLI API | `https://your-cli-proxy.com` | | `PROXY_URL` | Адрес прокси для исходящих запросов, поддержка HTTP/HTTPS/SOCKS5 | `http://127.0.0.1:7890` | diff --git a/README.md b/README.md index 28a65472..3361b4dc 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ LEGACY_REASONING_IN_CONTENT=false # 推理输出格式,false=reasoning_content SIMPLE_MODEL_MAP=false # 简化模型映射 (true/false) MODELS_CACHE_TTL=3600 # 模型列表缓存有效期(秒),0=永不过期 AGENT_TURN_MAX_ATTEMPTS=3 # 单个 Agent 回合生成有效工具调用/最终态的最大尝试数(2-6) +AGENT_TURN_MAX_TOOL_CALLS=24 # Anthropic 路径单轮文本通道 tool_use 上限(4-256),到数即截断上游 AGENT_TURN_ALLOW_PROSE_WITH_TOOLS=false # 允许工具调用回合同时带可见正文(Anthropic 客户端) AGENT_TURN_ACCEPT_BARE_FINAL=false # 允许没有 包装的可见正文作为正常结束 AGENT_CONTEXT_FILE_THRESHOLD_BYTES=92160 # 超过阈值时外置完整 Agent 上下文 @@ -135,10 +136,12 @@ CACHE_MODE=default # 图片缓存模式 (default/file) | `OUTPUT_THINK` | 是否显示 AI 思考过程 | `true` 或 `false` | | `LEGACY_REASONING_IN_CONTENT` | 推理输出格式。默认 `false`=推理走独立的 `reasoning_content` 字段;`true`=旧版行为(`` 并入 `content`) | `true` 或 `false` | | `SIMPLE_MODEL_MAP` | 简化模型映射,只返回基础模型不包含变体 | `true` 或 `false` | +| `MODEL_MAP` | 入站模型名映射:`alias=qwen-id,...,*=fallback`。精确匹配优先(末尾 `[..]` 先去掉、不区分大小写),上游已有的 Qwen id 原样透传,其余走 `*`;只作用于 `/v1/chat/completions` 与 `/v1/messages`。也可在管理面板「系统设置 → 模型映射」里在线编辑,面板保存的映射优先于本变量;详见 `.env.example` | `*=qwen3.8-max-thinking` | | `MODELS_CACHE_TTL` | 模型列表缓存有效期(秒),过期后下次请求自动向上游刷新;`0` 表示永不过期 | `3600` | | `AGENT_TURN_ALLOW_PROSE_WITH_TOOLS` | 放宽回合门禁:允许同一回合既有有效工具调用又有可见正文。Anthropic Messages API 允许 `text` 与 `tool_use` 共存,Claude Code 等客户端因此会被严格模式反复判为 `invalid_tool_call` | `false` | | `AGENT_TURN_ACCEPT_BARE_FINAL` | 放宽回合门禁:把有可见正文但缺少 `` 包装的回合按 `finish_reason=stop` 接受,而不是判为 `bare` 并重试 | `false` | | `AGENT_TURN_MAX_ATTEMPTS` | 工具请求在一次 HTTP 回合内生成有效 `tool_calls`、明确完成态或阻塞态的最大尝试数;范围 2–6,耗尽后非流式请求返回 HTTP 429/503,SSE 请求返回显式错误帧,绝不伪装成正常 `stop` | `3` | +| `AGENT_TURN_MAX_TOOL_CALLS` | Anthropic 路径:一轮 Agent 回合里文本通道 `tool_use` 的上限(4–256)。模型在叙述的 `[TOOL CALL]` 之后失控(同一调用重复上百次、幻想整段会话)时,第 N 个已放行的调用之后立刻终止上游,已放行的调用以 `stop_reason=tool_use` 交付;更早的 delta 里已放行过调用之后再出现重复、被拒绝的调用或正文/思考同样截断 | `24` | | `AGENT_CONTEXT_FILE_THRESHOLD_BYTES` | Agent 请求体超过此大小时,将完整工具定义和历史自动外置为 Qwen 文本文档,避免触发约 128 KiB 的 WAF 限制 | `92160`(90 KiB) | | `AGENT_CONTEXT_LIVE_PROMPT_BYTES` | 上下文外置后,实时请求中保留的工具协议、system/developer 指令、原始任务、最近工具进度和当前结果的最大大小 | `49152`(48 KiB) | | `QWEN_CHAT_PROXY_URL` | 自定义 Chat API 反代地址 | `https://your-proxy.com` | diff --git a/_bmad-output/implementation-artifacts/spec-anthropic-agent-loop-parity.md b/_bmad-output/implementation-artifacts/spec-anthropic-agent-loop-parity.md deleted file mode 100644 index d9819c3b..00000000 --- a/_bmad-output/implementation-artifacts/spec-anthropic-agent-loop-parity.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -title: 'Anthropic Endpoint Agent Loop Parity' -type: 'bugfix' -created: '2026-08-30' -status: 'done' -review_loop_iteration: 1 -baseline_commit: 'cd38d91055a9a142d9e269004ce7d30bde0447d2' -context: -- CLAUDE.md ---- - - - -## Intent - -**Problem:** Claude Code hallucinates "Tool Bash does not exists" / "Tool Read does not exists" when using the Anthropic-compatible `/v1/messages` endpoint, but works correctly through the OpenAI-compatible `/v1/chat/completions` endpoint. Root cause verified by code inspection: `buildInternalRequest` in `src/controllers/anthropic.js` (lines 216-333) is missing two critical agent-loop injections that `chat-middleware.js` applies to every tool-enabled OpenAI request: - -1. **Missing `ensureAgentCurrentEnvelope`** — wraps user content with `# Current message` marker + JSON structure so Qwen upstream distinguishes current turn from history. -2. **Missing `buildAgentTurnDirective`** — appends explicit agent-loop contract instructing model that client executes tools and sends results back, preventing premature completion or tool-name hallucination. -3. **Missing `afterToolResult` detection** — without detecting when last message is a `tool_result`, the directive always uses initial-task framing instead of continuation framing during multi-turn tool loops. - -Without these, Qwen model receives raw tool definitions but no behavioral contract, causing it to treat tools as informational rather than actionable and hallucinate validation errors. - -**Approach:** Add the three missing pieces to `buildInternalRequest` in exact same order as OpenAI path (`chat-middleware.js` lines 144-172): envelope wrap → prefix system+tool prompt → append turn directive. Detect `afterToolResult` from original messages array before flattening. - -## Boundaries & Constraints - -**Always:** -- Touch only `src/controllers/anthropic.js` — specifically imports (line 14) and `buildInternalRequest` function (lines 216-333). -- Match existing code style: CommonJS requires, Chinese comments where adjacent code uses them, same variable naming patterns. -- `ensureAgentCurrentEnvelope` imported from `chat-middleware.js` (line 205 export). -- `buildAgentTurnDirective` added to existing import from `agent-turn.js` (line 14). -- Verify no require cycle between `anthropic.js` and `chat-middleware.js` after adding cross-import. - -**Ask First:** None. - -**Never:** -- Modify `chat-middleware.js`, `agent-turn.js`, or `tool-prompt.js` — they already export needed functions. -- Change response handling, SSE streaming, tag stripping, or error formatting — separate concerns. -- Inject agent-loop primitives when `hasTools` is false. - - - -## Code Map - -- `src/controllers/anthropic.js:14` -- Import line for `agent-turn.js`; add `buildAgentTurnDirective` to existing destructured require. -- `src/controllers/anthropic.js:216-333` -- `buildInternalRequest` function; sole modification target. -- `src/controllers/anthropic.js:217-223` -- Before `flattenAnthropicMessages(messages)` call at line 223: detect `afterToolResult` by checking if last entry in original `messages` array has role `user` with any `tool_result` content block. Pattern: `const originalLast = Array.isArray(messages) ? messages[messages.length - 1] : null; const afterToolResult = originalLast?.role === 'user' && Array.isArray(originalLast?.content) && originalLast.content.some(b => b?.type === 'tool_result');` -- `src/controllers/anthropic.js:228-229` -- `hasTools` flag and `toolPrompt` construction; gate all new injections on `hasTools`. -- `src/controllers/anthropic.js:242-262` -- Prefix concatenation block. After prefix+content assembly completes (after line 262), apply `ensureAgentCurrentEnvelope(last.content, last.role || 'user')` to the content, then append `buildAgentTurnDirective({ afterToolResult })` after the wrapped content. This matches OpenAI ordering: envelope wrap first, then prefix prepend, then directive append. -- `src/middlewares/chat-middleware.js:16-38` -- `ensureAgentCurrentEnvelope` definition; idempotent guard at line 19 prevents double-wrapping when `parserMessages` already added JSONL markers. Reference for behavior, do NOT modify. -- `src/middlewares/chat-middleware.js:115` -- OpenAI `afterToolResult` detection pattern (checks `role === 'tool'`). Anthropic equivalent checks for `tool_result` content block in user message instead. -- `src/middlewares/chat-middleware.js:144-172` -- OpenAI injection ordering reference: envelope → prefix → directive. -- `src/utils/agent-turn.js:253-270` -- `buildAgentTurnDirective` definition; accepts `{ afterToolResult }` boolean. -- `src/utils/agent-turn.js:299` -- Export of `buildAgentTurnDirective`. -- `src/middlewares/chat-middleware.js:205` -- Export of `ensureAgentCurrentEnvelope`. - -## Tasks & Acceptance - -**Execution:** -- [ ] `src/controllers/anthropic.js` -- Add `buildAgentTurnDirective` to line 14 import from `agent-turn.js` -- Required function currently missing from Anthropic path. -- [ ] `src/controllers/anthropic.js` -- Add new require for `ensureAgentCurrentEnvelope` from `../middlewares/chat-middleware.js` -- Function lives in middleware, not utils. Verify no circular dependency after adding. -- [ ] `src/controllers/anthropic.js` -- Detect `afterToolResult` from original `messages` array before `flattenAnthropicMessages` call (before line 223) -- Check last message for `tool_result` content block presence to match OpenAI path semantics adapted for Anthropic format. -- [ ] `src/controllers/anthropic.js` -- Inside `hasTools` guard after prefix concatenation (after line 262): wrap `last.content` with `ensureAgentCurrentEnvelope(content, role)`, then append `buildAgentTurnDirective({ afterToolResult })` after full content assembly -- Matches OpenAI injection ordering exactly. -- [ ] `src/controllers/anthropic.js` -- Handle edge case where `last.content` is undefined or empty string -- `ensureAgentCurrentEnvelope` handles this via `String(text || '')` coercion, but verify no literal "undefined" string leaks into output. - -**Acceptance Criteria:** -- Given an Anthropic `/v1/messages` request with non-empty `tools` array, when `buildInternalRequest` runs, then the last parsed message content contains `# Agent loop control (highest-priority output contract)` appended after user content. -- Given an Anthropic `/v1/messages` request with non-empty `tools` array, when `buildInternalRequest` runs, then the last parsed message content is wrapped with `# Current message` marker via `ensureAgentCurrentEnvelope` before tool/system prefix is prepended. -- Given an Anthropic `/v1/messages` request where the last original message contains a `tool_result` content block, when `buildInternalRequest` detects `afterToolResult`, then `buildAgentTurnDirective` receives `{ afterToolResult: true }` and produces continuation framing. -- Given an Anthropic `/v1/messages` request with no `tools` or empty array, when `buildInternalRequest` runs, then neither `ensureAgentCurrentEnvelope` nor `buildAgentTurnDirective` is invoked. -- Given an Anthropic `/v1/messages` request where the last message has no text content (only `tool_use` blocks), when `buildInternalRequest` runs, then no literal "undefined" string appears in the output content. -- Given the test suite at `tests/`, when `npm test` runs, then all existing tests pass without modification. -- Given the new cross-module require, when Node loads `anthropic.js`, then no circular dependency warning or runtime error occurs. - -## Spec Change Log - -- Party mode review identified 3 risks: (1) potential require cycle from anthropic→chat-middleware, (2) undefined content edge case, (3) afterToolResult detection timing. Investigation confirmed `ensureAgentCurrentEnvelope` is idempotent (guard at chat-middleware.js:19) and safe to add — no double-wrapping risk. Amendments applied: added no-cycle verification task, added undefined-content AC, specified exact afterToolResult detection code for Anthropic format. - -## Verification - -**Commands:** -- `npm test` -- expected: all tests pass, zero failures. -- `node -e "require('./src/controllers/anthropic.js')"` -- expected: no circular dependency error or warning. - -**Manual checks (if no CLI):** -- Send Anthropic `/v1/messages` request with tools via curl; verify upstream Qwen request body contains `# Agent loop control` and `# Current message` markers in last message content. -- Send follow-up request with `tool_result` block; verify directive text includes "The current message is a tool result from the same unfinished task". - -## Suggested Review Order - -**Agent-loop injection entry point** - -- New imports enabling agent-loop parity with OpenAI path - [`anthropic.js:14`](../../src/controllers/anthropic.js#L14) - -**afterToolResult detection** - -- Detects tool_result in original Anthropic messages before flattening - [`anthropic.js:223`](../../src/controllers/anthropic.js#L223) - -**tool_choice=none guard** - -- Prevents agent injection when tools disabled, matching OpenAI semantics - [`anthropic.js:234`](../../src/controllers/anthropic.js#L234) - -**Envelope + directive injection block** - -- Wraps content and appends turn directive after prefix assembly - [`anthropic.js:269`](../../src/controllers/anthropic.js#L269) diff --git a/docker/docker-compose-redis.yml b/docker/docker-compose-redis.yml index 394e2fdd..a4f08d2b 100644 --- a/docker/docker-compose-redis.yml +++ b/docker/docker-compose-redis.yml @@ -41,6 +41,9 @@ services: # 简化模型映射 (true: 只返回基础模型, false: 返回完整模型列表) # Simplified model mapping (true: base models only, false: full list) - SIMPLE_MODEL_MAP=false + # 入站模型名映射 (alias=qwen-id,...,*=fallback),详见 .env.example + # Incoming model name mapping (alias=qwen-id,...,*=fallback), see .env.example + # - MODEL_MAP=*=qwen3.8-max-thinking # redis 连接地址(必填) # Redis URL (required; use rediss:// for TLS) - REDIS_URL=redis://redis:6379 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 98c8eff9..741662f1 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -41,6 +41,9 @@ services: # 简化模型映射 (true: 只返回基础模型, false: 返回完整模型列表) # Simplified model mapping (true: base models only, false: full list) - SIMPLE_MODEL_MAP=false + # 入站模型名映射 (alias=qwen-id,...,*=fallback),详见 .env.example + # Incoming model name mapping (alias=qwen-id,...,*=fallback), see .env.example + # - MODEL_MAP=*=qwen3.8-max-thinking # redis 连接地址(如果使用redis模式,则必填,当redis使用tls时将redis://替换为rediss://) # Redis URL (required for redis mode; use rediss:// for TLS) - REDIS_URL= diff --git a/public/src/locales/en.json b/public/src/locales/en.json index 5812d99d..6b414a57 100644 --- a/public/src/locales/en.json +++ b/public/src/locales/en.json @@ -160,6 +160,32 @@ "searchText": "text mode", "simpleModelMap": "🎯 Simplified model mapping", "simpleModelMapDesc": "Only the basic model is returned, excluding variations such as thinking, search, and image.", + "modelMapTitle": "🗺️ Model mapping", + "modelMapHint": "Incoming model names are replaced by a Qwen model before the request goes upstream. Names not listed here go to the fallback row, except names that already are Qwen model ids: those pass through unchanged.", + "modelMapRulesHint": "On save, aliases are lowercased and a trailing [..] suffix is stripped. An alias equal to a Qwen model id overrides that id on purpose.", + "modelMapClusterHint": "With several PM2 workers, a saved map reaches the other workers at their next restart.", + "modelMapNoneModeHint": "DATA_SAVE_MODE=none: changes apply in memory only and are lost on restart.", + "modelMapAlias": "Incoming name", + "modelMapTarget": "Qwen model", + "modelMapPickTarget": "Select a model...", + "modelMapFallback": "Everything else (fallback)", + "modelMapFallbackNone": "First upstream chat model (default)", + "modelMapAdd": "+ Add row", + "modelMapRemove": "Remove", + "modelMapRestoreEnv": "Restore env map", + "modelMapUnassigned": "Seen unassigned", + "modelMapUnassignedHint": "Names that fell to the fallback since the last restart. Click one to add a row for it.", + "modelMapNoRows": "No entries yet", + "modelMapNoTargets": "Upstream model list unavailable: no Qwen models to choose from. Check the accounts and reload.", + "modelMapLoadFailed": "Settings could not be loaded; the mapping shown may be stale.", + "modelMapRowEmptyAlias": "row {n}: incoming name is empty", + "modelMapRowReservedAlias": "row {n}: incoming name must not contain , = or *", + "modelMapRowDuplicateAlias": "row {n}: duplicate incoming name \"{alias}\"", + "modelMapRowEmptyTarget": "row {n}: no Qwen model selected", + "modelMapOriginEnv": "env", + "modelMapOriginSaved": "saved", + "modelMapOriginUnsaved": "unsaved", + "modelMapNotUpstream": "(not upstream)", "thinkOutput": "💡 Think Output", "title": "System settings" }, @@ -183,6 +209,12 @@ "searchModeFailed": "Failed to save search information mode:", "searchModeSaved": "Search information mode saved successfully", "simpleMapFailed": "Simplified model mapping settings failed to save:", + "modelMapSaved": "Model mapping saved successfully", + "modelMapSavedNotPersisted": "Model mapping applied in memory only (DATA_SAVE_MODE=none, lost on restart)", + "modelMapSavedPersistFailed": "Model mapping applied in memory, but writing it to storage failed (check the server logs)", + "modelMapReset": "Model mapping restored from the MODEL_MAP env variable", + "modelMapConfirmReset": "Discard the dashboard-saved model mapping and go back to the MODEL_MAP env value?", + "modelMapFailed": "Failed to save model mapping:", "simpleMapSaved": "Simplified model mapping settings saved successfully", "thinkFailed": "Thinking output settings failed to save:", "thinkSaved": "Think output settings saved successfully" diff --git a/public/src/locales/ru.json b/public/src/locales/ru.json index d5f3a4a1..cd4fa123 100644 --- a/public/src/locales/ru.json +++ b/public/src/locales/ru.json @@ -149,6 +149,32 @@ "searchText": "Текст", "simpleModelMap": "🎯 Упрощённый список моделей", "simpleModelMapDesc": "Только базовые модели, без вариантов thinking/search/image", + "modelMapTitle": "🗺️ Сопоставление моделей", + "modelMapHint": "Входящее имя модели заменяется моделью Qwen до отправки запроса. Имена, которых здесь нет, идут в строку fallback, кроме имён, которые уже являются id моделей Qwen: они проходят без изменений.", + "modelMapRulesHint": "При сохранении алиасы приводятся к нижнему регистру, хвостовой суффикс [..] отбрасывается. Алиас, равный id модели Qwen, намеренно переопределяет этот id.", + "modelMapClusterHint": "При нескольких PM2-воркерах сохранённое сопоставление попадает в остальные воркеры после их перезапуска.", + "modelMapNoneModeHint": "DATA_SAVE_MODE=none: изменения действуют только в памяти и теряются при перезапуске.", + "modelMapAlias": "Входящее имя", + "modelMapTarget": "Модель Qwen", + "modelMapPickTarget": "Выберите модель...", + "modelMapFallback": "Всё остальное (fallback)", + "modelMapFallbackNone": "Первая chat-модель upstream (по умолчанию)", + "modelMapAdd": "+ Добавить строку", + "modelMapRemove": "Удалить", + "modelMapRestoreEnv": "Вернуть env-сопоставление", + "modelMapUnassigned": "Замечены без сопоставления", + "modelMapUnassignedHint": "Имена, попавшие в fallback с последнего перезапуска. Нажмите, чтобы добавить строку.", + "modelMapNoRows": "Записей нет", + "modelMapNoTargets": "Список моделей upstream недоступен: нечего выбрать. Проверьте аккаунты и обновите страницу.", + "modelMapLoadFailed": "Не удалось загрузить настройки; показанное сопоставление может быть устаревшим.", + "modelMapRowEmptyAlias": "строка {n}: входящее имя пустое", + "modelMapRowReservedAlias": "строка {n}: входящее имя не должно содержать , = или *", + "modelMapRowDuplicateAlias": "строка {n}: входящее имя \"{alias}\" повторяется", + "modelMapRowEmptyTarget": "строка {n}: модель Qwen не выбрана", + "modelMapOriginEnv": "env", + "modelMapOriginSaved": "сохранено", + "modelMapOriginUnsaved": "не сохранено", + "modelMapNotUpstream": "(нет в upstream)", "retryTitle": "🔁 Повтор chat-запросов при сетевых сбоях", "retryCountLabel": "Количество повторов (0-10)", "retryBackoffLabel": "Задержка между попытками, мс (0-60000)", @@ -173,6 +199,12 @@ "searchModeFailed": "Ошибка сохранения: ", "simpleMapSaved": "Настройка моделей сохранена", "simpleMapFailed": "Ошибка сохранения: ", + "modelMapSaved": "Сопоставление моделей сохранено", + "modelMapSavedNotPersisted": "Сопоставление применено только в памяти (DATA_SAVE_MODE=none, сбросится при перезапуске)", + "modelMapSavedPersistFailed": "Сопоставление применено в памяти, но записать его в хранилище не удалось (смотрите логи сервера)", + "modelMapReset": "Сопоставление восстановлено из переменной MODEL_MAP", + "modelMapConfirmReset": "Отбросить сохранённое в панели сопоставление и вернуться к значению переменной MODEL_MAP?", + "modelMapFailed": "Ошибка сохранения сопоставления:", "retrySaved": "Настройки повтора сохранены", "retryFailed": "Ошибка сохранения настроек повтора: ", "enterKey": "Введите API-ключ", diff --git a/public/src/locales/zh.json b/public/src/locales/zh.json index 9be41eb7..e2e0e04b 100644 --- a/public/src/locales/zh.json +++ b/public/src/locales/zh.json @@ -149,6 +149,32 @@ "searchText": "文本模式", "simpleModelMap": "🎯 简化模型映射", "simpleModelMapDesc": "只返回基础模型,不包含thinking、search、image等变体", + "modelMapTitle": "🗺️ 模型映射", + "modelMapHint": "请求发往上游前,入站模型名会被替换成 Qwen 模型。未列出的名字走回退行;但本身就是 Qwen 模型 id 的名字原样透传。", + "modelMapRulesHint": "保存时别名转小写并去掉末尾的 [..] 后缀。别名等于某个 Qwen 模型 id 时会覆盖该 id,这是有意为之。", + "modelMapClusterHint": "PM2 多 worker 时,保存的映射在其他 worker 重启后才生效。", + "modelMapNoneModeHint": "DATA_SAVE_MODE=none:修改只在内存中生效,重启后丢失。", + "modelMapAlias": "入站模型名", + "modelMapTarget": "Qwen 模型", + "modelMapPickTarget": "选择模型...", + "modelMapFallback": "其余名字(回退)", + "modelMapFallbackNone": "上游第一个聊天模型(默认)", + "modelMapAdd": "+ 添加一行", + "modelMapRemove": "删除", + "modelMapRestoreEnv": "恢复 env 映射", + "modelMapUnassigned": "已出现但未分配", + "modelMapUnassignedHint": "上次重启以来落到回退的名字。点击即可为它添加一行。", + "modelMapNoRows": "暂无映射", + "modelMapNoTargets": "上游模型列表不可用:没有可选的 Qwen 模型。请检查账号后重新加载。", + "modelMapLoadFailed": "设置加载失败,显示的映射可能已过期。", + "modelMapRowEmptyAlias": "第 {n} 行:入站模型名为空", + "modelMapRowReservedAlias": "第 {n} 行:入站模型名不能包含 , = 或 *", + "modelMapRowDuplicateAlias": "第 {n} 行:入站模型名 \"{alias}\" 重复", + "modelMapRowEmptyTarget": "第 {n} 行:未选择 Qwen 模型", + "modelMapOriginEnv": "env", + "modelMapOriginSaved": "已保存", + "modelMapOriginUnsaved": "未保存", + "modelMapNotUpstream": "(不在上游)", "retryTitle": "🔁 聊天请求网络重试", "retryCountLabel": "重试次数 (0-10)", "retryBackoffLabel": "重试间隔毫秒数 (0-60000)", @@ -173,6 +199,12 @@ "searchModeFailed": "搜索信息模式保存失败: ", "simpleMapSaved": "简化模型映射设置保存成功", "simpleMapFailed": "简化模型映射设置保存失败: ", + "modelMapSaved": "模型映射保存成功", + "modelMapSavedNotPersisted": "模型映射仅在内存中生效(DATA_SAVE_MODE=none,重启后丢失)", + "modelMapSavedPersistFailed": "模型映射已在内存中生效,但写入存储失败(请查看服务器日志)", + "modelMapReset": "模型映射已恢复为 MODEL_MAP 环境变量的值", + "modelMapConfirmReset": "放弃 dashboard 保存的模型映射,恢复为 MODEL_MAP 环境变量的值?", + "modelMapFailed": "模型映射保存失败:", "retrySaved": "聊天重试配置保存成功", "retryFailed": "聊天重试配置保存失败: ", "enterKey": "请输入API Key", diff --git a/public/src/views/dashboard.vue b/public/src/views/dashboard.vue index b5624e9e..96bb1c02 100644 --- a/public/src/views/dashboard.vue +++ b/public/src/views/dashboard.vue @@ -1452,14 +1452,16 @@ onBeforeUnmount(() => { position: absolute; top: 0; left: 0; - width: 0; + width: 100%; height: 100%; background: rgba(99, 102, 241, 0.1); - transition: width 0.3s ease; + transform: scaleX(0); + transform-origin: left; + transition: transform 0.3s ease; } .custom-checkbox:hover .checkbox-icon:before { - width: 100%; + transform: scaleX(1); } .custom-checkbox input:checked + .checkbox-icon svg { diff --git a/public/src/views/settings.vue b/public/src/views/settings.vue index 9407b8da..3fe0d62e 100644 --- a/public/src/views/settings.vue +++ b/public/src/views/settings.vue @@ -12,6 +12,96 @@
+ +
+
+
+
+ + {{ t('settings.modelMapHint') }} + {{ t('settings.modelMapRulesHint') }} + {{ t('settings.modelMapClusterHint') }} + {{ t('settings.modelMapNoneModeHint') }} +
+ + +
+
+ {{ t('settings.modelMapAlias') }} → {{ t('settings.modelMapTarget') }} + +
+ +
+ {{ t('settings.modelMapNoRows') }} +
+ +
+ + + + + {{ t('settings.modelMapOrigin' + originOf(row.alias, row.target)) }} + + +
+
+ + +
+ {{ t('settings.modelMapFallback') }} + + + + {{ t('settings.modelMapOrigin' + fallbackOrigin()) }} + +
+ + +
+ {{ t('settings.modelMapUnassigned') }} + {{ t('settings.modelMapUnassignedHint') }} +
+ +
+
+ +
{{ modelMapError }}
+ +
+ + +
+
+
+
@@ -194,7 +284,7 @@