From b9284ad8d6c6ec53bb67145ccc5ec635b059d6b7 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 19 Sep 2026 16:23:03 +0000 Subject: [PATCH 1/3] feat: finish model cost accounting --- .audit/model-cost-accounting.tsv | 8 + docs/pricing-snapshots/anthropic.md | 62 +- docs/pricing-snapshots/google-gemini.html | 2153 +++++++---------- docs/pricing-snapshots/metadata.json | 12 +- docs/pricing-snapshots/openai.md | 49 +- docs/testing.md | 2 +- package-lock.json | 482 ++++ package.json | 1 + supabase/functions/_shared/admin-cost.test.ts | 102 + supabase/functions/_shared/admin-cost.ts | 48 + supabase/functions/_shared/cost.test.ts | 15 +- supabase/functions/_shared/cost.ts | 5 + supabase/functions/_shared/model-pricing.ts | 4 +- supabase/functions/admin/index.ts | 115 +- supabase/functions/ai-stream/handler_test.ts | 4 + supabase/functions/ai-stream/index.ts | 23 +- supabase/functions/ai-stream/index_test.ts | 170 ++ supabase/functions/ai/index.ts | 10 +- 18 files changed, 1905 insertions(+), 1360 deletions(-) create mode 100644 .audit/model-cost-accounting.tsv create mode 100644 supabase/functions/_shared/admin-cost.test.ts create mode 100644 supabase/functions/_shared/admin-cost.ts create mode 100644 supabase/functions/ai-stream/index_test.ts diff --git a/.audit/model-cost-accounting.tsv b/.audit/model-cost-accounting.tsv new file mode 100644 index 000000000..d666e98b7 --- /dev/null +++ b/.audit/model-cost-accounting.tsv @@ -0,0 +1,8 @@ +ts phase decision why evidence result +2026-09-19T16:12:50Z isolation resumed feat/model-cost-accounting-20260907 in its dedicated T3 worktree kept the primary checkout and unrelated worktrees untouched git worktree list --porcelain isolated dirty feature branch confirmed +2026-09-19T16:12:50Z revalidation will rebase the accounting work onto current main before release main now routes inference through OpenRouter and changed both AI handlers origin/main 99c2b53, PR #43 old provider-specific implementation cannot ship unchanged +2026-09-19T16:16:50Z pricing refreshed all three official pricing captures with exact tsx 4.19.2 under Node 22 the September 7 unfinished capture was stale for this run docs/pricing-snapshots/metadata.json 11 normalized entries found and gemini-3.1-flash-lite-preview remains unknown +2026-09-19T16:38:03Z integration rebased onto current main and kept the OpenRouter-only handlers PR 43 replaced the plan's direct-provider runtime after the plan was written origin/main 99c2b53 accounting requirements applied to current architecture +2026-09-19T16:38:03Z verification ran the focused, Edge, Vitest, coverage, build, and Chromium gates under Node 22 required fresh evidence before opening the PR local command output 181 Edge, 161 Supabase Vitest, 1026 coverage, and 134 Chromium tests passed +2026-09-19T16:38:03Z browser recorded desktop and Pixel 7 admin accounting proof with isolated fixture data show importer volume while excluding it from model and feature cost rows /home/codex/.t3/userdata/attachments/f963842b-6d26-4bc1-9475-89e89233d3e6-e72a6d26-a7ed-4155-b759-badfb4c330dd-mp4.mp4 video and desktop/mobile screenshots captured +2026-09-19T16:49:00Z review fixed all three release-review blockers with focused regressions abort-after-finish, historical stored costs, and API row caps needed explicit coverage supabase/functions/ai-stream/index_test.ts, supabase/functions/_shared/admin-cost.test.ts reviewer confirmed no remaining P1 issue diff --git a/docs/pricing-snapshots/anthropic.md b/docs/pricing-snapshots/anthropic.md index 44df92de8..05793231b 100644 --- a/docs/pricing-snapshots/anthropic.md +++ b/docs/pricing-snapshots/anthropic.md @@ -12,23 +12,27 @@ For the most current pricing information, visit [claude.com/pricing](https://cla The following table shows pricing for all Claude models: -| Model | Base Input Tokens | 5m Cache Writes | 1h Cache Writes | Cache Hits & Refreshes | Output Tokens | -| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | --------------- | --------------- | ---------------------- | ------------- | -| Claude Fable 5 | $10 / MTok | $12.50 / MTok | $20 / MTok | $1 / MTok | $50 / MTok | -| Claude Mythos 5 ([limited availability](https://anthropic.com/glasswing)) | $10 / MTok | $12.50 / MTok | $20 / MTok | $1 / MTok | $50 / MTok | -| Claude Opus 5 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | -| Claude Opus 4.8 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | -| Claude Opus 4.7 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | -| Claude Opus 4.6 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | -| Claude Opus 4.5 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | -| Claude Opus 4.1 ([retired, except on Bedrock and Google Cloud](https://platform.claude.com/docs/en/about-claude/model-deprecations)) | $15 / MTok | $18.75 / MTok | $30 / MTok | $1.50 / MTok | $75 / MTok | -| Claude Opus 4 ([retired, except on Google Cloud](https://platform.claude.com/docs/en/about-claude/model-deprecations)) | $15 / MTok | $18.75 / MTok | $30 / MTok | $1.50 / MTok | $75 / MTok | -| Claude Sonnet 5 | $2 / MTok | $2.50 / MTok | $4 / MTok | $0.20 / MTok | $10 / MTok | -| Claude Sonnet 4.6 | $3 / MTok | $3.75 / MTok | $6 / MTok | $0.30 / MTok | $15 / MTok | -| Claude Sonnet 4.5 | $3 / MTok | $3.75 / MTok | $6 / MTok | $0.30 / MTok | $15 / MTok | -| Claude Sonnet 4 ([retired, except on Bedrock and Google Cloud](https://platform.claude.com/docs/en/about-claude/model-deprecations)) | $3 / MTok | $3.75 / MTok | $6 / MTok | $0.30 / MTok | $15 / MTok | -| Claude Haiku 4.5 | $1 / MTok | $1.25 / MTok | $2 / MTok | $0.10 / MTok | $5 / MTok | -| Claude Haiku 3.5 ([retired, except on Bedrock and Google Cloud](https://platform.claude.com/docs/en/about-claude/model-deprecations)) | $0.80 / MTok | $1 / MTok | $1.60 / MTok | $0.08 / MTok | $4 / MTok | +| Model | Base input tokens | 5m cache writes | 1h cache writes | Cache hits and refreshes | Output tokens | +| :------------------------------------------------------------------------------------------------------------------------------------ | :---------------- | :-------------- | :-------------- | :----------------------- | :------------ | +| Claude Fable 5.1 | $10 / MTok | $12.50 / MTok | $20 / MTok | $0.25 / MTok1 | $50 / MTok | +| Claude Mythos 5.1 ([limited availability](https://anthropic.com/glasswing)) | $10 / MTok | $12.50 / MTok | $20 / MTok | $0.25 / MTok1 | $50 / MTok | +| Claude Fable 5 | $10 / MTok | $12.50 / MTok | $20 / MTok | $1 / MTok | $50 / MTok | +| Claude Mythos 5 ([limited availability](https://anthropic.com/glasswing)) | $10 / MTok | $12.50 / MTok | $20 / MTok | $1 / MTok | $50 / MTok | +| Claude Opus 5 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | +| Claude Opus 4.8 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | +| Claude Opus 4.7 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | +| Claude Opus 4.6 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | +| Claude Opus 4.5 | $5 / MTok | $6.25 / MTok | $10 / MTok | $0.50 / MTok | $25 / MTok | +| Claude Opus 4.1 ([retired, except on Bedrock and Google Cloud](https://platform.claude.com/docs/en/about-claude/model-deprecations)) | $15 / MTok | $18.75 / MTok | $30 / MTok | $1.50 / MTok | $75 / MTok | +| Claude Opus 4 ([retired, except on Google Cloud](https://platform.claude.com/docs/en/about-claude/model-deprecations)) | $15 / MTok | $18.75 / MTok | $30 / MTok | $1.50 / MTok | $75 / MTok | +| Claude Sonnet 5 | $2 / MTok | $2.50 / MTok | $4 / MTok | $0.20 / MTok | $10 / MTok | +| Claude Sonnet 4.6 | $3 / MTok | $3.75 / MTok | $6 / MTok | $0.30 / MTok | $15 / MTok | +| Claude Sonnet 4.5 | $3 / MTok | $3.75 / MTok | $6 / MTok | $0.30 / MTok | $15 / MTok | +| Claude Sonnet 4 ([retired, except on Bedrock and Google Cloud](https://platform.claude.com/docs/en/about-claude/model-deprecations)) | $3 / MTok | $3.75 / MTok | $6 / MTok | $0.30 / MTok | $15 / MTok | +| Claude Haiku 4.5 | $1 / MTok | $1.25 / MTok | $2 / MTok | $0.10 / MTok | $5 / MTok | +| Claude Haiku 3.5 ([retired, except on Bedrock and Google Cloud](https://platform.claude.com/docs/en/about-claude/model-deprecations)) | $0.80 / MTok | $1 / MTok | $1.60 / MTok | $0.08 / MTok | $4 / MTok | + +*1 Cache hits and refreshes on Claude Fable 5.1 and Claude Mythos 5.1 are priced at 0.025x the base input price. All other models use the standard 0.1x multiplier.* The $2/$10 per million input/output token pricing for Claude Sonnet 5, announced at launch as introductory pricing through August 31, 2026, is now the standard price. The previously scheduled increase to $3/$15 per million input/output tokens on September 1, 2026 will not occur. @@ -138,13 +142,13 @@ There are two ways to enable prompt caching: Prompt caching uses the following pricing multipliers relative to base input token rates: -| Cache operation | Multiplier | Duration | -| -------------------- | ---------------------- | ------------------------------------ | -| 5-minute cache write | 1.25x base input price | Cache valid for 5 minutes | -| 1-hour cache write | 2x base input price | Cache valid for 1 hour | -| Cache read (hit) | 0.1x base input price | Same duration as the preceding write | +| Cache operation | Multiplier | Duration | +| -------------------- | ------------------------------------------------------------------------ | ------------------------------------ | +| 5-minute cache write | 1.25x base input price | Cache valid for 5 minutes | +| 1-hour cache write | 2x base input price | Cache valid for 1 hour | +| Cache read (hit) | 0.1x base input price (0.025x on Claude Fable 5.1 and Claude Mythos 5.1) | Same duration as the preceding write | -Cache write tokens are charged when content is first stored. Cache read tokens are charged when a subsequent request retrieves the cached content. A cache hit costs 10% of the standard input price, which means caching pays off after one cache read for the 5-minute duration (1.25x write), or after two cache reads for the 1-hour duration (2x write). +Cache write tokens are charged when content is first stored. Cache read tokens are charged when a subsequent request retrieves the cached content. A cache hit costs 10% of the standard input price, which means caching pays off after one cache read for the 5-minute duration (1.25x write), or after two cache reads for the 1-hour duration (2x write). On Claude Fable 5.1 and Claude Mythos 5.1, a cache hit costs 2.5% of the standard input price ($0.25 USD per million tokens). These multipliers stack with other pricing modifiers, including the Batch API discount and data residency. @@ -182,7 +186,9 @@ For more information, see [Fast mode](https://platform.claude.com/docs/en/build- The Batch API allows asynchronous processing of large volumes of requests with a 50% discount on both input and output tokens. | Model | Batch input | Batch output | -| ------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ------------- | +| :------------------------------------------------------------------------------------------------------------------------------------ | :----------- | :------------ | +| Claude Fable 5.1 | $5 / MTok | $25 / MTok | +| Claude Mythos 5.1 ([limited availability](https://anthropic.com/glasswing)) | $5 / MTok | $25 / MTok | | Claude Fable 5 | $5 / MTok | $25 / MTok | | Claude Mythos 5 ([limited availability](https://anthropic.com/glasswing)) | $5 / MTok | $25 / MTok | | Claude Opus 5 | $2.50 / MTok | $12.50 / MTok | @@ -501,19 +507,19 @@ Contact the sales team at [sales@anthropic.com](mailto:sales@anthropic.com) or t ## Frequently asked questions -**How is token usage calculated?** +### How is token usage calculated? Tokens are pieces of text that models process. As a rough estimate, 1 token is approximately 4 characters or 0.75 words in English. The exact count varies by language and content type. -**Are there free tiers or trials?** +### Are there free tiers or trials? New users receive a small amount of free credits to test the API. [Contact sales](mailto:sales@anthropic.com) for information about extended trials for enterprise evaluation. -**How do discounts stack?** +### How do discounts stack? Batch API and prompt caching discounts can be combined. For example, using both features together provides significant cost savings compared to standard API calls. See [prompt caching pricing](https://platform.claude.com/docs/en/about-claude/pricing#prompt-caching) for how the multipliers interact. -**What payment methods are accepted?** +### What payment methods are accepted? Major credit cards are accepted for standard accounts. Enterprise customers can arrange invoicing and other payment methods. diff --git a/docs/pricing-snapshots/google-gemini.html b/docs/pricing-snapshots/google-gemini.html index 90d09f551..477d2643a 100644 --- a/docs/pricing-snapshots/google-gemini.html +++ b/docs/pricing-snapshots/google-gemini.html @@ -25,11 +25,11 @@ + href="//fonts.googleapis.com/css2?family=Material+Icons&family=Material+Symbols+Outlined&display=block"> - - - + + All models + alt-paths=" /gemini-api/docs/models/gemini-3.1-flash-image /gemini-api/docs/models/gemini-3.1-flash-lite-image /gemini-api/docs/models/gemini-3.1-pro-preview /gemini-api/docs/models/gemini-3-pro-preview /gemini-api/docs/models/gemini-3-pro-image /gemini-api/docs/models/gemini-3.8-flash /gemini-api/docs/models/gemini-3.7-flash /gemini-api/docs/models/gemini-3.6-flash /gemini-api/docs/models/gemini-3.5-flash /gemini-api/docs/models/gemini-3.8-live /gemini-api/docs/models/gemini-3.8-live-extended-thinking /gemini-api/docs/models/gemini-3.5-live-translate-preview /gemini-api/docs/models/gemini-3-flash-preview /gemini-api/docs/models/gemini-3.1-flash-tts-preview /gemini-api/docs/models/veo-3.1-lite-generate-preview /gemini-api/docs/models/gemini-3.1-flash-live-preview /gemini-api/docs/models/gemini-3.5-flash-lite /gemini-api/docs/models/gemini-3.1-flash-lite /gemini-api/docs/models/gemini-3.1-flash-lite-preview /gemini-api/docs/models/gemini-2.5-flash /gemini-api/docs/models/gemini-2.5-flash-preview-09-2025 /gemini-api/docs/models/gemini-2.5-flash-image /gemini-api/docs/models/gemini-2.5-flash-native-audio-preview-12-2025 /gemini-api/docs/models/gemini-2.5-flash-preview-tts /gemini-api/docs/models/gemini-2.5-flash-lite /gemini-api/docs/models/gemini-2.5-flash-lite-preview-09-2025 /gemini-api/docs/models/gemini-2.5-pro /gemini-api/docs/models/gemini-2.5-pro-preview-tts /gemini-api/docs/models/gemini-2.5-computer-use-preview-10-2025 /gemini-api/docs/models/gemini-2.0-flash /gemini-api/docs/models/gemini-2.0-flash-lite /gemini-api/docs/models/imagen /gemini-api/docs/models/veo-3.1-generate-preview /gemini-api/docs/models/veo-2.0-generate-001 /gemini-api/docs/models/gemini-embedding-001 /gemini-api/docs/models/gemini-embedding-2 /gemini-api/docs/models/gemini-robotics-er-1.5-preview /gemini-api/docs/models/gemini-robotics-er-2-preview /gemini-api/docs/models/gemini-robotics-er-2-streaming-preview /gemini-api/docs/models/gemini-robotics-er-1.6-preview /gemini-api/docs/models/deep-research-pro-preview-12-2025 /gemini-api/docs/models/deep-research-preview-04-2026 /gemini-api/docs/models/deep-research-max-preview-04-2026 /gemini-api/docs/models/antigravity-preview-05-2026 /gemini-api/docs/models/lyria-realtime-exp /gemini-api/docs/models/lyria-3.5 /gemini-api/docs/models/lyria-3-clip-preview /gemini-api/docs/models/lyria-3-pro-preview /gemini-api/docs/models/gemini-omni-flash /gemini-api/docs/models/gemini-3.5-transcribe ">All models
  • Gemini Omni Flash
  • -
  • +
    + Lyria 3.5 & Lyria Clip +
  • Text-to-speech
  • +
  • Transcribe
  • +
  • Live
  • @@ -961,37 +965,17 @@ Robotics + >Video understanding
  • @@ -1030,15 +1014,13 @@ devsite-nav-expandable">
  • + >Audio understanding
  • Audio transcription
  • @@ -1075,13 +1057,9 @@ class="devsite-nav-title" >Quickstart
  • -
  • Antigravity agent
  • + >Antigravity agent
  • Environments
  • +
  • Credentials
  • +
  • Hooks
  • -
  • Deep Research agent
  • + >Deep Research agent
  • @@ -1128,38 +1106,26 @@ class="devsite-nav-title" >URL context
  • -
  • Computer use
  • + >Computer use
  • File search
  • -
  • Combine tools and function calling
  • + >Combine tools and function calling
  • Live API
  • -
  • Overview
  • + >Overview
  • @@ -1175,6 +1141,18 @@ class="devsite-nav-title" >Capabilities
  • +
  • Thinking
  • + +
  • Live transcription
  • +
  • Live translation
  • @@ -1187,13 +1165,9 @@ class="devsite-nav-title" >Session management -
  • Ephemeral tokens
  • + >Ephemeral tokens
  • Batch API
  • -
  • Webhooks
  • + >Webhooks -
  • Flex inference
  • + >Flex inference -
  • Priority inference
  • + >Priority inference
  • Deploying your app
  • Agents in AI Studio Playground
  • Agents in AI Studio Playground
  • Try out LearnLM
  • Try out LearnLM
  • Troubleshooting
  • - The Interactions API is now generally available. We recommend using this API for access to all the latest features and models. + Gemini 3.8 Flash is now available. Try it out.
    @@ -3226,6 +3184,7 @@

    +

    Start building free of charge with generous limits, then scale up with @@ -3258,7 +3217,7 @@

    -

    Gemma 4

    +

    Gemma 4

    Our lightweight, state-of the art, open model built from the same technology @@ -7378,7 +7065,7 @@

    Pricing f File search Free of charge - Charged for embeddings at $0.15 / 1M tokens.
    Retrieved document tokens charged as regular tokens per model pricing. + Charged for embeddings at $0.15 / 1M tokens.
    Retrieved document tokens charged as regular tokens per model pricing. Custom Tools endpoint (Gemini 3.1 Pro Preview) @@ -7443,6 +7130,12 @@

    Pricing

    Notes

      +
    • Agentic video understanding: When using agentic video understanding, +token usage is variable based on the content loaded by the model rather than +full video length. This typically results in up to 88% fewer input tokens for +long-form video, though token counts depend on query complexity and dynamic +sampling depth (which may exceed 1 FPS for detailed visual segments). See +Agentic video understanding.
    • Document token billing: Tokens for the DOCUMENT modality (for example, PDFs) are billed at the image token rate. In API responses, these tokens appear under the DOCUMENT modality within promptTokensDetails.
    • Google AI Studio usage is free of charge in all available regions. See Billing FAQs for details.
    • Prices may differ from the prices listed here and the prices offered on @@ -7469,7 +7162,7 @@

      Notes

      @@ -7483,7 +7176,7 @@

      Notes

      @@ -7707,12 +7400,12 @@

      Notes

      - diff --git a/docs/pricing-snapshots/metadata.json b/docs/pricing-snapshots/metadata.json index 79aab91bd..83e24529a 100644 --- a/docs/pricing-snapshots/metadata.json +++ b/docs/pricing-snapshots/metadata.json @@ -1,7 +1,7 @@ { - "snapshotId": "official-docs-2026-08-22", - "snapshotContentHash": "e649d9fc6012fad889888747e4b09d6a909141048b2ce3f51fe55edc1a35df39", - "retrievedAt": "2026-08-22T10:31:29.666Z", + "snapshotId": "official-docs-2026-09-19", + "snapshotContentHash": "a9d257cf4850377cf50199bcecc4fb5a67afe0f7ac009f21d5d98d85f0c3dae2", + "retrievedAt": "2026-09-19T16:16:50.827Z", "currency": "USD", "sources": [ { @@ -9,21 +9,21 @@ "url": "https://platform.openai.com/docs/pricing", "file": "openai.md", "format": "markdown (official .md endpoint)", - "sha256": "1c54363f719988de1b4382035c9ad70422ad3ea6a99e77a094dd43e15a67169d" + "sha256": "63878741d55c9dc3c928cd88ac8f55399198daedfb49456f816d7d2cbab96768" }, { "provider": "anthropic", "url": "https://platform.claude.com/docs/en/about-claude/pricing", "file": "anthropic.md", "format": "markdown (official .md endpoint)", - "sha256": "c3ea88b25e2906d72bc7b26693bb752bfb97d31952e5b814cf7f62d4087b2f8b" + "sha256": "52ec9ccca111bc1635c300684c893fcedff8b146e5f6bed3c1c8c0fdf29a8d84" }, { "provider": "google", "url": "https://ai.google.dev/gemini-api/docs/pricing", "file": "google-gemini.html", "format": "html", - "sha256": "90e36d7256446436dd23eabb2f26294aa62105e9c373a13eca58ec9ddf1db2a1" + "sha256": "98839b6fa83ada71f90076d1d01e1a10af9650fe6007b565356c31feec3a4766" } ], "modelFindings": { diff --git a/docs/pricing-snapshots/openai.md b/docs/pricing-snapshots/openai.md index 839342adb..baf20e8d7 100644 --- a/docs/pricing-snapshots/openai.md +++ b/docs/pricing-snapshots/openai.md @@ -23,6 +23,7 @@ Standard | 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 | | gpt-5.6-sol | $4.00 | $0.40 | $5.00 | $20.00 | $8.00 | $0.80 | $10.00 | $30.00 | | gpt-5.6-terra | $2.00 | $0.20 | $2.50 | $12.00 | $4.00 | $0.40 | $5.00 | $18.00 | | gpt-5.6-luna | $0.20 | $0.02 | $0.25 | $1.20 | $0.40 | $0.04 | $0.50 | $1.80 | @@ -60,7 +61,7 @@ Standard | davinci-002 | $2.00 | - | - | $2.00 | - | - | - | - | | babbage-002 | $0.40 | - | - | $0.40 | - | - | - | - | -Regional processing (data residency) endpoints are charged a 10% uplift for models released on or after March 5, 2026, that are eligible for data residency. See our [Your data](https://developers.openai.com/api/docs/guides/your-data) guide for supported regions and processing details. [OpenAI models in Amazon Bedrock](https://developers.openai.com/api/docs/guides/amazon-bedrock) are billed through AWS and may differ from direct OpenAI pricing. Priority processing was renamed Fast mode on July 30, 2026. You can use either `service_tier: "priority"` or `service_tier: "fast"` in your API requests. [Learn more about Fast mode](https://developers.openai.com/api/docs/guides/fast-mode). GPT-5.6 Sol’s promotional pricing is available at least through November 21, 2026. +Regional processing (data residency) endpoints are charged a 10% uplift for models released on or after March 5, 2026, that are eligible for data residency. See our [Your data](https://developers.openai.com/api/docs/guides/your-data) guide for supported regions and processing details. [OpenAI models in Amazon Bedrock](https://developers.openai.com/api/docs/guides/amazon-bedrock) are billed through AWS. Bedrock pricing in commercial regions matches OpenAI direct pricing for equivalent services. Priority processing was renamed Fast mode on July 30, 2026. You can use either `service_tier: "priority"` or `service_tier: "fast"` in your API requests. [Learn more about Fast mode](https://developers.openai.com/api/docs/guides/fast-mode). GPT-5.6 Sol’s promotional pricing is available at least through November 21, 2026. @@ -75,6 +76,7 @@ Batch | 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 | $5.00 | $0.50 | $6.25 | $25.00 | $10.00 | $1.00 | $12.50 | $37.50 | | gpt-5.6-sol | $2.00 | $0.20 | $2.50 | $10.00 | $4.00 | $0.40 | $5.00 | $15.00 | | gpt-5.6-terra | $1.00 | $0.10 | $1.25 | $6.00 | $2.00 | $0.20 | $2.50 | $9.00 | | gpt-5.6-luna | $0.10 | $0.01 | $0.125 | $0.60 | $0.20 | $0.02 | $0.25 | $0.90 | @@ -125,6 +127,7 @@ Flex | 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 | $5.00 | $0.50 | $6.25 | $25.00 | $10.00 | $1.00 | $12.50 | $37.50 | | gpt-5.6-sol | $2.00 | $0.20 | $2.50 | $10.00 | $4.00 | $0.40 | $5.00 | $15.00 | | gpt-5.6-terra | $1.00 | $0.10 | $1.25 | $6.00 | $2.00 | $0.20 | $2.50 | $9.00 | | gpt-5.6-luna | $0.10 | $0.01 | $0.125 | $0.60 | $0.20 | $0.02 | $0.25 | $0.90 | @@ -157,6 +160,7 @@ Fast mode | 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 | | gpt-5.6-sol | $8.00 | $0.80 | $10.00 | $40.00 | $16.00 | $1.60 | $20.00 | $60.00 | | gpt-5.6-terra | $4.00 | $0.40 | $5.00 | $24.00 | $8.00 | $0.80 | $10.00 | $36.00 | | gpt-5.6-luna | $0.40 | $0.04 | $0.50 | $2.40 | $0.80 | $0.08 | $1.00 | $3.60 | @@ -176,7 +180,7 @@ Fast mode | o3 | $3.50 | $0.875 | - | $14.00 | - | - | - | - | | o4-mini | $2.00 | $0.50 | - | $8.00 | - | - | - | - | -Regional processing (data residency) endpoints are charged a 10% uplift for models released on or after March 5, 2026, that are eligible for data residency. See our [Your data](https://developers.openai.com/api/docs/guides/your-data) guide for supported regions and processing details. +Fast mode is unavailable for GPT-6 Astra with EU data residency. Use Standard processing for those requests. See [Fast mode compatibility](https://developers.openai.com/api/docs/guides/fast-mode). Regional processing (data residency) endpoints are charged a 10% uplift for models released on or after March 5, 2026, that are eligible for data residency. See our [Your data](https://developers.openai.com/api/docs/guides/your-data) guide for supported regions and processing details. @@ -204,15 +208,14 @@ Prices per 1M tokens. | gpt-5.6-sol | $4.00 | $0.40 | $5.00 | $20.00 | $8.00 | $0.80 | $10.00 | $30.00 | | gpt-5.6-cyber | $12.50 | $1.25 | $15.625 | $75.00 | - | - | - | - | | gpt-5.5-cyber | $12.50 | $1.25 | - | $75.00 | - | - | - | - | -| gpt-5.4-cyber | - | - | - | - | - | - | - | - | - `daybreak-blue-latest` and `daybreak-red-latest` are - aliases that currently point to `gpt-5.6-sol` and - `gpt-5.6-cyber`, respectively. As new frontier models are released - through the Daybreak program, these aliases will be updated to point to the - latest models, with pricing adjusted to match each underlying model. + `gpt-daybreak-blue-latest` and `gpt-daybreak-red-latest` + are aliases that currently point to `gpt-5.6-sol` and + `gpt-5.6-cyber`, respectively. As new models are released through + the Daybreak program, these aliases will be updated to point to the latest + models, with pricing adjusted to match each underlying model. @@ -228,6 +231,27 @@ Multimodal models +To estimate vision model input costs, use the [image input cost +calculator](https://developers.openai.com/api/docs/guides/image-cost-calculator). + + + + +GPT-Live sessions + + + + +[GPT-Live 1](https://developers.openai.com/api/docs/models/gpt-live-1) voice sessions are billed per second, +without rounding up to a whole minute. Backend model and tool usage is charged +separately. + +### Pricing Table data + +| Model | Price per minute | +| --- | --- | +| gpt-live-1 | $0.05 | + @@ -300,6 +324,10 @@ Standard | Model | Modality | Input | Cached input | Output | | --- | --- | --- | --- | --- | +| gpt-image-2.5-sunburst | Image | $8.00 | $2.00 | $30.00 | +| gpt-image-2.5-sunburst | Text | $5.00 | $1.25 | - | +| gpt-image-2.5-flare | Image | $8.00 | $2.00 | $30.00 | +| gpt-image-2.5-flare | Text | $5.00 | $1.25 | - | | gpt-image-2 | Image | $8.00 | $2.00 | $30.00 | | gpt-image-2 | Text | $5.00 | $1.25 | - | | gpt-image-1.5 | Image | $8.00 | $2.00 | $32.00 | @@ -475,12 +503,15 @@ Standard | --- | --- | --- | --- | --- | | ChatGPT | chat-latest | $5.00 | $0.50 | $30.00 | | Codex | gpt-5.3-codex | $1.75 | $0.175 | $14.00 | +| Life Sciences | gpt-rosalind-research | $5.00 | $0.50 | $25.00 | | Search | gpt-5-search-api | $1.25 | $0.125 | $10.00 | | Embedding | text-embedding-3-small | $0.02 | - | - | | Embedding | text-embedding-3-large | $0.13 | - | - | | Embedding | text-embedding-ada-002 | $0.10 | - | - | | Moderation | omni-moderation-latest | Free | - | - | +Billing for `gpt-rosalind-research` begins on October 5, 2026. Cache-write pricing does not apply to this model. Access is limited to approved internal research through the [trusted-access program](https://help.openai.com/en/articles/20001193-gpt-rosalind-for-life-sciences-research). All eligible organizations will continue to get access to the latest GPT-Rosalind models as they’re released. Regional processing (data residency) endpoints are charged a 10% uplift for models released on or after March 5, 2026, that are eligible for data residency. See our [Your data](https://developers.openai.com/api/docs/guides/your-data) guide for supported regions and processing details. + @@ -496,6 +527,8 @@ Fast mode | --- | --- | --- | --- | --- | | Codex | gpt-5.3-codex | $3.50 | $0.35 | $28.00 | +Regional processing (data residency) endpoints are charged a 10% uplift for models released on or after March 5, 2026, that are eligible for data residency. See our [Your data](https://developers.openai.com/api/docs/guides/your-data) guide for supported regions and processing details. + diff --git a/docs/testing.md b/docs/testing.md index efcb32162..11a259524 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -56,7 +56,7 @@ npm run test:edge This uses Deno directly for `supabase/functions/`, including tests that import deployed handler boundaries and remote Deno modules. The npm script declares the allowed filesystem, environment, and loopback-network permissions. -A browser-oriented Vitest pass does not replace native Deno tests. +A browser-oriented Vitest pass does not replace native Deno tests. The stream accounting handler tests use synthetic OpenRouter responses and inspect audit inserts for complete, interrupted, and truncated streams. The permission list includes the OpenRouter key name because the handler reads it, but these tests require no real provider credentials. ## Production build diff --git a/package-lock.json b/package-lock.json index bcf634f89..d46abfc81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,6 +90,7 @@ "jsdom": "^20.0.3", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", + "tsx": "4.19.2", "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", "vite": "^7.3.1", @@ -6799,6 +6800,19 @@ "node": ">= 0.4" } }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -9908,6 +9922,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -10759,6 +10783,464 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", + "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.23.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", + "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", + "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", + "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", + "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.23.1", + "@esbuild/android-arm": "0.23.1", + "@esbuild/android-arm64": "0.23.1", + "@esbuild/android-x64": "0.23.1", + "@esbuild/darwin-arm64": "0.23.1", + "@esbuild/darwin-x64": "0.23.1", + "@esbuild/freebsd-arm64": "0.23.1", + "@esbuild/freebsd-x64": "0.23.1", + "@esbuild/linux-arm": "0.23.1", + "@esbuild/linux-arm64": "0.23.1", + "@esbuild/linux-ia32": "0.23.1", + "@esbuild/linux-loong64": "0.23.1", + "@esbuild/linux-mips64el": "0.23.1", + "@esbuild/linux-ppc64": "0.23.1", + "@esbuild/linux-riscv64": "0.23.1", + "@esbuild/linux-s390x": "0.23.1", + "@esbuild/linux-x64": "0.23.1", + "@esbuild/netbsd-x64": "0.23.1", + "@esbuild/openbsd-arm64": "0.23.1", + "@esbuild/openbsd-x64": "0.23.1", + "@esbuild/sunos-x64": "0.23.1", + "@esbuild/win32-arm64": "0.23.1", + "@esbuild/win32-ia32": "0.23.1", + "@esbuild/win32-x64": "0.23.1" + } + }, + "node_modules/tsx/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 251e0da33..dd18b311f 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,7 @@ "jsdom": "^20.0.3", "postcss": "^8.5.6", "tailwindcss": "^3.4.17", + "tsx": "4.19.2", "typescript": "^5.8.3", "typescript-eslint": "^8.38.0", "vite": "^7.3.1", diff --git a/supabase/functions/_shared/admin-cost.test.ts b/supabase/functions/_shared/admin-cost.test.ts new file mode 100644 index 000000000..d03e420ff --- /dev/null +++ b/supabase/functions/_shared/admin-cost.test.ts @@ -0,0 +1,102 @@ +import { + collectAdminUsagePages, + getKnownCost, + hasUnknownCost, + isCostableAiRequest, + isNonAiActivity, +} from "./admin-cost.ts"; + +Deno.test("known successful AI rows contribute their stored cost", () => { + const row = { + feature: "ask", + status: "success", + cost_usd: 0.0125, + input_tokens: 100, + output_tokens: 50, + }; + + if (!isCostableAiRequest(row)) throw new Error("known AI row should be costable"); + if (hasUnknownCost(row)) throw new Error("known AI row should not be unknown"); + if (getKnownCost(row) !== 0.0125) throw new Error("known AI cost was not preserved"); +}); + +Deno.test("unknown successful AI rows are costable but have unknown cost", () => { + const row = { + feature: "generate", + status: "success", + cost_usd: null, + input_tokens: 100, + output_tokens: 50, + }; + + if (!isCostableAiRequest(row)) throw new Error("unknown AI row should be costable"); + if (!hasUnknownCost(row)) throw new Error("unknown AI row was not marked unknown"); + if (getKnownCost(row) !== 0) throw new Error("unknown AI row contributed known cost"); +}); + +Deno.test("rate-limited rows are not unknown, while provider failures are", () => { + const rateLimited = { feature: "ask", status: "rate_limited", cost_usd: 1.25, input_tokens: null, output_tokens: null }; + if (isCostableAiRequest(rateLimited)) throw new Error("rate-limited row should not be costable"); + if (hasUnknownCost(rateLimited)) throw new Error("rate-limited row should not be unknown cost"); + if (getKnownCost(rateLimited) !== 0) throw new Error("rate-limited cost leaked into AI totals"); + + for (const status of ["error", "cancelled"]) { + const providerFailure = { feature: "ask", status, cost_usd: null, input_tokens: 100, output_tokens: 50 }; + if (!isCostableAiRequest(providerFailure)) throw new Error(`${status} provider work should be costable`); + if (!hasUnknownCost(providerFailure)) throw new Error(`${status} provider work should be unknown cost`); + } +}); + +Deno.test("successful importer rows remain volume activity without AI cost", () => { + const row = { + feature: "import_source", + status: "success", + cost_usd: 1.25, + input_tokens: null, + output_tokens: null, + }; + + if (!isNonAiActivity(row)) throw new Error("importer row was classified as AI activity"); + if (isCostableAiRequest(row)) throw new Error("importer row should not be costable"); + if (hasUnknownCost(row)) throw new Error("importer row should not be unknown AI cost"); + if (getKnownCost(row) !== 0) throw new Error("importer cost leaked into AI totals"); +}); + +Deno.test("invalid stored costs remain unknown instead of becoming free", () => { + const row = { + feature: "ask", + status: "success", + cost_usd: Number.NaN, + input_tokens: 100, + output_tokens: 50, + }; + + if (!hasUnknownCost(row)) throw new Error("invalid cost should be unknown"); + if (getKnownCost(row) !== 0) throw new Error("invalid cost should not contribute to known totals"); +}); + +Deno.test("stored costs stay known when historical token columns are null", () => { + const row = { + feature: "ask", + status: "success", + cost_usd: 0.25, + input_tokens: null, + output_tokens: null, + }; + + if (hasUnknownCost(row)) throw new Error("stored cost was reclassified as unknown"); + if (getKnownCost(row) !== 0.25) throw new Error("stored cost was dropped"); +}); + +Deno.test("admin usage pagination reads past one full page", async () => { + const calls: Array<[number, number]> = []; + const rows = await collectAdminUsagePages(async (from, to) => { + calls.push([from, to]); + return { data: from === 0 ? [1, 2] : [3], error: null }; + }, 2); + + if (rows.join(",") !== "1,2,3") throw new Error(`rows were truncated: ${rows}`); + if (JSON.stringify(calls) !== JSON.stringify([[0, 1], [2, 3]])) { + throw new Error(`unexpected page ranges: ${JSON.stringify(calls)}`); + } +}); diff --git a/supabase/functions/_shared/admin-cost.ts b/supabase/functions/_shared/admin-cost.ts new file mode 100644 index 000000000..6aaf12ac6 --- /dev/null +++ b/supabase/functions/_shared/admin-cost.ts @@ -0,0 +1,48 @@ +export const IMPORT_SOURCE_FEATURE = "import_source"; + +export type AdminCostRow = { + feature?: string | null; + status?: string | null; + cost_usd?: number | string | null; + input_tokens?: number | null; + output_tokens?: number | null; +}; + +export function isNonAiActivity(row: Pick): boolean { + return row.feature === IMPORT_SOURCE_FEATURE; +} + +export function isCostableAiRequest(row: Pick): boolean { + return (row.status === "success" || row.status === "error" || row.status === "cancelled") && !isNonAiActivity(row); +} + +function isKnownCostValue(value: AdminCostRow["cost_usd"]): value is number | string { + if (value === null || value === undefined || (typeof value === "string" && value.trim() === "")) return false; + const cost = Number(value); + return Number.isFinite(cost) && cost >= 0; +} + +export function hasUnknownCost(row: AdminCostRow): boolean { + return isCostableAiRequest(row) && !isKnownCostValue(row.cost_usd); +} + +export function getKnownCost(row: AdminCostRow): number { + if (!isCostableAiRequest(row) || hasUnknownCost(row)) return 0; + if (!isKnownCostValue(row.cost_usd)) return 0; + const cost = Number(row.cost_usd); + return Number.isFinite(cost) ? cost : 0; +} + +export async function collectAdminUsagePages( + loadPage: (from: number, to: number) => Promise<{ data: T[] | null; error: unknown }>, + pageSize = 1000, +): Promise { + const rows: T[] = []; + for (let from = 0;; from += pageSize) { + const { data, error } = await loadPage(from, from + pageSize - 1); + if (error) throw error; + const page = data || []; + rows.push(...page); + if (page.length < pageSize) return rows; + } +} diff --git a/supabase/functions/_shared/cost.test.ts b/supabase/functions/_shared/cost.test.ts index 2538f927b..6217ddcab 100644 --- a/supabase/functions/_shared/cost.test.ts +++ b/supabase/functions/_shared/cost.test.ts @@ -1,6 +1,7 @@ import { calculateRequestCost, mergeNormalizedUsage, + markIncompleteUsage, normalizeAnthropicUsage, normalizeGoogleUsage, normalizeOpenRouterUsage, @@ -21,7 +22,7 @@ Deno.test("openai standard usage costs input and output at short-context rates", if (result.inputTokens !== 1000 || result.outputTokens !== 500) throw new Error("token passthrough failed"); // 1000/1M * $0.75 + 500/1M * $4.50 if (!approxEqual(result.costUsd, 0.00075 + 0.00225)) throw new Error(`unexpected cost ${result.costUsd}`); - if (result.pricingSnapshotId !== "official-docs-2026-08-22") throw new Error("snapshot id missing"); + if (result.pricingSnapshotId !== "official-docs-2026-09-19") throw new Error("snapshot id missing"); }); Deno.test("openai cached and reasoning tokens are not double counted", () => { @@ -260,3 +261,15 @@ Deno.test("observed usage selection prefers completed usage after abort and supp throw new Error("missing usage should remain undefined"); } }); + +Deno.test("incomplete stream usage preserves observations and fails closed without usage", () => { + const raw = { prompt_tokens: 100, completion_tokens: 20 }; + const observed = normalizeOpenAIUsage(raw); + for (const usage of [observed, undefined]) { + const marked = markIncompleteUsage(usage); + const cost = calculateRequestCost({ provider: "openai", model: "gpt-5.4-mini", usage: marked }); + if (!marked.incomplete || cost.costUsd !== null) throw new Error("Partial work must remain unknown-cost"); + if (usage && (marked.raw !== raw || cost.inputTokens !== 100 || cost.outputTokens !== 20)) throw new Error("Observed usage changed"); + } + if (observed.incomplete) throw new Error("Marker mutated original usage"); +}); diff --git a/supabase/functions/_shared/cost.ts b/supabase/functions/_shared/cost.ts index 2db7b61aa..3c39fc775 100644 --- a/supabase/functions/_shared/cost.ts +++ b/supabase/functions/_shared/cost.ts @@ -255,3 +255,8 @@ export function selectObservedUsage( ? usageError?.completedUsage ?? usageError?.usage : usageError?.usage ?? usageError?.completedUsage; } + +/** Preserve observed fragments without treating partial provider work as a complete bill. */ +export function markIncompleteUsage(usage?: NormalizedUsage): NormalizedUsage { + return { ...usage, incomplete: true }; +} diff --git a/supabase/functions/_shared/model-pricing.ts b/supabase/functions/_shared/model-pricing.ts index 1fccbdf75..37f8b797c 100644 --- a/supabase/functions/_shared/model-pricing.ts +++ b/supabase/functions/_shared/model-pricing.ts @@ -2,7 +2,7 @@ // Regenerate with `npx tsx scripts/update-model-pricing.ts`; see docs/pricing-snapshots/metadata.json // for retrieval timestamps, content hashes, and per-model findings. -export const PRICING_SNAPSHOT_ID = "official-docs-2026-08-22"; +export const PRICING_SNAPSHOT_ID = "official-docs-2026-09-19"; export const PRICING_SOURCE_URLS: Record = { openai: "https://platform.openai.com/docs/pricing", @@ -34,7 +34,7 @@ export interface ModelPricing { } // Every entry below was transcribed from the official pricing page captured in -// docs/pricing-snapshots/ on 2026-08-22 (snapshot official-docs-2026-08-22). Models absent from this list are +// docs/pricing-snapshots/ on 2026-09-19 (snapshot official-docs-2026-09-19). Models absent from this list are // unknown-priced on purpose: the calculator fails closed with costUsd=null. // gemini-3.1-flash-lite-preview has no exact entry on the official page at // retrieval time, so it is intentionally missing here. diff --git a/supabase/functions/admin/index.ts b/supabase/functions/admin/index.ts index f11fb4fa3..04b878a51 100644 --- a/supabase/functions/admin/index.ts +++ b/supabase/functions/admin/index.ts @@ -7,6 +7,7 @@ import { validateCreateAnnouncement, } from "../_shared/announcements.ts"; import { generateDemoCode, hashDemoCode, isCreatableDemoRole } from "../_shared/demo-code.ts"; +import { collectAdminUsagePages, getKnownCost, hasUnknownCost, isNonAiActivity } from "../_shared/admin-cost.ts"; // SECURITY: Allow specific origins for CORS const ALLOWED_ORIGINS = [ @@ -45,22 +46,6 @@ type UsageByUserRow = { unknownCostRequests?: number; }; -type CostAuditRow = { - user_id?: string | null; - cost_usd?: number | null; - input_tokens?: number | null; - output_tokens?: number | null; -}; - -function hasUnknownCost(row: CostAuditRow) { - return row.cost_usd === null || - row.cost_usd === undefined || - row.input_tokens === null || - row.input_tokens === undefined || - row.output_tokens === null || - row.output_tokens === undefined; -} - async function getAuthEmailMap(userIds: string[]): Promise<{ resolved: Map; failed: string[] }> { const wantedIds = new Set(userIds.filter(Boolean)); const emails = new Map(); @@ -318,84 +303,94 @@ Deno.serve(async (req) => { since.setDate(since.getDate() - days); // Total requests and cost - const { data: totals } = await supabase - .from("ai_requests") - .select("id, provider, model, cost_usd, input_tokens, output_tokens, status") - .gte("created_at", since.toISOString()); - - const totalRequests = totals?.length || 0; - const totalCost = totals?.reduce((sum, r) => sum + (r.cost_usd || 0), 0) || 0; + const usageRows = await collectAdminUsagePages(async (from, to) => { + const { data, error } = await supabase + .from("ai_requests") + .select("id, user_id, feature, provider, model, cost_usd, input_tokens, output_tokens, status, created_at") + .gte("created_at", since.toISOString()) + .order("created_at", { ascending: true }) + .order("id", { ascending: true }) + .range(from, to); + return { data, error }; + }); + const totalRequests = usageRows.length; + const totalCost = usageRows.reduce((sum, r) => sum + getKnownCost(r), 0); // "Unknown cost" means a provider request we cannot price. Rows // that never reached a provider (rate_limited) are not cost data // gaps and must not inflate the unknown count. - const isCostableRow = (r: { status: string }) => r.status === "success"; - const unknownCostRequests = totals?.filter((r) => isCostableRow(r) && hasUnknownCost(r)).length || 0; - const successRate = totals?.length - ? (totals.filter(r => r.status === "success").length / totals.length) * 100 + const unknownCostRequests = usageRows.filter((r) => hasUnknownCost(r)).length; + const successRate = usageRows.length + ? (usageRows.filter(r => r.status === "success").length / usageRows.length) * 100 : 100; // By model: known cost summed per model plus unknown-cost request counts. const modelAggregates = new Map(); - totals?.forEach((r) => { + usageRows.forEach((r) => { + if (isNonAiActivity(r)) return; const key = `${r.provider}/${r.model}`; const entry = modelAggregates.get(key) || { provider: r.provider, model: r.model, requests: 0, cost: 0, unknownCostRequests: 0 }; - entry.requests += 1; // request volume counts every row; only unknown-cost uses isCostableRow - if (r.cost_usd != null && Number.isFinite(Number(r.cost_usd))) entry.cost += Number(r.cost_usd); - if (isCostableRow(r) && hasUnknownCost(r)) entry.unknownCostRequests += 1; + entry.requests += 1; + entry.cost += getKnownCost(r); + if (hasUnknownCost(r)) entry.unknownCostRequests += 1; modelAggregates.set(key, entry); }); const byModel = Array.from(modelAggregates.values()).sort((a, b) => b.requests - a.requests); // Daily breakdown - const { data: daily } = await supabase.rpc("get_daily_usage", { days_back: days }); + const dailyAggregates = new Map(); + usageRows.forEach((r) => { + const date = typeof r.created_at === "string" ? r.created_at.slice(0, 10) : ""; + if (!date) return; + const entry = dailyAggregates.get(date) || { date, requests: 0, cost: 0, errors: 0 }; + entry.requests += 1; + entry.cost += getKnownCost(r); + if (r.status !== "success") entry.errors += 1; + dailyAggregates.set(date, entry); + }); + const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + const daily = Array.from(dailyAggregates.values()) + .sort((a, b) => b.date.localeCompare(a.date)) + .map((row) => ({ + ...row, + date: `${months[Number(row.date.slice(5, 7)) - 1] || row.date.slice(5, 7)} ${row.date.slice(8, 10)}`, + })); // By feature - const { data: byFeature } = await supabase - .from("ai_requests") - .select("feature, cost_usd, input_tokens, output_tokens, status") - .gte("created_at", since.toISOString()); - const featureCounts: Record = {}; const featureAggregates = new Map(); - byFeature?.forEach(r => { + usageRows.forEach(r => { featureCounts[r.feature] = (featureCounts[r.feature] || 0) + 1; + if (isNonAiActivity(r)) return; const entry = featureAggregates.get(r.feature) || { feature: r.feature, requests: 0, cost: 0, unknownCostRequests: 0 }; - entry.requests += 1; // request volume counts every row; only unknown-cost uses isCostableRow - if (r.cost_usd != null && Number.isFinite(Number(r.cost_usd))) entry.cost += Number(r.cost_usd); - if (isCostableRow(r) && hasUnknownCost(r)) entry.unknownCostRequests += 1; + entry.requests += 1; + entry.cost += getKnownCost(r); + if (hasUnknownCost(r)) entry.unknownCostRequests += 1; featureAggregates.set(r.feature, entry); }); const byFeatureCost = Array.from(featureAggregates.values()).sort((a, b) => b.requests - a.requests); // By user (top 10) - const { data: rawByUser } = await supabase.rpc("get_usage_by_user", { days_back: days, limit_count: 10 }); - const byUser = (rawByUser || []) as UsageByUserRow[]; + const userAggregates = new Map(); + usageRows.forEach((r) => { + if (!r.user_id) return; + const entry = userAggregates.get(r.user_id) || { user_id: r.user_id, requests: 0, cost: 0, unknownCostRequests: 0 }; + entry.requests += 1; + entry.cost += getKnownCost(r); + if (hasUnknownCost(r)) entry.unknownCostRequests = (entry.unknownCostRequests || 0) + 1; + userAggregates.set(r.user_id, entry); + }); + const byUser = Array.from(userAggregates.values()) + .sort((a, b) => b.requests - a.requests) + .slice(0, 10); const missingEmailUserIds = byUser .filter((row) => !row.email) .map((row) => row.user_id) .filter(Boolean); const userEmails = await getAuthEmailMap(missingEmailUserIds); - const topUserIds = byUser.map((row) => row.user_id).filter(Boolean); - const unknownCostsByUser = new Map(); - if (topUserIds.length > 0) { - const { data: unknownCostRows } = await supabase - .from("ai_requests") - .select("user_id, cost_usd, input_tokens, output_tokens, status") - .in("user_id", topUserIds) - .gte("created_at", since.toISOString()) - .eq("status", "success"); - - unknownCostRows?.forEach((row) => { - if (!hasUnknownCost(row)) return; - if (!row.user_id) return; - unknownCostsByUser.set(row.user_id, (unknownCostsByUser.get(row.user_id) || 0) + 1); - }); - } const byUserWithEmail = byUser.map((row) => ({ ...row, email: row.email || userEmails.resolved.get(row.user_id) || null, - unknownCostRequests: unknownCostsByUser.get(row.user_id) || 0, })); return new Response(JSON.stringify({ diff --git a/supabase/functions/ai-stream/handler_test.ts b/supabase/functions/ai-stream/handler_test.ts index c8e0dce10..0a7218098 100644 --- a/supabase/functions/ai-stream/handler_test.ts +++ b/supabase/functions/ai-stream/handler_test.ts @@ -135,6 +135,10 @@ Deno.test("streaming Ask forwards the shared prompt and returns provider SSE", a if (providerRequest?.model !== "z-ai/glm-5.3-flash") { throw new Error(`unexpected OpenRouter model: ${String(providerRequest?.model)}`); } + const streamOptions = providerRequest?.stream_options as { include_usage?: unknown } | undefined; + if (streamOptions?.include_usage !== true) { + throw new Error("streaming handler did not request terminal usage"); + } const system = messages?.find((message) => message.role === "system")?.content ?? ""; assertIncludes(system, "Answer in clear Markdown that can stream smoothly token by token."); assertIncludes(system, "Follow any explicit output-language instruction."); diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index 1cf204aa0..a58d3b4de 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -28,6 +28,7 @@ import { buildStreamingAskSystemPrompt } from "../_shared/prompts.ts"; import { calculateRequestCost, type CostResult, + markIncompleteUsage, normalizeOpenRouterUsage, type NormalizedUsage, } from "../_shared/cost.ts"; @@ -283,15 +284,7 @@ async function logUsage( raw_usage: cost.rawUsage ?? null, }; - let { error } = await supabase.from("ai_requests").insert({ ...row, ...auditFields }); - // Retry only when the audit columns do not exist yet (Postgres 42703 or - // PostgREST PGRST204). Any other error must not retry: the first insert - // may have committed, and a second would double-log the request. - const isMissingColumn = error?.code === "42703" || error?.code === "PGRST204"; - if (error && isMissingColumn) { - console.warn("[AI STREAM] Audit columns missing; logging base row only:", error.message); - ({ error } = await supabase.from("ai_requests").insert(row)); - } + const { error } = await supabase.from("ai_requests").insert({ ...row, ...auditFields }); if (error) throw error; } catch (err) { console.warn("[AI STREAM] Failed to log usage:", err); @@ -391,14 +384,12 @@ export async function handleRequest(req: Request): Promise { const encoder = new TextEncoder(); let accumulated = ""; let streamUsageRaw: Record | undefined; - let streamIncomplete = false; let streamSawDone = false; let streamSawFinishReason = false; const collectUsage = (): NormalizedUsage => { - const usage = streamUsageRaw + return streamUsageRaw ? normalizeOpenRouterUsage(streamUsageRaw) : { raw: { stream_usage_missing: true } }; - return streamIncomplete ? { ...usage, incomplete: true } : usage; }; const emit = (event: string, payload: Record) => { @@ -425,6 +416,7 @@ export async function handleRequest(req: Request): Promise { reasoning: { effort: reasoningEffort, exclude: true }, provider: { require_parameters: true }, stream: true, + stream_options: { include_usage: true }, }; if (modelEntry.supportsTemperature) payload.temperature = temperature; @@ -487,6 +479,7 @@ export async function handleRequest(req: Request): Promise { } } + if (req.signal.aborted) throw new DOMException("Client aborted", "AbortError"); if (!streamSawDone && !streamSawFinishReason) { throw new Error("OpenRouter stream ended before completion"); } @@ -506,18 +499,18 @@ export async function handleRequest(req: Request): Promise { } await logUsage(user.id, OPENROUTER_PROVIDER, model, collectUsage(), Date.now() - startedAt); } catch (err) { - streamIncomplete = true; cancelUpstream(); const errorMessage = err instanceof Error ? err.message : "Streaming failed"; const isAbort = req.signal.aborted || (err instanceof DOMException && err.name === "AbortError"); if (!isAbort) { - emit("error", { message: errorMessage }); + try { emit("error", { message: errorMessage }); } catch { /* client disconnected */ } } + const observedUsage = collectUsage(); await logUsage( user.id, OPENROUTER_PROVIDER, model, - collectUsage(), + !isAbort && (streamSawDone || streamSawFinishReason) ? observedUsage : markIncompleteUsage(observedUsage), Date.now() - startedAt, isAbort ? "cancelled" : "error", isAbort ? "client_abort" : "stream_error", diff --git a/supabase/functions/ai-stream/index_test.ts b/supabase/functions/ai-stream/index_test.ts new file mode 100644 index 000000000..d2693a3b3 --- /dev/null +++ b/supabase/functions/ai-stream/index_test.ts @@ -0,0 +1,170 @@ +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +for (const [key, value] of Object.entries({ + SUPABASE_URL: "https://supabase.test", + SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", + OPENROUTER_API_KEY: "test-openrouter-key", +})) Deno.env.set(key, value); + +const { handleRequest } = await import("./index.ts"); + +type Scenario = "complete" | "audit-error" | "truncated" | "provider-error" | "aborted" | "aborted-after-finish"; + +const usage = { + prompt_tokens: 12, + completion_tokens: 7, + total_tokens: 19, + cost: 0.0019, +}; + +function request(signal?: AbortSignal): Request { + return new Request("http://127.0.0.1/functions/v1/ai-stream", { + method: "POST", + signal, + headers: { Authorization: "Bearer test-user-token", "Content-Type": "application/json" }, + body: JSON.stringify({ + feature: "ask", + model: "z-ai/glm-5.3-flash", + input: { message: "Hello", mode: "general" }, + }), + }); +} + +function framesFor(scenario: Exclude): string { + const usageFrame = `data: ${JSON.stringify({ choices: [], usage })}\n\n`; + if (scenario === "complete" || scenario === "audit-error") { + return [ + `data: ${JSON.stringify({ choices: [{ delta: { content: "Hello" } }] })}\n\n`, + usageFrame, + "data: [DONE]\n\n", + ].join(""); + } + if (scenario === "truncated") { + return [ + `data: ${JSON.stringify({ choices: [{ delta: { content: "Partial" } }] })}\n\n`, + usageFrame, + ].join(""); + } + return [ + usageFrame, + `data: ${JSON.stringify({ error: { message: "provider exploded" } })}\n\n`, + ].join(""); +} + +async function runScenario(scenario: Scenario) { + const rows: Array> = []; + let providerCalls = 0; + let providerStartedResolve = () => {}; + const providerStarted = new Promise((resolve) => providerStartedResolve = resolve); + const originalFetch = globalThis.fetch; + const encoder = new TextEncoder(); + + globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + + if (url.origin === "https://supabase.test") { + if (url.pathname === "/auth/v1/user") return jsonResponse({ id: "user-1", aud: "authenticated" }); + if (url.pathname.includes("/rest/v1/user_roles")) return jsonResponse({ role: "user" }); + if (url.pathname.includes("/rest/v1/user_settings")) { + return jsonResponse({ + subscription_tier: "trial", + daily_limit_override: 50, + trial_ends_at: "2099-01-01T00:00:00.000Z", + grace_period_ends_at: null, + }); + } + if (url.pathname.includes("/rest/v1/rate_limits")) return jsonResponse({ limit_value: 50 }); + if (url.pathname.includes("/rest/v1/rpc/consume_daily_quota")) { + return jsonResponse([{ allowed: true, current_count: 1 }]); + } + if (url.pathname.includes("/rest/v1/ai_requests")) { + rows.push(JSON.parse(String(init?.body)) as Record); + return scenario === "audit-error" + ? jsonResponse({ code: "PGRST204", message: "Missing audit column" }, 400) + : new Response(null, { status: 201 }); + } + throw new Error(`Unexpected Supabase request: ${url}`); + } + + if (url.href !== "https://openrouter.ai/api/v1/chat/completions") { + throw new Error(`Unexpected request: ${url}`); + } + providerCalls += 1; + if (scenario !== "aborted" && scenario !== "aborted-after-finish") { + return new Response(framesFor(scenario), { headers: { "Content-Type": "text/event-stream" } }); + } + + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ choices: [{ delta: { content: "Partial" } }] })}\n\n`)); + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ choices: [], usage })}\n\n`)); + if (scenario === "aborted-after-finish") { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}\n\n`)); + } + providerStartedResolve(); + init?.signal?.addEventListener("abort", () => { + if (scenario === "aborted-after-finish") controller.close(); + else controller.error(new DOMException("Upstream aborted", "AbortError")); + }, { once: true }); + }, + pull() { + return new Promise(() => {}); + }, + }); + return new Response(body, { headers: { "Content-Type": "text/event-stream" } }); + }) as typeof fetch; + + try { + const caller = new AbortController(); + const response = await handleRequest(request(caller.signal)); + const bodyPromise = response.text(); + if (scenario === "aborted" || scenario === "aborted-after-finish") { + await providerStarted; + if (scenario === "aborted-after-finish") { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + caller.abort(); + } + return { body: await bodyPromise, providerCalls, rows }; + } finally { + globalThis.fetch = originalFetch; + } +} + +Deno.test("OpenRouter stream accounting bills only completed work", async () => { + for (const scenario of ["complete", "audit-error", "truncated", "provider-error", "aborted", "aborted-after-finish"] as const) { + const result = await runScenario(scenario); + if (result.providerCalls !== 1) throw new Error(`${scenario}: expected one provider call, got ${result.providerCalls}`); + if (result.rows.length !== 1) throw new Error(`${scenario}: expected one audit insert, got ${result.rows.length}`); + + const row = result.rows[0]; + if (row.provider !== "openrouter") throw new Error(`${scenario}: wrong provider ${String(row.provider)}`); + if (row.input_tokens !== usage.prompt_tokens || row.output_tokens !== usage.completion_tokens) { + throw new Error(`${scenario}: observed token counts were lost: ${JSON.stringify(row)}`); + } + const rawUsage = row.raw_usage as Record | null; + if (rawUsage?.prompt_tokens !== usage.prompt_tokens || rawUsage?.completion_tokens !== usage.completion_tokens) { + throw new Error(`${scenario}: raw provider usage was lost: ${JSON.stringify(row)}`); + } + + if (scenario === "complete" || scenario === "audit-error") { + if (row.status !== "success" || row.cost_usd !== usage.cost || !result.body.includes("event: final")) { + throw new Error(`complete stream was not billed: ${JSON.stringify(row)}\n${result.body}`); + } + continue; + } + + const expectedStatus = scenario === "aborted" || scenario === "aborted-after-finish" ? "cancelled" : "error"; + if (row.status !== expectedStatus || row.cost_usd !== null || result.body.includes("event: final")) { + throw new Error(`${scenario} stream was billed or completed: ${JSON.stringify(row)}\n${result.body}`); + } + if (scenario !== "aborted" && scenario !== "aborted-after-finish" && !result.body.includes("event: error")) { + throw new Error(`${scenario} stream did not emit an SSE error: ${result.body}`); + } + } +}); diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index a64c1aac6..ef4fd4d54 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -431,15 +431,7 @@ async function logUsage( raw_usage: cost.rawUsage ?? null, }; - let { error } = await supabase.from("ai_requests").insert({ ...row, ...auditFields }); - // Retry only when the audit columns do not exist yet (Postgres 42703 or - // PostgREST PGRST204). Any other error must not retry: the first insert - // may have committed, and a second would double-log the request. - const isMissingColumn = error?.code === "42703" || error?.code === "PGRST204"; - if (error && isMissingColumn) { - console.warn("[USAGE LOG] Audit columns missing; logging base row only:", error.message); - ({ error } = await supabase.from("ai_requests").insert(row)); - } + const { error } = await supabase.from("ai_requests").insert({ ...row, ...auditFields }); if (error) throw error; } catch (err) { console.warn("[USAGE LOG] Failed to log:", err); From 8db876ba97a2561ea2d24d0a50cd8a759832ccd0 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 19 Sep 2026 16:58:25 +0000 Subject: [PATCH 2/3] docs: record model accounting release proof --- .audit/model-cost-accounting.tsv | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.audit/model-cost-accounting.tsv b/.audit/model-cost-accounting.tsv index d666e98b7..275582dfc 100644 --- a/.audit/model-cost-accounting.tsv +++ b/.audit/model-cost-accounting.tsv @@ -4,5 +4,7 @@ ts phase decision why evidence result 2026-09-19T16:16:50Z pricing refreshed all three official pricing captures with exact tsx 4.19.2 under Node 22 the September 7 unfinished capture was stale for this run docs/pricing-snapshots/metadata.json 11 normalized entries found and gemini-3.1-flash-lite-preview remains unknown 2026-09-19T16:38:03Z integration rebased onto current main and kept the OpenRouter-only handlers PR 43 replaced the plan's direct-provider runtime after the plan was written origin/main 99c2b53 accounting requirements applied to current architecture 2026-09-19T16:38:03Z verification ran the focused, Edge, Vitest, coverage, build, and Chromium gates under Node 22 required fresh evidence before opening the PR local command output 181 Edge, 161 Supabase Vitest, 1026 coverage, and 134 Chromium tests passed -2026-09-19T16:38:03Z browser recorded desktop and Pixel 7 admin accounting proof with isolated fixture data show importer volume while excluding it from model and feature cost rows /home/codex/.t3/userdata/attachments/f963842b-6d26-4bc1-9475-89e89233d3e6-e72a6d26-a7ed-4155-b759-badfb4c330dd-mp4.mp4 video and desktop/mobile screenshots captured +2026-09-19T16:38:03Z browser recorded desktop and Pixel 7 admin accounting proof with isolated fixture data show importer volume while excluding it from model and feature cost rows https://github.com/user-attachments/assets/1e833f00-8163-41e1-a4ee-ca8d0bb28fa2 video attached to PR 46 2026-09-19T16:49:00Z review fixed all three release-review blockers with focused regressions abort-after-finish, historical stored costs, and API row caps needed explicit coverage supabase/functions/ai-stream/index_test.ts, supabase/functions/_shared/admin-cost.test.ts reviewer confirmed no remaining P1 issue +2026-09-19T16:57:59Z staging deployed the three changed functions and ran protected authenticated staging exercise the real account, provider, persistence, and cleanup path before review https://github.com/Programcode/DebateLab/actions/runs/35456451358 ai v18, ai-stream v18, admin v13, protected browser run passed +2026-09-19T16:57:59Z database inspected the staging request written by the protected run prove deployed OpenRouter accounting stored audit metadata and a real charge staging ai_requests at 2026-09-19T16:56:33Z 785 input, 70 output, 0.00015275 USD, raw usage and audit version present From f0a2ef83534cfd5f9970fc8462e2778b9a7e4fb1 Mon Sep 17 00:00:00 2001 From: Adam Date: Sat, 19 Sep 2026 17:58:20 +0000 Subject: [PATCH 3/3] fix: fail closed on stream exceptions --- .audit/model-cost-accounting.tsv | 1 + src/pages/AdminPage.tsx | 2 +- src/test/pages/AdminPage.test.tsx | 2 +- supabase/functions/ai-stream/index.ts | 4 ++-- supabase/functions/ai-stream/index_test.ts | 11 +++++++++-- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.audit/model-cost-accounting.tsv b/.audit/model-cost-accounting.tsv index 275582dfc..f857454ad 100644 --- a/.audit/model-cost-accounting.tsv +++ b/.audit/model-cost-accounting.tsv @@ -8,3 +8,4 @@ ts phase decision why evidence result 2026-09-19T16:49:00Z review fixed all three release-review blockers with focused regressions abort-after-finish, historical stored costs, and API row caps needed explicit coverage supabase/functions/ai-stream/index_test.ts, supabase/functions/_shared/admin-cost.test.ts reviewer confirmed no remaining P1 issue 2026-09-19T16:57:59Z staging deployed the three changed functions and ran protected authenticated staging exercise the real account, provider, persistence, and cleanup path before review https://github.com/Programcode/DebateLab/actions/runs/35456451358 ai v18, ai-stream v18, admin v13, protected browser run passed 2026-09-19T16:57:59Z database inspected the staging request written by the protected run prove deployed OpenRouter accounting stored audit metadata and a real charge staging ai_requests at 2026-09-19T16:56:33Z 785 input, 70 output, 0.00015275 USD, raw usage and audit version present +2026-09-19T17:57:28Z review made every stream exception fail closed and corrected the admin unknown-cost label provider errors after a finish reason must never retain a billable cost; unknown cost is broader than missing token data supabase/functions/ai-stream/index_test.ts, src/test/pages/AdminPage.test.tsx regression added; 181 Edge, 161 Supabase Vitest, 1026 coverage, and 134 Chromium tests passed diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx index f16eaed84..44d3cb93b 100644 --- a/src/pages/AdminPage.tsx +++ b/src/pages/AdminPage.tsx @@ -754,7 +754,7 @@ export default function AdminPage() {

      {stats?.totals.unknownCostRequests - ? `${stats.totals.unknownCostRequests} requests missing token data` + ? `${stats.totals.unknownCostRequests} requests with unknown cost` : 'Known API costs'}

      diff --git a/src/test/pages/AdminPage.test.tsx b/src/test/pages/AdminPage.test.tsx index 9598a958b..51a99df01 100644 --- a/src/test/pages/AdminPage.test.tsx +++ b/src/test/pages/AdminPage.test.tsx @@ -375,7 +375,7 @@ describe('AdminPage Integration', () => { expect(screen.getAllByText('Unknown').length).toBeGreaterThan(0); expect(screen.queryByText('$0.0000')).not.toBeInTheDocument(); - expect(screen.getByText('7 requests missing token data')).toBeInTheDocument(); + expect(screen.getByText('7 requests with unknown cost')).toBeInTheDocument(); }); it('creates a demo code and copies the raw code once', async () => { diff --git a/supabase/functions/ai-stream/index.ts b/supabase/functions/ai-stream/index.ts index a58d3b4de..91a6228b2 100644 --- a/supabase/functions/ai-stream/index.ts +++ b/supabase/functions/ai-stream/index.ts @@ -495,7 +495,7 @@ export async function handleRequest(req: Request): Promise { }; if (!req.signal.aborted) { - emit("final", { output }); + try { emit("final", { output }); } catch { /* client disconnected after provider completion */ } } await logUsage(user.id, OPENROUTER_PROVIDER, model, collectUsage(), Date.now() - startedAt); } catch (err) { @@ -510,7 +510,7 @@ export async function handleRequest(req: Request): Promise { user.id, OPENROUTER_PROVIDER, model, - !isAbort && (streamSawDone || streamSawFinishReason) ? observedUsage : markIncompleteUsage(observedUsage), + markIncompleteUsage(observedUsage), Date.now() - startedAt, isAbort ? "cancelled" : "error", isAbort ? "client_abort" : "stream_error", diff --git a/supabase/functions/ai-stream/index_test.ts b/supabase/functions/ai-stream/index_test.ts index d2693a3b3..d04aad787 100644 --- a/supabase/functions/ai-stream/index_test.ts +++ b/supabase/functions/ai-stream/index_test.ts @@ -13,7 +13,7 @@ for (const [key, value] of Object.entries({ const { handleRequest } = await import("./index.ts"); -type Scenario = "complete" | "audit-error" | "truncated" | "provider-error" | "aborted" | "aborted-after-finish"; +type Scenario = "complete" | "audit-error" | "truncated" | "provider-error" | "provider-error-after-finish" | "aborted" | "aborted-after-finish"; const usage = { prompt_tokens: 12, @@ -50,6 +50,13 @@ function framesFor(scenario: Exclude): string { usageFrame, ].join(""); } + if (scenario === "provider-error-after-finish") { + return [ + usageFrame, + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] })}\n\n`, + `data: ${JSON.stringify({ error: { message: "provider exploded" } })}\n\n`, + ].join(""); + } return [ usageFrame, `data: ${JSON.stringify({ error: { message: "provider exploded" } })}\n\n`, @@ -137,7 +144,7 @@ async function runScenario(scenario: Scenario) { } Deno.test("OpenRouter stream accounting bills only completed work", async () => { - for (const scenario of ["complete", "audit-error", "truncated", "provider-error", "aborted", "aborted-after-finish"] as const) { + for (const scenario of ["complete", "audit-error", "truncated", "provider-error", "provider-error-after-finish", "aborted", "aborted-after-finish"] as const) { const result = await runScenario(scenario); if (result.providerCalls !== 1) throw new Error(`${scenario}: expected one provider call, got ${result.providerCalls}`); if (result.rows.length !== 1) throw new Error(`${scenario}: expected one audit insert, got ${result.rows.length}`);