Skip to content

Commit fd061e4

Browse files
authored
Merge pull request #23 from RubricLab/pi-install
tokenmaxx install pi
2 parents a73d235 + 6bac863 commit fd061e4

8 files changed

Lines changed: 323 additions & 12 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
- [2026-08-18] tokenmaxx install pi routes pi through the proxy
12
- [2026-08-18] terminal themes prefer the bright ansi slots when they stay readable
23
- [2026-07-28] the dashboard follows the terminal's own colors
34
- [2026-07-23] an update re-applies routed configs on the next daemon start, fixes #17

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,5 @@
5959
"post-commit": "bun x @rubriclab/package post-commit"
6060
},
6161
"type": "module",
62-
"version": "0.0.61"
62+
"version": "0.0.62"
6363
}

‎src/cli.ts‎

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ import {
1111
healInstalledConfigs,
1212
installClaudeConfig,
1313
installCodexConfig,
14+
installPiConfig,
1415
installStatus,
16+
piStatus,
1517
uninstallClaudeConfig,
16-
uninstallCodexConfig
18+
uninstallCodexConfig,
19+
uninstallPiConfig
1720
} from './config-install.ts'
1821
import type { Account, ProviderId } from './domain.ts'
1922
import { ApplicationError, errorMessage } from './errors.ts'
@@ -241,8 +244,8 @@ function help(): string {
241244
'sign in an account · re-run to re-auth',
242245
'add --api-key to use an API key instead'
243246
),
244-
row('install', 'route codex & claude through tokenmaxx'),
245-
row('uninstall', 'restore your original config'),
247+
row('install [pi]', 'route codex & claude, or pi, through tokenmaxx'),
248+
row('uninstall [pi]', 'restore your original config'),
246249
'',
247250
head('Everyday'),
248251
row('list', 'accounts, health, and live usage'),
@@ -785,8 +788,24 @@ async function configureAutomation(
785788
}
786789
}
787790

788-
async function installConfig(context: ApplicationContext): Promise<void> {
791+
async function installConfig(context: ApplicationContext, targetArgument?: string): Promise<void> {
792+
if (targetArgument !== undefined && targetArgument !== 'pi') {
793+
throw new ApplicationError('USAGE', 'Usage: tokenmaxx install [pi]')
794+
}
789795
await ensureDaemon(context)
796+
if (targetArgument === 'pi') {
797+
const result = await installPiConfig(context.paths)
798+
if (!result.applied) {
799+
process.stdout.write(`Left ${result.path} alone: ${result.manual}\n`)
800+
return
801+
}
802+
process.stdout.write(
803+
`pi now has tokenmaxx-anthropic and tokenmaxx-openai providers (${result.path}).\n` +
804+
'Pick a tokenmaxx model with /model and requests route through the proxy.\n' +
805+
'Undo any time with: tokenmaxx uninstall pi\n'
806+
)
807+
return
808+
}
790809
await installCodexConfig(context.paths)
791810
await installClaudeConfig(context.paths)
792811
process.stdout.write(
@@ -796,7 +815,19 @@ async function installConfig(context: ApplicationContext): Promise<void> {
796815
)
797816
}
798817

799-
async function uninstallConfig(): Promise<void> {
818+
async function uninstallConfig(targetArgument?: string): Promise<void> {
819+
if (targetArgument !== undefined && targetArgument !== 'pi') {
820+
throw new ApplicationError('USAGE', 'Usage: tokenmaxx uninstall [pi]')
821+
}
822+
if (targetArgument === 'pi') {
823+
const result = await uninstallPiConfig()
824+
process.stdout.write(
825+
result.applied
826+
? `Removed the tokenmaxx providers from ${result.path}.\n`
827+
: `${result.manual === null ? 'pi was not routed; nothing to restore.' : `Left ${result.path} alone: ${result.manual}`}\n`
828+
)
829+
return
830+
}
800831
const codex = await uninstallCodexConfig()
801832
const claude = await uninstallClaudeConfig()
802833
if (codex === null && claude === null) {
@@ -878,6 +909,14 @@ async function doctor(context: ApplicationContext): Promise<void> {
878909
: 'not routed — run tokenmaxx install'
879910
}\n`
880911
)
912+
const pi = await piStatus()
913+
if (pi.present) {
914+
process.stdout.write(
915+
`${pi.routed ? 'ok ' : 'note '} pi ${
916+
pi.routed ? 'models.json has the tokenmaxx providers' : 'not routed — run tokenmaxx install pi'
917+
}\n`
918+
)
919+
}
881920
process.stdout.write(`state ${context.paths.database}\n`)
882921
const legacyDirectories = [join(context.paths.root, 'codex'), join(context.paths.root, 'claude')]
883922
const legacyDetected = await Promise.all(
@@ -1025,10 +1064,10 @@ export async function runCli(rawArguments: readonly string[]): Promise<number> {
10251064
listAccounts(context)
10261065
return 0
10271066
case 'install':
1028-
await installConfig(context)
1067+
await installConfig(context, arguments_[1])
10291068
return 0
10301069
case 'uninstall':
1031-
await uninstallConfig()
1070+
await uninstallConfig(arguments_[1])
10321071
return 0
10331072
case 'daemon':
10341073
switch (arguments_[1]) {

‎src/codex.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,7 @@ export async function codexUpstream(input: {
359359
return {
360360
accountId: input.account.id,
361361
baseUrl: upstreamFor('openai'),
362+
dialect: 'chatgpt',
362363
headers: {
363364
authorization: `Bearer ${auth.tokens.access_token}`,
364365
'chatgpt-account-id': codexIdentity(auth).accountId

‎src/config-install.test.ts‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import {
77
healInstalledConfigs,
88
installClaudeConfig,
99
installCodexConfig,
10+
installPiConfig,
1011
installStatus,
1112
uninstallClaudeConfig,
12-
uninstallCodexConfig
13+
uninstallCodexConfig,
14+
uninstallPiConfig
1315
} from './config-install.ts'
1416
import { applicationPaths } from './paths.ts'
1517

@@ -232,3 +234,54 @@ describe('healInstalledConfigs', () => {
232234
expect((await readClaudeSettings()).env?.ANTHROPIC_AUTH_TOKEN).toBe('managed-by-tokenmaxx')
233235
})
234236
})
237+
238+
describe('pi install', () => {
239+
test('providers merge into models.json and back out without touching the rest', async () => {
240+
process.env.PI_CODING_AGENT_DIR = join(home, 'pi-agent')
241+
await mkdir(process.env.PI_CODING_AGENT_DIR, { recursive: true })
242+
const modelsPath = join(process.env.PI_CODING_AGENT_DIR, 'models.json')
243+
await writeFile(
244+
modelsPath,
245+
JSON.stringify({
246+
providers: { mine: { api: 'anthropic-messages', baseUrl: 'https://example.com' } }
247+
})
248+
)
249+
const installed = await installPiConfig(applicationPaths())
250+
expect(installed.applied).toBe(true)
251+
const config = JSON.parse(await readFile(modelsPath, 'utf8'))
252+
expect(config.providers['tokenmaxx-anthropic'].api).toBe('anthropic-messages')
253+
expect(config.providers['tokenmaxx-openai'].baseUrl).toContain('/openai')
254+
expect(config.providers.mine.baseUrl).toBe('https://example.com')
255+
const removed = await uninstallPiConfig()
256+
expect(removed.applied).toBe(true)
257+
const restored = JSON.parse(await readFile(modelsPath, 'utf8'))
258+
expect(restored.providers['tokenmaxx-anthropic']).toBeUndefined()
259+
expect(restored.providers['tokenmaxx-openai']).toBeUndefined()
260+
expect(restored.providers.mine.baseUrl).toBe('https://example.com')
261+
delete process.env.PI_CODING_AGENT_DIR
262+
})
263+
264+
test('a missing models.json is created on install and reported clean on uninstall', async () => {
265+
process.env.PI_CODING_AGENT_DIR = join(home, 'pi-agent')
266+
const removed = await uninstallPiConfig()
267+
expect(removed.applied).toBe(false)
268+
expect(removed.manual).toBeNull()
269+
const installed = await installPiConfig(applicationPaths())
270+
expect(installed.applied).toBe(true)
271+
const config = JSON.parse(await readFile(installed.path, 'utf8'))
272+
expect(Object.keys(config.providers)).toEqual(['tokenmaxx-anthropic', 'tokenmaxx-openai'])
273+
delete process.env.PI_CODING_AGENT_DIR
274+
})
275+
276+
test('an unparseable models.json is left alone with manual instructions', async () => {
277+
process.env.PI_CODING_AGENT_DIR = join(home, 'pi-agent')
278+
await mkdir(process.env.PI_CODING_AGENT_DIR, { recursive: true })
279+
const modelsPath = join(process.env.PI_CODING_AGENT_DIR, 'models.json')
280+
await writeFile(modelsPath, '{ broken json')
281+
const result = await installPiConfig(applicationPaths())
282+
expect(result.applied).toBe(false)
283+
expect(result.manual).toContain('providers')
284+
expect(await readFile(modelsPath, 'utf8')).toBe('{ broken json')
285+
delete process.env.PI_CODING_AGENT_DIR
286+
})
287+
})

‎src/config-install.ts‎

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdir, readFile, writeFile } from 'node:fs/promises'
1+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
22
import { homedir } from 'node:os'
33
import { dirname, join } from 'node:path'
44
import type { ApplicationPaths } from './paths.ts'
@@ -219,3 +219,124 @@ export async function healInstalledConfigs(paths: ApplicationPaths): Promise<str
219219
await writeFile(stampPath, `${VERSION}\n`)
220220
return healed
221221
}
222+
223+
export interface PiResult {
224+
path: string
225+
applied: boolean
226+
manual: string | null
227+
}
228+
229+
function piModelsPath(): string {
230+
return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent'), 'models.json')
231+
}
232+
233+
const piProviderKeys = ['tokenmaxx-anthropic', 'tokenmaxx-openai']
234+
235+
// The anthropic ids pair with an API-key account (subscription auth is not for
236+
// third-party harnesses); gpt-5.6-sol is the one id the ChatGPT codex backend
237+
// accepts for subscription accounts.
238+
const piAnthropicModelIds = ['claude-opus-4-8', 'claude-sonnet-4-6']
239+
const piOpenaiModelIds = ['gpt-5.6-sol']
240+
241+
function piProviders(paths: ApplicationPaths): Record<string, unknown> {
242+
const models = (ids: readonly string[]) => ids.map(id => ({ id, reasoning: true }))
243+
return {
244+
'tokenmaxx-anthropic': {
245+
api: 'anthropic-messages',
246+
apiKey: dummyAuthToken,
247+
baseUrl: proxyBaseUrl(paths, 'anthropic'),
248+
models: models(piAnthropicModelIds)
249+
},
250+
'tokenmaxx-openai': {
251+
api: 'openai-responses',
252+
apiKey: dummyAuthToken,
253+
baseUrl: proxyBaseUrl(paths, 'openai'),
254+
models: models(piOpenaiModelIds)
255+
}
256+
}
257+
}
258+
259+
function parseJsonObject(raw: string): Record<string, unknown> | null {
260+
if (raw.trim().length === 0) {
261+
return {}
262+
}
263+
try {
264+
const parsed = JSON.parse(raw)
265+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
266+
? (parsed as Record<string, unknown>)
267+
: null
268+
} catch {
269+
return null
270+
}
271+
}
272+
273+
function ensureObject(parent: Record<string, unknown>, key: string): Record<string, unknown> {
274+
const value = parent[key]
275+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
276+
parent[key] = {}
277+
}
278+
return parent[key] as Record<string, unknown>
279+
}
280+
281+
async function writePiProviders(
282+
providers: Record<string, unknown> | null,
283+
manual: string
284+
): Promise<PiResult> {
285+
const path = piModelsPath()
286+
const raw = await readFileOrEmpty(path)
287+
const config = parseJsonObject(raw)
288+
if (config === null) {
289+
return { applied: false, manual, path }
290+
}
291+
const bucket = ensureObject(config, 'providers')
292+
for (const key of piProviderKeys) {
293+
delete bucket[key]
294+
}
295+
if (providers !== null) {
296+
Object.assign(bucket, providers)
297+
}
298+
await mkdir(dirname(path), { recursive: true })
299+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 })
300+
return { applied: true, manual: null, path }
301+
}
302+
303+
// pi re-reads models.json every time /model opens, so no restart is needed.
304+
export async function installPiConfig(paths: ApplicationPaths): Promise<PiResult> {
305+
return writePiProviders(
306+
piProviders(paths),
307+
`could not parse it as JSON — add this under providers yourself:\n${JSON.stringify(piProviders(paths), null, 2)}`
308+
)
309+
}
310+
311+
export async function uninstallPiConfig(): Promise<PiResult> {
312+
const raw = await readFile(piModelsPath(), 'utf8').catch(() => null)
313+
if (raw === null) {
314+
return { applied: false, manual: null, path: piModelsPath() }
315+
}
316+
return writePiProviders(
317+
null,
318+
'could not parse it as JSON — remove the tokenmaxx-anthropic and tokenmaxx-openai providers yourself'
319+
)
320+
}
321+
322+
export interface PiStatus {
323+
present: boolean
324+
routed: boolean
325+
}
326+
327+
// pi counts as present when its binary is on PATH or its agent directory
328+
// exists — someone who installed pi but never launched it has only the binary.
329+
export async function piStatus(
330+
which: (binary: string) => string | null = Bun.which
331+
): Promise<PiStatus> {
332+
const path = piModelsPath()
333+
const raw = await readFile(path, 'utf8').catch(() => null)
334+
const present =
335+
raw !== null ||
336+
which('pi') !== null ||
337+
(await stat(dirname(dirname(path))).then(
338+
() => true,
339+
() => false
340+
))
341+
return { present, routed: raw?.includes('tokenmaxx-anthropic') ?? false }
342+
}

‎src/proxy.test.ts‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from 'bun:test'
2-
import { createUsageObserver, proxyIdentity, startProxy } from './proxy.ts'
2+
import { adaptChatGptRequest, createUsageObserver, proxyIdentity, startProxy } from './proxy.ts'
33

44
type Observed = {
55
model: string | null
@@ -22,6 +22,53 @@ function observe(provider: 'openai' | 'anthropic', body: string, chunkSize = 7):
2222
return seen
2323
}
2424

25+
describe('chatgpt dialect adapter', () => {
26+
test('lifts system messages into instructions and drops max_output_tokens', () => {
27+
const adapted = JSON.parse(
28+
adaptChatGptRequest(
29+
JSON.stringify({
30+
input: [
31+
{ content: [{ text: 'You are a helpful agent.', type: 'input_text' }], role: 'system' },
32+
{ content: [{ text: 'hi', type: 'input_text' }], role: 'user' }
33+
],
34+
max_output_tokens: 4096,
35+
model: 'gpt-5.6-sol',
36+
store: false,
37+
stream: true
38+
})
39+
)
40+
)
41+
expect(adapted.instructions).toBe('You are a helpful agent.')
42+
expect(adapted.input).toHaveLength(1)
43+
expect(adapted.input[0].role).toBe('user')
44+
expect(adapted.max_output_tokens).toBeUndefined()
45+
})
46+
47+
test('merges lifted developer messages after existing instructions', () => {
48+
const adapted = JSON.parse(
49+
adaptChatGptRequest(
50+
JSON.stringify({
51+
input: [
52+
{ content: [{ text: 'Prefer short replies.', type: 'input_text' }], role: 'developer' }
53+
],
54+
instructions: 'You are a coding agent.'
55+
})
56+
)
57+
)
58+
expect(adapted.instructions).toBe('You are a coding agent.\n\nPrefer short replies.')
59+
expect(adapted.input).toHaveLength(0)
60+
})
61+
62+
test('leaves codex-shaped requests and non-json bodies alone', () => {
63+
const codexShaped = JSON.stringify({
64+
input: [{ content: [{ text: 'hi', type: 'input_text' }], role: 'user' }],
65+
instructions: 'You are Codex.'
66+
})
67+
expect(JSON.parse(adaptChatGptRequest(codexShaped))).toEqual(JSON.parse(codexShaped))
68+
expect(adaptChatGptRequest('not json')).toBe('not json')
69+
})
70+
})
71+
2572
describe('createUsageObserver', () => {
2673
test('codex SSE stream without content-type', () => {
2774
const body = [

0 commit comments

Comments
 (0)