Skip to content

Add price variants for batch APIs and service tiers - #547

Open
Kludex wants to merge 18 commits into
mainfrom
batch-api-prices
Open

Add price variants for batch APIs and service tiers#547
Kludex wants to merge 18 commits into
mainfrom
batch-api-prices

Conversation

@Kludex

@Kludex Kludex commented Aug 3, 2026

Copy link
Copy Markdown
Member

Adds prices that depend on how a request was served, and the batch-API rates to go in them. Closes part of #429, and is the price-data half of what #115 and #390 ask for.

Shape

price_variants is an optional sibling of prices on a model. Each entry names the pricing context it applies to and the prices that replace the standard ones:

prices:
  input_mtok: 5
  output_mtok: 25
  web_searches_kcount: 10
price_variants:
  - when: { service_tier: batch }
    prices:
      input_mtok: 2.5
      output_mtok: 12.5

when draws on a fixed parameter set named after the fields providers report them in: service_tier for the mutually exclusive rate cards (batch today, flex and priority once we have verified rates), speed for Anthropic's fast mode, inference_geo for data residency. Anthropic and Groq both return service_tier: "batch" in their usage payloads, so a caller can pass what the provider told them straight through.

A variant overrides prices key by key, so only the keys whose rate actually changes need listing. That matters because discounts are not uniform per unit:

  • Anthropic halves every token rate but publishes no batch rate for web_searches_kcount
  • Google bills batch cache hits "at the standard context caching rates" on most models, but halves them on the 3.x Flash family
  • Groq's batch discount does not stack with prompt caching - all batch tokens bill at the halved input rate regardless of cache status
  • xAI's discount is 20%, not 50%, and only on four models

An omitted key keeping its standard rate is exactly right for all of those. A model with no batch_prices is charged its standard rates.

Callers select a variant explicitly:

calc_price(usage, model_ref='claude-opus-5', provider_id='anthropic', price_context={'service_tier': 'batch'})
calcPrice(usage, 'claude-opus-5', { priceContext: { service_tier: 'batch' }, providerId: 'anthropic' })

batch=True / batch: true is shorthand for the batch tier, as is --batch on both CLIs. price_context is also accepted by ExtractedUsage.calc_price() and ModelInfo.calc_price().

Why a new field rather than when on the existing prices list

A general request-context mechanism could also go on the existing ConditionalPrice entries, which is what #445 and #447 do. That shape cannot ship to v2. I tested each variant against the released clients by serving it as a mutated data.json to genai-prices==0.1.1 and @pydantic/genai-prices@0.1.1 through their real ingest paths:

Feed change Python 0.1.1 JS 0.1.1
New sibling key on the model object silently ignored, price unchanged silently ignored, price unchanged
Extra key inside an existing constraint silently drops the key, so the batch entry becomes the last active one and every ordinary request bills at the batch rate rejects the entire feed, all providers, keeps stale data
Entirely new constraint shape rejects the entire feed, permanently rejects the entire feed, permanently
New sibling key on a ConditionalPrice with no constraint treated as unconditionally active, silent mispricing same

Since prices/new_data/v2/data.json goes live to those clients on merge, a new field is the only shape that is safe - and it is what lets the when vocabulary grow later without a v3 contract. Publishing v3 instead was considered: v3 is already spoken for by the phase-2 spec (a {units, providers} wrapper for auto-updating units) which freezes v2 at cutover, so attaching tier pricing to it would mean either delaying this or forcing v3 early.

Stacking is worth being explicit about: of the four axes in #429, only data residency is a true multiplier (1.1x on all token categories). Fast mode is documented as not stacking with batch, so it sits on the same pick-one axis as the service tiers. No design considered here expresses a multiplier - a tier map and when/values both fall back to enumerating the cross product with pre-multiplied absolutes. Residency wants a separate multiplier concept, which is additive on top of this.

Verification

Ran real batch jobs against five providers and checked the computed price against each published rate table. All matched exactly:

Provider Job Result
Anthropic 2 requests on claude-haiku-4-5, one writing the cache and one reading it exactly 0.5x standard across input, cache write and cache read
OpenAI 2 requests on gpt-5-nano, 6272 cached tokens 0.5x, both per-result and for the batch object's aggregate usage
Gemini inline batch on gemini-3.1-flash-lite 0.5x
Mistral batch job on mistral-small-latest 0.5x
Groq batch job on llama-3.3-70b-versatile 0.5x

Those payloads are pinned as fixtures in tests/test_price_variants.py and mirrored in packages/js/src/__tests__/priceVariants.test.ts, covering the discount ratio per provider, fall-through for undiscounted units, type-strict when matching, and independent resolution of dated variants.

Worth noting for #429: batch mode cannot be auto-detected in general. Anthropic and Groq report service_tier: "batch", but OpenAI reports "default", Gemini reports serviceTier: "standard" and Mistral reports null - so batch has to stay a caller-supplied flag.

Data coverage

91 models: openai 35, anthropic 18, google 18, mistral 13, groq 5, x-ai 2. Every rate comes from the provider's published batch table, and models the provider will not accept for batch processing are left without batch_prices so they fall back to standard rates (Groq's Batch API takes five chat models; 15 mistral.yml entries match nothing the Mistral API serves).

Deliberately left out, since each needs its own verification pass rather than an assumption: AWS Bedrock and Azure batch, Claude on Vertex/Bedrock, Together (only six named models are discounted, the rest bill at standard rates), Cohere (no published discount at all), OpenAI's audio/realtime/embeddings rows and gpt-5.3/codex/chat-latest, which have no batch pane, and OpenAI's fine-tuned rows, whose batch pane columns do not align unambiguously with the standard pane.

Checks

make lint, make typecheck, make test (787 passed, 100% coverage), npm run ci (1859 passed) and npm run lint all pass; make build is idempotent.

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

Add `batch_prices`, an optional sibling of `prices` on a model with the same
shape, holding the rates a provider charges for requests made through its batch
API. Callers opt in with `calc_price(..., batch=True)`,
`calcPrice(usage, id, {batch: true})` or the CLI's `--batch`.

`batch_prices` overrides `prices` key by key, so only the keys whose rate
actually changes need listing. Batch discounts are not uniform per unit:
Anthropic halves every token rate but publishes no batch rate for web searches,
Google bills batch cache hits at the standard cache rate on most models, Groq's
batch rate ignores cache status entirely, and xAI's discount is 20% rather than
50%. An omitted key keeping its standard rate is exactly right for those.

Data covers 126 models across anthropic, openai, google, mistral, groq and
x-ai, from each provider's published batch rates.
Comment thread prices/providers/x_ai.yml Outdated
Comment thread prices/providers/x_ai.yml Outdated
Comment thread prices/new_data/v2/data_slim.json Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 28 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread prices/src/prices/package_data.py Outdated
Comment thread prices/providers/x_ai.yml Outdated
An independent re-verification pass against each provider's live pricing pages
turned up three problems in the first commit:

- Groq's Batch API accepts five chat models (openai/gpt-oss-20b,
  openai/gpt-oss-120b, llama-3.3-70b-versatile, llama-3.1-8b-instant,
  meta-llama/llama-guard-4-12b), not all 29 in the file. Batch prices on the
  other 24, mostly decommissioned models, described a discount that cannot be
  obtained.
- 15 mistral.yml entries match no model the Mistral API currently serves; they
  exist to match third-party price sources and are not batchable either.
- grok-4.20-multi-agent's standard rates come from an OpenRouter sync and do
  not match xAI's published $1.25/$0.20/$2.50, so a 0.8x derivation of them was
  wrong in absolute terms. Dropped rather than propagated; correcting the
  standard rates belongs in its own change.

Also adds the batch rates that were missed: gemini-embedding-001 and
gemini-embedding-2, which publish their own batch column, and gpt-3.5-turbo,
gpt-4 and gpt-4-turbo, whose batch rows are exactly half their standard ones.
@Kludex

Kludex commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Ran an independent re-verification pass over every number in this PR against the providers' live pricing pages (one auditor per provider, each re-deriving the rates from scratch rather than checking my arithmetic). Pushed 9a5f56c with the corrections. Summary of what it found, including things that turn out to be pre-existing.

Fixed in this PR

  • Groq batch-eligibility. console.groq.com/docs/batch lists exactly five chat models: openai/gpt-oss-20b, openai/gpt-oss-120b, llama-3.3-70b-versatile, llama-3.1-8b-instant, meta-llama/llama-guard-4-12b. I had applied the 50% to all 29 models in groq.yml, most of them decommissioned. Removed the other 24.
  • Mistral model set. 15 entries in mistral.yml match nothing in GET /v1/models on the live Mistral API (mistral-7b, mixtral-8x7b, pixtral-large, the mistral-small-3.x-24b-instruct OpenRouter slugs, ...). They exist to match price sources, and cannot be submitted to Mistral's batch endpoint. Removed; the 13 that resolve to a live model id keep their batch rates.
  • grok-4.20-multi-agent. Dropped, see the thread above - its standard rates are OpenRouter-sourced and do not match xAI's published figures, so any derivation of them is wrong.
  • Missed coverage added. gemini-embedding-001 and gemini-embedding-2 publish their own Batch column ($0.075, and $0.10 / $0.225 / $3.25 / $6.00); gpt-3.5-turbo, gpt-4 and gpt-4-turbo have batch rows at exactly half their standard ones.

Everything else was verified correct. For OpenAI the auditor pulled the unrounded SSR data behind the pricing page rather than the rendered 2-dp cells, and all 32 blocks matched cell for cell.

Pre-existing issues found on the way (not addressed here)

Worth their own change - flagging rather than fixing, since correcting first-party standard rates has different review implications:

  • x_ai.yml: grok-4.20-multi-agent standard is $2 / $0.20 / $6, xAI publishes $1.25 / $0.20 / $2.50. grok-4.5 cached input is $0.50, xAI publishes $0.30.
  • x_ai.yml: no model has the ≥200k-token tier xAI applies (grok-4.3, grok-4.20, grok-4.20-multi-agent, grok-build-0.1 all double above the threshold; grok-4.5 goes to $4 / $0.60 / $12).
  • openai.yml: gpt-5.5 has no long-context tier although the pricing page publishes one ($5.00 / $0.50 / $22.50 above 272K); gpt-4o-2024-05-13 is folded into the gpt-4o entry but is priced separately by OpenAI ($5 / $15).
  • mistral.yml: several entries carry OpenRouter prices that no longer match mistral.ai/pricing/api (mistral-large, mistral-medium-3, ministral-8b); mistral-embed has an output_mtok although an embeddings model bills no output tokens.
  • anthropic.yml: claude-mythos-5 is on Anthropic's pricing and batch pages but absent from the file.

One open judgement call

Anthropic's batch page says "All usage is charged at 50% of the standard API prices", but the batch pricing table lists only token rates and the FAQ scopes it to "input tokens, output tokens, and any special tokens". I have left web_searches_kcount out of batch_prices, so web searches bill at the full $10/1k in batch. That errs towards over-charging rather than under-charging, but if you read the "all usage" wording literally it should be $5/1k - happy to flip it.

The mistral.yml half of "Restrict batch prices to models the provider will
actually batch" was left out of that commit, so the published data no longer
matched the provider YAML it is generated from and the build hook rewrote it.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 8 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread prices/providers/google.yml Outdated
Records where each set of batch rates comes from and why some models have none:
Groq's eligible-model list, Google's standard-rate cache hits, xAI's 20%, and
Anthropic's undiscounted web searches. Also dates the two Gemini embedding
models, whose standard and batch rates were both checked against the pricing
page today.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 9 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread prices/providers/google.yml
Kludex added 2 commits August 3, 2026 13:26
A parity sweep over all 91 models with batch prices (8 timestamps x 4 usage
shapes, both engines) found no arithmetic drift, but three gaps around it:

- JS treated a JSON `null` in batch_prices differently from Python: Python
  falls back to the standard rate for that key, JS wrote the null into the
  merged prices and then threw on it, and a null `batch_prices` threw outright.
  Reachable through caller-supplied provider data, which the published feed's
  `exclude_none` does not cover.
- `batch_prices` could omit a dated change that `prices` makes, which would
  charge today's batch rate against requests made before it - the batch
  equivalent of overwriting a `prices` block. The build now requires
  `batch_prices` to repeat every constraint `prices` uses.
- `make collapse-models` compared only `prices` when merging a child model into
  its parent, so it could drop or misapply batch rates. No model pairs are
  affected today.

Also adds `--batch` to the JS CLI, which the Python CLI already had.
Comment thread packages/js/src/engine.ts Outdated
@Kludex

Kludex commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Second verification round, this time on the engine rather than the numbers: a parity harness ran every one of the 91 models with batch prices through both implementations at 8 timestamps straddling each constraint boundary in the data, against 4 usage shapes (above and below tier thresholds, cache read, cache write + 1h), batch and standard - 5824 rows, no arithmetic drift between Python and JS. It also confirmed the overlay does not mutate the shared bundled ModelPrice (batch → standard → batch on the same model round-trips), that ModelPrice subclasses survive it with their overridden calc_price intact, and that no batch-only price key escapes the ancestor/join validation.

Three gaps around the arithmetic did turn up, fixed in 4930f40 and 81d95a5:

  • JS and Python disagreed on a JSON null in batch_prices. Python treats an unset key as absent and falls back to the standard rate; JS wrote the null into the merged prices and then threw Invalid price value, and a batch_prices: null threw Cannot convert undefined or null to object. Not reachable from the published feed, which dumps with exclude_none, but reachable through caller-supplied options.provider - a documented path in the JS README. Both now use != null, with a test.
  • Nothing stopped batch_prices from skipping a dated change that prices makes. A model with a dated standard history and a flat batch block passed the build and would then charge today's batch rate against requests from before the change - a 10x error in the case I built, and the batch equivalent of the "never overwrite a prices block" rule. The build now requires batch_prices to repeat every constraint prices uses; all six models with dated prices already satisfy it. Documented in AGENTS.md and prices/README.md.
  • make collapse-models compared only prices when merging a child model into its parent, so it could drop a child's batch rates or hand it the parent's. No model pairs are affected today; the comparison now includes batch_prices.

Also added --batch to the JS CLI, which the Python one already had (genai-prices calc openai:gpt-5-nano -i 1000000 -o 1000000 gives $0.45, --batch gives $0.225 in both).

One more pre-existing item for the list above, latent rather than live: Python's TimeOfDateConstraint.active is start <= t < end with no midnight wrap, while the JS engine explicitly handles end < start. A price window that crosses midnight would resolve differently in the two engines. Both deepseek windows are 00:30Z-16:30Z, so nothing in the data hits it, but batch_prices is now a second field that could.

Kludex added 3 commits August 3, 2026 13:30
An empty `batch_prices` list made both engines resolve a conditional list with
nothing in it, raising IndexError in Python and TypeError in JS instead of
falling back to the standard prices. The build rejects an empty list, so this
was only reachable through caller-supplied provider data.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 11 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="prices/src/prices/collapse.py">

<violation number="1" location="prices/src/prices/collapse.py:36">
P3: Equivalent empty batch overrides now block model collapsing: `None` and `ModelPrice()` both use standard prices, but compare unequal here. Normalize empty batch overrides before comparing so collapse continues to remove semantically identical models.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread prices/src/prices/collapse.py Outdated
Comment thread AGENTS.md Outdated
Kludex added 2 commits August 3, 2026 13:44
`batch_prices: {}` was accepted and meant nothing, which also made two otherwise
identical models compare unequal in `collapse_provider`. An empty list was
already rejected; an empty mapping now is too, so the only way to say "charge
the standard prices" is to omit the field. The runtime keeps treating an empty
overlay as a no-op, since caller-supplied data is not built here.
`batch_prices` could only ever say one thing. Replace it with `price_variants`,
a list of entries that each name the pricing context they apply to:

    prices:
      input_mtok: 5
      output_mtok: 25
      web_searches_kcount: 10
    price_variants:
      - when: { service_tier: batch }
        prices:
          input_mtok: 2.5
          output_mtok: 12.5

`when` draws on a fixed set of parameters named after the fields providers
report them in: `service_tier` for the mutually exclusive rate cards (batch
today, flex and priority when we have rates for them), `speed` for Anthropic's
fast mode, `inference_geo` for data residency. Anthropic and Groq both report
`service_tier: "batch"` in their usage payloads, so a caller can pass what the
provider told them straight through rather than translating it.

Callers select a variant with `price_context` / `priceContext`; `batch=True` is
kept as shorthand for `{'service_tier': 'batch'}`, as is the CLIs' `--batch`.
Resolution is unchanged otherwise: the first matching variant's prices override
the standard ones key by key, and dated variants resolve exactly as `prices` do.

This stays a new optional field on the model, so released clients ignore it
rather than rejecting the feed - which is what lets the vocabulary grow without
a v3 contract. Adding `when` to the existing `prices` list could not: an old
client reads an entry with no `constraint` as unconditionally active and
silently misprices every request.
@Kludex

Kludex commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Refactored to generalise this beyond batch, per review feedback that a batch-only field forecloses flex/priority. batch_prices is now price_variants, a list of entries that each name the pricing context they apply to:

prices:
  input_mtok: 5
  output_mtok: 25
  web_searches_kcount: 10
price_variants:
  - when: { service_tier: batch }
    prices:
      input_mtok: 2.5
      output_mtok: 12.5

when draws on a fixed parameter set named after the fields providers report them in - service_tier for the mutually exclusive rate cards, speed for Anthropic's fast mode, inference_geo for data residency. Callers pass price_context / priceContext; batch=True and --batch stay as shorthand for {service_tier: 'batch'}.

Three things worth recording about the shape, since they were not obvious going in.

service_tier: batch, not batch: true. Alex's when/values spec (#447) has both, which would have been a live defect - two spellings of one condition that do not match each other, so YAML drifts and a caller passing one silently gets standard prices. service_tier wins because it is the providers' own spelling: Anthropic and Groq both literally return service_tier: "batch" in the payloads I captured, so a caller can now pass what the provider told them straight through:

price = extracted_usage.calc_price(price_context={'service_tier': response['usage']['service_tier']})

This did not need a v3 feed. The suggestion was to publish v3 rather than let v2 compatibility dictate the shape, and that is right in general - but a new field is invisible to released clients, so the vocabulary can grow additively. What genuinely cannot go in v2 is when on the existing prices list, which is #447's shape: an old client reads an entry with no constraint as unconditionally active and silently misprices every request. That asymmetry is what the design turns on. Worth knowing separately: v3 is already spoken for by the phase-2 spec (a {units, providers} wrapper for auto-updating units), which freezes v2 at cutover and is a 5-7 PR project - so attaching tier pricing to it would have meant either delaying this or forcing v3 early.

Stacking is still unsolved, by any of the designs considered. Of the four axes in #429, only data residency is a true multiplier (1.1x on all token categories, stacks with batch); fast mode is documented as not stacking with batch, so it belongs on the same pick-one axis. Neither a tier map nor when/values can express a multiplier - both fall back to enumerating the cross product with pre-multiplied absolutes at identical entry counts. If we want residency done properly it wants a separate multiplier concept, which is additive on top of this and needs no reshaping.

The remaining point from that review - making clients tolerant of schema additions - is a separate PR and I would suggest doing it next. Today an unknown constraint shape makes both released clients reject the entire feed permanently, and an unknown key inside a known constraint makes JS reject everything while Python silently misprices. The fix is small (~4 files: classify unrecognised entries instead of throwing, skip them in resolution, fix the fallback so it never lands on one, and close the Python extra='ignore' hole so both engines agree on what "unrecognised" means). It is worth being clear-eyed that it is defence-in-depth, not a licence to widen v2: released 0.1.1 clients fetch the same pinned URL forever, so it only protects clients shipped after it.

Data and behaviour are unchanged - same 91 models, same verified rates, the real-provider batch payloads still price identically. Python 787 tests at 100% coverage, JS 1859, lint and typecheck clean on both.

@Kludex Kludex changed the title Add batch API prices Add price variants for batch APIs and service tiers Aug 3, 2026
Comment thread prices/providers/x_ai.yml
Comment thread prices/src/prices/prices_types.py

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 29 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread prices/src/prices/prices_types.py Outdated
Comment thread packages/python/genai_prices/types.py
Comment thread packages/js/src/api.ts Outdated
Comment thread packages/js/src/engine.ts
Comment thread tests/test_price_variants.py
Comment thread prices/providers/mistral.yml
Comment thread packages/js/src/types.ts Outdated
Comment thread prices/providers/groq.yml
Kludex added 2 commits August 3, 2026 15:11
Resolution flattened every matching variant into one dated list, so a request
matching both a general and a more specific variant got whichever was listed
last - the opposite of the documented first-match rule, and a difference that
only shows up once a second `when` parameter carries data. Both engines now
pick the first matching `when`, then resolve that group's dated entries as
before.

Also from review: group variants by a type-aware key so `1` and `'1'` cannot
collapse into one group at build time, narrow `PriceVariant.when` in the JS
types to the parameter set the build already enforces, and rebuild the
conditional-resolution fixture so it is a shape `make build` would accept.
`grok-4.20` matched only that exact alias, but the batch-eligible IDs xAI
documents are `grok-4.20-0309-reasoning` and `grok-4.20-0309-non-reasoning`,
neither of which resolved to any model at all - so the batch variant added here
could never apply to a real batch request. Both now match, with their
`x-ai/`-prefixed forms.

Also updates the provider `price_comments` that still named `batch_prices`.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 6 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/js/src/types.ts">

<violation number="1" location="packages/js/src/types.ts:47">
P2: An optional `when` member accepts `undefined`, which the matcher treats as matching an absent context field. A custom variant built from an optional provider field can therefore override prices for `{}` or unrelated context payloads; reject undefined `when` values before matching (or validate them at the public-data boundary).</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/js/src/types.ts
Narrowing `PriceVariant.when` to a partial record let its values be undefined,
and the matcher compared an undefined expectation against an absent context
field and called that a match - so `when: {service_tier: undefined}` matched
every context that did not set a service tier. Python had the same hole via a
`None` value, reachable from caller-supplied provider data in both.

An expected value that is not set now never matches, so the variant is skipped
and the standard prices apply. Both `Object.entries` and the declared value type
hide that `undefined`/`None` can be there at all, so the matchers now say so in
their signatures rather than trusting the narrower inferred type.
Comment thread packages/js/src/engine.ts Outdated
An empty `when` matched every request that supplied any pricing context at all,
because "every parameter matches" is vacuously true with no parameters. The
build already requires at least one, but caller-supplied provider data does not
go through it, and both engines had the hole. A variant naming no parameter now
matches nothing, so the standard prices apply.
@alexmojaki

Copy link
Copy Markdown
Contributor

I think we should consider dropping the public batch=True / batch: true shorthand (and --batch) from this first PR, while keeping the underlying explicit price_context / priceContext mechanism.

The shorthand presents batch as a provider-independent capability, but it only changes the result for models that currently have a matching when: {service_tier: batch} variant. Everywhere else it silently returns standard pricing. That collapses several materially different states:

  • the model supports batch at the standard rate;
  • the provider/model does not support batch;
  • batch pricing has not been researched or added yet;
  • the intended variant exists but model matching or context spelling failed.

It also makes the library's synthetic service_tier: batch look equivalent to the provider wire field, although OpenAI batch results report default, Gemini reports standard, and Mistral does not provide a useful batch discriminator. Once callers start passing batch=True universally, changing a missing variant to warn/error—or separating request processing mode from provider service tier—becomes a public API compatibility problem.

The explicit context API is enough to ship and validate the data model in this PR. A provider-independent convenience flag can be added later once we have decided the no-match behavior and the canonical meaning of batch across providers. If the shorthand stays, I think it at least needs an observable diagnostic when no batch variant matched; silently producing the standard price makes incomplete coverage indistinguishable from verified standard pricing.

Per review: a provider-independent `batch=True` presents batch as a capability
of the library rather than of the data, but it only changes the result where a
`when: {service_tier: batch}` variant exists. Everywhere else it silently
returns the standard price, which makes four different states look identical -
the model bills the same in batch, the provider has no batch API, nobody has
researched its rates yet, or model matching and context spelling failed.

It also fixed the library's synthetic `service_tier: batch` as if it were the
providers' wire value, which only Anthropic and Groq actually report; OpenAI
returns `default`, Gemini `standard`, Mistral nothing. Shipping the shorthand
would make deciding the no-match behaviour, or separating request processing
mode from provider service tier, a public API break later.

Callers now pass `price_context={'service_tier': 'batch'}` explicitly, which is
enough to ship and validate the data model. `--batch` becomes
`--price-context service_tier=batch` on both CLIs, so they reach the mechanism
rather than a convenience wrapper over it.
@Kludex

Kludex commented Aug 3, 2026

Copy link
Copy Markdown
Member Author

Applied in 43467b5 - the batch=True / batch: true shorthand and --batch are gone; price_context / priceContext is the only way in.

Your point about the four collapsed states is the one that decided it for me. It also applies to the explicit API, but much less sharply: price_context={"service_tier": "batch"} is a specific question with a documented answer, whereas batch=True reads as "price this as a batch request" and quietly answers a different question when no variant matches.

One judgement call worth flagging, since it is adjacent to what you asked for rather than part of it. Deleting --batch outright would have left both CLIs unable to reach price variants at all, so I replaced it with --price-context service_tier=batch (repeatable key=value) rather than dropping the capability. That is the explicit mechanism rather than a convenience flag, so I think it is on the right side of your objection - but it is new surface, and I am happy to drop it and leave the CLIs standard-price-only if you would rather this PR add no CLI API at all.

Both CLIs agree, including the failure path:

$ genai-prices -p calc -i 1000000 -o 100000 --price-context service_tier=batch claude-opus-5
   Total Price: $3.750
$ genai-prices -p calc -i 1000 --price-context bogus claude-opus-5
Invalid --price-context 'bogus', expected `key=value`

The no-match diagnostic you raised is still unbuilt, deliberately - it needs the same decision about what "no variant matched" should mean, and I would rather that land with the client-tolerance work than be guessed at here. 795 Python tests at 100% coverage, 1862 JS, everything else unchanged.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 13 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/js/src/cli.ts Outdated
Passing the same parameter twice silently kept the last value, so a typo or a
pair of conflicting flags produced a plausible price rather than a complaint.
Two flags at the same level have no principled winner, so both CLIs now say so.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread packages/js/src/cli.ts Outdated
`parameter in context` also finds inherited Object.prototype members, so a first
`--price-context constructor=...` was rejected as a duplicate, and assigning
`__proto__` set the prototype instead of a key. The context object now has a
null prototype and the guard checks own properties only.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants