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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
- [2026-07-22] alpha channel
- [2026-07-22] api keys and extra usage, fixes #10
- [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)
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.53"
"version": "0.0.54"
}
37 changes: 36 additions & 1 deletion src/claude.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, test } from 'bun:test'
import { type ClaudeOauth, refreshClaudeCredential, registerClaudeAccount } from './claude.ts'
import {
type ClaudeOauth,
claudeUpstream,
refreshClaudeCredential,
registerClaudeAccount
} from './claude.ts'
import type { CredentialVault } from './vault.ts'

function memoryVault(initial: Record<string, string>): CredentialVault & {
Expand Down Expand Up @@ -125,3 +130,33 @@ describe('refreshClaudeCredential', () => {
expect(result.accessToken).toBe('rotated-access')
})
})

describe('api key accounts', () => {
test('an api key account injects x-api-key and strips the oauth header', async () => {
const vault = memoryVault({ 'claude-key:1': 'sk-ant-test' })
const injection = await claudeUpstream({
account: {
auth: 'apiKey',
createdAt: '2026-07-01T00:00:00.000Z',
enabled: true,
externalAccountId: null,
externalUserId: null,
health: 'ready',
id: '00000000-0000-4000-8000-000000000201',
identity: 'work api key',
label: 'work api key',
onThreshold: 'switch',
plan: 'api',
profilePath: null,
provider: 'anthropic',
secretReference: 'claude-key:1',
updatedAt: '2026-07-01T00:00:00.000Z'
},
forceRefresh: false,
vault
})
expect(injection.baseUrl).toBe('https://api.anthropic.com')
expect(injection.headers['x-api-key']).toBe('sk-ant-test')
expect(injection.stripHeaders).toContain('authorization')
})
})
144 changes: 143 additions & 1 deletion src/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { z } from 'zod'
import {
type Account,
AccountEmailSchema,
type ExtraUsage,
type FetchImplementation,
type ProviderProbeResult,
type UsageSnapshot,
Expand Down Expand Up @@ -202,6 +203,69 @@ async function readClaudeCredential(
return ClaudeOauthSchema.parse(JSON.parse(serialized))
}

async function readApiKey(vault: CredentialVault, reference: string): Promise<string> {
const key = await vault.read(reference)
if (key === null) {
throw new ApplicationError('CREDENTIAL_MISSING', `Missing credential ${reference}`)
}
return key
}

const anthropicVersion = '2023-06-01'

async function validateAnthropicApiKey(
key: string,
fetchImplementation: FetchImplementation
): Promise<void> {
const response = await fetchImplementation('https://api.anthropic.com/v1/models?limit=1', {
headers: { 'anthropic-version': anthropicVersion, 'x-api-key': key },
signal: AbortSignal.timeout(10_000)
})
if (response.status === 401 || response.status === 403) {
throw new ApplicationError('ACCESS_TOKEN_REJECTED', 'Anthropic rejected this API key')
}
if (!response.ok && response.status !== 429) {
throw new ApplicationError(
'PROVIDER_UNREACHABLE',
`Anthropic API returned HTTP ${response.status}`
)
}
}

export async function registerClaudeApiKeyAccount(input: {
vault: CredentialVault
key: string
label: string
fetchImplementation?: FetchImplementation
}): Promise<Account> {
const key = input.key.trim()
if (key.length === 0) {
throw new ApplicationError('USAGE', 'The API key is empty')
}
await validateAnthropicApiKey(key, input.fetchImplementation ?? fetch)
const id = crypto.randomUUID()
const secretReference = `claude-key:${id}`
await input.vault.write(secretReference, key)
const now = new Date().toISOString()
return {
auth: 'apiKey',
createdAt: now,
enabled: true,
externalAccountId: null,
externalUserId: null,
health: 'ready',
id,
identity: input.label,
label: input.label,
onThreshold: 'switch',
plan: null,
profilePath: null,
provider: 'anthropic',
secretReference,
updatedAt: now
}
}

export async function refreshClaudeCredential(input: {
reference: string
vault: CredentialVault
Expand Down Expand Up @@ -312,6 +376,7 @@ export async function registerClaudeAccount(input: {
await input.vault.write(secretReference, JSON.stringify(credential))
const now = new Date().toISOString()
return {
auth: 'oauth',
createdAt: now,
enabled: true,
externalAccountId: profile.accountId,
Expand All @@ -320,6 +385,7 @@ export async function registerClaudeAccount(input: {
id,
identity: email.data,
label: email.data,
onThreshold: 'switch',
plan: claudePlanTier(credential),
profilePath: null,
provider: 'anthropic',
Expand Down Expand Up @@ -369,6 +435,14 @@ export async function claudeUpstream(input: {
`${input.account.label} has no stored credential`
)
}
if (input.account.auth === 'apiKey') {
return {
accountId: input.account.id,
baseUrl: upstreamFor('anthropic'),
headers: { 'x-api-key': await readApiKey(input.vault, reference) },
stripHeaders: ['authorization']
}
}
let credential = await readClaudeCredential(input.vault, reference)
const now = input.now ?? (() => Date.now())
const stale = credential.expiresAt - now() <= refreshMarginMilliseconds
Expand Down Expand Up @@ -426,17 +500,67 @@ const LimitSchema = z
})
.passthrough()

const MoneySchema = z
.object({
amount_minor: z.number(),
currency: z.string().nullish(),
exponent: z.number().int().nullish()
})
.passthrough()

const ExtraUsageResponseSchema = z
.object({
is_enabled: z.boolean().nullish(),
monthly_limit: z.number().nullish(),
spend_limit_reached: z.boolean().nullish(),
used_credits: z.number().nullish(),
utilization: z.number().nullish()
})
.passthrough()

const SpendResponseSchema = z
.object({
balance: MoneySchema.nullish(),
limit: MoneySchema.nullish(),
used: MoneySchema.nullish()
})
.passthrough()

const UsageResponseSchema = z
.object({
extra_usage: ExtraUsageResponseSchema.nullish(),
five_hour: UsageWindowResponseSchema.nullish(),
limits: z.array(LimitSchema).nullish(),
seven_day: UsageWindowResponseSchema.nullish(),
seven_day_oauth_apps: UsageWindowResponseSchema.nullish(),
seven_day_opus: UsageWindowResponseSchema.nullish(),
seven_day_sonnet: UsageWindowResponseSchema.nullish()
seven_day_sonnet: UsageWindowResponseSchema.nullish(),
spend: SpendResponseSchema.nullish()
})
.passthrough()

function moneyToUsd(money: z.infer<typeof MoneySchema> | null | undefined): number | null {
if (money == null) {
return null
}
return money.amount_minor / 10 ** (money.exponent ?? 2)
}

function claudeExtraUsage(body: z.infer<typeof UsageResponseSchema>): ExtraUsage | null {
const extra = body.extra_usage
if (extra == null) {
return null
}
return {
balanceUsd: moneyToUsd(body.spend?.balance),
enabled: extra.is_enabled === true,
exhausted: extra.spend_limit_reached === true,
limitUsd: moneyToUsd(body.spend?.limit),
spentUsd: moneyToUsd(body.spend?.used),
usedPercent: extra.utilization == null ? null : Math.min(100, normalizePercent(extra.utilization))
}
}

function resetTimestamp(value: string | number | null | undefined): string | null {
if (value == null) {
return null
Expand Down Expand Up @@ -559,11 +683,13 @@ async function fetchClaudeUsage(input: {
}
return {
accountId: input.accountId,
extraUsage: claudeExtraUsage(body),
hardLimitReached:
windows.some(window => window.kind === 'hard' && window.usedPercent >= 100) ||
limits.some(
limit => limit.severity != null && exhaustedSeverities.has(limit.severity.toLowerCase())
),
measuredSpendUsd: null,
observedAt: new Date().toISOString(),
provider: 'anthropic',
source: 'claudeUsageEndpoint',
Expand Down Expand Up @@ -620,6 +746,22 @@ export async function probeClaude(input: {
if (reference === null) {
throw new ApplicationError('CREDENTIAL_MISSING', `${account.label} has no stored credential`)
}
if (account.auth === 'apiKey') {
await validateAnthropicApiKey(await readApiKey(vault, reference), fetchImplementation)
return {
account: { ...account, health: 'ready', updatedAt: input.now().toISOString() },
usage: {
accountId: account.id,
extraUsage: null,
hardLimitReached: false,
measuredSpendUsd: null,
observedAt: input.now().toISOString(),
provider: 'anthropic',
source: 'apiKeyProbe',
windows: []
}
}
}
const refresh = (staleAccessToken: string) =>
refreshClaudeCredential({
fetchImplementation,
Expand Down
11 changes: 11 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { describe, expect, test } from 'bun:test'
import { stripTerminalNoise } from './cli.ts'

describe('stripTerminalNoise', () => {
test('terminal chatter never survives into a typed answer', () => {
expect(stripTerminalNoise('\x1b[B\x1b[Ask-ant-abc123')).toBe('sk-ant-abc123')
expect(stripTerminalNoise('\x1bP1+r4d73=1b5d\x1b\\sk-proj-xyz')).toBe('sk-proj-xyz')
expect(stripTerminalNoise('\x1b]0;4:00 on ttys005\x07my key')).toBe('my key')
expect(stripTerminalNoise(' plain-key-42 ')).toBe('plain-key-42')
})
})
Loading
Loading