Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,5 +59,5 @@
"post-commit": "bun x @rubriclab/package post-commit"
},
"type": "module",
"version": "0.0.61"
"version": "0.0.62"
}
53 changes: 46 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -785,8 +788,24 @@ async function configureAutomation(
}
}

async function installConfig(context: ApplicationContext): Promise<void> {
async function installConfig(context: ApplicationContext, targetArgument?: string): Promise<void> {
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(
Expand All @@ -796,7 +815,19 @@ async function installConfig(context: ApplicationContext): Promise<void> {
)
}

async function uninstallConfig(): Promise<void> {
async function uninstallConfig(targetArgument?: string): Promise<void> {
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) {
Expand Down Expand Up @@ -878,6 +909,14 @@ async function doctor(context: ApplicationContext): Promise<void> {
: '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(
Expand Down Expand Up @@ -1025,10 +1064,10 @@ export async function runCli(rawArguments: readonly string[]): Promise<number> {
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]) {
Expand Down
1 change: 1 addition & 0 deletions src/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 54 additions & 1 deletion src/config-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
healInstalledConfigs,
installClaudeConfig,
installCodexConfig,
installPiConfig,
installStatus,
uninstallClaudeConfig,
uninstallCodexConfig
uninstallCodexConfig,
uninstallPiConfig
} from './config-install.ts'
import { applicationPaths } from './paths.ts'

Expand Down Expand Up @@ -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
})
})
123 changes: 122 additions & 1 deletion src/config-install.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -219,3 +219,124 @@ export async function healInstalledConfigs(paths: ApplicationPaths): Promise<str
await writeFile(stampPath, `${VERSION}\n`)
return healed
}

export interface PiResult {
path: string
applied: boolean
manual: string | null
}

function piModelsPath(): string {
return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent'), 'models.json')
}

const piProviderKeys = ['tokenmaxx-anthropic', 'tokenmaxx-openai']

// The anthropic ids pair with an API-key account (subscription auth is not for
// third-party harnesses); gpt-5.6-sol is the one id the ChatGPT codex backend
// accepts for subscription accounts.
const piAnthropicModelIds = ['claude-opus-4-8', 'claude-sonnet-4-6']
const piOpenaiModelIds = ['gpt-5.6-sol']

function piProviders(paths: ApplicationPaths): Record<string, unknown> {
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<string, unknown> | 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<string, unknown>)
: null
} catch {
return null
}
}

function ensureObject(parent: Record<string, unknown>, key: string): Record<string, unknown> {
const value = parent[key]
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
parent[key] = {}
}
return parent[key] as Record<string, unknown>
}

async function writePiProviders(
providers: Record<string, unknown> | null,
manual: string
): Promise<PiResult> {
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<PiResult> {
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<PiResult> {
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<PiStatus> {
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 }
}
49 changes: 48 additions & 1 deletion src/proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 = [
Expand Down
Loading
Loading