Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/ai/src/model/ModelPricing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ const KEY_SUFFIX_BOUNDARY = new Set(["-", "_", ":", "/", "@"]);
* fabricated unit, and `undefined` is the honest answer. It is consulted only
* after the exact lookups, so a table that deliberately prices an image model
* still wins. Providers should pass the SAME matcher their effort policy uses
* rather than a second copy of it.
* rather than a second copy of it — which is why it is asked about the same
* prefix-stripped, lower-cased id the walk matches, so a matcher anchored at
* `^` fires on `models/…` and `google/…` as it does on the bare id.
*/
export function resolveModelPricingFromTable(
table: Readonly<Record<string, ModelPricing>>,
Expand All @@ -149,7 +151,7 @@ export function resolveModelPricingFromTable(
// id has deliberately priced it — Gemini prices several image models — and
// that is a stronger statement than any matcher. What the guard blocks is the
// walk BORROWING a sibling's card for an id the table never named.
if (notTokenBilled?.(modelId) === true) return undefined;
if (notTokenBilled?.(id) === true) return undefined;

let best: string | undefined;
for (const key of Object.keys(table)) {
Expand Down
17 changes: 17 additions & 0 deletions packages/ai/src/model/__tests__/ModelPricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,23 @@ describe("resolveModelPricingFromTable", () => {
);
});

it("sees the same id the walk does, prefix stripped and lower-cased", () => {
// A provider writes the matcher for the bare id its table is keyed on, so
// an anchored one only fires if the guard is asked about the stripped
// form. Asked about the raw id, `models/gpt-4o-image` walks straight past
// it and takes `gpt-4o`'s per-token card for a model billed per image.
const anchoredImage = (id: string): boolean => /^gpt-.*-image(?:-|$)/i.test(id);
expect(
resolveModelPricingFromTable(table, "gpt-4o-image", [], anchoredImage)
).toBeUndefined();
expect(
resolveModelPricingFromTable(table, "models/gpt-4o-image", ["models/"], anchoredImage)
).toBeUndefined();
expect(
resolveModelPricingFromTable(table, "OpenAI/GPT-4o-Image", ["openai/"], anchoredImage)
).toBeUndefined();
});

it("leaves a text id alone, and is optional", () => {
expect(resolveModelPricingFromTable(table, "gpt-4o", [], isImage)).toBe(table["gpt-4o"]);
expect(resolveModelPricingFromTable(table, "gpt-4o-2024-08-06", [], isImage)).toBe(
Expand Down
12 changes: 12 additions & 0 deletions packages/test/src/test/ai-provider-api/ModelSearchPricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,4 +154,16 @@ describe("a rate card must match the model's billing unit", () => {
expect(getGeminiModelPricing("gemini-2.5-flash-image-preview")).toBeUndefined();
expect(getGeminiModelPricing("gemini-2.5-flash")).toBeDefined();
});

it("refuses an unnamed image id carrying one of the prefixes it strips", () => {
// `models/…` is the shape Gemini's ListModels returns, which is why the
// prefix is declared at all. Gemini's image matchers are anchored, so the
// guard has to be asked about the stripped id or a prefixed image model
// resolves the text sibling's per-1M-token card.
expect(getGeminiModelPricing("models/gemini-2.5-flash-image-preview")).toBeUndefined();
expect(getGeminiModelPricing("google/gemini-2.5-flash-image-preview")).toBeUndefined();
expect(getGeminiModelPricing("google-gemini/gemini-2.5-flash-image-preview")).toBeUndefined();
// The prefixed text sibling still resolves; only the borrowing is blocked.
expect(getGeminiModelPricing("models/gemini-2.5-flash")).toBeDefined();
});
});
20 changes: 20 additions & 0 deletions packages/test/src/test/ai-provider-api/OpenRouterPricing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,26 @@ describe("OpenRouter prices from the rate it quoted", () => {
expect(priceOf({ prompt: "-1", completion: "-1" })).toBeUndefined();
});

it("leaves an unpriced completion rate unpriced rather than free", () => {
// A router entry quotes a prompt rate and `-1` for completion when the
// output rate varies by upstream, and a partial card omits the field
// outright. Read as zero, the estimate renders as a confident figure that
// omits the side of the bill most of the spend sits on; left unset, the
// counter is reported unpriced and the total carries its `~`.
expect(priceOf({ prompt: "0.000003", completion: "-1" })).toEqual({
currency: "USD",
input: 3,
output: undefined,
cached: undefined,
});
expect(priceOf({ prompt: "0.000003" })).toEqual({
currency: "USD",
input: 3,
output: undefined,
cached: undefined,
});
});

it("reports no card when the record carries no quote at all", () => {
expect(priceOf(undefined)).toBeUndefined();
expect(priceOf(null)).toBeUndefined();
Expand Down
2 changes: 1 addition & 1 deletion providers/openrouter/src/ai/OpenRouterQueuedProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class OpenRouterQueuedProvider extends createCloudProviderClass<OpenRoute
return {
currency: "USD",
input,
output: perMillion(quoted.completion) ?? 0,
output: perMillion(quoted.completion),
cached: perMillion(quoted.input_cache_read),
};
}
Expand Down
Loading