recording-cost-report.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import * as fs from "node:fs/promises"
  2. import * as path from "node:path"
  3. const RECORDINGS_DIR = path.resolve(import.meta.dir, "..", "test", "fixtures", "recordings")
  4. const MODELS_DEV_URL = "https://models.dev/api.json"
  5. type JsonRecord = Record<string, unknown>
  6. type Pricing = {
  7. readonly input?: number
  8. readonly output?: number
  9. readonly cache_read?: number
  10. readonly cache_write?: number
  11. readonly reasoning?: number
  12. }
  13. type Usage = {
  14. readonly inputTokens: number
  15. readonly outputTokens: number
  16. readonly cacheReadTokens: number
  17. readonly cacheWriteTokens: number
  18. readonly reasoningTokens: number
  19. readonly reportedCost: number
  20. }
  21. type Row = Usage & {
  22. readonly cassette: string
  23. readonly provider: string
  24. readonly model: string
  25. readonly estimatedCost: number
  26. readonly pricingSource: string
  27. }
  28. const isRecord = (value: unknown): value is JsonRecord =>
  29. value !== null && typeof value === "object" && !Array.isArray(value)
  30. const asNumber = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : 0)
  31. const asString = (value: unknown) => (typeof value === "string" ? value : undefined)
  32. const readJson = async (file: string) => JSON.parse(await Bun.file(file).text()) as unknown
  33. const walk = async (dir: string): Promise<ReadonlyArray<string>> =>
  34. (await fs.readdir(dir, { withFileTypes: true }))
  35. .flatMap((entry) => {
  36. const file = path.join(dir, entry.name)
  37. return entry.isDirectory() ? [] : [file]
  38. })
  39. .concat(
  40. ...(await Promise.all(
  41. (await fs.readdir(dir, { withFileTypes: true }))
  42. .filter((entry) => entry.isDirectory())
  43. .map((entry) => walk(path.join(dir, entry.name))),
  44. )),
  45. )
  46. const providerFromUrl = (url: string) => {
  47. if (url.includes("api.openai.com")) return "openai"
  48. if (url.includes("api.anthropic.com")) return "anthropic"
  49. if (url.includes("generativelanguage.googleapis.com")) return "google"
  50. if (url.includes("bedrock")) return "amazon-bedrock"
  51. if (url.includes("openrouter.ai")) return "openrouter"
  52. if (url.includes("api.x.ai")) return "xai"
  53. if (url.includes("api.groq.com")) return "groq"
  54. if (url.includes("api.deepseek.com")) return "deepseek"
  55. if (url.includes("api.together.xyz")) return "togetherai"
  56. return "unknown"
  57. }
  58. const providerAliases: Record<string, ReadonlyArray<string>> = {
  59. openai: ["openai"],
  60. anthropic: ["anthropic"],
  61. google: ["google"],
  62. "amazon-bedrock": ["amazon-bedrock"],
  63. openrouter: ["openrouter", "openai", "anthropic", "google"],
  64. xai: ["xai"],
  65. groq: ["groq"],
  66. deepseek: ["deepseek"],
  67. togetherai: ["togetherai"],
  68. }
  69. const modelAliases = (model: string) => [
  70. model,
  71. model.replace(/^models\//, ""),
  72. model.replace(/-\d{8}$/, ""),
  73. model.replace(/-\d{4}-\d{2}-\d{2}$/, ""),
  74. model.replace(/-\d{4}-\d{2}-\d{2}$/, "").replace(/-\d{8}$/, ""),
  75. model.replace(/^openai\//, ""),
  76. model.replace(/^anthropic\//, ""),
  77. model.replace(/^google\//, ""),
  78. ]
  79. const pricingFor = (models: JsonRecord, provider: string, model: string) => {
  80. for (const providerID of providerAliases[provider] ?? [provider]) {
  81. const providerEntry = models[providerID]
  82. if (!isRecord(providerEntry) || !isRecord(providerEntry.models)) continue
  83. for (const modelID of modelAliases(model)) {
  84. const modelEntry = providerEntry.models[modelID]
  85. if (isRecord(modelEntry) && isRecord(modelEntry.cost))
  86. return { pricing: modelEntry.cost as Pricing, source: `${providerID}/${modelID}` }
  87. }
  88. }
  89. return { pricing: undefined, source: "missing" }
  90. }
  91. const estimateCost = (usage: Usage, pricing: Pricing | undefined) => {
  92. if (!pricing) return 0
  93. return (
  94. (usage.inputTokens * (pricing.input ?? 0) +
  95. usage.outputTokens * (pricing.output ?? 0) +
  96. usage.cacheReadTokens * (pricing.cache_read ?? 0) +
  97. usage.cacheWriteTokens * (pricing.cache_write ?? 0) +
  98. usage.reasoningTokens * (pricing.reasoning ?? 0)) /
  99. 1_000_000
  100. )
  101. }
  102. const emptyUsage = (): Usage => ({
  103. inputTokens: 0,
  104. outputTokens: 0,
  105. cacheReadTokens: 0,
  106. cacheWriteTokens: 0,
  107. reasoningTokens: 0,
  108. reportedCost: 0,
  109. })
  110. const addUsage = (a: Usage, b: Usage): Usage => ({
  111. inputTokens: a.inputTokens + b.inputTokens,
  112. outputTokens: a.outputTokens + b.outputTokens,
  113. cacheReadTokens: a.cacheReadTokens + b.cacheReadTokens,
  114. cacheWriteTokens: a.cacheWriteTokens + b.cacheWriteTokens,
  115. reasoningTokens: a.reasoningTokens + b.reasoningTokens,
  116. reportedCost: a.reportedCost + b.reportedCost,
  117. })
  118. const usageFromObject = (usage: unknown): Usage => {
  119. if (!isRecord(usage)) return emptyUsage()
  120. const promptDetails = isRecord(usage.prompt_tokens_details) ? usage.prompt_tokens_details : {}
  121. const completionDetails = isRecord(usage.completion_tokens_details) ? usage.completion_tokens_details : {}
  122. const inputDetails = isRecord(usage.input_tokens_details) ? usage.input_tokens_details : {}
  123. const outputDetails = isRecord(usage.output_tokens_details) ? usage.output_tokens_details : {}
  124. const cacheWriteTokens = asNumber(promptDetails.cache_write_tokens) + asNumber(inputDetails.cache_write_tokens)
  125. return {
  126. inputTokens: asNumber(usage.prompt_tokens) + asNumber(usage.input_tokens),
  127. outputTokens: asNumber(usage.completion_tokens) + asNumber(usage.output_tokens),
  128. cacheReadTokens: asNumber(promptDetails.cached_tokens) + asNumber(inputDetails.cached_tokens),
  129. cacheWriteTokens,
  130. reasoningTokens: asNumber(completionDetails.reasoning_tokens) + asNumber(outputDetails.reasoning_tokens),
  131. reportedCost: asNumber(usage.cost),
  132. }
  133. }
  134. const jsonPayloads = (body: string) =>
  135. body
  136. .split("\n")
  137. .map((line) => line.trim())
  138. .filter((line) => line.startsWith("data:"))
  139. .map((line) => line.slice("data:".length).trim())
  140. .filter((line) => line !== "" && line !== "[DONE]")
  141. .flatMap((line) => {
  142. try {
  143. return [JSON.parse(line) as unknown]
  144. } catch {
  145. return []
  146. }
  147. })
  148. const usageFromResponseBody = (body: string) =>
  149. jsonPayloads(body).reduce<Usage>((usage, payload) => {
  150. if (!isRecord(payload)) return usage
  151. return addUsage(
  152. usage,
  153. addUsage(
  154. usageFromObject(payload.usage),
  155. usageFromObject(isRecord(payload.response) ? payload.response.usage : undefined),
  156. ),
  157. )
  158. }, emptyUsage())
  159. const modelFromRequest = (request: unknown) => {
  160. if (!isRecord(request)) return "unknown"
  161. const requestBody = asString(request.body)
  162. if (!requestBody) return "unknown"
  163. try {
  164. const body = JSON.parse(requestBody) as unknown
  165. if (!isRecord(body)) return "unknown"
  166. return asString(body.model) ?? "unknown"
  167. } catch {
  168. return "unknown"
  169. }
  170. }
  171. const rowFor = (models: JsonRecord, file: string, cassette: unknown): Row | undefined => {
  172. if (!isRecord(cassette) || !Array.isArray(cassette.interactions)) return undefined
  173. const first = cassette.interactions.find(isRecord)
  174. if (!first || !isRecord(first.request)) return undefined
  175. const provider = providerFromUrl(asString(first.request.url) ?? "")
  176. const model = modelFromRequest(first.request)
  177. const usage = cassette.interactions.filter(isRecord).reduce<Usage>((total, interaction) => {
  178. if (!isRecord(interaction.response)) return total
  179. const responseBody = asString(interaction.response.body)
  180. if (!responseBody) return total
  181. return addUsage(total, usageFromResponseBody(responseBody))
  182. }, emptyUsage())
  183. const priced = pricingFor(models, provider, model)
  184. return {
  185. cassette: path.relative(RECORDINGS_DIR, file),
  186. provider,
  187. model,
  188. ...usage,
  189. estimatedCost: estimateCost(usage, priced.pricing),
  190. pricingSource: priced.source,
  191. }
  192. }
  193. const money = (value: number) => (value === 0 ? "$0.000000" : `$${value.toFixed(6)}`)
  194. const tokens = (value: number) => value.toLocaleString("en-US")
  195. const models = (await (await fetch(MODELS_DEV_URL)).json()) as JsonRecord
  196. const rows = (
  197. await Promise.all(
  198. (await walk(RECORDINGS_DIR))
  199. .filter((file) => file.endsWith(".json"))
  200. .map(async (file) => rowFor(models, file, await readJson(file))),
  201. )
  202. ).filter((row): row is Row => row !== undefined)
  203. const totals = rows.reduce(
  204. (total, row) => ({
  205. ...addUsage(total, row),
  206. estimatedCost: total.estimatedCost + row.estimatedCost,
  207. }),
  208. { ...emptyUsage(), estimatedCost: 0 },
  209. )
  210. console.log("# Recording Cost Report")
  211. console.log("")
  212. console.log(`Pricing: ${MODELS_DEV_URL}`)
  213. console.log(`Cassettes: ${rows.length}`)
  214. console.log(`Reported cost: ${money(totals.reportedCost)}`)
  215. console.log(`Estimated cost: ${money(totals.estimatedCost)}`)
  216. console.log("")
  217. console.log("| Provider | Model | Input | Output | Reasoning | Reported | Estimated | Pricing | Cassette |")
  218. console.log("|---|---:|---:|---:|---:|---:|---:|---|---|")
  219. for (const row of rows.toSorted((a, b) => b.reportedCost + b.estimatedCost - (a.reportedCost + a.estimatedCost))) {
  220. if (row.inputTokens + row.outputTokens + row.reasoningTokens + row.reportedCost + row.estimatedCost === 0) continue
  221. console.log(
  222. `| ${row.provider} | ${row.model} | ${tokens(row.inputTokens)} | ${tokens(row.outputTokens)} | ${tokens(row.reasoningTokens)} | ${money(row.reportedCost)} | ${money(row.estimatedCost)} | ${row.pricingSource} | ${row.cassette} |`,
  223. )
  224. }