Most LLM API clients ship loose any types for usage fields, which turns billing into a guessing game. Strong typescript token usage cost types let you catch missing prompt_tokens at compile time and compute spend without string parsing. This guide walks through building a typed client layer that survives real provider responses and keeps your finance team off your back.
Step 1: Define the core usage and cost types
Start by modeling what providers actually return. OpenAI-compatible APIs report prompt_tokens, completion_tokens, and total_tokens. Newer models add cache accounting. Capture those as optional fields so you don’t break on older responses.
export interface TokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
}
export interface CostBreakdown {
inputCost: number;
outputCost: number;
cacheReadCost: number;
totalCost: number;
currency: 'USD';
}
Keep the token counts as raw integers. Store cost as floating point dollars, not cents, to avoid multiplying everywhere. If you later support non-USD billing, make currency a union rather than a literal.
The typescript token usage cost types above are the contract for every downstream function. No function should accept a bare number for tokens when it means promptTokens.
Step 2: Map provider responses to your types
Providers nest usage under usage. The field names are snake_case, not camelCase. Write a pure mapper that converts and backfills defaults.
interface RawUsage {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
prompt_tokens_details?: {
cached_tokens?: number;
cache_creation_tokens?: number;
};
}
export function toTokenUsage(raw: RawUsage | undefined): TokenUsage {
if (!raw) {
throw new Error('usage field missing from response');
}
const prompt = raw.prompt_tokens ?? 0;
const completion = raw.completion_tokens ?? 0;
const total = raw.total_tokens ?? prompt + completion;
return {
promptTokens: prompt,
completionTokens: completion,
totalTokens: total,
cacheReadTokens: raw.prompt_tokens_details?.cached_tokens,
cacheWriteTokens: raw.prompt_tokens_details?.cache_creation_tokens,
};
}
Never trust total_tokens to be present. Compute it when absent. This defensive mapping is what keeps your typescript token usage cost types honest when a provider silently changes its payload.
Step 3: Add a typed cost calculator
Pricing is model-specific and changes. Define a pricing table and a function that multiplies tokens by rates. Use per-token rates, not per-1k, to avoid division bugs.
interface ModelPricing {
inputPerToken: number;
outputPerToken: number;
cacheReadPerToken: number;
}
const PRICING: Record<string, ModelPricing> = {
'gpt-4o': {
inputPerToken: 0.000005,
outputPerToken: 0.000015,
cacheReadPerToken: 0.00000125,
},
};
export function computeCost(
usage: TokenUsage,
model: string,
pricing: Record<string, ModelPricing> = PRICING
): CostBreakdown {
const rate = pricing[model];
if (!rate) throw new Error(`No pricing for model ${model}`);
const inputCost = usage.promptTokens * rate.inputPerToken;
const outputCost = usage.completionTokens * rate.outputPerToken;
const cacheReadCost = (usage.cacheReadTokens ?? 0) * rate.cacheReadPerToken;
return {
inputCost,
outputCost,
cacheReadCost,
totalCost: inputCost + outputCost + cacheReadCost,
currency: 'USD',
};
}
Round only at display time. Floating point drift across millions of calls is real; keep full precision in the typescript token usage cost types until you render.
Step 4: Validate at runtime with a type guard
Compile-time types vanish at runtime. A malformed response from a flaky provider can still slip through any boundaries. Add a guard that runs before you trust the data.
export function isTokenUsage(u: unknown): u is TokenUsage {
if (typeof u !== 'object' || u === null) return false;
const t = u as Record<string, unknown>;
return (
typeof t.promptTokens === 'number' &&
typeof t.completionTokens === 'number' &&
typeof t.totalTokens === 'number' &&
(t.cacheReadTokens === undefined || typeof t.cacheReadTokens === 'number')
);
}
Use it in your mapper or at the edge of the network call. If the guard fails, log the raw payload and throw. This is cheaper than debugging a NaN in the monthly invoice.
Step 5: Wrap a fetch client with typed return
Now compose a typed chat function. We’ll target an OpenAI-compatible endpoint. If you route through a gateway like n4n.ai, the per-token usage metering arrives in the same usage object, so the mapper from Step 2 needs no changes.
interface ChatResult {
content: string;
usage: TokenUsage;
cost: CostBreakdown;
}
export async function chat(
baseUrl: string,
apiKey: string,
model: string,
messages: { role: string; content: string }[]
): Promise<ChatResult> {
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({ model, messages }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const usage = toTokenUsage(json.usage);
if (!isTokenUsage(usage)) throw new Error('invalid usage shape');
const cost = computeCost(usage, model);
return {
content: json.choices[0].message.content,
usage,
cost,
};
}
The return type ChatResult is the only thing your business logic sees. It encodes the typescript token usage cost types end to end.
Step 6: Handle fallback and partial usage
Automatic fallback between providers can yield a response where cache_read_tokens is absent even if your primary model supports it. Your optional fields already absorb that. But watch for total_tokens mismatches when a fallback model uses different tokenization.
Write a reconciliation check:
export function reconcileUsage(u: TokenUsage): TokenUsage {
const computed = u.promptTokens + u.completionTokens;
if (u.totalTokens !== computed) {
// Trust the sum; some providers exclude cache tokens from total.
return { ...u, totalTokens: computed };
}
return u;
}
Call reconcileUsage inside toTokenUsage after mapping. This prevents a provider’s accounting quirk from inflating totalCost via a stale total.
Step 7: Write tests to verify success
You verify the pipeline with two test layers: pure function tests and a mocked integration test. Below is a minimal Vitest example.
import { describe, it, expect } from 'vitest';
import { toTokenUsage, computeCost, isTokenUsage } from './client';
describe('token usage types', () => {
it('maps raw usage with cache details', () => {
const raw = {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
prompt_tokens_details: { cached_tokens: 4, cache_creation_tokens: 2 },
};
const u = toTokenUsage(raw);
expect(u).toEqual({
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
cacheReadTokens: 4,
cacheWriteTokens: 2,
});
expect(isTokenUsage(u)).toBe(true);
});
it('computes cost from pricing', () => {
const u = toTokenUsage({ prompt_tokens: 1000, completion_tokens: 1000 });
const cost = computeCost(u, 'gpt-4o');
expect(cost.inputCost).toBeCloseTo(0.005);
expect(cost.outputCost).toBeCloseTo(0.015);
expect(cost.totalCost).toBeCloseTo(0.02);
});
});
Run vitest run. Green tests confirm your typescript token usage cost types map and price correctly. For integration confidence, mock fetch to return a canned usage object and assert chat() resolves to a ChatResult with non-negative totalCost.
Verification checklist
tsc --noEmitpasses with strict mode on.- Unit tests for mapping, cost, and guard are green.
- A manual call against a staging endpoint logs
usageandcostwithoutundefinedfields. - Billing export matches the sum of
totalCostacross a day’s requests within floating-point tolerance.
That last point is the real success criterion. If finance can reconcile without a spreadsheet macro, the types did their job.
Step 8: Extend for multi-provider routing
When you send routing directives or cache-control hints, the response usage may include provider-specific surcharges. Add a provider?: string field to CostBreakdown and branch pricing on it. Keep the core typescript token usage cost types unchanged; extend, don’t rewrite.
interface CostBreakdown {
inputCost: number;
outputCost: number;
cacheReadCost: number;
totalCost: number;
currency: 'USD';
provider?: string;
}
This keeps the client forward-compatible as you add models from the 240+ available behind an OpenAI-compatible gateway, without touching the token counting logic.
Build the types once, map ruthlessly, and let the compiler reject the invoices you’d otherwise write by hand.