From 6bac8639ab056d95fd2e28140dd26c28181ba841 Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Tue, 18 Aug 2026 18:23:35 -0400 Subject: [PATCH] install pi --- CHANGELOG.md | 1 + package.json | 2 +- src/cli.ts | 53 +++++++++++++--- src/codex.ts | 1 + src/config-install.test.ts | 55 ++++++++++++++++- src/config-install.ts | 123 ++++++++++++++++++++++++++++++++++++- src/proxy.test.ts | 49 ++++++++++++++- src/proxy.ts | 51 ++++++++++++++- 8 files changed, 323 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb97c06..40ec4af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- [2026-08-18] tokenmaxx install pi routes pi through the proxy - [2026-08-18] terminal themes prefer the bright ansi slots when they stay readable - [2026-07-28] the dashboard follows the terminal's own colors - [2026-07-23] an update re-applies routed configs on the next daemon start, fixes #17 diff --git a/package.json b/package.json index 070b983..dbd13ae 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.61" + "version": "0.0.62" } diff --git a/src/cli.ts b/src/cli.ts index d450542..356b520 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,9 +11,12 @@ import { healInstalledConfigs, installClaudeConfig, installCodexConfig, + installPiConfig, installStatus, + piStatus, uninstallClaudeConfig, - uninstallCodexConfig + uninstallCodexConfig, + uninstallPiConfig } from './config-install.ts' import type { Account, ProviderId } from './domain.ts' import { ApplicationError, errorMessage } from './errors.ts' @@ -241,8 +244,8 @@ function help(): string { 'sign in an account · re-run to re-auth', 'add --api-key to use an API key instead' ), - row('install', 'route codex & claude through tokenmaxx'), - row('uninstall', 'restore your original config'), + row('install [pi]', 'route codex & claude, or pi, through tokenmaxx'), + row('uninstall [pi]', 'restore your original config'), '', head('Everyday'), row('list', 'accounts, health, and live usage'), @@ -785,8 +788,24 @@ async function configureAutomation( } } -async function installConfig(context: ApplicationContext): Promise { +async function installConfig(context: ApplicationContext, targetArgument?: string): Promise { + if (targetArgument !== undefined && targetArgument !== 'pi') { + throw new ApplicationError('USAGE', 'Usage: tokenmaxx install [pi]') + } await ensureDaemon(context) + if (targetArgument === 'pi') { + const result = await installPiConfig(context.paths) + if (!result.applied) { + process.stdout.write(`Left ${result.path} alone: ${result.manual}\n`) + return + } + process.stdout.write( + `pi now has tokenmaxx-anthropic and tokenmaxx-openai providers (${result.path}).\n` + + 'Pick a tokenmaxx model with /model and requests route through the proxy.\n' + + 'Undo any time with: tokenmaxx uninstall pi\n' + ) + return + } await installCodexConfig(context.paths) await installClaudeConfig(context.paths) process.stdout.write( @@ -796,7 +815,19 @@ async function installConfig(context: ApplicationContext): Promise { ) } -async function uninstallConfig(): Promise { +async function uninstallConfig(targetArgument?: string): Promise { + if (targetArgument !== undefined && targetArgument !== 'pi') { + throw new ApplicationError('USAGE', 'Usage: tokenmaxx uninstall [pi]') + } + if (targetArgument === 'pi') { + const result = await uninstallPiConfig() + process.stdout.write( + result.applied + ? `Removed the tokenmaxx providers from ${result.path}.\n` + : `${result.manual === null ? 'pi was not routed; nothing to restore.' : `Left ${result.path} alone: ${result.manual}`}\n` + ) + return + } const codex = await uninstallCodexConfig() const claude = await uninstallClaudeConfig() if (codex === null && claude === null) { @@ -878,6 +909,14 @@ async function doctor(context: ApplicationContext): Promise { : 'not routed — run tokenmaxx install' }\n` ) + const pi = await piStatus() + if (pi.present) { + process.stdout.write( + `${pi.routed ? 'ok ' : 'note '} pi ${ + pi.routed ? 'models.json has the tokenmaxx providers' : 'not routed — run tokenmaxx install pi' + }\n` + ) + } process.stdout.write(`state ${context.paths.database}\n`) const legacyDirectories = [join(context.paths.root, 'codex'), join(context.paths.root, 'claude')] const legacyDetected = await Promise.all( @@ -1025,10 +1064,10 @@ export async function runCli(rawArguments: readonly string[]): Promise { listAccounts(context) return 0 case 'install': - await installConfig(context) + await installConfig(context, arguments_[1]) return 0 case 'uninstall': - await uninstallConfig() + await uninstallConfig(arguments_[1]) return 0 case 'daemon': switch (arguments_[1]) { diff --git a/src/codex.ts b/src/codex.ts index 0772e08..8edc6e2 100644 --- a/src/codex.ts +++ b/src/codex.ts @@ -359,6 +359,7 @@ export async function codexUpstream(input: { return { accountId: input.account.id, baseUrl: upstreamFor('openai'), + dialect: 'chatgpt', headers: { authorization: `Bearer ${auth.tokens.access_token}`, 'chatgpt-account-id': codexIdentity(auth).accountId diff --git a/src/config-install.test.ts b/src/config-install.test.ts index 4c40e13..3ad2d06 100644 --- a/src/config-install.test.ts +++ b/src/config-install.test.ts @@ -7,9 +7,11 @@ import { healInstalledConfigs, installClaudeConfig, installCodexConfig, + installPiConfig, installStatus, uninstallClaudeConfig, - uninstallCodexConfig + uninstallCodexConfig, + uninstallPiConfig } from './config-install.ts' import { applicationPaths } from './paths.ts' @@ -232,3 +234,54 @@ describe('healInstalledConfigs', () => { expect((await readClaudeSettings()).env?.ANTHROPIC_AUTH_TOKEN).toBe('managed-by-tokenmaxx') }) }) + +describe('pi install', () => { + test('providers merge into models.json and back out without touching the rest', async () => { + process.env.PI_CODING_AGENT_DIR = join(home, 'pi-agent') + await mkdir(process.env.PI_CODING_AGENT_DIR, { recursive: true }) + const modelsPath = join(process.env.PI_CODING_AGENT_DIR, 'models.json') + await writeFile( + modelsPath, + JSON.stringify({ + providers: { mine: { api: 'anthropic-messages', baseUrl: 'https://example.com' } } + }) + ) + const installed = await installPiConfig(applicationPaths()) + expect(installed.applied).toBe(true) + const config = JSON.parse(await readFile(modelsPath, 'utf8')) + expect(config.providers['tokenmaxx-anthropic'].api).toBe('anthropic-messages') + expect(config.providers['tokenmaxx-openai'].baseUrl).toContain('/openai') + expect(config.providers.mine.baseUrl).toBe('https://example.com') + const removed = await uninstallPiConfig() + expect(removed.applied).toBe(true) + const restored = JSON.parse(await readFile(modelsPath, 'utf8')) + expect(restored.providers['tokenmaxx-anthropic']).toBeUndefined() + expect(restored.providers['tokenmaxx-openai']).toBeUndefined() + expect(restored.providers.mine.baseUrl).toBe('https://example.com') + delete process.env.PI_CODING_AGENT_DIR + }) + + test('a missing models.json is created on install and reported clean on uninstall', async () => { + process.env.PI_CODING_AGENT_DIR = join(home, 'pi-agent') + const removed = await uninstallPiConfig() + expect(removed.applied).toBe(false) + expect(removed.manual).toBeNull() + const installed = await installPiConfig(applicationPaths()) + expect(installed.applied).toBe(true) + const config = JSON.parse(await readFile(installed.path, 'utf8')) + expect(Object.keys(config.providers)).toEqual(['tokenmaxx-anthropic', 'tokenmaxx-openai']) + delete process.env.PI_CODING_AGENT_DIR + }) + + test('an unparseable models.json is left alone with manual instructions', async () => { + process.env.PI_CODING_AGENT_DIR = join(home, 'pi-agent') + await mkdir(process.env.PI_CODING_AGENT_DIR, { recursive: true }) + const modelsPath = join(process.env.PI_CODING_AGENT_DIR, 'models.json') + await writeFile(modelsPath, '{ broken json') + const result = await installPiConfig(applicationPaths()) + expect(result.applied).toBe(false) + expect(result.manual).toContain('providers') + expect(await readFile(modelsPath, 'utf8')).toBe('{ broken json') + delete process.env.PI_CODING_AGENT_DIR + }) +}) diff --git a/src/config-install.ts b/src/config-install.ts index cbab16d..4061663 100644 --- a/src/config-install.ts +++ b/src/config-install.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, stat, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' import type { ApplicationPaths } from './paths.ts' @@ -219,3 +219,124 @@ export async function healInstalledConfigs(paths: ApplicationPaths): Promise { + const models = (ids: readonly string[]) => ids.map(id => ({ id, reasoning: true })) + return { + 'tokenmaxx-anthropic': { + api: 'anthropic-messages', + apiKey: dummyAuthToken, + baseUrl: proxyBaseUrl(paths, 'anthropic'), + models: models(piAnthropicModelIds) + }, + 'tokenmaxx-openai': { + api: 'openai-responses', + apiKey: dummyAuthToken, + baseUrl: proxyBaseUrl(paths, 'openai'), + models: models(piOpenaiModelIds) + } + } +} + +function parseJsonObject(raw: string): Record | null { + if (raw.trim().length === 0) { + return {} + } + try { + const parsed = JSON.parse(raw) + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null + } catch { + return null + } +} + +function ensureObject(parent: Record, key: string): Record { + const value = parent[key] + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + parent[key] = {} + } + return parent[key] as Record +} + +async function writePiProviders( + providers: Record | null, + manual: string +): Promise { + const path = piModelsPath() + const raw = await readFileOrEmpty(path) + const config = parseJsonObject(raw) + if (config === null) { + return { applied: false, manual, path } + } + const bucket = ensureObject(config, 'providers') + for (const key of piProviderKeys) { + delete bucket[key] + } + if (providers !== null) { + Object.assign(bucket, providers) + } + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }) + return { applied: true, manual: null, path } +} + +// pi re-reads models.json every time /model opens, so no restart is needed. +export async function installPiConfig(paths: ApplicationPaths): Promise { + return writePiProviders( + piProviders(paths), + `could not parse it as JSON — add this under providers yourself:\n${JSON.stringify(piProviders(paths), null, 2)}` + ) +} + +export async function uninstallPiConfig(): Promise { + const raw = await readFile(piModelsPath(), 'utf8').catch(() => null) + if (raw === null) { + return { applied: false, manual: null, path: piModelsPath() } + } + return writePiProviders( + null, + 'could not parse it as JSON — remove the tokenmaxx-anthropic and tokenmaxx-openai providers yourself' + ) +} + +export interface PiStatus { + present: boolean + routed: boolean +} + +// pi counts as present when its binary is on PATH or its agent directory +// exists — someone who installed pi but never launched it has only the binary. +export async function piStatus( + which: (binary: string) => string | null = Bun.which +): Promise { + const path = piModelsPath() + const raw = await readFile(path, 'utf8').catch(() => null) + const present = + raw !== null || + which('pi') !== null || + (await stat(dirname(dirname(path))).then( + () => true, + () => false + )) + return { present, routed: raw?.includes('tokenmaxx-anthropic') ?? false } +} diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 5189685..7616d4c 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { createUsageObserver, proxyIdentity, startProxy } from './proxy.ts' +import { adaptChatGptRequest, createUsageObserver, proxyIdentity, startProxy } from './proxy.ts' type Observed = { model: string | null @@ -22,6 +22,53 @@ function observe(provider: 'openai' | 'anthropic', body: string, chunkSize = 7): return seen } +describe('chatgpt dialect adapter', () => { + test('lifts system messages into instructions and drops max_output_tokens', () => { + const adapted = JSON.parse( + adaptChatGptRequest( + JSON.stringify({ + input: [ + { content: [{ text: 'You are a helpful agent.', type: 'input_text' }], role: 'system' }, + { content: [{ text: 'hi', type: 'input_text' }], role: 'user' } + ], + max_output_tokens: 4096, + model: 'gpt-5.6-sol', + store: false, + stream: true + }) + ) + ) + expect(adapted.instructions).toBe('You are a helpful agent.') + expect(adapted.input).toHaveLength(1) + expect(adapted.input[0].role).toBe('user') + expect(adapted.max_output_tokens).toBeUndefined() + }) + + test('merges lifted developer messages after existing instructions', () => { + const adapted = JSON.parse( + adaptChatGptRequest( + JSON.stringify({ + input: [ + { content: [{ text: 'Prefer short replies.', type: 'input_text' }], role: 'developer' } + ], + instructions: 'You are a coding agent.' + }) + ) + ) + expect(adapted.instructions).toBe('You are a coding agent.\n\nPrefer short replies.') + expect(adapted.input).toHaveLength(0) + }) + + test('leaves codex-shaped requests and non-json bodies alone', () => { + const codexShaped = JSON.stringify({ + input: [{ content: [{ text: 'hi', type: 'input_text' }], role: 'user' }], + instructions: 'You are Codex.' + }) + expect(JSON.parse(adaptChatGptRequest(codexShaped))).toEqual(JSON.parse(codexShaped)) + expect(adaptChatGptRequest('not json')).toBe('not json') + }) +}) + describe('createUsageObserver', () => { test('codex SSE stream without content-type', () => { const body = [ diff --git a/src/proxy.ts b/src/proxy.ts index 88a7469..5e659f1 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -8,6 +8,7 @@ export interface UpstreamInjection { headers: Record appendHeaders?: Record stripHeaders?: readonly string[] + dialect?: 'chatgpt' } interface ProxyCredentialSource { @@ -200,6 +201,51 @@ export async function proxyIdentity(port: number): Promise<'tokenmaxx' | 'foreig } } +interface ResponsesInputMessage { + role?: string + content?: { type?: string; text?: string }[] +} + +function messageText(item: ResponsesInputMessage): string { + return (Array.isArray(item.content) ? item.content : []) + .map(part => part.text ?? '') + .filter(text => text.length > 0) + .join('\n') +} + +// The ChatGPT codex backend rejects requests third-party harnesses send to a +// standard Responses endpoint: system messages must ride in `instructions` +// ("System messages are not allowed") and `max_output_tokens` is unsupported. +// Codex's own requests already have that shape, so this is a no-op for them. +export function adaptChatGptRequest(raw: string): string { + let parsed: { input?: unknown; instructions?: unknown; [key: string]: unknown } + try { + parsed = JSON.parse(raw) as typeof parsed + } catch { + return raw + } + if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.input)) { + return raw + } + const isSystem = (item: unknown): item is ResponsesInputMessage => { + const role = (item as ResponsesInputMessage | null)?.role + return role === 'system' || role === 'developer' + } + const lifted = parsed.input.filter(isSystem).map(messageText) + const instructions = [ + ...(typeof parsed.instructions === 'string' ? [parsed.instructions] : []), + ...lifted + ] + .filter(text => text.length > 0) + .join('\n\n') + const { max_output_tokens: _dropped, ...rest } = parsed + return JSON.stringify({ + ...rest, + input: parsed.input.filter(item => !isSystem(item)), + ...(instructions.length > 0 ? { instructions } : {}) + }) +} + const strippedRequestHeaders = [ 'host', 'connection', @@ -288,7 +334,10 @@ function createProxyHandler(options: ProxyOptions): ProxyHandler { request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer() const send = (injection: UpstreamInjection): Promise => doFetch(`${injection.baseUrl.replace(/\/$/, '')}${route.rest}${url.search}`, { - body, + body: + injection.dialect === 'chatgpt' && body !== undefined + ? adaptChatGptRequest(new TextDecoder().decode(body)) + : body, headers: forwardHeaders(request.headers, injection), method: request.method, redirect: 'manual',