From 3d2bdfceefef8d07280d371262107b4c7ea97b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filip=20Kujund=C5=BEi=C4=87?= Date: Fri, 11 Sep 2026 14:47:41 +0200 Subject: [PATCH] fix: quote and pin Infron service tiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Infron sells the same model at several service tiers. `min_prompt_price` and `min_completion_price` are the floor across all of them, so every model with a flex tier was advertised at a batch-job price nobody gets by default: 25 of 286 chat models, among them gpt-6-astra at $5/$25 against the $7.5/$37.5 a default request actually bills. Price each tier from its own row in `providers[]` and pin that tier on the request, so the price quoted is the price charged. Tiers beyond the default are listed under their own `:` ids, letting callers opt into flex or priority by model name: puter.ai.chat(prompt, { model: 'infron:openai/gpt-6-astra:flex' }) Suffix parsing matches the catalog exactly before reading a trailing segment as a tier, since catalog ids can carry a colon of their own (`deepseek/deepseek-v4-flash:free`). Tier variants take the context window of their own offering, which differs from the model-level figure for 11 of them. Billing was never wrong — it bills Infron's reported `cost` — but the understated prices fed the credit gate's output cap, which let a request run roughly 50% past the balance it was gated against, and the fallback path that prices per token when a response carries no cost. Co-Authored-By: Claude Opus 5 (1M context) --- .../providers/infron/InfronProvider.test.ts | 217 +++++++++++++++++- .../providers/infron/InfronProvider.ts | 204 ++++++++++++---- 2 files changed, 378 insertions(+), 43 deletions(-) diff --git a/src/backend/drivers/ai-chat/providers/infron/InfronProvider.test.ts b/src/backend/drivers/ai-chat/providers/infron/InfronProvider.test.ts index 3358fb3cb..0765a82b3 100644 --- a/src/backend/drivers/ai-chat/providers/infron/InfronProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/infron/InfronProvider.test.ts @@ -120,6 +120,82 @@ const SAMPLE_API_MODELS = [ min_prompt_price: 2, min_completion_price: 10, }, + { + // Sold at several service tiers; the flex tier undercuts standard. + id: 'openai/gpt-6-astra', + display_name: 'OpenAI: GPT-6 Astra', + category_type: 'LLM', + supported_endpoint_types: ['openai'], + context_length: 1050000, + max_output_tokens: 128000, + min_prompt_price: 5, + min_completion_price: 25, + providers: [ + { + provider_slug: 'openai/flex', + service_tier: 'flex', + prompt_price: 5, + completion_price: 25, + context_length: 400000, + }, + { + provider_slug: 'azure', + service_tier: 'standard', + prompt_price: 7.5, + completion_price: 37.5, + }, + { + provider_slug: 'openai', + service_tier: 'standard', + prompt_price: 10, + completion_price: 50, + }, + { + provider_slug: 'openai/priority', + service_tier: 'priority', + prompt_price: 20, + completion_price: 100, + }, + ], + }, + { + // Flex is the only tier on offer — nothing to pin. + id: 'example/flex-only-model', + display_name: 'Flex Only', + category_type: 'LLM', + supported_endpoint_types: ['openai'], + context_length: 128000, + max_output_tokens: 4096, + min_prompt_price: 1, + min_completion_price: 2, + providers: [ + { + provider_slug: 'example/flex', + service_tier: 'flex', + prompt_price: 1, + completion_price: 2, + }, + ], + }, + { + // Catalog ids can contain a colon of their own. + id: 'example/colon-model:free', + display_name: 'Colon Model (free)', + category_type: 'LLM', + supported_endpoint_types: ['openai'], + context_length: 64000, + max_output_tokens: 4096, + min_prompt_price: 0, + min_completion_price: 0, + providers: [ + { + provider_slug: 'example', + service_tier: 'standard', + prompt_price: 0, + completion_price: 0, + }, + ], + }, { // Non-chat modality — filtered out. id: 'black-forest-labs/flux-2.1', @@ -262,10 +338,87 @@ describe('InfronProvider model catalog', () => { expect(axiosRequestMock).toHaveBeenCalledTimes(1); }); + it('quotes the tier Infron routes to by default, not the cheaper flex tier', async () => { + const { provider } = makeProvider(); + const models = await provider.models(); + // Cheapest standard offering is Azure at $7.5/$37.5 per million, + // not the $5/$25 flex tier the catalog floor reports. + expect(models).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'infron:openai/gpt-6-astra', + costs: expect.objectContaining({ + prompt: 750, + completion: 3750, + input_cache_read: 750, + }), + }), + ]), + ); + }); + + it('lists a :flex id alongside the default-tier id, each at its own price', async () => { + const { provider } = makeProvider(); + const models = await provider.models(); + + expect(models).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'infron:openai/gpt-6-astra', + costs: expect.objectContaining({ + prompt: 750, + completion: 3750, + }), + }), + expect.objectContaining({ + id: 'infron:openai/gpt-6-astra:flex', + costs: expect.objectContaining({ + prompt: 500, + completion: 2500, + }), + }), + expect.objectContaining({ + id: 'infron:openai/gpt-6-astra:priority', + costs: expect.objectContaining({ + prompt: 2000, + completion: 10000, + }), + }), + ]), + ); + }); + + it('takes a tier variant context window from its own offering', async () => { + const { provider } = makeProvider(); + const models = await provider.models(); + const flex = models.find( + (m) => m.id === 'infron:openai/gpt-6-astra:flex', + ); + // The flex offering caps context below the model-level figure. + expect(flex?.context).toBe(400000); + }); + + it('accepts the explicit :standard spelling as an alias of the plain id', async () => { + const { provider } = makeProvider(); + const models = await provider.models(); + const base = models.find((m) => m.id === 'infron:openai/gpt-6-astra'); + expect(base?.aliases).toContain('openai/gpt-6-astra:standard'); + }); + + it('still offers an explicit tier id when no default tier is sold', async () => { + const { provider } = makeProvider(); + const ids = await provider.list(); + // The plain id leaves routing to Infron; the :flex id pins the one + // tier on offer. Same price, different routing guarantee. + expect(ids).toContain('infron:example/flex-only-model'); + expect(ids).toContain('infron:example/flex-only-model:flex'); + }); + it('converts USD-per-million-token prices to microcents per token', async () => { const { provider } = makeProvider(); const models = await provider.models(); - // $10/M tokens → 10 * 100 = 1000 microcents per token. + // $10/M tokens → 10 * 100 = 1000 microcents per token. This model + // carries no per-tier breakdown, so the catalog floor is used. expect(models).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -314,6 +467,68 @@ describe('InfronProvider.complete request shape', () => { expect(args.usage).toEqual({ include: true }); }); + it('pins the standard service tier so billing matches the quoted price', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + + await withTestActor(() => + provider.complete({ + model: 'infron:openai/gpt-6-astra', + messages: [{ role: 'user', content: 'hello' }], + }), + ); + + expect(createMock.mock.calls[0]![0].provider).toEqual({ + service_tier: 'standard', + }); + }); + + it('leaves routing to Infron for models with no standard tier', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + + await withTestActor(() => + provider.complete({ + model: 'infron:example/flex-only-model', + messages: [{ role: 'user', content: 'hello' }], + }), + ); + + expect('provider' in createMock.mock.calls[0]![0]).toBe(false); + }); + + it('pins the tier named by a :flex model id and strips it from the wire id', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + + await withTestActor(() => + provider.complete({ + model: 'infron:openai/gpt-6-astra:flex', + messages: [{ role: 'user', content: 'hello' }], + }), + ); + + const [args] = createMock.mock.calls[0]!; + expect(args.model).toBe('openai/gpt-6-astra'); + expect(args.provider).toEqual({ service_tier: 'flex' }); + }); + + it('keeps a colon-bearing catalog id intact rather than reading it as a tier', async () => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + + await withTestActor(() => + provider.complete({ + model: 'infron:example/colon-model:free', + messages: [{ role: 'user', content: 'hello' }], + }), + ); + + const [args] = createMock.mock.calls[0]!; + expect(args.model).toBe('example/colon-model:free'); + expect(args.provider).toEqual({ service_tier: 'standard' }); + }); + it('only sets stream_options.include_usage when streaming', async () => { const { provider } = makeProvider(); diff --git a/src/backend/drivers/ai-chat/providers/infron/InfronProvider.ts b/src/backend/drivers/ai-chat/providers/infron/InfronProvider.ts index b46fa97ec..90ec23eb6 100644 --- a/src/backend/drivers/ai-chat/providers/infron/InfronProvider.ts +++ b/src/backend/drivers/ai-chat/providers/infron/InfronProvider.ts @@ -31,12 +31,25 @@ import type { ICompleteArguments, } from '../../types.js'; +/** + * One upstream offering of a model in Infron's catalog. The same model is often + * served at several service tiers with different prices; `flex` is + * batch-oriented and cheapest, `priority` is the fastest and dearest. + */ +type InfronApiProviderOffer = { + provider_slug?: string; + service_tier?: string; + prompt_price?: number; + completion_price?: number; + context_length?: number; +}; + /** * Shape of one entry in Infron's `GET /v1/models` catalog. Unlike OpenRouter - * there is no `pricing` object; prices are USD per million tokens in - * `min_prompt_price` / `min_completion_price`, and the catalog mixes non-chat - * modalities (image, video, embeddings) that this provider filters out via - * `category_type`. + * there is no `pricing` object; prices are USD per million tokens, per offering + * in `providers` and as a catalog-wide floor in `min_prompt_price` / + * `min_completion_price`. The catalog mixes non-chat modalities (image, video, + * embeddings) that this provider filters out via `category_type`. */ type InfronApiModel = { id: string; @@ -49,6 +62,117 @@ type InfronApiModel = { min_prompt_price?: number; min_completion_price?: number; min_request_price?: number; + providers?: InfronApiProviderOffer[]; +}; + +/** Tier Infron routes to when a request carries no explicit `service_tier`. */ +const DEFAULT_SERVICE_TIER = 'standard'; + +/** What one tier of a model costs, in USD per million tokens. */ +type InfronTierPrices = { + prompt: number; + completion: number; + context?: number; +}; + +/** + * Cheapest offering of each service tier a model sells. Tiers differ in price + * and sometimes in context window, so each one is quoted from its own row + * rather than from the catalog-wide `min_*` floor. + */ +const pricesByTier = (model: InfronApiModel) => { + const byTier = new Map(); + for (const offer of model.providers ?? []) { + const tier = offer.service_tier; + if (!tier) continue; + const prompt = offer.prompt_price ?? 0; + const completion = offer.completion_price ?? 0; + const seen = byTier.get(tier); + if (seen && seen.prompt + seen.completion <= prompt + completion) { + continue; + } + byTier.set(tier, { prompt, completion, context: offer.context_length }); + } + return byTier; +}; + +/** + * Splits a model id into the id Infron expects on the wire and the tier to pin. + * Catalog ids can contain a colon themselves (`…:free`), so an exact catalog + * match always wins over reading the last segment as a tier suffix. + */ +const resolveTier = (id: string, catalog: InfronApiModel[]) => { + const tiersOf = (model: InfronApiModel) => + new Set((model.providers ?? []).map((offer) => offer.service_tier)); + + const exact = catalog.find((model) => model.id === id); + if (exact) { + return { + wireModelId: id, + // Unsuffixed ids are quoted at the default tier, so pin it — left + // unset, Infron load-balances across tiers and could bill another. + tier: tiersOf(exact).has(DEFAULT_SERVICE_TIER) + ? DEFAULT_SERVICE_TIER + : undefined, + }; + } + + const cut = id.lastIndexOf(':'); + const base = + cut > 0 ? catalog.find((m) => m.id === id.slice(0, cut)) : undefined; + const tier = id.slice(cut + 1); + if (base && tiersOf(base).has(tier)) return { wireModelId: base.id, tier }; + return { wireModelId: id, tier: undefined }; +}; + +/** + * One listed model: the default tier under the plain id, or a single service + * tier under a `…:` id priced from that tier's own offering. + */ +const coerceModel = ( + model: InfronApiModel, + prices: InfronTierPrices, + tier?: string, +): IChatModel => { + const suffix = tier ? `:${tier}` : ''; + const shortId = model.id.split('/').slice(1).join('/'); + // Catalog prices are USD per million tokens; costs are microcents per + // token, so the conversion is ×100. + const promptCost = Math.round(prices.prompt * 100); + return { + id: `infron:${model.id}${suffix}`, + name: `${model.display_name || model.id} (Infron${tier ? `, ${tier}` : ''})`, + aliases: [ + `${model.id}${suffix}`, + ...(model.display_name && !tier ? [model.display_name] : []), + `infron/${model.id}${suffix}`, + `${shortId}${suffix}`, + // The plain id already means the default tier; accept the + // explicit spelling of it too. + ...(tier ? [] : [`${model.id}:${DEFAULT_SERVICE_TIER}`]), + ], + context: tier + ? (prices.context ?? model.context_length) + : model.context_length, + max_tokens: model.max_output_tokens ?? 0, + costs_currency: 'usd-cents', + input_cost_key: 'prompt', + output_cost_key: 'completion', + costs: { + tokens: 1_000_000, + prompt: promptCost, + completion: Math.round(prices.completion * 100), + // The catalog carries no cache-read price; charge the full + // prompt rate in the fallback path so cached tokens are never + // billed below list. The normal path bills the + // gateway-reported cost instead. + input_cache_read: promptCost, + // USD per request → microcents per request. + request: Math.round( + (model.min_request_price ?? 0) * 1_000_000 * 100, + ), + }, + }; }; type InfronUsage = OpenAI.Completions.CompletionUsage & { @@ -112,17 +236,25 @@ export class InfronProvider implements IChatProvider { [m.id, ...(m.aliases || [])].includes(model), ) || availableModels.find((m) => m.id === this.getDefaultModel())!; - const modelIdForParams = modelUsed.id.startsWith('infron:') + const catalogId = modelUsed.id.startsWith('infron:') ? modelUsed.id.slice('infron:'.length) : modelUsed.id; + // A `…:` id carries the tier to pin; a plain id means the + // default tier. Only tiers the model actually sells are pinned — + // Infron routes freely (and reports the tier back) when unset. + const { wireModelId, tier } = resolveTier( + catalogId, + await this.#rawModels(), + ); + const actor = Context.get('actor'); messages = await OpenAIUtil.process_input_messages(messages); const completionParams = { messages, - model: modelIdForParams, + model: wireModelId, ...(tools ? { tools } : {}), max_tokens, temperature, @@ -132,6 +264,10 @@ export class InfronProvider implements IChatProvider { stream_options: { include_usage: true }, } : {}), + // Without this Infron load-balances across service tiers, so a + // request could be billed at a tier other than the one whose + // price we quote in the catalog. + ...(tier ? { provider: { service_tier: tier } } : {}), // Surfaces the authoritative `cost` field (USD) on the // response so metering doesn't depend on catalog prices. usage: { include: true }, @@ -216,7 +352,8 @@ export class InfronProvider implements IChatProvider { }); } - async models() { + /** The catalog as Infron returns it, kv-cached and shared by callers. */ + async #rawModels(): Promise { let models = kv.get(KV_MODELS_KEY) as InfronApiModel[] | undefined; if (!models) { try { @@ -235,7 +372,11 @@ export class InfronProvider implements IChatProvider { console.log(e); } } - if (!models) return []; + return models ?? []; + } + + async models() { + const models = await this.#rawModels(); const coerced_models: IChatModel[] = []; for (const model of models) { // The catalog mixes chat with image/video/embedding/search @@ -245,40 +386,19 @@ export class InfronProvider implements IChatProvider { if (!(model.supported_endpoint_types ?? []).includes('openai')) { continue; } - // Catalog prices are USD per million tokens; costs are - // microcents per token, so the conversion is ×100. - const promptCost = Math.round((model.min_prompt_price ?? 0) * 100); - coerced_models.push({ - id: `infron:${model.id}`, - name: `${model.display_name || model.id} (Infron)`, - aliases: [ - model.id, - ...(model.display_name ? [model.display_name] : []), - `infron/${model.id}`, - model.id.split('/').slice(1).join('/'), - ], - context: model.context_length, - max_tokens: model.max_output_tokens ?? 0, - costs_currency: 'usd-cents', - input_cost_key: 'prompt', - output_cost_key: 'completion', - costs: { - tokens: 1_000_000, - prompt: promptCost, - completion: Math.round( - (model.min_completion_price ?? 0) * 100, - ), - // The catalog carries no cache-read price; charge the - // full prompt rate in the fallback path so cached - // tokens are never billed below list. The normal path - // bills the gateway-reported cost instead. - input_cache_read: promptCost, - // USD per request → microcents per request. - request: Math.round( - (model.min_request_price ?? 0) * 1_000_000 * 100, - ), - }, - }); + const byTier = pricesByTier(model); + const defaultPrices = byTier.get(DEFAULT_SERVICE_TIER) ?? { + prompt: model.min_prompt_price ?? 0, + completion: model.min_completion_price ?? 0, + }; + // The unsuffixed id is the default tier; every other tier the + // model sells gets its own `…:` id so callers can ask for + // one by name and see what it costs. + coerced_models.push(coerceModel(model, defaultPrices)); + for (const [tier, prices] of byTier) { + if (tier === DEFAULT_SERVICE_TIER) continue; + coerced_models.push(coerceModel(model, prices, tier)); + } } return coerced_models; }