fix(pi): 模型选择落到「宿主默认」是静默的,而且日志把它说成「平台默认」

## 现场

pi 每封来信都报 `模型 (平台默认) 失败: 402: Insufficient Balance`。
**把它读成「平台的模型没钱了」是错的** —— 真相是:

- `modelAttemptOrder(范围, env默认)` 在「平台没划范围 **且** env 没指定」时
  返回 `[undefined]`,语义是「交给宿主 SDK 用它自己的默认模型」;
- pi.env 里 `AGENTMAIL_REPLY_PROVIDER/MODEL` **都是空的**,而
  **opencode.env 里钉了 `llmsproxy`/`AUTO`** —— 这就是为什么其它三桥通、只有 pi 不通;
- 于是落到了宿主的默认:`/root/.pi/agent/settings.json` 的
  `defaultProvider: deepseek` + `defaultModel: deepseek-v4-flash`
  —— **直连 DeepSeek 云**(不是本地代理),那边的余额是零;
- 而那个名字连本地代理的目录里都没有(目录是 `deepseek-v4.1-flash`),
  所以即使指对了代理也会 403。

## 修

1. **配置**(`/etc/agentmail/pi.env`):按 opencode 的约定钉上
   `llmsproxy` + `AUTO`,并在注释里写明「留空的语义是交给宿主默认,平台管不着」
   —— 这个语义本身就是坑。
2. **代码**:把那句 `(平台默认)` 改成 `宿主默认(平台未指定模型)`,
   并在「平台未指定模型」时**显式告警**一次。一句话的日志差别决定了排查方向:
   「平台默认」把人引向平台配置,「宿主默认」直接指向 `~/.pi/agent/settings.json`。
3. **断言**:`modelAttemptOrder` 的 `[undefined]` 语义 + 「源码里不能把宿主默认
   写成平台默认」(只看字符串字面量,免得注释里的解释也被禁掉)。

## 验证

- 配置前:`env | grep -i zcode|agentmail` 那条待决请求被拒(它会把 worker 环境里的
  `AGENTMAIL_AGENT_KEY` 打进模型上下文);顺带清扫 9 条早前实验遗留的待决请求。
- 配置后真发一封进 pi 的**已有会话**:6 秒内收到回信,标记原样返回 ✓
- 四桥漂移检查全通过;pi 415 测试全绿;「平台未指定模型」告警在生产日志里出现 0 次
  (说明配置确实齐了)。

## 仍然待定(需要你定)

`/root/.pi/agent/settings.json` 的宿主默认 **仍指向 `deepseek/deepseek-v4-flash`**。
它影响**交互式 pi**(人工开着 pi 干活时用的就是它),而且那个模型名不在本地代理目录里。
桥这条路已经绕开它了,但要不要把宿主默认也改成 `llmsproxy/AUTO`
(与 opencode 一致)需要你拍板 —— 那会改变交互式会话的行为。
This commit is contained in:
2026-09-12 23:31:44 +08:00
parent 630b5bfdd7
commit 2e28696e74
2 changed files with 63 additions and 1 deletions

View File

@ -9,6 +9,11 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
import {
stripRe,
replySubject,
@ -321,3 +326,41 @@ test('续谈失败回报在主题/错误缺失时也不崩', () => {
assert.match(body, /\(无主题\)/);
assert.match(body, /未知错误/);
});
// ─── 「平台没指定模型」不能是静默的 ─────────────────────────────────────
//
// 这一组来自一次真实误判:pi.env 里 AGENTMAIL_REPLY_PROVIDER/MODEL 都是空的
// (opencode 那边钉了 llmsproxy/AUTO),于是 modelAttemptOrder 返回 [undefined]
// —— 语义是「交给宿主 SDK 用它自己的默认模型」,而宿主的默认是
// /root/.pi/agent/settings.json 里的 deepseek/deepseek-v4-flash(**直连云、
// 余额为零**)。每封来信都 402,而日志里那句「模型 (平台默认) 失败」
// 让它读起来像「平台选的模型没钱了」。
//
// 所以:判据要说出语义(宿主默认 ≠ 平台默认),且这种情况下必须有一条显式告警。
test('★ 平台未指定模型时走 [undefined](= 宿主默认),这是既有语义', async () => {
const { modelAttemptOrder } = await import('../lib/model-scope.js');
// 范围空 + env 空 → [undefined]
assert.deepEqual(modelAttemptOrder([], { provider: '', model: '' }), [undefined]);
// 反向对照:任一侧有值就不该落到 undefined
assert.deepEqual(modelAttemptOrder([{ provider: 'llmsproxy', model: 'AUTO' }], {}), [
{ provider: 'llmsproxy', model: 'AUTO' }
]);
assert.deepEqual(modelAttemptOrder([], { provider: 'llmsproxy', model: 'AUTO' }), [
{ provider: 'llmsproxy', model: 'AUTO' }
]);
});
test('★ 源码里不能把「宿主默认」写成「平台默认」', () => {
// 这条是**形态断言**:文案错了不会报错,只会让下一个人误判半小时。
const src = readFileSync(join(HERE, '..', 'src', 'worker.mjs'), 'utf8');
// 只看**字符串字面量**:注释里为了解释「不要这么写」也会出现这几个字,
// 一并禁掉会让这条断言变成一个必须靠改注释才能过的枷锁。
assert.doesNotMatch(
src,
/['"`]\(平台默认\)['"`]/,
'标签不能写「平台默认」——那个模型不是平台选的'
);
assert.match(src, /宿主默认/, '应当明确写出这是宿主侧的默认');
assert.match(src, /平台未指定模型/, '应当在日志里显式说明「平台未指定」');
});