Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/adapters/cli/pi-model.ts
Original file line number Diff line number Diff line change
@@ -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;
}
4 changes: 3 additions & 1 deletion src/adapters/cli/pi.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down
85 changes: 85 additions & 0 deletions test/pi-model.test.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): 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',
]);
});
});