From f0114a9f700a319069a9f7270a381e78b1793bbb Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Wed, 22 Jul 2026 15:53:13 -0400 Subject: [PATCH 1/4] install into openclaw, pi, and hermes, fixes #11 --- CHANGELOG.md | 3 +- package.json | 2 +- src/cli.ts | 66 +++++++++- src/config-install.test.ts | 72 ++++++++++- src/config-install.ts | 240 ++++++++++++++++++++++++++++++++++++- 5 files changed, 373 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41911f6..190d044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ -- [2026-07-22] alpha channel +- [2026-07-22] install into openclaw, pi, and hermes, fixes #11 +- [2026-07-22] [alpha channel](https://github.com/RubricLab/tokenmaxx/commit/5e106a78a47a6265b37dbd3713cbe17635458372) - [2026-07-21] follow claude's profile email rename - [2026-07-21] [failed logins say why](https://github.com/RubricLab/tokenmaxx/commit/e28f1472327450ca8e7640ca7ab5c3b945790a31) - [2026-07-20] [readme](https://github.com/RubricLab/tokenmaxx/commit/e2d9002286227126cb529961d6bad9ca4bc36f6c) diff --git a/package.json b/package.json index 2ffa1fe..c3d402d 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.53" + "version": "0.0.54" } diff --git a/src/cli.ts b/src/cli.ts index a8d4272..108206f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,11 +7,15 @@ import { z } from 'zod' import { registerClaudeAccount } from './claude.ts' import { registerCodexAccount } from './codex.ts' import { + type HarnessTarget, + harnessStatus, installClaudeConfig, installCodexConfig, + installHarnessConfig, installStatus, uninstallClaudeConfig, - uninstallCodexConfig + uninstallCodexConfig, + uninstallHarnessConfig } from './config-install.ts' import type { Account, ProviderId } from './domain.ts' import { ApplicationError, errorMessage } from './errors.ts' @@ -234,7 +238,7 @@ function help(): string { '', head('Setup'), row('login ', 'sign in an account · re-run to re-auth'), - row('install', 'route codex & claude through tokenmaxx'), + row('install [openclaw|pi|hermes]', 'route codex & claude, or a harness'), row('uninstall', 'restore your original config'), '', head('Everyday'), @@ -635,8 +639,33 @@ async function configureAutomation( } } -async function installConfig(context: ApplicationContext): Promise { +const harnessTargets = new Set(['openclaw', 'pi', 'hermes']) + +function harnessTarget(value: string | undefined): HarnessTarget | null { + return value !== undefined && harnessTargets.has(value as HarnessTarget) + ? (value as HarnessTarget) + : null +} + +async function installConfig(context: ApplicationContext, targetArgument?: string): Promise { + const target = harnessTarget(targetArgument) + if (targetArgument !== undefined && target === null) { + throw new ApplicationError('USAGE', 'Usage: tokenmaxx install [openclaw|pi|hermes]') + } await ensureDaemon(context) + if (target !== null) { + const result = await installHarnessConfig(target, context.paths) + if (!result.applied) { + process.stdout.write(`Left ${result.path} alone: ${result.manual}\n`) + return + } + process.stdout.write( + `${target} now has tokenmaxx-anthropic and tokenmaxx-openai providers (${result.path}).\n` + + `Pick a tokenmaxx model inside ${target} and requests route through the proxy.\n` + + `Undo any time with: tokenmaxx uninstall ${target}\n` + ) + return + } await installCodexConfig(context.paths) await installClaudeConfig(context.paths) process.stdout.write( @@ -646,7 +675,20 @@ async function installConfig(context: ApplicationContext): Promise { ) } -async function uninstallConfig(): Promise { +async function uninstallConfig(targetArgument?: string): Promise { + const target = harnessTarget(targetArgument) + if (targetArgument !== undefined && target === null) { + throw new ApplicationError('USAGE', 'Usage: tokenmaxx uninstall [openclaw|pi|hermes]') + } + if (target !== null) { + const result = await uninstallHarnessConfig(target) + process.stdout.write( + result.applied + ? `Removed the tokenmaxx providers from ${result.path}.\n` + : (result.manual ?? `${target} was not routed; nothing to restore.`) + '\n' + ) + return + } const codex = await uninstallCodexConfig() const claude = await uninstallClaudeConfig() if (codex === null && claude === null) { @@ -728,6 +770,18 @@ async function doctor(context: ApplicationContext): Promise { : 'not routed — run tokenmaxx install' }\n` ) + for (const harness of await harnessStatus()) { + if (!harness.present) { + continue + } + process.stdout.write( + `${harness.routed ? 'ok ' : 'note '} ${harness.target.padEnd(8)} ${ + harness.routed + ? 'has the tokenmaxx providers' + : `not routed — run tokenmaxx install ${harness.target}` + }\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( @@ -856,10 +910,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/config-install.test.ts b/src/config-install.test.ts index c73e716..716b75e 100644 --- a/src/config-install.test.ts +++ b/src/config-install.test.ts @@ -3,7 +3,13 @@ import { mkdtempSync, rmSync } from 'node:fs' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { installCodexConfig, installStatus, uninstallCodexConfig } from './config-install.ts' +import { + installCodexConfig, + installHarnessConfig, + installStatus, + uninstallCodexConfig, + uninstallHarnessConfig +} from './config-install.ts' import { applicationPaths } from './paths.ts' const legacyBrokenConfig = `model = "gpt-5.6-sol" @@ -109,3 +115,67 @@ describe('installCodexConfig', () => { expect(parsed.model_provider).toBe('tokenmaxx') }) }) + +describe('harness installs', () => { + test('openclaw providers merge in and back out without touching the rest', async () => { + process.env.OPENCLAW_CONFIG_PATH = join(home, 'openclaw.json') + await writeFile( + process.env.OPENCLAW_CONFIG_PATH, + JSON.stringify({ agents: { defaults: { model: { primary: 'anthropic/claude-opus-4-8' } } } }) + ) + const installed = await installHarnessConfig('openclaw', applicationPaths()) + expect(installed.applied).toBe(true) + const config = JSON.parse(await readFile(installed.path, 'utf8')) + expect(config.models.providers['tokenmaxx-anthropic'].api).toBe('anthropic-messages') + expect(config.models.providers['tokenmaxx-openai'].baseUrl).toContain('/openai') + expect(config.agents.defaults.model.primary).toBe('anthropic/claude-opus-4-8') + const removed = await uninstallHarnessConfig('openclaw') + expect(removed.applied).toBe(true) + const restored = JSON.parse(await readFile(installed.path, 'utf8')) + expect(restored.models.providers['tokenmaxx-anthropic']).toBeUndefined() + delete process.env.OPENCLAW_CONFIG_PATH + }) + + test('a json5 openclaw config is left alone with manual instructions', async () => { + process.env.OPENCLAW_CONFIG_PATH = join(home, 'openclaw.json') + await writeFile(process.env.OPENCLAW_CONFIG_PATH, '{\n // my settings\n models: {},\n}\n') + const result = await installHarnessConfig('openclaw', applicationPaths()) + expect(result.applied).toBe(false) + expect(result.manual).toContain('models.providers') + expect(await readFile(result.path, 'utf8')).toContain('// my settings') + delete process.env.OPENCLAW_CONFIG_PATH + }) + + test('pi models.json gains and loses the providers cleanly', async () => { + process.env.PI_CODING_AGENT_DIR = join(home, 'pi-agent') + const installed = await installHarnessConfig('pi', applicationPaths()) + expect(installed.applied).toBe(true) + const config = JSON.parse(await readFile(installed.path, 'utf8')) + expect(config.providers['tokenmaxx-anthropic'].baseUrl).toContain('/anthropic') + const removed = await uninstallHarnessConfig('pi') + expect(removed.applied).toBe(true) + expect(JSON.parse(await readFile(installed.path, 'utf8')).providers).toEqual({}) + delete process.env.PI_CODING_AGENT_DIR + }) + + test('hermes gets a marked block that round-trips, and defers when providers exist', async () => { + process.env.HERMES_HOME = join(home, 'hermes') + await mkdir(process.env.HERMES_HOME, { recursive: true }) + const configPath = join(process.env.HERMES_HOME, 'config.yaml') + await writeFile(configPath, 'model:\n default: "claude-opus-4-8"\n') + const installed = await installHarnessConfig('hermes', applicationPaths()) + expect(installed.applied).toBe(true) + const written = await readFile(configPath, 'utf8') + expect(written).toContain('api_mode: "codex_responses"') + expect(written).toContain('model:') + const removed = await uninstallHarnessConfig('hermes') + expect(removed.applied).toBe(true) + expect(await readFile(configPath, 'utf8')).not.toContain('tokenmaxx-anthropic') + + await writeFile(configPath, 'providers:\n mine:\n base_url: "https://example.com"\n') + const deferred = await installHarnessConfig('hermes', applicationPaths()) + expect(deferred.applied).toBe(false) + expect(await readFile(configPath, 'utf8')).not.toContain('tokenmaxx') + delete process.env.HERMES_HOME + }) +}) diff --git a/src/config-install.ts b/src/config-install.ts index 9339c2d..c21494e 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' @@ -191,3 +191,241 @@ export async function installStatus(): Promise { } return { claudeRouted, codexRouted, codexStale } } + +export type HarnessTarget = 'openclaw' | 'pi' | 'hermes' + +export interface HarnessResult { + path: string + applied: boolean + manual: string | null +} + +function openclawConfigPath(): string { + return process.env.OPENCLAW_CONFIG_PATH ?? join(homedir(), '.openclaw', 'openclaw.json') +} + +function piModelsPath(): string { + return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent'), 'models.json') +} + +function hermesConfigPath(): string { + return join(process.env.HERMES_HOME ?? join(homedir(), '.hermes'), 'config.yaml') +} + +const anthropicModels = [ + { contextWindow: 200_000, id: 'claude-opus-4-8', maxTokens: 32_000, name: 'Opus via tokenmaxx' }, + { + contextWindow: 200_000, + id: 'claude-sonnet-4-6', + maxTokens: 32_000, + name: 'Sonnet via tokenmaxx' + } +] +const openaiModels = [ + { contextWindow: 400_000, id: 'gpt-5.6-sol', maxTokens: 128_000, name: 'GPT via tokenmaxx' }, + { contextWindow: 400_000, id: 'gpt-5.6-codex', maxTokens: 128_000, name: 'Codex via tokenmaxx' } +] + +function openclawProviders(paths: ApplicationPaths): Record { + const zeroCost = { cacheRead: 0, cacheWrite: 0, input: 0, output: 0 } + const model = (entry: (typeof anthropicModels)[number]) => ({ + contextWindow: entry.contextWindow, + cost: zeroCost, + id: entry.id, + input: ['text', 'image'], + maxTokens: entry.maxTokens, + name: entry.name, + reasoning: true + }) + return { + 'tokenmaxx-anthropic': { + api: 'anthropic-messages', + apiKey: dummyAuthToken, + baseUrl: proxyBaseUrl(paths, 'anthropic'), + models: anthropicModels.map(model) + }, + 'tokenmaxx-openai': { + api: 'openai-responses', + apiKey: dummyAuthToken, + baseUrl: proxyBaseUrl(paths, 'openai'), + models: openaiModels.map(model) + } + } +} + +function piProviders(paths: ApplicationPaths): Record { + return { + 'tokenmaxx-anthropic': { + api: 'anthropic-messages', + apiKey: dummyAuthToken, + baseUrl: proxyBaseUrl(paths, 'anthropic'), + models: anthropicModels.map(entry => ({ id: entry.id, reasoning: true })) + }, + 'tokenmaxx-openai': { + api: 'openai-responses', + apiKey: dummyAuthToken, + baseUrl: proxyBaseUrl(paths, 'openai'), + models: openaiModels.map(entry => ({ id: entry.id, reasoning: true })) + } + } +} + +function hermesManagedBlock(paths: ApplicationPaths): string { + return [ + topBeginMarker, + 'providers:', + ' tokenmaxx-anthropic:', + ` base_url: "${proxyBaseUrl(paths, 'anthropic')}"`, + ` api_key: "${dummyAuthToken}"`, + ' api_mode: "anthropic_messages"', + ' tokenmaxx-openai:', + ` base_url: "${proxyBaseUrl(paths, 'openai')}"`, + ` api_key: "${dummyAuthToken}"`, + ' api_mode: "codex_responses"', + topEndMarker + ].join('\n') +} + +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 + } +} + +async function writeJsonProviders( + path: string, + providersOf: (config: Record) => Record, + providers: Record | null, + manual: string +): Promise { + const raw = await readFileOrEmpty(path) + const config = parseJsonObject(raw) + if (config === null) { + return { applied: false, manual, path } + } + const bucket = providersOf(config) + for (const key of ['tokenmaxx-anthropic', 'tokenmaxx-openai']) { + 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 } +} + +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 +} + +function openclawBucket(config: Record): Record { + return ensureObject(ensureObject(config, 'models'), 'providers') +} + +function piBucket(config: Record): Record { + return ensureObject(config, 'providers') +} + +export async function installHarnessConfig( + target: HarnessTarget, + paths: ApplicationPaths +): Promise { + if (target === 'openclaw') { + return writeJsonProviders( + openclawConfigPath(), + openclawBucket, + openclawProviders(paths), + `could not parse it as JSON (JSON5 comments?) — add this under models.providers yourself:\n${JSON.stringify(openclawProviders(paths), null, 2)}` + ) + } + if (target === 'pi') { + return writeJsonProviders( + piModelsPath(), + piBucket, + piProviders(paths), + `could not parse it as JSON — add this under providers yourself:\n${JSON.stringify(piProviders(paths), null, 2)}` + ) + } + const path = hermesConfigPath() + const raw = await readFileOrEmpty(path) + const stripped = stripMarkedBlock(raw, topBeginMarker, topEndMarker).trimEnd() + if (/^providers\s*:/m.test(stripped)) { + return { + applied: false, + manual: `it already defines providers, and yaml duplicate keys silently override — merge this into that mapping yourself:\n${hermesManagedBlock(paths)}`, + path + } + } + await mkdir(dirname(path), { recursive: true }) + await writeFile( + path, + `${stripped.length === 0 ? '' : `${stripped}\n\n`}${hermesManagedBlock(paths)}\n`, + { mode: 0o600 } + ) + return { applied: true, manual: null, path } +} + +export async function uninstallHarnessConfig(target: HarnessTarget): Promise { + if (target === 'openclaw' || target === 'pi') { + const path = target === 'openclaw' ? openclawConfigPath() : piModelsPath() + const raw = await readFile(path, 'utf8').catch(() => null) + if (raw === null) { + return { applied: false, manual: null, path } + } + return writeJsonProviders( + path, + target === 'openclaw' ? openclawBucket : piBucket, + null, + 'could not parse it as JSON — remove the tokenmaxx-anthropic and tokenmaxx-openai providers yourself' + ) + } + const path = hermesConfigPath() + const raw = await readFile(path, 'utf8').catch(() => null) + if (raw === null || !raw.includes(topBeginMarker)) { + return { applied: false, manual: null, path } + } + await writeFile(path, `${stripMarkedBlock(raw, topBeginMarker, topEndMarker).trim()}\n`, { + mode: 0o600 + }) + return { applied: true, manual: null, path } +} + +export interface HarnessStatus { + target: HarnessTarget + present: boolean + routed: boolean +} + +export async function harnessStatus(): Promise { + const entries: { target: HarnessTarget; path: string }[] = [ + { path: openclawConfigPath(), target: 'openclaw' }, + { path: piModelsPath(), target: 'pi' }, + { path: hermesConfigPath(), target: 'hermes' } + ] + return Promise.all( + entries.map(async ({ target, path }) => { + const raw = await readFile(path, 'utf8').catch(() => null) + const configDir = dirname(target === 'pi' ? dirname(path) : path) + const present = + raw !== null || + (await stat(configDir).then( + () => true, + () => false + )) + return { present, routed: raw?.includes('tokenmaxx-anthropic') ?? false, target } + }) + ) +} From b013c39c3f8f398cbdefb4060e091b6cb13d5934 Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Wed, 22 Jul 2026 18:49:32 -0400 Subject: [PATCH 2/4] the proxy lifts system prompts for the chatgpt backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party harnesses send standard Responses requests: system messages in the input array and max_output_tokens set. The ChatGPT codex backend rejects both. The proxy now adapts oauth-bound openai requests (system and developer messages move into instructions, max_output_tokens drops) so openclaw, pi, and hermes work without harness-specific dialects. Also ships only gpt-5.6-sol — the one model the backend accepts. --- CHANGELOG.md | 10 +++++++++ package.json | 2 +- src/codex.ts | 1 + src/config-install.ts | 5 +++-- src/proxy.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++- src/proxy.ts | 50 ++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 112 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67b8682..77b2dbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +- [2026-07-22] the proxy lifts system prompts for the chatgpt backend + +Third-party harnesses send standard Responses requests: system messages +in the input array and max_output_tokens set. The ChatGPT codex backend +rejects both. The proxy now adapts oauth-bound openai requests (system +and developer messages move into instructions, max_output_tokens drops) +so openclaw, pi, and hermes work without harness-specific dialects. +Also ships only gpt-5.6-sol — the one model the backend accepts. + +Co-Authored-By: Claude Fable 5 - [2026-07-22] install into openclaw, pi, and hermes, fixes #11 - [2026-07-22] session reset time on analytics, fixes #9 - [2026-07-22] logout diff --git a/package.json b/package.json index d62acdc..1e20747 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.57" + "version": "0.0.58" } 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.ts b/src/config-install.ts index c21494e..e2b8265 100644 --- a/src/config-install.ts +++ b/src/config-install.ts @@ -221,9 +221,10 @@ const anthropicModels = [ name: 'Sonnet via tokenmaxx' } ] +// The ChatGPT codex backend accepts exactly this model id for subscription +// accounts (probed 2026-07-22; gpt-5.6-codex and friends are all rejected). const openaiModels = [ - { contextWindow: 400_000, id: 'gpt-5.6-sol', maxTokens: 128_000, name: 'GPT via tokenmaxx' }, - { contextWindow: 400_000, id: 'gpt-5.6-codex', maxTokens: 128_000, name: 'Codex via tokenmaxx' } + { contextWindow: 400_000, id: 'gpt-5.6-sol', maxTokens: 128_000, name: 'GPT via tokenmaxx' } ] function openclawProviders(paths: ApplicationPaths): Record { diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 5189685..189bebe 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -1,5 +1,52 @@ import { describe, expect, test } from 'bun:test' -import { createUsageObserver, proxyIdentity, startProxy } from './proxy.ts' +import { adaptChatGptRequest, createUsageObserver, proxyIdentity, startProxy } from './proxy.ts' + +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') + }) +}) type Observed = { model: string | null diff --git a/src/proxy.ts b/src/proxy.ts index 88a7469..35b86ab 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,50 @@ 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. +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 +333,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', From 2beea4de80f8b9339c526d3b7fbff3f1a78abd57 Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Wed, 22 Jul 2026 19:23:10 -0400 Subject: [PATCH 3/4] settings shows the harnesses and scrolls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A harnesses box joins settings: green on when routed, warn off with an enter hint when installed but not routed, grey when the harness is not on the machine (binary or config detection). Enter installs or removes the providers inline — no CLI round-trip. The settings tab now windows its rows to the terminal height with more indicators, and the 2s background tick no longer holds the busy flag that was eating keypresses. --- CHANGELOG.md | 15 ++- package.json | 2 +- src/config-install.ts | 8 +- src/tui/dashboard.ts | 267 +++++++++++++++++++++++++++++++++++------- 4 files changed, 247 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b2dbd..2503be6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,15 @@ -- [2026-07-22] the proxy lifts system prompts for the chatgpt backend +- [2026-07-22] settings shows the harnesses and scrolls + +A harnesses box joins settings: green on when routed, warn off with an +enter hint when installed but not routed, grey when the harness is not +on the machine (binary or config detection). Enter installs or removes +the providers inline — no CLI round-trip. The settings tab now windows +its rows to the terminal height with more indicators, and the 2s +background tick no longer holds the busy flag that was eating +keypresses. + +Co-Authored-By: Claude Fable 5 +- [2026-07-22] [the proxy lifts system prompts for the chatgpt backend Third-party harnesses send standard Responses requests: system messages in the input array and max_output_tokens set. The ChatGPT codex backend @@ -7,7 +18,7 @@ and developer messages move into instructions, max_output_tokens drops) so openclaw, pi, and hermes work without harness-specific dialects. Also ships only gpt-5.6-sol — the one model the backend accepts. -Co-Authored-By: Claude Fable 5 +Co-Authored-By: Claude Fable 5 ](https://github.com/RubricLab/tokenmaxx/commit/b8462fc888c18c3495c913cfb6376d4f865b221b) - [2026-07-22] install into openclaw, pi, and hermes, fixes #11 - [2026-07-22] session reset time on analytics, fixes #9 - [2026-07-22] logout diff --git a/package.json b/package.json index 1e20747..4e156d2 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.58" + "version": "0.0.59" } diff --git a/src/config-install.ts b/src/config-install.ts index e2b8265..36151b5 100644 --- a/src/config-install.ts +++ b/src/config-install.ts @@ -410,7 +410,12 @@ export interface HarnessStatus { routed: boolean } -export async function harnessStatus(): Promise { +// A harness counts as present when its binary is on PATH or its config +// exists — someone who installed openclaw but never launched it has the +// binary and no config dir. +export async function harnessStatus( + which: (binary: string) => string | null = Bun.which +): Promise { const entries: { target: HarnessTarget; path: string }[] = [ { path: openclawConfigPath(), target: 'openclaw' }, { path: piModelsPath(), target: 'pi' }, @@ -422,6 +427,7 @@ export async function harnessStatus(): Promise { const configDir = dirname(target === 'pi' ? dirname(path) : path) const present = raw !== null || + which(target) !== null || (await stat(configDir).then( () => true, () => false diff --git a/src/tui/dashboard.ts b/src/tui/dashboard.ts index 9f4adc1..bb867dd 100644 --- a/src/tui/dashboard.ts +++ b/src/tui/dashboard.ts @@ -1,4 +1,11 @@ import { Box, createCliRenderer, parseColor, type RGBA, Text } from '@opentui/core' +import { + type HarnessStatus, + type HarnessTarget, + harnessStatus, + installHarnessConfig, + uninstallHarnessConfig +} from '../config-install.ts' import type { Account, AnalyticsSnapshot, @@ -20,6 +27,7 @@ import { requestResetCredits, requestSwitch } from '../ipc.ts' +import { applicationPaths } from '../paths.ts' import { availableUpdate } from '../version.ts' import { buildScenario } from './fixtures.ts' import { @@ -762,12 +770,14 @@ function dwellLabel(milliseconds: number): string { return minutes === 0 ? 'off' : `${minutes}m` } -interface SettingRow { - provider: ProviderId - key: 'routing' | 'auto' | 'threshold' | 'dwell' | 'window' - windowId?: string - windowLabel?: string -} +type SettingRow = + | { + provider: ProviderId + key: 'routing' | 'auto' | 'threshold' | 'dwell' | 'window' + windowId?: string + windowLabel?: string + } + | { key: 'harness'; provider?: undefined; target: HarnessTarget } function providerWindows(snapshot: DashboardSnapshot, provider: ProviderId): UsageWindow[] { const seen = new Map() @@ -784,19 +794,22 @@ function providerWindows(snapshot: DashboardSnapshot, provider: ProviderId): Usa ) } -function buildSettingRows(snapshot: DashboardSnapshot): SettingRow[] { - return providerOrder.flatMap(provider => [ - { key: 'routing' as const, provider }, - { key: 'auto' as const, provider }, - { key: 'threshold' as const, provider }, - { key: 'dwell' as const, provider }, - ...providerWindows(snapshot, provider).map(window => ({ - key: 'window' as const, - provider, - windowId: window.id, - windowLabel: window.label - })) - ]) +function buildSettingRows(snapshot: DashboardSnapshot, harnesses: HarnessStatus[]): SettingRow[] { + return [ + ...providerOrder.flatMap(provider => [ + { key: 'routing' as const, provider }, + { key: 'auto' as const, provider }, + { key: 'threshold' as const, provider }, + { key: 'dwell' as const, provider }, + ...providerWindows(snapshot, provider).map(window => ({ + key: 'window' as const, + provider, + windowId: window.id, + windowLabel: window.label + })) + ]), + ...harnesses.map(status => ({ key: 'harness' as const, target: status.target })) + ] } function settingsPanel( @@ -804,11 +817,21 @@ function settingsPanel( snapshot: DashboardSnapshot, allRows: SettingRow[], provider: ProviderId, - selected: number + selected: number, + visible: Set ) { const state = snapshot.providers.find(s => s.provider === provider) const policy = state?.policy - const rows = allRows.map((row, index) => ({ index, row })).filter(e => e.row.provider === provider) + const rows = allRows + .map((row, index) => ({ index, row })) + .flatMap(e => + e.row.key !== 'harness' && e.row.provider === provider && visible.has(e.index) + ? [{ index: e.index, row: e.row }] + : [] + ) + if (rows.length === 0) { + return null + } const lines = rows.map(entry => { const { row } = entry const isSelected = entry.index === selected @@ -896,17 +919,120 @@ function settingsPanel( ) } -function settingsBody(ctx: Ctx, snapshot: DashboardSnapshot, rows: SettingRow[], selected: number) { - return column( - ctx, - [ - settingsPanel(ctx, snapshot, rows, 'openai', selected), - settingsPanel(ctx, snapshot, rows, 'anthropic', selected) - ], - 78 +function harnessPanel( + ctx: Ctx, + statuses: HarnessStatus[], + allRows: SettingRow[], + selected: number, + visible: Set +) { + const entries = allRows + .map((row, index) => ({ index, row })) + .flatMap(e => + e.row.key === 'harness' && visible.has(e.index) ? [{ index: e.index, target: e.row.target }] : [] + ) + if (entries.length === 0) { + return null + } + const lines = entries.map(entry => { + const status = statuses.find(s => s.target === entry.target) + const isSelected = entry.index === selected + const present = status?.present ?? false + const routed = status?.routed ?? false + const value = routed ? 'on' : present ? 'off' : '—' + const hint = routed + ? 'requests route through tokenmaxx' + : present + ? '⏎ routes it through tokenmaxx' + : 'not installed' + const valueColor = routed ? ctx.theme.good : present ? ctx.theme.warn : ctx.theme.faint + return Box( + { + backgroundColor: isSelected ? rgb(ctx.theme.selected) : rgb(ctx.theme.bg), + flexDirection: 'row', + width: '100%' + }, + Text({ content: isSelected ? ' ▸ ' : ' ', fg: rgb(ctx.theme.accent) }), + Text({ + content: pad(entry.target, 12), + fg: rgb(present ? (isSelected ? ctx.theme.fg : ctx.theme.dim) : ctx.theme.faint) + }), + Text({ attributes: 1, content: pad(value, 7), fg: rgb(valueColor) }), + Text({ content: pad(hint, 40), fg: rgb(ctx.theme.faint) }) + ) + }) + return Box( + { + border: true, + borderColor: rgb(ctx.theme.border), + borderStyle: 'rounded', + flexDirection: 'column', + flexShrink: 0, + title: ' harnesses ', + titleColor: rgb(ctx.theme.dim), + width: '100%' + }, + ...lines ) } +function settingsBody( + ctx: Ctx, + snapshot: DashboardSnapshot, + harnesses: HarnessStatus[], + rows: SettingRow[], + state: ViewState, + budget: number +) { + const total = rows.length + const openaiCount = rows.filter(r => r.key !== 'harness' && r.provider === 'openai').length + const anthropicCount = rows.filter(r => r.key !== 'harness' && r.provider === 'anthropic').length + const panelOf = (index: number): number => + index < openaiCount ? 0 : index < openaiCount + anthropicCount ? 1 : 2 + // Rows cost one line each; entering a new panel adds its borders (and the + // column gap after the previous panel). Four lines stay reserved for the + // scroll indicators and their gaps. + const lastVisible = (scroll: number): number => { + let used = 4 + let last = scroll + let lastPanel = -1 + for (let index = scroll; index < total; index += 1) { + const cost = 1 + (panelOf(index) === lastPanel ? 0 : lastPanel === -1 ? 2 : 3) + if (used + cost > budget && index > scroll) { + break + } + used += cost + lastPanel = panelOf(index) + last = index + } + return last + } + state.settingsScroll = Math.max(0, Math.min(state.settingsScroll, total - 1)) + if (state.settingsSelected < state.settingsScroll) { + state.settingsScroll = state.settingsSelected + } + while ( + state.settingsScroll < state.settingsSelected && + lastVisible(state.settingsScroll) < state.settingsSelected + ) { + state.settingsScroll += 1 + } + const start = state.settingsScroll + const end = lastVisible(start) + const visible = new Set() + for (let index = start; index <= end; index += 1) { + visible.add(index) + } + const children = [ + ...(start > 0 ? [centered(Text({ content: '↑ more', fg: rgb(ctx.theme.faint) }))] : []), + settingsPanel(ctx, snapshot, rows, 'openai', state.settingsSelected, visible), + settingsPanel(ctx, snapshot, rows, 'anthropic', state.settingsSelected, visible), + harnessPanel(ctx, harnesses, rows, state.settingsSelected, visible), + ...(end < total - 1 ? [centered(Text({ content: '↓ more', fg: rgb(ctx.theme.faint) }))] : []) + ].filter(child => child !== null) + return column(ctx, children, 78) +} + function accountsBody(ctx: Ctx, snapshot: DashboardSnapshot, rows: Row[], selected: number) { const note = legend(ctx, snapshot) const width = accountsWidth(ctx, snapshot) @@ -1066,6 +1192,7 @@ interface ViewState { tab: Tab selected: number settingsSelected: number + settingsScroll: number timeframeIndex: number analyticsView: AnalyticsView modelScroll: number @@ -1084,7 +1211,13 @@ type DashboardAction = | { kind: 'routing'; provider: ProviderId; enable: boolean } | { kind: 'update'; version: string } -function view(ctx: Ctx, analytics: AnalyticsSnapshot, rows: Row[], state: ViewState) { +function view( + ctx: Ctx, + analytics: AnalyticsSnapshot, + rows: Row[], + harnesses: HarnessStatus[], + state: ViewState +) { const clock = new Date(ctx.now).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) const freshestMillis = analytics.snapshot.usage .map(u => Date.parse(u.observedAt)) @@ -1173,8 +1306,16 @@ function view(ctx: Ctx, analytics: AnalyticsSnapshot, rows: Row[], state: ViewSt : settingsBody( ctx, analytics.snapshot, - buildSettingRows(analytics.snapshot), - state.settingsSelected + harnesses, + buildSettingRows(analytics.snapshot, harnesses), + state, + Math.max( + 6, + ctx.rows - + 8 - + (state.alert === '' ? 0 : 2) - + (state.updateAvailable !== null && !state.updateDismissed ? 2 : 0) + ) ) ) children.push(Box({ flexGrow: 1 })) @@ -1228,6 +1369,13 @@ export async function runTuiDashboard( ? await readAnalytics(socketPath) : buildScenario(fixture.name, simulatedNow) let rows = orderedRows(analytics.snapshot) + let harnesses: HarnessStatus[] = live + ? await harnessStatus() + : [ + { present: true, routed: true, target: 'openclaw' }, + { present: true, routed: false, target: 'pi' }, + { present: false, routed: false, target: 'hermes' } + ] const state: ViewState = { addConfirm: null, alert: options.alert ?? '', @@ -1236,6 +1384,7 @@ export async function runTuiDashboard( note: '', resetConfirm: null, selected: 0, + settingsScroll: 0, settingsSelected: 0, tab: 'accounts', timeframeIndex: 2, @@ -1274,6 +1423,7 @@ export async function runTuiDashboard( }, analytics, rows, + harnesses, state ) } catch { @@ -1304,16 +1454,30 @@ export async function runTuiDashboard( } } - const reload = (refresh: boolean) => - withBusy(refresh ? 'refreshing…' : '', async () => { - if (refresh) { - await refreshUsage(socketPath) - } + const reload = () => + withBusy('refreshing…', async () => { + await refreshUsage(socketPath) analytics = await readAnalytics(socketPath) rows = orderedRows(analytics.snapshot) + harnesses = await harnessStatus() clampSelection() }) + // The background tick must never hold `busy` — a keypress landing during a + // held tick would be silently dropped. + const quietReload = async () => { + if (busy) { + return + } + try { + analytics = await readAnalytics(socketPath) + rows = orderedRows(analytics.snapshot) + harnesses = await harnessStatus() + clampSelection() + paint() + } catch {} + } + const needsLogin = (accountId: string): boolean => { const account = analytics.snapshot.accounts.find(a => a.id === accountId) return ( @@ -1452,10 +1616,31 @@ export async function runTuiDashboard( } const adjustSetting = (delta: number) => { - const row = buildSettingRows(analytics.snapshot)[state.settingsSelected] + const row = buildSettingRows(analytics.snapshot, harnesses)[state.settingsSelected] if (row === undefined) { return } + if (row.key === 'harness') { + const status = harnesses.find(s => s.target === row.target) + if (status === undefined || !status.present) { + return + } + void withBusy( + status.routed ? `unrouting ${row.target}…` : `routing ${row.target}…`, + async () => { + if (status.routed) { + await uninstallHarnessConfig(row.target) + } else { + const result = await installHarnessConfig(row.target, applicationPaths()) + if (result.manual !== null) { + throw new Error(`${row.target} needs a manual edit — run: tokenmaxx install ${row.target}`) + } + } + harnesses = await harnessStatus() + } + ) + return + } const policy = currentPolicy(row.provider) if (row.key === 'routing') { toggleRouting(row.provider) @@ -1486,7 +1671,7 @@ export async function runTuiDashboard( await new Promise(resolve => { const tick = 250 const interval = live - ? setInterval(() => void reload(false).catch(() => undefined), 2_000) + ? setInterval(() => void quietReload(), 2_000) : fixture.timewarp > 0 ? setInterval(() => { simulatedNow += tick * fixture.timewarp @@ -1589,7 +1774,7 @@ export async function runTuiDashboard( paint() } else if (key.name === 'r') { if (!(state.tab === 'accounts' && openResetConfirm()) && live) { - void reload(true) + void reload() } } else if (state.tab === 'analytics') { if (key.name === 'left' || key.name === 'h') { @@ -1612,7 +1797,7 @@ export async function runTuiDashboard( } } } else if (state.tab === 'settings') { - const settingCount = buildSettingRows(analytics.snapshot).length + const settingCount = buildSettingRows(analytics.snapshot, harnesses).length if (key.name === 'up' || key.name === 'k') { state.settingsSelected = Math.max(0, state.settingsSelected - 1) paint() From e5d6dce1d56ed0e26766a8c96b4a482a9175c345 Mon Sep 17 00:00:00 2001 From: DexterStorey Date: Wed, 22 Jul 2026 19:29:54 -0400 Subject: [PATCH 4/4] clipped settings panels stay open A panel cut off by scrolling keeps its title but drops its bottom border, so it reads as continuing into the more line instead of finished. --- CHANGELOG.md | 11 +++++++++-- package.json | 2 +- src/tui/dashboard.ts | 46 ++++++++++++++++++++++++++++++++++++-------- 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2503be6..3107720 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ -- [2026-07-22] settings shows the harnesses and scrolls +- [2026-07-22] clipped settings panels stay open + +A panel cut off by scrolling keeps its title but drops its bottom +border, so it reads as continuing into the more line instead of +finished. + +Co-Authored-By: Claude Fable 5 +- [2026-07-22] [settings shows the harnesses and scrolls A harnesses box joins settings: green on when routed, warn off with an enter hint when installed but not routed, grey when the harness is not @@ -8,7 +15,7 @@ its rows to the terminal height with more indicators, and the 2s background tick no longer holds the busy flag that was eating keypresses. -Co-Authored-By: Claude Fable 5 +Co-Authored-By: Claude Fable 5 ](https://github.com/RubricLab/tokenmaxx/commit/89ba154b91d73db4059f9a44704633141497e0a0) - [2026-07-22] [the proxy lifts system prompts for the chatgpt backend Third-party harnesses send standard Responses requests: system messages diff --git a/package.json b/package.json index 4e156d2..eb753a1 100644 --- a/package.json +++ b/package.json @@ -59,5 +59,5 @@ "post-commit": "bun x @rubriclab/package post-commit" }, "type": "module", - "version": "0.0.59" + "version": "0.0.60" } diff --git a/src/tui/dashboard.ts b/src/tui/dashboard.ts index bb867dd..3ae0fbe 100644 --- a/src/tui/dashboard.ts +++ b/src/tui/dashboard.ts @@ -1,4 +1,11 @@ -import { Box, createCliRenderer, parseColor, type RGBA, Text } from '@opentui/core' +import { + type BorderSides, + Box, + createCliRenderer, + parseColor, + type RGBA, + Text +} from '@opentui/core' import { type HarnessStatus, type HarnessTarget, @@ -812,13 +819,18 @@ function buildSettingRows(snapshot: DashboardSnapshot, harnesses: HarnessStatus[ ] } +// A panel clipped by scrolling keeps its title but loses its bottom border, +// so it reads as continuing into the "↓ more" line instead of complete. +const openBottomBorder: BorderSides[] = ['top', 'left', 'right'] + function settingsPanel( ctx: Ctx, snapshot: DashboardSnapshot, allRows: SettingRow[], provider: ProviderId, selected: number, - visible: Set + visible: Set, + openBottom: boolean ) { const state = snapshot.providers.find(s => s.provider === provider) const policy = state?.policy @@ -904,7 +916,7 @@ function settingsPanel( const auto = policy?.enabled ? `⟳ auto ${policy.thresholdPercent}%` : 'auto off' return Box( { - border: true, + border: openBottom ? openBottomBorder : true, borderColor: rgb(routed ? ctx.theme.border : ctx.theme.warn), borderStyle: 'rounded', flexDirection: 'column', @@ -924,7 +936,8 @@ function harnessPanel( statuses: HarnessStatus[], allRows: SettingRow[], selected: number, - visible: Set + visible: Set, + openBottom: boolean ) { const entries = allRows .map((row, index) => ({ index, row })) @@ -963,7 +976,7 @@ function harnessPanel( }) return Box( { - border: true, + border: openBottom ? openBottomBorder : true, borderColor: rgb(ctx.theme.border), borderStyle: 'rounded', flexDirection: 'column', @@ -1023,11 +1036,28 @@ function settingsBody( for (let index = start; index <= end; index += 1) { visible.add(index) } + const clippedBelow = (lastRow: number): boolean => end < lastRow const children = [ ...(start > 0 ? [centered(Text({ content: '↑ more', fg: rgb(ctx.theme.faint) }))] : []), - settingsPanel(ctx, snapshot, rows, 'openai', state.settingsSelected, visible), - settingsPanel(ctx, snapshot, rows, 'anthropic', state.settingsSelected, visible), - harnessPanel(ctx, harnesses, rows, state.settingsSelected, visible), + settingsPanel( + ctx, + snapshot, + rows, + 'openai', + state.settingsSelected, + visible, + clippedBelow(openaiCount - 1) + ), + settingsPanel( + ctx, + snapshot, + rows, + 'anthropic', + state.settingsSelected, + visible, + clippedBelow(openaiCount + anthropicCount - 1) + ), + harnessPanel(ctx, harnesses, rows, state.settingsSelected, visible, clippedBelow(total - 1)), ...(end < total - 1 ? [centered(Text({ content: '↓ more', fg: rgb(ctx.theme.faint) }))] : []) ].filter(child => child !== null) return column(ctx, children, 78)