From 1b5c6f7d9424775eb7478821a5e22cdd6e47a736 Mon Sep 17 00:00:00 2001 From: shiroha Date: Tue, 25 Aug 2026 18:09:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(pi):=20=E8=A3=B8=E6=A8=A1=E5=9E=8B=20id=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=A1=A5=20settings.defaultProvider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi 收到 `--model grok-4.6` 时会绕过 settings.json 的 defaultProvider, 在多个已认证 provider 上撞同名 id 直接退出。启动前把裸 slug 写成 `provider/id`;已带斜杠的配置保持原样。 --- src/adapters/cli/pi-model.ts | 51 ++++++++++++++++++++++ src/adapters/cli/pi.ts | 4 +- test/pi-model.test.ts | 85 ++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 src/adapters/cli/pi-model.ts create mode 100644 test/pi-model.test.ts diff --git a/src/adapters/cli/pi-model.ts b/src/adapters/cli/pi-model.ts new file mode 100644 index 0000000000..c2bd70757f --- /dev/null +++ b/src/adapters/cli/pi-model.ts @@ -0,0 +1,51 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export type PiModelLookup = { + homeDir?: string; + settingsPath?: string; +}; + +/** Pi accepts `--model provider/id`. A slash already pins the provider. */ +export function isPiQualifiedModel(model: string): boolean { + return model.includes('/'); +} + +export function piSettingsPath(homeDir: string = homedir()): string { + const overlay = process.env.PI_CODING_AGENT_DIR?.trim(); + if (overlay) return join(overlay, 'settings.json'); + return join(homeDir, '.pi', 'agent', 'settings.json'); +} + +export function readPiDefaultProvider(lookup: PiModelLookup | string = {}): string | undefined { + const path = typeof lookup === 'string' + ? piSettingsPath(lookup) + : (lookup.settingsPath ?? piSettingsPath(lookup.homeDir)); + if (!existsSync(path)) return undefined; + try { + const raw = JSON.parse(readFileSync(path, 'utf8')) as { defaultProvider?: unknown }; + return typeof raw.defaultProvider === 'string' && raw.defaultProvider.trim() + ? raw.defaultProvider.trim() + : undefined; + } catch { + return undefined; + } +} + +/** + * Qualify a BotConfig.model so Pi does not treat a shared slug as ambiguous. + * + * Bare `grok-4.6` collides across github-copilot / opencode / xai / tako-* + * even when settings.defaultProvider is set — `--model` bypasses that default. + */ +export function resolvePiModelFlag( + model: string | undefined, + lookup: PiModelLookup | string = {}, +): string | undefined { + const trimmed = model?.trim(); + if (!trimmed) return undefined; + if (isPiQualifiedModel(trimmed)) return trimmed; + const provider = readPiDefaultProvider(lookup); + return provider ? `${provider}/${trimmed}` : trimmed; +} diff --git a/src/adapters/cli/pi.ts b/src/adapters/cli/pi.ts index 1d2e96dbe3..024172ff98 100644 --- a/src/adapters/cli/pi.ts +++ b/src/adapters/cli/pi.ts @@ -1,6 +1,7 @@ import { resolveCommand } from './registry.js'; import { BOTMUX_SHELL_HINTS } from './shared-hints.js'; import { preparePiInitialPromptArg } from './pi-initial-prompt.js'; +import { resolvePiModelFlag } from './pi-model.js'; import type { CliAdapter, PtyHandle } from './types.js'; import { delay } from '../../utils/timing.js'; @@ -85,7 +86,8 @@ export function createPiAdapter(pathOverride?: string): CliAdapter { const args = [ '--session-id', sessionId, ]; - if (model?.trim()) args.push('--model', model.trim()); + const resolvedModel = resolvePiModelFlag(model); + if (resolvedModel) args.push('--model', resolvedModel); // Pi's interactive mode processes positional initial messages after TUI // startup, avoiding stdin races while keeping the native TUI visible. if (initialPrompt) args.push(initialPrompt); diff --git a/test/pi-model.test.ts b/test/pi-model.test.ts new file mode 100644 index 0000000000..7f0629e012 --- /dev/null +++ b/test/pi-model.test.ts @@ -0,0 +1,85 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { createPiAdapter } from '../src/adapters/cli/pi.js'; +import { + isPiQualifiedModel, + piSettingsPath, + readPiDefaultProvider, + resolvePiModelFlag, +} from '../src/adapters/cli/pi-model.js'; + +describe('pi model qualification', () => { + afterEach(() => { + delete process.env.PI_CODING_AGENT_DIR; + }); + + function makeHome(settings?: Record): string { + const home = mkdtempSync(join(tmpdir(), 'botmux-pi-model-')); + mkdirSync(join(home, '.pi', 'agent'), { recursive: true }); + if (settings) { + writeFileSync(join(home, '.pi', 'agent', 'settings.json'), JSON.stringify(settings)); + } + return home; + } + + it('treats provider/id as already qualified', () => { + expect(isPiQualifiedModel('team-gateway/grok-4.6')).toBe(true); + expect(isPiQualifiedModel('grok-4.6')).toBe(false); + }); + + it('reads defaultProvider from settings.json', () => { + const home = makeHome({ defaultProvider: 'team-gateway', defaultModel: 'grok-4.6' }); + expect(readPiDefaultProvider(home)).toBe('team-gateway'); + expect(piSettingsPath(home)).toBe(join(home, '.pi', 'agent', 'settings.json')); + }); + + it('prefers PI_CODING_AGENT_DIR over ~/.pi', () => { + const home = makeHome({ defaultProvider: 'ignored' }); + const overlay = join(home, 'overlay'); + mkdirSync(overlay, { recursive: true }); + writeFileSync(join(overlay, 'settings.json'), JSON.stringify({ defaultProvider: 'tako' })); + process.env.PI_CODING_AGENT_DIR = overlay; + expect(readPiDefaultProvider(home)).toBe('tako'); + }); + + it('prefixes a bare model with defaultProvider', () => { + const home = makeHome({ defaultProvider: 'team-gateway' }); + expect(resolvePiModelFlag('grok-4.6', home)).toBe('team-gateway/grok-4.6'); + }); + + it('leaves provider/id untouched', () => { + const home = makeHome({ defaultProvider: 'team-gateway' }); + expect(resolvePiModelFlag('tako/grok-4.6', home)).toBe('tako/grok-4.6'); + }); + + it('keeps a bare model when settings have no defaultProvider', () => { + const home = makeHome({ defaultModel: 'grok-4.6' }); + expect(resolvePiModelFlag('grok-4.6', home)).toBe('grok-4.6'); + }); + + it('omits the model flag when BotConfig.model is empty', () => { + expect(resolvePiModelFlag(undefined, makeHome({ defaultProvider: 'team-gateway' }))).toBeUndefined(); + expect(resolvePiModelFlag(' ', makeHome({ defaultProvider: 'team-gateway' }))).toBeUndefined(); + }); + + it('ignores unreadable settings and keeps the bare model', () => { + const home = makeHome(); + writeFileSync(join(home, '.pi', 'agent', 'settings.json'), '{not-json'); + expect(resolvePiModelFlag('grok-4.6', home)).toBe('grok-4.6'); + }); + + it('buildArgs still accepts an already-qualified model', () => { + const args = createPiAdapter('/usr/bin/pi').buildArgs({ + sessionId: 'sess-pi', + resume: false, + model: 'team-gateway/grok-4.6', + }); + expect(args).toEqual([ + '--session-id', 'sess-pi', + '--model', 'team-gateway/grok-4.6', + ]); + }); +});