Skip to content
Open
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
2 changes: 2 additions & 0 deletions sdk/typescript/.gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
/dist/
/node_modules
/reports/
/.stryker-tmp/
/private_release/dist/
/*.tsbuildinfo
3 changes: 3 additions & 0 deletions sdk/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"lint": "tsc --noEmit",
"prepack": "node --run build",
"test": "bun test --timeout 30000 ./tests-ts",
"test:mutation": "stryker run",
"test:package": "node scripts/smoke-package.mjs",
"types": "pnpm run generate:models:check && tsc --noEmit"
},
Expand All @@ -68,9 +69,11 @@
"smol-toml": "1.6.1"
},
"devDependencies": {
"@stryker-mutator/core": "9.6.1",
"@types/bun": "1.3.13",
"@types/node": "22.19.17",
"@types/papaparse": "5.3.15",
"fast-check": "4.9.0",
"json-schema-to-typescript": "15.0.4",
"prettier": "3.2.5",
"typescript": "5.7.3"
Expand Down
1,125 changes: 1,125 additions & 0 deletions sdk/typescript/pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions sdk/typescript/scripts/check-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ const distFiles = new Set(
"config",
"contract",
"cost",
"cost-model",
"errors",
"index",
"knowledge-base",
Expand Down
122 changes: 122 additions & 0 deletions sdk/typescript/src/cost-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
export interface ScanCost {
model: string;
inputTokens: number;
cachedInputTokens: number;
cacheWriteInputTokens: number;
outputTokens: number;
estimatedUsd: number;
}

type ModelPricing = readonly [
input: number,
cachedInput: number,
cacheWriteInput: number,
output: number,
];

export interface ScanTokenUsage {
input_tokens: number;
cached_input_tokens: number;
cache_write_input_tokens: number;
output_tokens: number;
reasoning_output_tokens: number;
total_tokens: number;
}

const MODEL_PRICING_NANODOLLARS: Readonly<Record<string, ModelPricing>> = {
"gpt-5.6": [5_000, 500, 6_250, 30_000],
"gpt-5.6-sol": [5_000, 500, 6_250, 30_000],
"gpt-5.6-terra": [2_000, 200, 2_500, 12_000],
"gpt-5.6-luna": [200, 20, 250, 1_200],
};

export function tokenUsage(value: unknown): ScanTokenUsage | null {
if (!isRecord(value)) return null;
const input = value["input_tokens"];
const cached = value["cached_input_tokens"] ?? 0;
const canonicalCacheWrite = value["cache_write_input_tokens"];
const legacyCacheWrite = value["cache_write_tokens"];
const cacheWrite =
canonicalCacheWrite === 0 &&
isTokenCount(input) &&
isTokenCount(cached) &&
isTokenCount(legacyCacheWrite) &&
legacyCacheWrite > 0 &&
cached + legacyCacheWrite <= input
? legacyCacheWrite
: canonicalCacheWrite ?? legacyCacheWrite ?? 0;
const output = value["output_tokens"];
const reasoning = value["reasoning_output_tokens"] ?? 0;
if (
!isTokenCount(input) ||
!isTokenCount(cached) ||
!isTokenCount(cacheWrite) ||
!isTokenCount(output) ||
!isTokenCount(reasoning) ||
cached + cacheWrite > input ||
reasoning > output
) {
return null;
}
return {
input_tokens: input,
cached_input_tokens: cached,
cache_write_input_tokens: cacheWrite,
output_tokens: output,
reasoning_output_tokens: reasoning,
total_tokens: input + output,
};
}

export function estimateScanCost(
model: string | undefined,
usage: unknown,
): ScanCost | null {
if (model === undefined) return null;
const pricingModel = model.startsWith("openai.")
? model.slice("openai.".length)
: model;
const pricing = MODEL_PRICING_NANODOLLARS[pricingModel];
const normalized = tokenUsage(usage);
if (pricing === undefined || normalized === null) return null;
const [inputRate, cachedInputRate, cacheWriteInputRate, outputRate] = pricing;
const {
input_tokens: inputTokens,
cached_input_tokens: cachedInputTokens,
cache_write_input_tokens: cacheWriteInputTokens,
output_tokens: outputTokens,
} = normalized;

const nanodollars =
(inputTokens - cachedInputTokens - cacheWriteInputTokens) * inputRate +
cachedInputTokens * cachedInputRate +
cacheWriteInputTokens * cacheWriteInputRate +
outputTokens * outputRate;
if (!Number.isSafeInteger(nanodollars)) return null;

return {
model,
inputTokens,
cachedInputTokens,
cacheWriteInputTokens,
outputTokens,
estimatedUsd: nanodollars / 1_000_000_000,
};
}

export function formatUsd(value: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 9,
}).format(value);
}

function isTokenCount(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
126 changes: 7 additions & 119 deletions sdk/typescript/src/cost.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import {
estimateScanCost,
tokenUsage,
type ScanCost,
type ScanTokenUsage,
} from "./cost-model.js";
export { estimateScanCost, formatUsd, type ScanCost } from "./cost-model.js";
import { open, readdir } from "node:fs/promises";
import { join, relative, sep } from "node:path";
import {
Expand All @@ -9,38 +16,13 @@ import {
type ScanProgress,
} from "./worker-progress.js";

export interface ScanCost {
model: string;
inputTokens: number;
cachedInputTokens: number;
cacheWriteInputTokens: number;
outputTokens: number;
estimatedUsd: number;
}

export interface ScanSessionEvent {
threadId: string;
parentThreadId: string | null;
worker?: number;
event: Record<string, unknown>;
}

type ModelPricing = readonly [
input: number,
cachedInput: number,
cacheWriteInput: number,
output: number,
];

interface ScanTokenUsage {
input_tokens: number;
cached_input_tokens: number;
cache_write_input_tokens: number;
output_tokens: number;
reasoning_output_tokens: number;
total_tokens: number;
}

interface SessionReasoning {
id: string;
text: string;
Expand Down Expand Up @@ -90,13 +72,6 @@ interface ScanCostSnapshot {
cost: ScanCost | null;
}

const MODEL_PRICING_NANODOLLARS: Readonly<Record<string, ModelPricing>> = {
"gpt-5.6": [5_000, 500, 6_250, 30_000],
"gpt-5.6-sol": [5_000, 500, 6_250, 30_000],
"gpt-5.6-terra": [2_000, 200, 2_500, 12_000],
"gpt-5.6-luna": [200, 20, 250, 1_200],
};

const COST_POLL_INTERVAL_MS = 100;
const SESSION_READ_SIZE = 64 * 1_024;

Expand Down Expand Up @@ -781,44 +756,6 @@ function sessionContentText(
.join("\n");
}

function tokenUsage(value: unknown): ScanTokenUsage | null {
if (!isRecord(value)) return null;
const input = value["input_tokens"];
const cached = value["cached_input_tokens"] ?? 0;
const canonicalCacheWrite = value["cache_write_input_tokens"];
const legacyCacheWrite = value["cache_write_tokens"];
const cacheWrite =
canonicalCacheWrite === 0 &&
isTokenCount(input) &&
isTokenCount(cached) &&
isTokenCount(legacyCacheWrite) &&
legacyCacheWrite > 0 &&
cached + legacyCacheWrite <= input
? legacyCacheWrite
: canonicalCacheWrite ?? legacyCacheWrite ?? 0;
const output = value["output_tokens"];
const reasoning = value["reasoning_output_tokens"] ?? 0;
if (
!isTokenCount(input) ||
!isTokenCount(cached) ||
!isTokenCount(cacheWrite) ||
!isTokenCount(output) ||
!isTokenCount(reasoning) ||
cached + cacheWrite > input ||
reasoning > output
) {
return null;
}
return {
input_tokens: input,
cached_input_tokens: cached,
cache_write_input_tokens: cacheWrite,
output_tokens: output,
reasoning_output_tokens: reasoning,
total_tokens: input + output,
};
}

function addTokenUsage(
previous: ScanTokenUsage | null,
next: ScanTokenUsage,
Expand Down Expand Up @@ -860,52 +797,3 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function isMissingFile(error: unknown): boolean {
return isRecord(error) && error["code"] === "ENOENT";
}

export function estimateScanCost(
model: string | undefined,
usage: unknown,
): ScanCost | null {
if (model === undefined) return null;
const pricingModel = model.startsWith("openai.")
? model.slice("openai.".length)
: model;
const pricing = MODEL_PRICING_NANODOLLARS[pricingModel];
const normalized = tokenUsage(usage);
if (pricing === undefined || normalized === null) return null;
const [inputRate, cachedInputRate, cacheWriteInputRate, outputRate] = pricing;
const {
input_tokens: inputTokens,
cached_input_tokens: cachedInputTokens,
cache_write_input_tokens: cacheWriteInputTokens,
output_tokens: outputTokens,
} = normalized;

const nanodollars =
(inputTokens - cachedInputTokens - cacheWriteInputTokens) * inputRate +
cachedInputTokens * cachedInputRate +
cacheWriteInputTokens * cacheWriteInputRate +
outputTokens * outputRate;
if (!Number.isSafeInteger(nanodollars)) return null;

return {
model,
inputTokens,
cachedInputTokens,
cacheWriteInputTokens,
outputTokens,
estimatedUsd: nanodollars / 1_000_000_000,
};
}

export function formatUsd(value: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 9,
}).format(value);
}

function isTokenCount(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
2 changes: 1 addition & 1 deletion sdk/typescript/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { formatUsd, type ScanCost } from "./cost.js";
import { formatUsd, type ScanCost } from "./cost-model.js";

/** Returns the original error message without altering its contents. */
export function errorMessage(error: unknown): string {
Expand Down
12 changes: 12 additions & 0 deletions sdk/typescript/stryker.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json",
"testRunner": "command",
"commandRunner": {
"command": "bun test --timeout 30000 ./tests-ts/cost-model.property.test.ts ./tests-ts/errors.test.ts ./tests-ts/errors.property.test.ts ./tests-ts/worker-progress.test.ts ./tests-ts/worker-progress.property.test.ts"
},
"mutate": ["src/cost-model.ts", "src/errors.ts", "src/worker-progress.ts"],
"coverageAnalysis": "off",
"reporters": ["clear-text", "progress", "json", "html"],
"concurrency": 2,
"thresholds": { "break": 0 }
}
Loading
Loading