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

@ -551,11 +551,30 @@ async function run() {
provider: job.config.replyProvider,
model: job.config.replyModel,
});
// 平台既没划范围、env 也没指定 → 这一轮的模型由**宿主**决定,平台无法控制。
// 这不该是静默的:实测它让每封来信都撞上宿主默认指向的一个没余额的直连
// provider而日志读起来像「平台的模型没钱了」见上面 label 的注释)。
if (attempts.length === 1 && !attempts[0]) {
log(
'注意:平台未指定模型(模型范围为空 且 AGENTMAIL_REPLY_MODEL 未设置),' +
'本轮将使用**宿主的**默认模型,平台无法控制它是哪一个。' +
'要固定下来请设 AGENTMAIL_REPLY_PROVIDER / AGENTMAIL_REPLY_MODEL或在配置页划定范围。'
);
}
const failures = [];
let live = { session, sessionManager };
for (const route of attempts) {
const label = route ? `${route.provider}/${route.model}` : '(平台默认)';
// 标签要说出**语义**,不能写「平台默认」——那是错的,而且误导过。
//
// `modelAttemptOrder` 在「平台未划范围 + env 未指定」时返回 `[undefined]`
// 意思是「交给宿主 SDK 用它自己的默认模型」。那是**宿主的**默认
// pi 的 /root/.pi/agent/settings.json平台管不着它。
//
// 写成「平台默认」之后,日志里的 `模型 (平台默认) 失败: 402` 看起来像
// 「平台选的模型没钱了」,而真相是宿主默认指向了一个平台从未选用过的
// 直连 provider实测deepseek/deepseek-v4-flash直连云、余额为零
const label = route ? `${route.provider}/${route.model}` : '宿主默认(平台未指定模型)';
if (route) {
const model = modelRuntime.getModel(route.provider, route.model);
if (!model) {

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, /平台未指定模型/, '应当在日志里显式说明「平台未指定」');
});