diff --git a/.agents/skills/run-codex/SKILL.md b/.agents/skills/run-codex/SKILL.md index efbffc51a..e6bc521d6 100644 --- a/.agents/skills/run-codex/SKILL.md +++ b/.agents/skills/run-codex/SKILL.md @@ -29,10 +29,13 @@ npm run codex-test -- -p "Your prompt here" | Option | Short | Description | Default | |--------|-------|-------------|---------| -| `--prompt` | `-p` | Prompt to send to Codex (required) | - | +| `--prompt` | `-p` | Prompt to send to Codex (unless listing models) | - | | `--cwd` | `-c` | Working directory for the session | Current dir | | `--output` | `-o` | Output format: `all`, `codex`, `acp`, `summary` | `all` | | `--json` | - | Output events as JSON | false | +| `--list-models` | - | Read live model IDs and efforts without a turn | false | +| `--model` | `-m` | Exact advertised `id[effort]` | Codex default | +| `--mcp-servers` | - | JSON file containing ACP MCP server entries | none | | `--help` | `-h` | Show help message | - | ### Examples @@ -72,3 +75,5 @@ The script outputs events in real-time as they arrive: - Event type frequency 4. **PromptResponse**: Full response object including `_meta.quota.token_count` + +The harness honors `CODEX_PATH` and `CODEX_CONFIG`. Run `--list-models` before choosing a model; never guess model IDs. Client permission requests are cancelled and fail the probe. JSON mode emits JSON lines; use `npm run --silent` to suppress npm banners. The harness is included in `npm run typecheck`. diff --git a/.agents/skills/run-codex/scripts/run-codex-test.ts b/.agents/skills/run-codex/scripts/run-codex-test.ts index 78ea6e43a..400eb162f 100644 --- a/.agents/skills/run-codex/scripts/run-codex-test.ts +++ b/.agents/skills/run-codex/scripts/run-codex-test.ts @@ -1,209 +1,106 @@ #!/usr/bin/env tsx -/** - * Run real Codex with a prompt to test and verify code during development. - * - * Usage: - * npm run codex-test -- --prompt "Your prompt here" - * npm run codex-test -- -p "Hello" -c /path/to/project - * npm run codex-test -- -p "Hello" -o codex --json - */ - -import path from "node:path"; +/** Real app-server/ACP probe. Never approves a permission request. */ import fs from "node:fs"; -import {CodexAcpClient} from "../../../../src/CodexAcpClient"; -import {type CodexConnectionEvent, CodexAppServerClient} from "../../../../src/CodexAppServerClient"; +import {parseArgs} from "node:util"; +import * as acp from "@agentclientprotocol/sdk"; +import {CodexAcpClient, type JsonObject} from "../../../../src/CodexAcpClient"; +import {CodexAppServerClient} from "../../../../src/CodexAppServerClient"; import {startCodexConnection} from "../../../../src/CodexJsonRpcConnection"; import {CodexAcpServer} from "../../../../src/CodexAcpServer"; import type {AcpClientConnection} from "../../../../src/ACPSessionConnection"; -import {RequestError} from "@agentclientprotocol/sdk/unstable"; - -// Parse command line arguments -function parseArgs(): { prompt: string; cwd: string; output: string; json: boolean } { - const args = process.argv.slice(2); - let prompt = ""; - let cwd = process.cwd(); - let output = "all"; - let json = false; - - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === "--prompt" || arg === "-p") { - prompt = args[++i] || ""; - } else if (arg === "--cwd" || arg === "-c") { - cwd = args[++i] || process.cwd(); - } else if (arg === "--output" || arg === "-o") { - output = args[++i] || "all"; - } else if (arg === "--json") { - json = true; - } else if (arg === "--help" || arg === "-h") { - console.log(` -Usage: npm run codex-test -- [options] -Options: - -p, --prompt Prompt to send to Codex (required) - -c, --cwd Working directory for the session (default: current dir) - -o, --output Output type: all, codex, acp, summary (default: all) - --json Output events as JSON - -h, --help Show this help message - -Examples: - npm run codex-test -- -p "What files are here?" - npm run codex-test -- -p "Read README" -c /path/to/project - npm run codex-test -- -p "Hello" -o codex --json -`); - process.exit(0); - } - } - - if (!prompt) { - console.error("Error: --prompt is required"); - process.exit(1); - } - - return { prompt, cwd, output, json }; +const {values} = parseArgs({options: { + prompt: {type: "string", short: "p"}, + cwd: {type: "string", short: "c", default: process.cwd()}, + output: {type: "string", short: "o", default: "all"}, + json: {type: "boolean", default: false}, + model: {type: "string", short: "m"}, + "list-models": {type: "boolean", default: false}, + "mcp-servers": {type: "string"}, + help: {type: "boolean", short: "h", default: false}, +}}); + +if (values.help) { + console.log(`Usage: npm run codex-test -- [options] + -p, --prompt Prompt (required unless listing models) + -c, --cwd Session working directory + -o, --output all, codex, acp, summary (default: all) + -m, --model Exact ACP model ID from --list-models + --list-models Read the live model catalog without starting a turn + --mcp-servers JSON array of ACP MCP server entries + --json Emit JSON lines only + -h, --help Show help +Uses the bundled Codex unless CODEX_PATH is set; forwards CODEX_CONFIG. +Permission requests are cancelled and make the run fail.`); +} else { + await main().catch(error => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); } -type MethodCallEvent = { method: string; args: unknown[] }; - -function createMockAcpConnection( - events: MethodCallEvent[], - onNotification: (method: string, params: unknown) => void, -): AcpClientConnection { - return { - notify: async (method: string, params?: unknown) => { - events.push({method, args: [params]}); - onNotification(method, params); +async function main(): Promise { + if (!values.prompt && !values["list-models"]) throw new Error("--prompt or --list-models is required"); + if (!["all", "codex", "acp", "summary"].includes(values.output)) throw new Error("Invalid --output"); + const config: unknown = JSON.parse(process.env["CODEX_CONFIG"] ?? "{}"); + if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("CODEX_CONFIG must be an object"); + const mcpServers: acp.McpServer[] = values["mcp-servers"] + ? JSON.parse(fs.readFileSync(values["mcp-servers"], "utf8")) : []; + if (!Array.isArray(mcpServers)) throw new Error("--mcp-servers must contain an array"); + const emit = (type: string, data: unknown) => console.log(values.json + ? JSON.stringify({type, data}) : `[${type}] ${JSON.stringify(data, null, 2)}`); + const connection = startCodexConnection(process.env["CODEX_PATH"]); + const client = new CodexAppServerClient(connection.connection); + const adapter = new CodexAcpClient(client, config as JsonObject); + let codexEvents = 0; + let acpEvents = 0; + let permissionRequests = 0; + const acpConnection: AcpClientConnection = { + notify: async (method: string, params: unknown) => { + acpEvents++; + if (values.output === "all" || values.output === "acp") emit("acp", {method, params}); }, request: async (method: string) => { - throw RequestError.methodNotFound(method); + if (method === acp.methods.client.session.requestPermission) { + permissionRequests++; + emit("permission-cancelled", {method}); + return {outcome: {outcome: "cancelled"}} as never; + } + throw new Error(`Harness does not implement client request ${method}`); }, }; -} - -async function main() { - const { prompt, cwd, output, json } = parseArgs(); - - // Find Codex binary - const pathToCodex = path.resolve(process.cwd(), "node_modules", ".bin", process.platform === "win32" ? "codex.cmd" : "codex"); - if (!fs.existsSync(pathToCodex)) { - console.error(`Error: Codex binary not found at ${pathToCodex}. Did you run 'npm install'?`); - process.exit(1); - } - - console.log("=".repeat(60)); - console.log("Codex Test Runner"); - console.log("=".repeat(60)); - console.log(`Prompt: ${prompt}`); - console.log(`CWD: ${cwd}`); - console.log(`Output: ${output}`); - console.log("=".repeat(60)); - console.log(""); - - // Start Codex connection - const codexConnection = startCodexConnection(pathToCodex); - const codexAppServerClient = new CodexAppServerClient(codexConnection.connection); - - // Collect events - const codexEvents: CodexConnectionEvent[] = []; - const acpEvents: MethodCallEvent[] = []; - - codexAppServerClient.onClientTransportEvent((event) => { - codexEvents.push(event); - if (output === "all" || output === "codex") { - if (json) { - console.log(JSON.stringify(event)); - } else { - console.log(`[CODEX] ${event.eventType}:`, JSON.stringify(event, null, 2)); - } - } - }); - - const acpConnection = createMockAcpConnection(acpEvents, (method, params) => { - if (output === "all" || output === "acp") { - if (json) { - console.log(JSON.stringify({method, params})); - } else { - console.log(`[ACP] ${method}:`, JSON.stringify(params, null, 2)); - } - } + const agent = new CodexAcpServer(acpConnection, adapter, undefined, () => connection.process.exitCode); + client.onClientTransportEvent(event => { + codexEvents++; + if (values.output === "all" || values.output === "codex") emit("codex", event); }); - const codexAcpClient = new CodexAcpClient(codexAppServerClient); - const codexAcpAgent = new CodexAcpServer(acpConnection, codexAcpClient, undefined, () => codexConnection.process.exitCode); - try { - // Initialize - console.log("\n--- Initializing ---\n"); - await codexAcpAgent.initialize({ protocolVersion: 1 }); - - // Check auth - const authRequirement = await codexAcpClient.readAuthRequirement(); - if (authRequirement.required) { - console.error("Error: Authentication required. Please login to Codex first."); - process.exitCode = 1; + await agent.initialize({protocolVersion: acp.PROTOCOL_VERSION}); + const models = await adapter.fetchAvailableModels(); + const available = models.filter(model => !model.hidden).flatMap(model => + model.supportedReasoningEfforts.map(({reasoningEffort}) => ({ + modelId: `${model.id}[${reasoningEffort}]`, + model: model.model, + isDefault: model.isDefault && reasoningEffort === model.defaultReasoningEffort, + }))); + if (values["list-models"]) { + emit("models", available); return; } - - // Create session - console.log("\n--- Creating Session ---\n"); - const sessionResponse = await codexAcpAgent.newSession({ cwd, mcpServers: [] }); - console.log(`Session ID: ${sessionResponse.sessionId}`); - console.log(`Model: ${sessionResponse.models?.currentModelId}`); - - // Send prompt - console.log("\n--- Sending Prompt ---\n"); - const startTime = Date.now(); - const promptResponse = await codexAcpAgent.prompt({ - sessionId: sessionResponse.sessionId, - prompt: [{ type: "text", text: prompt }] - }); - const duration = Date.now() - startTime; - - // Summary - if (output === "all" || output === "summary") { - console.log("\n" + "=".repeat(60)); - console.log("Summary"); - console.log("=".repeat(60)); - console.log(`Stop Reason: ${promptResponse.stopReason}`); - console.log(`Duration: ${duration}ms`); - console.log(`Codex Events: ${codexEvents.length}`); - console.log(`ACP Events: ${acpEvents.length}`); - - const usage = promptResponse.usage; - if (usage) { - console.log(`\nToken Usage:`); - console.log(` Input Tokens: ${usage.inputTokens}`); - console.log(` Cached Input: ${usage.cachedReadTokens ?? 0}`); - console.log(` Output Tokens: ${usage.outputTokens}`); - console.log(` Reasoning Tokens: ${usage.thoughtTokens ?? 0}`); - console.log(` Total Tokens: ${usage.totalTokens}`); - } - - // Event type breakdown - const eventTypes = new Map(); - for (const event of codexEvents) { - const key = `${event.eventType}:${"method" in event ? event.method : "response"}`; - eventTypes.set(key, (eventTypes.get(key) || 0) + 1); - } - console.log(`\nEvent Types:`); - for (const [type, count] of eventTypes) { - console.log(` ${type}: ${count}`); - } + if (values.model && !available.some(model => model.modelId === values.model)) { + throw new Error(`Model ${values.model} is not advertised; use --list-models`); } - - // Full response meta - if (output === "all") { - console.log("\n--- PromptResponse ---"); - console.log(JSON.stringify(promptResponse, null, 2)); - } - - await codexAcpAgent.closeSession({sessionId: sessionResponse.sessionId}); - } catch (err) { - console.error("Error:", err); - process.exitCode = 1; + if ((await adapter.readAuthRequirement()).required) throw new Error("Authentication required. Login to Codex first."); + const session = await agent.newSession({cwd: values.cwd, mcpServers, _meta: {mcpStartupAwaitTimeoutMs: 30_000}}); + if (values.model) await agent.unstable_setSessionModel({sessionId: session.sessionId, modelId: values.model}); + emit("session", {sessionId: session.sessionId, model: agent.getSessionState(session.sessionId).currentModelId}); + const start = Date.now(); + const response = await agent.prompt({sessionId: session.sessionId, prompt: [{type: "text", text: values.prompt!}]}); + emit("summary", {response, durationMs: Date.now() - start, codexEvents, acpEvents, permissionRequests}); + await agent.closeSession({sessionId: session.sessionId}); + if (permissionRequests) throw new Error("Permission requests were cancelled; validation did not pass"); } finally { - codexConnection.connection.end(); - codexConnection.process.kill(); + connection.connection.end(); + connection.process.kill(); } } - -main(); diff --git a/.agents/skills/run-codex/tsconfig.json b/.agents/skills/run-codex/tsconfig.json new file mode 100644 index 000000000..d607da876 --- /dev/null +++ b/.agents/skills/run-codex/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": {"rootDir": "../../..", "noEmit": true}, + "include": ["scripts/**/*.ts"], + "exclude": [] +} diff --git a/docs/pricing-column-regression-2026-10-24.md b/docs/pricing-column-regression-2026-10-24.md new file mode 100644 index 000000000..4940c6da7 --- /dev/null +++ b/docs/pricing-column-regression-2026-10-24.md @@ -0,0 +1,34 @@ +# Pricing-column regression, 2026-10-24 + +The published OpenAI Markdown pricing table changed from +`input, cached input, cache writes, output` to +`input, output, cached input, 30m write, 12h write` within each context band. +The positional parser therefore read Astra cached input as $50/M instead of $1/M, +and output as $12.50/M instead of $50/M in standard short-context mode. + +The reported $402.42 was reproduced exactly from this chat's recorded usage: +8,136,314 input tokens, including 8,014,208 cached input tokens, and 39,285 output +tokens. Fresh input was 122,106; cache writes were zero. The old column mapping +produced $402.4225225; the correct mapping produces an API-rate estimate of +$11.199518 for that same point in time. This is an estimate, not a billed invoice. + +Source: https://developers.openai.com/api/docs/pricing.md, fetched 2026-10-24. +The regression fixture retains the relevant official table headers and Astra rows. + +The parser now maps normalized, uniquely named headers, tolerates reordered/new +columns and repeated pricing sections, and rejects missing/ambiguous columns, +empty rates, and contradictory duplicates. Both prior and current table layouts +are covered. The change is shared by ACP v1 and v2 cost calculations. + +Verification: 1,886 tests passed, 36 skipped; typecheck and build passed. A live +fetch through the corrected provider confirmed current standard/fast rates. + +The current development chat's running adapter instance was narrowly repaired +under the Node debugger after verifying the exact session ID, standard Astra +model, zero cache writes and the known-bad rate mapping. Its accumulated amount +was recomputed from its existing cumulative counters; no messages or token usage +were rewritten. At repair, $518.5491025 became $14.171778 before the next usage +increment. Subsequent raw ACP usage records carry the corrected totals. The +breakpoint was removed and the debugger listener shut down. The built adapter +contains the durable parser fix; other already-running adapter instances are not +hot-patched and need a normal restart to load it. diff --git a/package.json b/package.json index 7f9385a50..a344729b2 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,8 @@ "test:integration": "vitest run --no-file-parallelism --hookTimeout=30000 src/__tests__/integration", "test:e2e": "npm run build && RUN_E2E_TESTS=true vitest run --no-file-parallelism --retry=2 src/__tests__/CodexACPAgent/e2e", "test:watch": "vitest", - "typecheck": "tsc --noEmit && tsc --noEmit -p examples/tsconfig.json", - "codex-test": "tsx .agents/skills/run-codex/scripts/run-codex-test.ts" + "typecheck": "tsc --noEmit && tsc --noEmit -p examples/tsconfig.json && tsc --noEmit -p .agents/skills/run-codex/tsconfig.json", + "codex-test": "node --import tsx .agents/skills/run-codex/scripts/run-codex-test.ts" }, "homepage": "https://github.com/agentclientprotocol/codex-acp#readme", "bugs": { diff --git a/readme-dev.md b/readme-dev.md index 124975f3e..72487fdba 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -116,6 +116,22 @@ Command replies, review results, and terminal/retrying errors retain their exist failure channels. Clients advertising session compaction support continue to receive the dedicated compaction lifecycle instead of the legacy completion advisory. + +### Real Codex model harness + +`npm run codex-test -- --list-models --output summary --json` reads the live, +paginated app-server catalog without starting a model turn. Use an exact advertised +`id[effort]` with `--model` when running a prompt. The harness uses the bundled +Codex version unless `CODEX_PATH` is set and forwards `CODEX_CONFIG` to sessions. +`--mcp-servers ` accepts a JSON array of ACP MCP transport entries. All client +permission requests are cancelled and cause the probe to fail; it never grants +shell, filesystem, or MCP approval. `--json` emits JSON lines (use `npm run --silent` +to suppress npm's banner). + +On 2026-10-23 the bundled 0.164.0 app-server advertised `gpt-6.1-sol[medium]` as +the default and `gpt-6-astra[low]` as an available alternative. The live-test fixture +uses these verified IDs; runtime model availability continues to come from Codex. + ### Unsupported skills The adapter disables Codex skills whose output ACP clients cannot show, and leaves them out of the skill commands and diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index b83503524..565da632b 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -170,6 +170,8 @@ import { type ThreadGoalSnapshot, toThreadGoalSnapshot, } from "./ThreadGoalSnapshot"; +import {OpenAiPricingProvider, type PricingProvider} from "./PricingProvider"; +import {SessionCostTracker} from "./SessionCostTracker"; import { subagentProtocol, type SubagentAwareSessionCapabilities, @@ -286,6 +288,7 @@ export interface SessionState extends SessionIndexTitleState { sessionTitle: string | null; sessionTitleSource: "unset" | "fallback" | "explicit" | "unknown"; sessionFailure?: SessionFailure; + costTracker: SessionCostTracker; titleGen?: TitleGenerator; /** Predicts the follow-up prompt of a finished turn; set for AIR clients only. */ promptSuggestions?: PromptSuggestionGenerator | undefined; @@ -497,6 +500,7 @@ export class CodexAcpServer { private readonly getExitCode: () => number | null; private readonly getRecentStderr: () => string; private readonly sessionFailureEpoch: string; + private readonly pricingProvider: PricingProvider; private availableCommands: CodexCommands; private clientInfo: acp.Implementation | null; private clientCapabilities: acp.ClientCapabilities | null; @@ -583,6 +587,7 @@ export class CodexAcpServer { getExitCode?: () => number | null, getRecentStderr?: () => string, codexProcessState?: CodexProcessState, + pricingProvider: PricingProvider = new OpenAiPricingProvider(), ) { this.sessions = new Map(); this.pendingTurnStarts = new Map(); @@ -631,6 +636,7 @@ export class CodexAcpServer { this.getExitCode = getExitCode ?? (() => this.codexProcessState?.connection.process.exitCode ?? null); this.getRecentStderr = getRecentStderr ?? (() => this.codexProcessState?.stderr ?? ""); this.sessionFailureEpoch = randomUUID(); + this.pricingProvider = pricingProvider; this.clientInfo = null; this.clientCapabilities = null; this.capabilities = ClientCapabilities.DEFAULT; @@ -1361,6 +1367,11 @@ export class CodexAcpServer { const sessionMcpServers = this.resolveSessionMcpServers(requestedMcpServers, operation === "resume"); const currentModel = this.findCurrentModel(models, currentModelId); const currentModelSupportsFast = modelSupportsFast(currentModel); + const costTracker = await this.createSessionCostTracker( + models, + authProvider, + "sessionId" in request, + ); if (this.setupResponseOrdering && operation !== "resume") this.clientDelivery.beginSessionSetup(sessionId); const sessionState: SessionState = { sessionId: sessionId, @@ -1393,6 +1404,7 @@ export class CodexAcpServer { goalRevision: 0, sessionTitle: sessionMetadata.sessionTitle ?? null, sessionTitleSource: operation === "resume" ? "unknown" : "unset", + costTracker, subagents: this.createSubagentRouter(sessionId), asyncTasks: this.createAsyncTasks(sessionId), compactions: new CodexSessionCompactions(), @@ -1577,6 +1589,18 @@ export class CodexAcpServer { return authProvider === null || authProvider === "openai"; } + private async createSessionCostTracker( + models: readonly Model[], + authProvider: string | null, + baselineInitialUsage: boolean, + ): Promise { + if (!this.authProviderUsesOpenAiAccount(authProvider)) { + return SessionCostTracker.disabled(); + } + const pricing = await this.pricingProvider.getPricing(models); + return new SessionCostTracker(pricing, baselineInitialUsage); + } + private authProvidersMatch(a: string | null, b: string | null): boolean { if (this.authProviderUsesOpenAiAccount(a) && this.authProviderUsesOpenAiAccount(b)) { return true; @@ -3530,6 +3554,7 @@ export class CodexAcpServer { const sessionMcpServers = this.resolveSessionMcpServers(requestedMcpServers, true); const currentModel = this.findCurrentModel(models, currentModelId); const currentModelSupportsFast = modelSupportsFast(currentModel); + const costTracker = await this.createSessionCostTracker(models, authProvider, true); const sessionState: SessionState = { sessionId: sessionId, currentModelId: currentModelId, @@ -3561,6 +3586,7 @@ export class CodexAcpServer { goalRevision: 0, sessionTitle: null, sessionTitleSource: "unset", + costTracker, subagents: this.createSubagentRouter(sessionId, true), asyncTasks: this.createAsyncTasks(sessionId), compactions: new CodexSessionCompactions(), diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 6a15f096e..80fbf9932 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -1353,10 +1353,20 @@ export class CodexEventHandler { return null; } + const cost = this.sessionState.totalTokenUsage === null || this.sessionState.lastTokenUsage === null + ? null + : this.sessionState.costTracker.update( + this.sessionState.totalTokenUsage, + this.sessionState.lastTokenUsage, + this.sessionState.currentModelId, + this.sessionState.fastModeEnabled, + ); + return { sessionUpdate: "usage_update", used, size, + ...(cost === null ? {} : {cost}), }; } diff --git a/src/PricingProvider.ts b/src/PricingProvider.ts new file mode 100644 index 000000000..e0dfb42c2 --- /dev/null +++ b/src/PricingProvider.ts @@ -0,0 +1,167 @@ +import type {Model} from "./app-server/v2"; +import {logger} from "./Logger"; + +export interface TokenRates { + input: number; + cachedInput: number; + output: number; +} + +export interface TierPricing { + shortContext: TokenRates; + longContext?: TokenRates; +} + +export interface ModelPricing { + standard: TierPricing; + fast?: TierPricing; +} + +export type ModelPricingSnapshot = ReadonlyMap; + +export interface PricingProvider { + getPricing(models: readonly Model[]): Promise; +} + +const OPENAI_PRICING_URL = "https://developers.openai.com/api/docs/pricing.md"; +const PRICING_FETCH_TIMEOUT_MS = 5_000; + +export class OpenAiPricingProvider implements PricingProvider { + private pricingDocument: Promise | null = null; + + async getPricing(models: readonly Model[]): Promise { + if (models.length === 0) return new Map(); + + try { + const markdown = await this.getPricingDocument(); + return parseModelPricing(markdown, models.map(model => model.id)); + } catch (error) { + logger.error("Failed to load OpenAI model pricing", error); + return new Map(); + } + } + + private async getPricingDocument(): Promise { + if (this.pricingDocument === null) { + this.pricingDocument = this.fetchPricingDocument().catch(error => { + this.pricingDocument = null; + throw error; + }); + } + return await this.pricingDocument; + } + + private async fetchPricingDocument(): Promise { + const response = await fetch(OPENAI_PRICING_URL, { + headers: {accept: "text/markdown"}, + signal: AbortSignal.timeout(PRICING_FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error(`OpenAI pricing request failed with HTTP ${response.status}`); + } + return await response.text(); + } +} + +function parseModelPricing(markdown: string, modelIds: readonly string[]): ModelPricingSnapshot { + const requestedModels = new Map(); + for (const modelId of modelIds) { + const pricingId = pricingModelId(modelId); + const matchingIds = requestedModels.get(pricingId) ?? []; + matchingIds.push(modelId); + requestedModels.set(pricingId, matchingIds); + } + + const standard = parsePricingSection(markdown, "Standard pricing data", requestedModels); + const fast = parsePricingSection(markdown, "Fast pricing data", requestedModels); + const result = new Map(); + for (const [modelId, standardPricing] of standard) { + const fastPricing = fast.get(modelId); + result.set(modelId, { + standard: standardPricing, + ...(fastPricing === undefined ? {} : {fast: fastPricing}), + }); + } + return result; +} + +function parsePricingSection( + markdown: string, + heading: string, + requestedModels: ReadonlyMap, +): Map { + const result = new Map(); + const ambiguous = new Set(); + let inSection = false; + let headers: string[] | null = null; + + for (const line of markdown.split(/\r?\n/)) { + if (line.startsWith("### ")) { + inSection = line.trim() === `### ${heading}`; + headers = null; + continue; + } + if (!inSection || !line.startsWith("|")) continue; + const cells = line.split("|").slice(1, -1).map(cell => cell.trim()); + if (cells[0]?.toLowerCase() === "model") { + headers = cells.map(cell => cell.toLowerCase().replace(/\s+/g, " ")); + continue; + } + if (headers === null || cells.length !== headers.length || cells[0]?.startsWith("---")) continue; + const column = (name: string): string | undefined => { + const index = headers!.indexOf(name); + return index < 0 || headers!.lastIndexOf(name) !== index ? undefined : cells[index]; + }; + const documentedModel = cells[0]?.replace(/\s+\([^)]*\)\s*$/, ""); + if (documentedModel === undefined) continue; + const matchingModelIds = requestedModels.get(documentedModel); + if (matchingModelIds === undefined) continue; + + // Column positions changed when output moved and cache-write durations were added. + // Unknown or ambiguous headers must never produce a plausible but incorrect price. + const shortContext = parseTokenRates( + column("short context input"), column("short context cached input"), column("short context output"), + ); + if (shortContext === null) continue; + const longContext = parseTokenRates( + column("long context input"), column("long context cached input"), column("long context output"), + ); + const pricing: TierPricing = { + shortContext, + ...(longContext === null ? {} : {longContext}), + }; + for (const modelId of matchingModelIds) { + if (ambiguous.has(modelId)) continue; + const existing = result.get(modelId); + if (existing && JSON.stringify(existing) !== JSON.stringify(pricing)) { + result.delete(modelId); + ambiguous.add(modelId); + } else { + result.set(modelId, pricing); + } + } + } + return result; +} + +function parseTokenRates( + inputValue: string | undefined, + cachedInputValue: string | undefined, + outputValue: string | undefined, +): TokenRates | null { + const input = parseUsdRate(inputValue); + const cachedInput = parseUsdRate(cachedInputValue); + const output = parseUsdRate(outputValue); + if (input === null || cachedInput === null || output === null) return null; + return {input, cachedInput, output}; +} + +function parseUsdRate(value: string | undefined): number | null { + if (value === undefined || !/^\$?(?:\d[\d,]*)(?:\.\d+)?$/.test(value)) return null; + const amount = Number(value.replace(/[$,]/g, "")); + return Number.isFinite(amount) && amount >= 0 ? amount : null; +} + +function pricingModelId(modelId: string): string { + return modelId.toLowerCase().replace(/-\d{4}-\d{2}-\d{2}$/, ""); +} diff --git a/src/SessionCostTracker.ts b/src/SessionCostTracker.ts new file mode 100644 index 000000000..64b4465be --- /dev/null +++ b/src/SessionCostTracker.ts @@ -0,0 +1,95 @@ +import type {Cost} from "@agentclientprotocol/sdk"; +import type {TokenCount} from "./TokenCount"; +import type {ModelPricingSnapshot, TierPricing, TokenRates} from "./PricingProvider"; + +const LONG_CONTEXT_THRESHOLD = 272_000; +const TOKENS_PER_MILLION = 1_000_000; + +export class SessionCostTracker { + private previousTotalUsage: TokenCount | null = null; + private amountUsd = 0; + private available: boolean; + + constructor( + private readonly pricing: ModelPricingSnapshot, + private baselineInitialUsage = false, + ) { + this.available = pricing.size > 0; + } + + static disabled(): SessionCostTracker { + return new SessionCostTracker(new Map()); + } + + update( + totalUsage: TokenCount, + lastUsage: TokenCount, + currentModelId: string, + fastModeEnabled: boolean, + ): Cost | null { + const delta = usageDelta(totalUsage, this.previousTotalUsage); + this.previousTotalUsage = {...totalUsage}; + if (!this.available || delta === null || delta.cacheWriteInputTokens > 0) { + this.available = false; + return null; + } + + const modelId = currentModelId.replace(/\[[^\]]*]$/, ""); + const modelPricing = this.pricing.get(modelId); + const tierPricing = fastModeEnabled ? modelPricing?.fast : modelPricing?.standard; + const rates = selectRates(tierPricing, lastUsage); + if (rates === null) { + this.available = false; + return null; + } + + if (this.baselineInitialUsage) { + this.baselineInitialUsage = false; + return {amount: this.amountUsd, currency: "USD"}; + } + + const incrementalCost = ( + delta.inputTokens * rates.input + + delta.cachedInputTokens * rates.cachedInput + + delta.outputTokens * rates.output + ) / TOKENS_PER_MILLION; + if (!Number.isFinite(incrementalCost) || incrementalCost < 0) { + this.available = false; + return null; + } + + this.amountUsd += incrementalCost; + return {amount: this.amountUsd, currency: "USD"}; + } +} + +function selectRates(pricing: TierPricing | undefined, lastUsage: TokenCount): TokenRates | null { + if (pricing === undefined) return null; + const inputTokens = lastUsage.inputTokens + lastUsage.cachedInputTokens; + if (inputTokens > LONG_CONTEXT_THRESHOLD) { + return pricing.longContext ?? null; + } + return pricing.shortContext; +} + +function usageDelta(current: TokenCount, previous: TokenCount | null): TokenCount | null { + if (previous === null) return {...current}; + if ( + current.totalTokens < previous.totalTokens + || current.inputTokens < previous.inputTokens + || current.cacheWriteInputTokens < previous.cacheWriteInputTokens + || current.cachedInputTokens < previous.cachedInputTokens + || current.outputTokens < previous.outputTokens + || current.reasoningOutputTokens < previous.reasoningOutputTokens + ) { + return null; + } + return { + totalTokens: current.totalTokens - previous.totalTokens, + inputTokens: current.inputTokens - previous.inputTokens, + cachedInputTokens: current.cachedInputTokens - previous.cachedInputTokens, + cacheWriteInputTokens: current.cacheWriteInputTokens - previous.cacheWriteInputTokens, + outputTokens: current.outputTokens - previous.outputTokens, + reasoningOutputTokens: current.reasoningOutputTokens - previous.reasoningOutputTokens, + }; +} diff --git a/src/__tests__/CodexACPAgent/data/model-filtering.json b/src/__tests__/CodexACPAgent/data/model-filtering.json index 3029f888f..ee13e580f 100644 --- a/src/__tests__/CodexACPAgent/data/model-filtering.json +++ b/src/__tests__/CodexACPAgent/data/model-filtering.json @@ -1,4 +1,64 @@ [ + { + "modelId": "gpt-6.1-sol[low]", + "name": "6.1 Sol (low)", + "description": "Sol from the live app-server model catalog (2026-10-03). low effort." + }, + { + "modelId": "gpt-6.1-sol[medium]", + "name": "6.1 Sol (medium)", + "description": "Sol from the live app-server model catalog (2026-10-03). medium effort." + }, + { + "modelId": "gpt-6.1-sol[high]", + "name": "6.1 Sol (high)", + "description": "Sol from the live app-server model catalog (2026-10-03). high effort." + }, + { + "modelId": "gpt-6.1-sol[xhigh]", + "name": "6.1 Sol (xhigh)", + "description": "Sol from the live app-server model catalog (2026-10-03). xhigh effort." + }, + { + "modelId": "gpt-6.1-sol[max]", + "name": "6.1 Sol (max)", + "description": "Sol from the live app-server model catalog (2026-10-03). max effort." + }, + { + "modelId": "gpt-6.1-sol[ultra]", + "name": "6.1 Sol (ultra)", + "description": "Sol from the live app-server model catalog (2026-10-03). ultra effort." + }, + { + "modelId": "gpt-6-astra[low]", + "name": "6 Astra (low)", + "description": "Astra from the app-server model catalog. low effort." + }, + { + "modelId": "gpt-6-astra[medium]", + "name": "6 Astra (medium)", + "description": "Astra from the app-server model catalog. medium effort." + }, + { + "modelId": "gpt-6-astra[high]", + "name": "6 Astra (high)", + "description": "Astra from the app-server model catalog. high effort." + }, + { + "modelId": "gpt-6-astra[xhigh]", + "name": "6 Astra (xhigh)", + "description": "Astra from the app-server model catalog. xhigh effort." + }, + { + "modelId": "gpt-6-astra[max]", + "name": "6 Astra (max)", + "description": "Astra from the app-server model catalog. max effort." + }, + { + "modelId": "gpt-6-astra[ultra]", + "name": "6 Astra (ultra)", + "description": "Astra from the app-server model catalog. ultra effort." + }, { "modelId": "gpt-5.2[medium]", "name": "5.2 (medium)", diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts index 4898e9f3b..6f635c66a 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e-models-availability.test.ts @@ -1,8 +1,5 @@ import {afterEach, beforeEach, expect, it} from "vitest"; -import {createAuthenticatedFixture, describeE2E, type SpawnedAgentFixture,} from "./acp-e2e-test-utils"; -import {ModelId} from "../../../ModelId"; - -const DEFAULT_MODEL_ID = ModelId.create("gpt-6-astra", "low") +import {createAuthenticatedFixture, describeE2E, type SpawnedAgentFixture, DEFAULT_TEST_MODEL_ID, OTHER_TEST_MODEL_ID,} from "./acp-e2e-test-utils"; describeE2E("Models availability", () => { let fixture: SpawnedAgentFixture; @@ -18,6 +15,7 @@ describeE2E("Models availability", () => { it(`default model is available`, async () => { const session = await fixture.createSession(); const models = session.models?.availableModels?.map(m => m.modelId); - expect(models).toContain(DEFAULT_MODEL_ID.toString()) + expect(models).toContain(DEFAULT_TEST_MODEL_ID.toString()); + expect(models).toContain(OTHER_TEST_MODEL_ID.toString()); }); }); diff --git a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts index 97cc64442..d01a24b39 100644 --- a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts +++ b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts @@ -9,8 +9,8 @@ import {removeDirectoryWithRetry, writeCodexHomeConfig} from "../../acp-test-uti import type {PermissionResponder} from "./permission-responders"; import type {LegacyNewSessionResponse} from "../../../AcpExtensions"; -export const DEFAULT_TEST_MODEL_ID = ModelId.create("gpt-6-luna", "low"); -export const OTHER_TEST_MODEL_ID = ModelId.create("gpt-5.5", "low"); +export const DEFAULT_TEST_MODEL_ID = ModelId.create("gpt-6.1-sol", "medium"); +export const OTHER_TEST_MODEL_ID = ModelId.create("gpt-6-astra", "low"); export interface TestSkill { readonly name: string; diff --git a/src/__tests__/CodexACPAgent/model-filtering.test.ts b/src/__tests__/CodexACPAgent/model-filtering.test.ts index df967cdee..ef16fbee4 100644 --- a/src/__tests__/CodexACPAgent/model-filtering.test.ts +++ b/src/__tests__/CodexACPAgent/model-filtering.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import type { Model, ReasoningEffortOption } from "../../app-server/v2"; -import { createCodexMockTestFixture } from "../acp-test-utils"; +import { createCodexMockTestFixture, createTestModel } from "../acp-test-utils"; describe("Model filtering", () => { it("filters available models by id allowlist", async () => { @@ -13,6 +13,26 @@ describe("Model filtering", () => { const efforts: ReasoningEffortOption[] = [defaultEffort, fastEffort]; const models: Model[] = [ + createTestModel({ + id: "gpt-6.1-sol", + model: "gpt-6.1-sol", + displayName: "gpt-6.1-sol", + description: "Sol from the live app-server model catalog (2026-10-03).", + supportedReasoningEfforts: (["low", "medium", "high", "xhigh", "max", "ultra"] as const).map( + reasoningEffort => ({reasoningEffort, description: `${reasoningEffort} effort.`}), + ), + defaultReasoningEffort: "medium", + }), + createTestModel({ + id: "gpt-6-astra", + model: "gpt-6-astra", + displayName: "gpt-6-astra", + description: "Astra from the app-server model catalog.", + supportedReasoningEfforts: (["low", "medium", "high", "xhigh", "max", "ultra"] as const).map( + reasoningEffort => ({reasoningEffort, description: `${reasoningEffort} effort.`}), + ), + defaultReasoningEffort: "low", + }), { id: "gpt-5.2", model: "gpt-5.2-model-field", diff --git a/src/__tests__/PricingProvider.test.ts b/src/__tests__/PricingProvider.test.ts new file mode 100644 index 000000000..02d260143 --- /dev/null +++ b/src/__tests__/PricingProvider.test.ts @@ -0,0 +1,86 @@ +import {readFile} from "node:fs/promises"; +import {afterEach, describe, expect, it, vi} from "vitest"; +import {OpenAiPricingProvider} from "../PricingProvider"; +import {SessionCostTracker} from "../SessionCostTracker"; +import {createTestModel} from "./acp-test-utils"; + +describe("Astra pricing", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("uses published standard and fast rates on both sides of the context threshold", async () => { + const markdown = await readFile(new URL("./data/astra-pricing.md", import.meta.url), "utf8"); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(markdown))); + const pricing = await new OpenAiPricingProvider().getPricing([ + createTestModel({id: "gpt-6-astra", model: "gpt-6-astra"}), + ]); + const costs = []; + for (const fast of [false, true]) { + for (const inputTokens of [172_000, 172_001]) { + const usage = { + inputTokens, + cachedInputTokens: 100_000, + outputTokens: 10_000, + reasoningOutputTokens: 0, + cacheWriteInputTokens: 0, + totalTokens: inputTokens + 110_000, + }; + costs.push({ + fast, + contextTokens: inputTokens + usage.cachedInputTokens, + cost: new SessionCostTracker(pricing).update(usage, usage, "gpt-6-astra[max]", fast), + }); + } + } + await expect(JSON.stringify({pricing: Object.fromEntries(pricing), costs}, null, 2)) + .toMatchFileSnapshot("data/astra-costs.json"); + }); +}); + +describe("pricing table schema changes", () => { + afterEach(() => vi.unstubAllGlobals()); + + async function parse(markdown: string) { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(markdown))); + return new OpenAiPricingProvider().getPricing([ + createTestModel({id: "gpt-6-astra", model: "gpt-6-astra"}), + ]); + } + + it("reads reordered columns with both cache-write durations and reproduces the reported chat", async () => { + const markdown = await readFile(new URL("./data/astra-pricing-current.md", import.meta.url), "utf8"); + const pricing = await parse(markdown); + expect(pricing.get("gpt-6-astra")).toEqual({ + standard: { + shortContext: {input: 10, cachedInput: 1, output: 50}, + longContext: {input: 20, cachedInput: 2, output: 75}, + }, + fast: { + shortContext: {input: 20, cachedInput: 2, output: 100}, + longContext: {input: 40, cachedInput: 4, output: 150}, + }, + }); + // One real update was priced at $8.0548 by the positional parser. + const usage = {inputTokens: 175, cachedInputTokens: 161024, outputTokens: 148, + totalTokens: 161347, cacheWriteInputTokens: 0, reasoningOutputTokens: 0}; + expect(new SessionCostTracker(pricing).update(usage, usage, "gpt-6-astra", false)?.amount) + .toBeCloseTo(0.170174, 8); + }); + + it("fails closed for renamed, duplicate, missing or empty required columns", async () => { + const markdown = await readFile(new URL("./data/astra-pricing-current.md", import.meta.url), "utf8"); + for (const changed of [ + markdown.replaceAll("Short context cached input", "Unknown rate"), + markdown.replaceAll("Short context output", "Short context input"), + markdown.replaceAll(" | Short context cached input", ""), + markdown.replaceAll("$1.00", ""), + ]) { + expect((await parse(changed)).size).toBe(0); + } + }); + + it("handles repeated sections but rejects conflicting rates for the same model", async () => { + const markdown = await readFile(new URL("./data/astra-pricing-current.md", import.meta.url), "utf8"); + expect((await parse(markdown + "\n" + markdown)).size).toBe(1); + expect((await parse(markdown + "\n" + markdown.replaceAll("$10.00", "$11.00"))).size).toBe(0); + }); +}); diff --git a/src/__tests__/SessionCostTracker.test.ts b/src/__tests__/SessionCostTracker.test.ts new file mode 100644 index 000000000..f2b111aeb --- /dev/null +++ b/src/__tests__/SessionCostTracker.test.ts @@ -0,0 +1,73 @@ +import {describe, expect, it} from "vitest"; +import type {ModelPricingSnapshot} from "../PricingProvider"; +import {SessionCostTracker} from "../SessionCostTracker"; + +const pricing: ModelPricingSnapshot = new Map([ + ["gpt-test", { + standard: { + shortContext: {input: 10, cachedInput: 2, output: 20}, + longContext: {input: 10, cachedInput: 2, output: 20}, + }, + }], +]); + +describe("SessionCostTracker", () => { + it("stops reporting cost when cache writes have no known rate", async () => { + const tracker = new SessionCostTracker(pricing); + const first = usage(100, 50, 25); + const withWrites = {...usage(200, 100, 50), cacheWriteInputTokens: 30}; + const costs = [ + tracker.update(first, first, "gpt-test[medium]", false), + tracker.update(withWrites, withWrites, "gpt-test[medium]", false), + tracker.update(usage(300, 150, 75), first, "gpt-test[medium]", false), + ]; + await expect(JSON.stringify(costs, null, 2)).toMatchFileSnapshot("data/cache-write-costs.json"); + }); + + it("accumulates USD cost from cumulative token usage", () => { + const tracker = new SessionCostTracker(pricing); + + expect(tracker.update( + usage(1_000_000, 500_000, 250_000), + usage(1_000_000, 500_000, 250_000), + "gpt-test[medium]", + false, + )).toEqual({amount: 16, currency: "USD"}); + + expect(tracker.update( + usage(1_100_000, 550_000, 300_000), + usage(100_000, 50_000, 50_000), + "gpt-test[medium]", + false, + )).toEqual({amount: 18.1, currency: "USD"}); + }); + + it("baselines existing usage when a session is resumed", () => { + const tracker = new SessionCostTracker(pricing, true); + + expect(tracker.update( + usage(1_000_000, 500_000, 250_000), + usage(1_000_000, 500_000, 250_000), + "gpt-test[medium]", + false, + )).toEqual({amount: 0, currency: "USD"}); + + expect(tracker.update( + usage(1_100_000, 550_000, 300_000), + usage(100_000, 50_000, 50_000), + "gpt-test[medium]", + false, + )).toEqual({amount: 2.1, currency: "USD"}); + }); +}); + +function usage(inputTokens: number, cachedInputTokens: number, outputTokens: number) { + return { + totalTokens: inputTokens + cachedInputTokens + outputTokens, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens: 0, + cacheWriteInputTokens: 0, + }; +} diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 5b826b5f0..391d45841 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -5,6 +5,7 @@ import {ToolCallReports} from "../ToolCallReports"; import {CodexAppServerClient, type CodexConnectionEvent} from '../CodexAppServerClient'; import {type CodexConnection, startCodexConnection} from "../CodexJsonRpcConnection"; import {CodexAcpServer, type CodexProcessState, type SessionState} from "../CodexAcpServer"; +import {SessionCostTracker} from "../SessionCostTracker"; import {ACPSessionConnection, type AcpClientConnection} from "../ACPSessionConnection"; import type {ServerNotification} from "../app-server"; import type {MessageConnection} from "vscode-jsonrpc/node"; @@ -116,6 +117,7 @@ export function createBaseTestFixture(config: ConnectionConfig): TestFixture { config.getExitCode, undefined, config.codexProcessState, + {getPricing: async () => new Map()}, ); const transportEvents: CodexConnectionEvent[] = []; @@ -450,6 +452,7 @@ export function createTestSessionState(overrides?: Partial): Sessi goalRevision: 0, sessionTitle: null, sessionTitleSource: "unknown", + costTracker: SessionCostTracker.disabled(), compactions: new CodexSessionCompactions(), toolCallReports: new ToolCallReports(), openToolCalls: new CodexSessionToolCalls(), diff --git a/src/__tests__/data/astra-costs.json b/src/__tests__/data/astra-costs.json new file mode 100644 index 000000000..33a47def8 --- /dev/null +++ b/src/__tests__/data/astra-costs.json @@ -0,0 +1,64 @@ +{ + "pricing": { + "gpt-6-astra": { + "standard": { + "shortContext": { + "input": 10, + "cachedInput": 1, + "output": 50 + }, + "longContext": { + "input": 20, + "cachedInput": 2, + "output": 75 + } + }, + "fast": { + "shortContext": { + "input": 20, + "cachedInput": 2, + "output": 100 + }, + "longContext": { + "input": 40, + "cachedInput": 4, + "output": 150 + } + } + } + }, + "costs": [ + { + "fast": false, + "contextTokens": 272000, + "cost": { + "amount": 2.32, + "currency": "USD" + } + }, + { + "fast": false, + "contextTokens": 272001, + "cost": { + "amount": 4.39002, + "currency": "USD" + } + }, + { + "fast": true, + "contextTokens": 272000, + "cost": { + "amount": 4.64, + "currency": "USD" + } + }, + { + "fast": true, + "contextTokens": 272001, + "cost": { + "amount": 8.78004, + "currency": "USD" + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/data/astra-pricing-current.md b/src/__tests__/data/astra-pricing-current.md new file mode 100644 index 000000000..8c6347f7d --- /dev/null +++ b/src/__tests__/data/astra-pricing-current.md @@ -0,0 +1,13 @@ + + +### Standard pricing data + +| Model | Short context input | Short context output | Short context cached input | Short context 30m write | Short context 12h write | Long context input | Long context output | Long context cached input | Long context 30m write | Long context 12h write | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| gpt-6-astra | $10.00 | $50.00 | $1.00 | $12.50 | $25.00 | $20.00 | $75.00 | $2.00 | $25.00 | $50.00 | + +### Fast pricing data + +| Model | Short context input | Short context output | Short context cached input | Short context 30m write | Short context 12h write | Long context input | Long context output | Long context cached input | Long context 30m write | Long context 12h write | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| gpt-6-astra | $20.00 | $100.00 | $2.00 | $25.00 | $50.00 | $40.00 | $150.00 | $4.00 | $50.00 | $100.00 | diff --git a/src/__tests__/data/astra-pricing.md b/src/__tests__/data/astra-pricing.md new file mode 100644 index 000000000..3fcaec6bf --- /dev/null +++ b/src/__tests__/data/astra-pricing.md @@ -0,0 +1,13 @@ + + +### Standard pricing data + +| Model | Short context input | Short context cached input | Short context cache writes | Short context output | Long context input | Long context cached input | Long context cache writes | Long context output | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| gpt-6-astra | $10.00 | $1.00 | $12.50 | $50.00 | $20.00 | $2.00 | $25.00 | $75.00 | + +### Fast pricing data + +| Model | Short context input | Short context cached input | Short context cache writes | Short context output | Long context input | Long context cached input | Long context cache writes | Long context output | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| gpt-6-astra | $20.00 | $2.00 | $25.00 | $100.00 | $40.00 | $4.00 | $50.00 | $150.00 | diff --git a/src/__tests__/data/cache-write-costs.json b/src/__tests__/data/cache-write-costs.json new file mode 100644 index 000000000..9b0a9bc6d --- /dev/null +++ b/src/__tests__/data/cache-write-costs.json @@ -0,0 +1,8 @@ +[ + { + "amount": 0.0016, + "currency": "USD" + }, + null, + null +] \ No newline at end of file