From 634784dde2643239eec2d1f765f24c9d0e34f54b Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 7 Jul 2026 12:08:07 +0100 Subject: [PATCH 1/7] fix(webapp): reject replaying a run into an environment outside its project (#52) --- ...reject-cross-project-replay-environment.md | 6 + .../app/v3/services/replayTaskRun.server.ts | 36 ++++++ .../replayTaskRunEnvironmentScoping.test.ts | 109 ++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 .server-changes/reject-cross-project-replay-environment.md create mode 100644 apps/webapp/test/replayTaskRunEnvironmentScoping.test.ts diff --git a/.server-changes/reject-cross-project-replay-environment.md b/.server-changes/reject-cross-project-replay-environment.md new file mode 100644 index 00000000000..0acf99ee34a --- /dev/null +++ b/.server-changes/reject-cross-project-replay-environment.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Tighten environment scoping when replaying a run. diff --git a/apps/webapp/app/v3/services/replayTaskRun.server.ts b/apps/webapp/app/v3/services/replayTaskRun.server.ts index ed2292e32b2..d626ec2ae8c 100644 --- a/apps/webapp/app/v3/services/replayTaskRun.server.ts +++ b/apps/webapp/app/v3/services/replayTaskRun.server.ts @@ -24,6 +24,42 @@ type OverrideOptions = { export class ReplayTaskRunService extends BaseService { public async call(existingTaskRun: TaskRun, overrideOptions: OverrideOptions = {}) { + // An override environment must belong to the same project as the source + // run. The source project is derived from the run's own environment rather + // than existingTaskRun.projectId, since the buffered-run fallback passes a + // synthetic TaskRun with no projectId. Only check when a distinct override + // is supplied; otherwise the run's own environment is used. + if ( + overrideOptions.environmentId && + overrideOptions.environmentId !== existingTaskRun.runtimeEnvironmentId + ) { + const [overrideEnvironment, sourceEnvironment] = await Promise.all([ + this._prisma.runtimeEnvironment.findFirst({ + where: { id: overrideOptions.environmentId }, + select: { projectId: true }, + }), + this._prisma.runtimeEnvironment.findFirst({ + where: { id: existingTaskRun.runtimeEnvironmentId }, + select: { projectId: true }, + }), + ]); + if ( + !overrideEnvironment || + !sourceEnvironment || + overrideEnvironment.projectId !== sourceEnvironment.projectId + ) { + logger.warn("Refusing to replay a run into an environment outside its project", { + taskRunId: existingTaskRun.id, + taskRunFriendlyId: existingTaskRun.friendlyId, + sourceEnvironmentId: existingTaskRun.runtimeEnvironmentId, + sourceProjectId: sourceEnvironment?.projectId ?? null, + overrideEnvironmentId: overrideOptions.environmentId, + overrideProjectId: overrideEnvironment?.projectId ?? null, + }); + throw new Error("Cannot replay a run into an environment outside its project"); + } + } + const authenticatedEnvironment = await findEnvironmentById( overrideOptions.environmentId ?? existingTaskRun.runtimeEnvironmentId ); diff --git a/apps/webapp/test/replayTaskRunEnvironmentScoping.test.ts b/apps/webapp/test/replayTaskRunEnvironmentScoping.test.ts new file mode 100644 index 00000000000..326bea65ba0 --- /dev/null +++ b/apps/webapp/test/replayTaskRunEnvironmentScoping.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, vi } from "vitest"; +import { randomBytes } from "crypto"; +import type { TaskRun } from "@trigger.dev/database"; +import { postgresTest } from "@internal/testcontainers"; +import { seedTestEnvironment } from "./helpers/seedTestEnvironment"; +import { seedTestRun } from "./helpers/seedTestRun"; + +// The service runs against the testcontainer prisma passed to its constructor. +// These empty stubs just satisfy the module-level db.server imports so the +// module tree loads; the guard under test uses the injected `this._prisma`. +vi.mock("~/db.server", () => ({ + prisma: {}, + $replica: {}, + runOpsNewPrisma: {}, + runOpsLegacyPrisma: {}, + runOpsNewReplica: {}, + runOpsLegacyReplica: {}, +})); + +import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server"; + +vi.setConfig({ testTimeout: 60_000 }); + +describe("ReplayTaskRunService environment scoping", () => { + postgresTest( + "refuses to replay a run into an environment in another tenant's project", + async ({ prisma }) => { + // Tenant A owns the run; the override targets tenant B's environment. + // Distinct orgs => distinct projects. + const tenantA = await seedTestEnvironment(prisma); + const tenantB = await seedTestEnvironment(prisma); + + const { run } = await seedTestRun(prisma, { + environmentId: tenantA.environment.id, + projectId: tenantA.project.id, + }); + + const service = new ReplayTaskRunService(prisma); + + await expect(service.call(run, { environmentId: tenantB.environment.id })).rejects.toThrow( + "Cannot replay a run into an environment outside its project" + ); + + // No run was created in tenant B's project. + const runsInVictimProject = await prisma.taskRun.count({ + where: { projectId: tenantB.project.id }, + }); + expect(runsInVictimProject).toBe(0); + } + ); + + postgresTest( + "refuses to replay when the override environment id does not exist", + async ({ prisma }) => { + const tenantA = await seedTestEnvironment(prisma); + const { run } = await seedTestRun(prisma, { + environmentId: tenantA.environment.id, + projectId: tenantA.project.id, + }); + + const service = new ReplayTaskRunService(prisma); + + await expect(service.call(run, { environmentId: "env_does_not_exist" })).rejects.toThrow( + "Cannot replay a run into an environment outside its project" + ); + } + ); + + postgresTest( + "allows a same-project override even when the source run carries no projectId", + async ({ prisma }) => { + // The buffered-run fallback passes a synthetic TaskRun with a + // runtimeEnvironmentId but no projectId. A same-project override must + // still be allowed, since the source project comes from the run's + // environment, not from the (absent) projectId. + const tenant = await seedTestEnvironment(prisma); + const suffix = randomBytes(4).toString("hex"); + const stagingEnvironment = await prisma.runtimeEnvironment.create({ + data: { + slug: "staging", + type: "STAGING", + apiKey: `tr_stg_${suffix}`, + pkApiKey: `pk_stg_${suffix}`, + shortcode: `stg${suffix.slice(0, 1)}`, + projectId: tenant.project.id, + organizationId: tenant.organization.id, + }, + }); + + // Mirror the synthetic dashboard replay run: source env present, no projectId. + const syntheticRun = { + id: "run_buffered_synthetic", + friendlyId: "run_buffered_synthetic", + runtimeEnvironmentId: tenant.environment.id, + } as unknown as TaskRun; + + const service = new ReplayTaskRunService(prisma); + + // The guard must pass; execution then proceeds past it (into stubbed + // db.server), so any resulting error must NOT be the rejection. + const error = await service + .call(syntheticRun, { environmentId: stagingEnvironment.id }) + .catch((e) => e as Error); + expect(error?.message).not.toBe( + "Cannot replay a run into an environment outside its project" + ); + } + ); +}); From 7fd827861243aee66a6e20958835ce1421d1937e Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Tue, 7 Jul 2026 12:08:25 +0100 Subject: [PATCH 2/7] fix(webapp): SSRF hardening for alert webhooks + platform-notification URL XSS (supersedes #40) (#51) --- ...den-alert-webhook-and-notification-urls.md | 6 + .../navigation/HelpAndFeedbackPopover.tsx | 3 +- .../route.tsx | 16 + .../route.tsx | 13 +- ...i.v1.projects.$projectRef.alertChannels.ts | 2 + .../services/platformNotifications.server.ts | 21 +- apps/webapp/app/utils/sanitizeUrl.ts | 14 + .../alerts/createAlertChannel.server.ts | 14 + .../v3/services/alerts/deliverAlert.server.ts | 4 +- .../alerts/deliverErrorGroupAlert.server.ts | 4 +- .../alerts/safeWebhookFetch.server.ts | 161 + .../services/alerts/safeWebhookUrl.server.ts | 182 + apps/webapp/test/safeWebhookFetch.test.ts | 66 + apps/webapp/test/safeWebhookUrl.test.ts | 117 + apps/webapp/test/sanitizeUrl.test.ts | 33 + .../llm-model-catalog/src/defaultPrices.ts | 6169 +++++++++-------- .../llm-model-catalog/src/modelCatalog.ts | 4059 +++++------ 17 files changed, 5576 insertions(+), 5308 deletions(-) create mode 100644 .server-changes/harden-alert-webhook-and-notification-urls.md create mode 100644 apps/webapp/app/utils/sanitizeUrl.ts create mode 100644 apps/webapp/app/v3/services/alerts/safeWebhookFetch.server.ts create mode 100644 apps/webapp/app/v3/services/alerts/safeWebhookUrl.server.ts create mode 100644 apps/webapp/test/safeWebhookFetch.test.ts create mode 100644 apps/webapp/test/safeWebhookUrl.test.ts create mode 100644 apps/webapp/test/sanitizeUrl.test.ts diff --git a/.server-changes/harden-alert-webhook-and-notification-urls.md b/.server-changes/harden-alert-webhook-and-notification-urls.md new file mode 100644 index 00000000000..607e558a3a1 --- /dev/null +++ b/.server-changes/harden-alert-webhook-and-notification-urls.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Harden URL handling for alert webhooks and platform notifications diff --git a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx index 2159fa5fe27..cf3cb62aaa5 100644 --- a/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx +++ b/apps/webapp/app/components/navigation/HelpAndFeedbackPopover.tsx @@ -8,6 +8,7 @@ import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon"; import { RadarPulseIcon } from "~/assets/icons/RadarPulseIcon"; import { StarIcon } from "~/assets/icons/StarIcon"; import { useShortcutKeys } from "~/hooks/useShortcutKeys"; +import { sanitizeHttpUrl } from "~/utils/sanitizeUrl"; import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route"; import { useRecentChangelogs } from "~/routes/resources.platform-changelogs"; import { cn } from "~/utils/cn"; @@ -158,7 +159,7 @@ export function HelpAndFeedback({ trailingIconClassName="text-text-dimmed" inactiveIconColor="text-text-dimmed" activeIconColor="text-text-dimmed" - to={entry.actionUrl ?? "https://trigger.dev/changelog"} + to={sanitizeHttpUrl(entry.actionUrl) ?? "https://trigger.dev/changelog"} target="_blank" /> ))} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx index 597abb3b170..563155468f4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.alerts.new/route.tsx @@ -42,6 +42,10 @@ import { type CreateAlertChannelOptions, CreateAlertChannelService, } from "~/v3/services/alerts/createAlertChannel.server"; +import { + assertSafeWebhookUrl, + UnsafeWebhookUrlError, +} from "~/v3/services/alerts/safeWebhookUrl.server"; const FormSchema = z .object({ @@ -189,6 +193,18 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { return json(submission.reply({ formErrors: ["Project not found"] })); } + // Validate the webhook URL before storing it, for an inline field error. + if (submission.value.type === "WEBHOOK") { + try { + await assertSafeWebhookUrl(submission.value.channelValue); + } catch (error) { + if (error instanceof UnsafeWebhookUrlError) { + return json(submission.reply({ fieldErrors: { channelValue: [error.message] } })); + } + throw error; + } + } + const service = new CreateAlertChannelService(); const alertChannel = await service.call( project.externalRef, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx index 3ffe35908a5..769a4868266 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors/route.tsx @@ -24,6 +24,7 @@ import { type CreateAlertChannelOptions, CreateAlertChannelService, } from "~/v3/services/alerts/createAlertChannel.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { useSearchParams } from "~/hooks/useSearchParam"; @@ -144,8 +145,16 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { deduplicationKey: `error-webhook:${url}:${environment.type}`, channel: { type: "WEBHOOK", url }, }; - const channel = await service.call(project.externalRef, userId, options); - processedChannelIds.add(channel.id); + try { + const channel = await service.call(project.externalRef, userId, options); + processedChannelIds.add(channel.id); + } catch (error) { + // CreateAlertChannelService rejects unsafe webhook URLs. + if (error instanceof ServiceValidationError) { + return json(submission.reply({ fieldErrors: { webhooks: [error.message] } })); + } + throw error; + } } const editableTypes = new Set(["WEBHOOK"]); diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts index 73597075615..b030cba119e 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.alertChannels.ts @@ -67,6 +67,8 @@ export async function action({ request, params }: ActionFunctionArgs) { return json({ error: "webhook url is required" }, { status: 422 }); } + // The webhook URL is validated in CreateAlertChannelService.call(); + // an unsafe URL surfaces as a ServiceValidationError -> 422 below. const alertChannel = await service.call(projectRef, authenticationResult.userId, { name: body.data.name, alertTypes: body.data.alertTypes.map((type) => diff --git a/apps/webapp/app/services/platformNotifications.server.ts b/apps/webapp/app/services/platformNotifications.server.ts index 19faa038215..fa2e0ea8429 100644 --- a/apps/webapp/app/services/platformNotifications.server.ts +++ b/apps/webapp/app/services/platformNotifications.server.ts @@ -30,13 +30,30 @@ const DiscoverySchema = z.object({ matchBehavior: z.enum(["show-if-found", "show-if-not-found"]), }); +// Constrain URL fields to http/https; `.url()` alone accepts other schemes +// that would be unsafe to render into an ``. +const httpUrl = z + .string() + .url() + .refine( + (v) => { + try { + const proto = new URL(v).protocol; + return proto === "http:" || proto === "https:"; + } catch { + return false; + } + }, + { message: "URL must use http or https" } + ); + const CardDataV1Schema = z.object({ type: z.enum(["card", "info", "warn", "error", "success", "changelog"]), title: z.string(), description: z.string(), - image: z.string().url().optional(), + image: httpUrl.optional(), actionLabel: z.string().optional(), - actionUrl: z.string().url().optional(), + actionUrl: httpUrl.optional(), dismissOnAction: z.boolean().optional(), discovery: DiscoverySchema.optional(), }); diff --git a/apps/webapp/app/utils/sanitizeUrl.ts b/apps/webapp/app/utils/sanitizeUrl.ts new file mode 100644 index 00000000000..0ce2b31f415 --- /dev/null +++ b/apps/webapp/app/utils/sanitizeUrl.ts @@ -0,0 +1,14 @@ +// Return the URL only if it uses an http(s) scheme, else `undefined` so callers +// can fall back to a default. Use for any URL rendered into an ``. + +const SAFE_HTTP_PROTOCOLS = new Set(["http:", "https:"]); + +export function sanitizeHttpUrl(url: string | undefined | null): string | undefined { + if (!url) return undefined; + try { + const parsed = new URL(url); + return SAFE_HTTP_PROTOCOLS.has(parsed.protocol) ? parsed.href : undefined; + } catch { + return undefined; + } +} diff --git a/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts b/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts index 3b0a3a13360..c9163667bed 100644 --- a/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts +++ b/apps/webapp/app/v3/services/alerts/createAlertChannel.server.ts @@ -10,6 +10,7 @@ import { encryptSecret } from "~/services/secrets/secretStore.server"; import { alertsWorker } from "~/v3/alertsWorker.server"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; import { BaseService, ServiceValidationError } from "../baseService.server"; +import { assertSafeWebhookUrl, UnsafeWebhookUrlError } from "./safeWebhookUrl.server"; export type CreateAlertChannelOptions = { name: string; @@ -46,6 +47,19 @@ export class CreateAlertChannelService extends BaseService { throw new ServiceValidationError("Project not found"); } + // Validate webhook URLs here (not per-route) so every caller is covered. + // Delivery re-validates at connect time via safeWebhookFetch. + if (options.channel.type === "WEBHOOK") { + try { + await assertSafeWebhookUrl(options.channel.url); + } catch (error) { + if (error instanceof UnsafeWebhookUrlError) { + throw new ServiceValidationError(error.message); + } + throw error; + } + } + const environmentTypes = options.environmentTypes.length === 0 ? (["STAGING", "PRODUCTION"] satisfies RuntimeEnvironmentType[]) diff --git a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts index 845430735c8..0905f7c768f 100644 --- a/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts +++ b/apps/webapp/app/v3/services/alerts/deliverAlert.server.ts @@ -49,6 +49,7 @@ import { alertsWorker } from "~/v3/alertsWorker.server"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; import { fromPromise } from "neverthrow"; import { BaseService } from "../baseService.server"; +import { safeWebhookFetch } from "./safeWebhookFetch.server"; import { CURRENT_API_VERSION } from "~/api/versions"; import type { RunStore } from "@internal/run-store"; import type { ControlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; @@ -1033,7 +1034,8 @@ export class DeliverAlertService extends BaseService { const signature = await subtle.sign("HMAC", key, hashPayload); const signatureHex = Buffer.from(signature).toString("hex"); - const response = await fetch(webhook.url, { + // Deliver via the SSRF-safe wrapper (see safeWebhookFetch.server.ts). + const response = await safeWebhookFetch(webhook.url, { method: "POST", headers: { "content-type": "application/json", diff --git a/apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.ts b/apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.ts index b9c1e7ddf90..6234a92cec6 100644 --- a/apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.ts +++ b/apps/webapp/app/v3/services/alerts/deliverErrorGroupAlert.server.ts @@ -24,6 +24,7 @@ import { logger } from "~/services/logger.server"; import { decryptSecret } from "~/services/secrets/secretStore.server"; import { subtle } from "crypto"; import { generateErrorGroupWebhookPayload } from "./errorGroupWebhook.server"; +import { safeWebhookFetch } from "./safeWebhookFetch.server"; type ErrorAlertClassification = "new_issue" | "regression" | "unignored"; @@ -255,7 +256,8 @@ export class DeliverErrorGroupAlertService { const signature = await subtle.sign("HMAC", key, hashPayload); const signatureHex = Buffer.from(signature).toString("hex"); - const response = await fetch(webhookProperties.data.url, { + // Deliver via the SSRF-safe wrapper (see safeWebhookFetch.server.ts). + const response = await safeWebhookFetch(webhookProperties.data.url, { method: "POST", headers: { "content-type": "application/json", diff --git a/apps/webapp/app/v3/services/alerts/safeWebhookFetch.server.ts b/apps/webapp/app/v3/services/alerts/safeWebhookFetch.server.ts new file mode 100644 index 00000000000..cb3cba3ca3c --- /dev/null +++ b/apps/webapp/app/v3/services/alerts/safeWebhookFetch.server.ts @@ -0,0 +1,161 @@ +import http from "node:http"; +import https from "node:https"; +import { promises as dnsPromises } from "node:dns"; +import type { LookupFunction } from "node:net"; +import { logger } from "~/services/logger.server"; +import { + assertAddressAllowed, + assertSafeWebhookUrl, + assertSafeWebhookUrlLexical, + UnsafeWebhookUrlError, +} from "./safeWebhookUrl.server"; + +/** + * `fetch`-like wrapper for delivering user-supplied webhook URLs. The lexical + * check is shared with the storage-time gate (`assertSafeWebhookUrlLexical`). + * + * Validation is bound to the actual connection: the request goes through + * `node:http`/`node:https` with a custom DNS `lookup` that validates every + * resolved address before the socket connects, so the connected address is the + * one that was checked. Redirects are followed manually and re-validated per + * hop, capped at `MAX_REDIRECTS`. + */ + +// Re-exported so callers/tests don't reach into the underlying module. +export { assertSafeWebhookUrl, assertSafeWebhookUrlLexical, UnsafeWebhookUrlError }; + +const MAX_REDIRECTS = 5; + +export type SafeWebhookFetchInit = { + method?: string; + headers?: Record; + body?: string | Buffer; + signal?: AbortSignal; + redirectLimit?: number; +}; + +// DNS lookup that validates every resolved address before handing it to the +// connector; any unsafe address fails the whole lookup. On error we pass an +// empty address list, which net ignores when err is set. +const safeLookup: LookupFunction = (hostname, options, callback) => { + dnsPromises + .lookup(hostname, { + all: true, + family: options.family, + hints: options.hints, + verbatim: options.verbatim, + }) + .then((addresses) => { + try { + for (const { address, family } of addresses) { + assertAddressAllowed(address, family); + } + } catch (err) { + callback(err as NodeJS.ErrnoException, []); + return; + } + if (options.all) { + callback(null, addresses); + } else { + callback(null, addresses[0].address, addresses[0].family); + } + }) + .catch((err) => callback(err as NodeJS.ErrnoException, [])); +}; + +// Single request with no redirect following, using the validating lookup. The +// response body is drained and discarded (callers only need status / headers), +// which also frees the socket. +function requestOnce(urlStr: string, init: SafeWebhookFetchInit): Promise { + const url = new URL(urlStr); + const mod = url.protocol === "https:" ? https : http; + // Set Content-Length explicitly (as fetch does for string/Buffer bodies) + // rather than falling back to chunked transfer-encoding, which some + // webhook receivers reject. + const headers: Record = { ...(init.headers ?? {}) }; + if ( + init.body != null && + headers["content-length"] === undefined && + headers["Content-Length"] === undefined + ) { + headers["content-length"] = String(Buffer.byteLength(init.body)); + } + return new Promise((resolve, reject) => { + const req = mod.request( + url, + { + method: init.method ?? "GET", + headers, + lookup: safeLookup, + signal: init.signal, + }, + (res) => { + res.on("data", () => {}); + res.on("end", () => { + const responseHeaders = new Headers(); + for (const [key, value] of Object.entries(res.headers)) { + if (Array.isArray(value)) { + for (const v of value) responseHeaders.append(key, v); + } else if (value !== undefined) { + responseHeaders.set(key, value); + } + } + resolve( + new Response(null, { + status: res.statusCode ?? 502, + statusText: res.statusMessage ?? "", + headers: responseHeaders, + }) + ); + }); + res.on("error", reject); + } + ); + req.on("error", reject); + if (init.body != null) { + req.write(init.body); + } + req.end(); + }); +} + +/** + * Tenant-supplied-URL fetch with connection-bound SSRF validation and manual, + * per-hop redirect validation. + */ +export async function safeWebhookFetch( + rawUrl: string, + init: SafeWebhookFetchInit = {} +): Promise { + let nextUrl = assertSafeWebhookUrlLexical(rawUrl).href; + const limit = init.redirectLimit ?? MAX_REDIRECTS; + + for (let hop = 0; hop <= limit; hop++) { + const response = await requestOnce(nextUrl, init); + if (response.status < 300 || response.status >= 400) { + return response; + } + const location = response.headers.get("location"); + if (!location) return response; + if (hop === limit) { + throw new UnsafeWebhookUrlError( + `Refusing to deliver webhook to ${nextUrl}: exceeded redirect limit (${limit}) following ${location}` + ); + } + const target = new URL(location, nextUrl); + try { + nextUrl = assertSafeWebhookUrlLexical(target.href).href; + } catch (err) { + logger.warn("Refusing to follow webhook redirect", { + from: nextUrl, + to: target.href, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } + } + // Unreachable — the loop always returns or throws. + throw new UnsafeWebhookUrlError( + `Refusing to deliver webhook to ${nextUrl}: exhausted redirect loop` + ); +} diff --git a/apps/webapp/app/v3/services/alerts/safeWebhookUrl.server.ts b/apps/webapp/app/v3/services/alerts/safeWebhookUrl.server.ts new file mode 100644 index 00000000000..37a4c1e15dc --- /dev/null +++ b/apps/webapp/app/v3/services/alerts/safeWebhookUrl.server.ts @@ -0,0 +1,182 @@ +// Validator for user-supplied webhook URLs that the server fetches later +// (alert channels, error-group webhooks). Rejects non-http(s) schemes and +// private/loopback/link-local/reserved hosts. +// +// Two entry points: +// - `assertSafeWebhookUrlLexical` — sync, no network. Runs on every +// delivery hop; the connect-time bound lookup below is authoritative. +// - `assertSafeWebhookUrl` — storage-time gate: lexical check plus a +// best-effort DNS resolution for early, friendly rejection. +// +// The authoritative guard is at delivery time: `safeWebhookFetch` binds +// validation into the connection's own DNS lookup, so the address actually +// connected to is the one that was checked. + +import { promises as dnsPromises } from "node:dns"; + +export class UnsafeWebhookUrlError extends Error { + constructor(message: string) { + super(message); + this.name = "UnsafeWebhookUrlError"; + } +} + +function isUnsafeIPv4(host: string): boolean { + // Reject if the host parses as a 4-octet IPv4 in any of the unsafe ranges. + const parts = host.split("."); + if (parts.length !== 4) return false; + const nums = parts.map((p) => Number(p)); + if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false; + const [a, b] = nums; + // 0.0.0.0/8 (unspecified) + if (a === 0) return true; + // 127/8 loopback + if (a === 127) return true; + // 10/8 + if (a === 10) return true; + // 172.16/12 + if (a === 172 && b >= 16 && b <= 31) return true; + // 192.168/16 + if (a === 192 && b === 168) return true; + // 169.254/16 link-local + if (a === 169 && b === 254) return true; + // 100.64/10 carrier-grade NAT + if (a === 100 && b >= 64 && b <= 127) return true; + // 224/4 multicast + if (a >= 224 && a <= 239) return true; + // 240/4 reserved + if (a >= 240) return true; + return false; +} + +function isUnsafeIPv6(host: string): boolean { + // URL.hostname keeps the brackets for IPv6 literals ([::1]); DNS results + // and IP literals elsewhere are unbracketed. Strip brackets so both work. + const lower = ( + host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host + ).toLowerCase(); + // loopback + if (lower === "::1") return true; + // unspecified + if (lower === "::" || lower === "::0" || lower === "0:0:0:0:0:0:0:0") return true; + // link-local fe80::/10 + if (/^fe[89ab][0-9a-f]?:/.test(lower)) return true; + // ULA fc00::/7 + if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true; + // multicast ff00::/8 + if (lower.startsWith("ff")) return true; + // IPv4-mapped, dotted form: ::ffff:a.b.c.d + const mappedDotted = lower.match(/^::ffff:([0-9.]+)$/); + if (mappedDotted && isUnsafeIPv4(mappedDotted[1])) return true; + // IPv4-mapped, hex form: ::ffff:7f00:1 (how Node normalizes ::ffff:127.0.0.1). + // The two trailing hextets encode the 32-bit IPv4 address. + const mappedHex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/); + if (mappedHex) { + const hi = parseInt(mappedHex[1], 16); + const lo = parseInt(mappedHex[2], 16); + const ipv4 = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; + if (isUnsafeIPv4(ipv4)) return true; + } + return false; +} + +/** + * Throw if a resolved IP address falls in a disallowed range. Exposed so the + * delivery-time connector can validate the actual address it connects to. + */ +export function assertAddressAllowed(address: string, family: number): void { + if (family === 4 && isUnsafeIPv4(address)) { + throw new UnsafeWebhookUrlError( + `Webhook URL resolves to a private/loopback/link-local address: ${address}` + ); + } + if (family === 6 && isUnsafeIPv6(address)) { + throw new UnsafeWebhookUrlError( + `Webhook URL resolves to a private/loopback/link-local IPv6 address: ${address}` + ); + } +} + +function isUnsafeHostname(host: string): boolean { + const lower = host.toLowerCase(); + if (lower === "localhost" || lower.endsWith(".localhost")) return true; + if (lower === "internal" || lower.endsWith(".internal")) return true; + if (lower === "local" || lower.endsWith(".local")) return true; + return false; +} + +function isIPLiteral(host: string): boolean { + // Strip brackets: URL.hostname keeps them for IPv6 literals ([::1]). + const bare = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + // IPv4: four dot-separated 0-255 octets. + if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(bare)) return true; + // IPv6: at least one `:` and only hex / `:` / `.` (the `.` allows + // IPv4-mapped notation like ::ffff:1.2.3.4). + if (bare.includes(":") && /^[0-9a-fA-F:.]+$/.test(bare)) return true; + return false; +} + +/** + * Best-effort storage-time DNS check: resolve `hostname` and throw if a + * returned address is unsafe. Not a security boundary — resolution failures + * don't block the save, since delivery re-validates at connect time. + */ +async function assertResolvedAddressesSafe(hostname: string): Promise { + let addresses: Array<{ address: string; family: number }>; + try { + addresses = await dnsPromises.lookup(hostname, { all: true }); + } catch { + // Unresolvable right now — don't block the save; connect-time is authoritative. + return; + } + for (const { address, family } of addresses) { + assertAddressAllowed(address, family); + } +} + +/** + * Synchronous, no-network SSRF check: scheme allow-list plus IP-literal + * and hostname range checks. Used on every delivery hop, where the + * connect-time bound lookup is the authoritative range check. + */ +export function assertSafeWebhookUrlLexical(rawUrl: string): URL { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + throw new UnsafeWebhookUrlError("Webhook URL is not a valid URL"); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new UnsafeWebhookUrlError(`Webhook URL must use http or https (got ${parsed.protocol})`); + } + const host = parsed.hostname; + if (!host) { + throw new UnsafeWebhookUrlError("Webhook URL must have a hostname"); + } + if (isUnsafeHostname(host)) { + throw new UnsafeWebhookUrlError(`Webhook URL host is not allowed: ${host}`); + } + if (isUnsafeIPv4(host)) { + throw new UnsafeWebhookUrlError( + `Webhook URL points at a private/loopback/link-local address: ${host}` + ); + } + if (isUnsafeIPv6(host)) { + throw new UnsafeWebhookUrlError( + `Webhook URL points at a private/loopback/link-local IPv6 address: ${host}` + ); + } + return parsed; +} + +/** + * Storage-time gate: lexical check plus a best-effort DNS resolution of + * registrable domains, for early rejection before storage. + */ +export async function assertSafeWebhookUrl(rawUrl: string): Promise { + const parsed = assertSafeWebhookUrlLexical(rawUrl); + if (!isIPLiteral(parsed.hostname)) { + await assertResolvedAddressesSafe(parsed.hostname); + } + return parsed; +} diff --git a/apps/webapp/test/safeWebhookFetch.test.ts b/apps/webapp/test/safeWebhookFetch.test.ts new file mode 100644 index 00000000000..ee9e9400b71 --- /dev/null +++ b/apps/webapp/test/safeWebhookFetch.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { + assertSafeWebhookUrl, + UnsafeWebhookUrlError, +} from "../app/v3/services/alerts/safeWebhookFetch.server.js"; + +// assertSafeWebhookUrl is the per-hop check behind safeWebhookFetch. These +// cases are decided lexically / by IP literal, so no network is needed; the +// DNS-resolution branch and redirect-following are not exercised here. +describe("assertSafeWebhookUrl", () => { + it("accepts a public http(s) URL given as an IP literal (no DNS needed)", async () => { + await expect(assertSafeWebhookUrl("https://93.184.216.34/hook")).resolves.toBeInstanceOf(URL); + }); + + it("rejects non-http(s) schemes", async () => { + for (const url of ["file:///etc/passwd", "gopher://x/_", "javascript:alert(1)", "ftp://h/x"]) { + await expect(assertSafeWebhookUrl(url)).rejects.toBeInstanceOf(UnsafeWebhookUrlError); + } + }); + + it("rejects loopback / unspecified / RFC1918 / link-local IPv4 literals", async () => { + for (const host of [ + "127.0.0.1", + "0.0.0.0", + "10.1.2.3", + "172.16.0.1", + "172.31.255.1", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", // CGNAT 100.64/10 + "100.127.255.255", + ]) { + await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf( + UnsafeWebhookUrlError + ); + } + }); + + it("rejects multicast and reserved IPv4 ranges", async () => { + for (const host of ["224.0.0.1", "239.1.1.1", "240.0.0.1"]) { + await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf( + UnsafeWebhookUrlError + ); + } + }); + + it("rejects loopback/internal hostnames before any DNS lookup", async () => { + for (const host of ["localhost", "svc.internal", "db.local", "0.0.0.0"]) { + await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf( + UnsafeWebhookUrlError + ); + } + }); + + it("rejects unsafe IPv6 literals", async () => { + for (const host of ["[::1]", "[fe80::1]", "[fc00::1]", "[::ffff:127.0.0.1]"]) { + await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf( + UnsafeWebhookUrlError + ); + } + }); + + it("rejects malformed URLs", async () => { + await expect(assertSafeWebhookUrl("not a url")).rejects.toBeInstanceOf(UnsafeWebhookUrlError); + }); +}); diff --git a/apps/webapp/test/safeWebhookUrl.test.ts b/apps/webapp/test/safeWebhookUrl.test.ts new file mode 100644 index 00000000000..a66534fdee2 --- /dev/null +++ b/apps/webapp/test/safeWebhookUrl.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { + assertAddressAllowed, + assertSafeWebhookUrl, + UnsafeWebhookUrlError, +} from "../app/v3/services/alerts/safeWebhookUrl.server.js"; + +// These cases are decided by the lexical / IP-literal checks, so no network is +// needed. The DNS-resolution branch is not exercised here. +describe("assertSafeWebhookUrl", () => { + it("accepts a public http(s) URL given as an IP literal (no DNS needed)", async () => { + await expect(assertSafeWebhookUrl("https://93.184.216.34/hook?x=1")).resolves.toBeInstanceOf( + URL + ); + }); + + it("rejects non-http(s) schemes", async () => { + for (const url of [ + "file:///etc/passwd", + "gopher://evil/_", + "ftp://host/x", + "data:text/plain,hi", + "javascript:alert(1)", + ]) { + await expect(assertSafeWebhookUrl(url)).rejects.toBeInstanceOf(UnsafeWebhookUrlError); + } + }); + + it("rejects loopback and unspecified IPv4", async () => { + for (const host of ["127.0.0.1", "127.9.9.9", "0.0.0.0"]) { + await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf( + UnsafeWebhookUrlError + ); + } + }); + + it("rejects RFC1918 private ranges", async () => { + for (const host of ["10.0.0.1", "172.16.0.1", "172.31.255.1", "192.168.1.1"]) { + await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf( + UnsafeWebhookUrlError + ); + } + }); + + it("rejects link-local incl. the cloud metadata address", async () => { + await expect( + assertSafeWebhookUrl("http://169.254.169.254/latest/meta-data/") + ).rejects.toBeInstanceOf(UnsafeWebhookUrlError); + }); + + it("rejects CGNAT, multicast and reserved ranges", async () => { + for (const host of ["100.64.0.1", "224.0.0.1", "239.1.1.1", "240.0.0.1"]) { + await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf( + UnsafeWebhookUrlError + ); + } + }); + + it("rejects loopback/internal hostnames before any DNS lookup", async () => { + for (const host of ["localhost", "foo.localhost", "svc.internal", "db.local"]) { + await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf( + UnsafeWebhookUrlError + ); + } + }); + + it("rejects unsafe IPv6 literals", async () => { + for (const host of ["[::1]", "[fe80::1]", "[fc00::1]", "[::ffff:127.0.0.1]"]) { + await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf( + UnsafeWebhookUrlError + ); + } + }); + + // Regression: bracketed IPv6 literals and Node's hex-normalized IPv4-mapped + // addresses (::ffff:127.0.0.1 -> ::ffff:7f00:1) must be caught lexically. + it("rejects bracketed and hex-mapped IPv6 loopback lexically", async () => { + for (const host of ["[::1]", "[::ffff:7f00:1]"]) { + await expect(assertSafeWebhookUrl(`http://${host}/hook`)).rejects.toBeInstanceOf( + UnsafeWebhookUrlError + ); + } + }); + + it("rejects malformed URLs", async () => { + await expect(assertSafeWebhookUrl("not a url")).rejects.toBeInstanceOf(UnsafeWebhookUrlError); + }); +}); + +// assertAddressAllowed is the connect-time check that safeWebhookFetch runs +// inside the socket's DNS lookup. These cases cover its address-range logic. +describe("assertAddressAllowed", () => { + it("allows public IPv4 / IPv6 addresses", () => { + expect(() => assertAddressAllowed("93.184.216.34", 4)).not.toThrow(); + expect(() => assertAddressAllowed("2606:2800:220:1:248:1893:25c8:1946", 6)).not.toThrow(); + }); + + it("rejects loopback / private / CGNAT / link-local IPv4 (incl. metadata)", () => { + for (const addr of [ + "127.0.0.1", + "0.0.0.0", + "10.1.2.3", + "172.16.0.1", + "192.168.1.1", + "169.254.169.254", + "100.64.0.1", + ]) { + expect(() => assertAddressAllowed(addr, 4)).toThrow(UnsafeWebhookUrlError); + } + }); + + it("rejects loopback / ULA / link-local / mapped IPv6", () => { + for (const addr of ["::1", "fe80::1", "fc00::1", "::ffff:127.0.0.1"]) { + expect(() => assertAddressAllowed(addr, 6)).toThrow(UnsafeWebhookUrlError); + } + }); +}); diff --git a/apps/webapp/test/sanitizeUrl.test.ts b/apps/webapp/test/sanitizeUrl.test.ts new file mode 100644 index 00000000000..ecabd3bc575 --- /dev/null +++ b/apps/webapp/test/sanitizeUrl.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeHttpUrl } from "../app/utils/sanitizeUrl.js"; + +// sanitizeHttpUrl returns undefined for anything that isn't http(s), so callers +// fall back to a safe default rather than rendering it into an href. +describe("sanitizeHttpUrl", () => { + it("passes through http and https URLs", () => { + expect(sanitizeHttpUrl("https://trigger.dev/changelog")).toBe("https://trigger.dev/changelog"); + expect(sanitizeHttpUrl("http://example.com/x?y=1")).toBe("http://example.com/x?y=1"); + }); + + it("rejects script-bearing and non-http(s) schemes", () => { + for (const url of [ + "javascript:alert(1)", + "javascript:alert(document.cookie)//", + "data:text/html,", + "vbscript:msgbox(1)", + "file:///etc/passwd", + ]) { + expect(sanitizeHttpUrl(url)).toBeUndefined(); + } + }); + + it("returns undefined for empty / nullish input", () => { + expect(sanitizeHttpUrl(undefined)).toBeUndefined(); + expect(sanitizeHttpUrl(null)).toBeUndefined(); + expect(sanitizeHttpUrl("")).toBeUndefined(); + }); + + it("returns undefined for unparseable input", () => { + expect(sanitizeHttpUrl("not a url")).toBeUndefined(); + }); +}); diff --git a/internal-packages/llm-model-catalog/src/defaultPrices.ts b/internal-packages/llm-model-catalog/src/defaultPrices.ts index fb347c2bef6..982b6b2ec15 100644 --- a/internal-packages/llm-model-catalog/src/defaultPrices.ts +++ b/internal-packages/llm-model-catalog/src/defaultPrices.ts @@ -6,3091 +6,3106 @@ import type { DefaultModelDefinition } from "./types.js"; export const defaultModelPrices: DefaultModelDefinition[] = [ { - "modelName": "gpt-4o", - "matchPattern": "(?i)^(openai/)?(gpt-4o)$", - "startDate": "2024-05-13T23:15:07.670Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000025, - "input_cached_tokens": 0.00000125, - "input_cache_read": 0.00000125, - "output": 0.00001 - } - } - ] - }, - { - "modelName": "gpt-4o-2024-05-13", - "matchPattern": "(?i)^(openai/)?(gpt-4o-2024-05-13)$", - "startDate": "2024-05-13T23:15:07.670Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000005, - "output": 0.000015 - } - } - ] - }, - { - "modelName": "gpt-4-1106-preview", - "matchPattern": "(?i)^(openai/)?(gpt-4-1106-preview)$", - "startDate": "2024-04-23T10:37:17.092Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00001, - "output": 0.00003 - } - } - ] - }, - { - "modelName": "gpt-4-turbo-vision", - "matchPattern": "(?i)^(openai/)?(gpt-4(-\\d{4})?-vision-preview)$", - "startDate": "2024-01-24T10:19:21.693Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00001, - "output": 0.00003 - } - } - ] - }, - { - "modelName": "gpt-4-32k", - "matchPattern": "(?i)^(openai/)?(gpt-4-32k)$", - "startDate": "2024-01-24T10:19:21.693Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00006, - "output": 0.00012 - } - } - ] - }, - { - "modelName": "gpt-4-32k-0613", - "matchPattern": "(?i)^(openai/)?(gpt-4-32k-0613)$", - "startDate": "2024-01-24T10:19:21.693Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00006, - "output": 0.00012 - } - } - ] - }, - { - "modelName": "gpt-3.5-turbo-1106", - "matchPattern": "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo-1106)$", - "startDate": "2024-01-24T10:19:21.693Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000001, - "output": 0.000002 - } - } - ] - }, - { - "modelName": "gpt-3.5-turbo-0613", - "matchPattern": "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo-0613)$", - "startDate": "2024-01-24T10:19:21.693Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000015, - "output": 0.000002 - } - } - ] - }, - { - "modelName": "gpt-4-0613", - "matchPattern": "(?i)^(openai/)?(gpt-4-0613)$", - "startDate": "2024-01-24T10:19:21.693Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00003, - "output": 0.00006 - } - } - ] - }, - { - "modelName": "gpt-3.5-turbo-instruct", - "matchPattern": "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo-instruct)$", - "startDate": "2024-01-24T10:19:21.693Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000015, - "output": 0.000002 - } - } - ] - }, - { - "modelName": "text-ada-001", - "matchPattern": "(?i)^(text-ada-001)$", - "startDate": "2024-01-24T18:18:50.861Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "total": 0.000004 - } - } - ] - }, - { - "modelName": "text-babbage-001", - "matchPattern": "(?i)^(text-babbage-001)$", - "startDate": "2024-01-24T18:18:50.861Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "total": 5e-7 - } - } - ] - }, - { - "modelName": "text-curie-001", - "matchPattern": "(?i)^(text-curie-001)$", - "startDate": "2024-01-24T18:18:50.861Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "total": 0.00002 - } - } - ] - }, - { - "modelName": "text-davinci-001", - "matchPattern": "(?i)^(text-davinci-001)$", - "startDate": "2024-01-24T18:18:50.861Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "total": 0.00002 - } - } - ] - }, - { - "modelName": "text-davinci-002", - "matchPattern": "(?i)^(text-davinci-002)$", - "startDate": "2024-01-24T18:18:50.861Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "total": 0.00002 - } - } - ] - }, - { - "modelName": "text-davinci-003", - "matchPattern": "(?i)^(text-davinci-003)$", - "startDate": "2024-01-24T18:18:50.861Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "total": 0.00002 - } - } - ] - }, - { - "modelName": "text-embedding-ada-002-v2", - "matchPattern": "(?i)^(text-embedding-ada-002-v2)$", - "startDate": "2024-01-24T18:18:50.861Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "total": 1e-7 - } - } - ] - }, - { - "modelName": "text-embedding-ada-002", - "matchPattern": "(?i)^(text-embedding-ada-002)$", - "startDate": "2024-01-24T18:18:50.861Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "total": 1e-7 - } - } - ] - }, - { - "modelName": "gpt-3.5-turbo-16k-0613", - "matchPattern": "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo-16k-0613)$", - "startDate": "2024-02-03T17:29:57.350Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000003, - "output": 0.000004 - } - } - ] - }, - { - "modelName": "gpt-3.5-turbo-0301", - "matchPattern": "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo-0301)$", - "startDate": "2024-01-24T10:19:21.693Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000002, - "output": 0.000002 - } - } - ] - }, - { - "modelName": "gpt-4-32k-0314", - "matchPattern": "(?i)^(openai/)?(gpt-4-32k-0314)$", - "startDate": "2024-01-24T10:19:21.693Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00006, - "output": 0.00012 - } - } - ] - }, - { - "modelName": "gpt-4-0314", - "matchPattern": "(?i)^(openai/)?(gpt-4-0314)$", - "startDate": "2024-01-24T10:19:21.693Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00003, - "output": 0.00006 - } - } - ] - }, - { - "modelName": "gpt-4", - "matchPattern": "(?i)^(openai/)?(gpt-4)$", - "startDate": "2024-01-24T10:19:21.693Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00003, - "output": 0.00006 - } - } - ] - }, - { - "modelName": "claude-instant-1.2", - "matchPattern": "(?i)^(anthropic/)?(claude-instant-1.2)$", - "startDate": "2024-01-30T15:44:13.447Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00000163, - "output": 0.00000551 - } - } - ] - }, - { - "modelName": "claude-2.0", - "matchPattern": "(?i)^(anthropic/)?(claude-2.0)$", - "startDate": "2024-01-30T15:44:13.447Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000008, - "output": 0.000024 - } - } - ] - }, - { - "modelName": "claude-2.1", - "matchPattern": "(?i)^(anthropic/)?(claude-2.1)$", - "startDate": "2024-01-30T15:44:13.447Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000008, - "output": 0.000024 - } - } - ] - }, - { - "modelName": "claude-1.3", - "matchPattern": "(?i)^(anthropic/)?(claude-1.3)$", - "startDate": "2024-01-30T15:44:13.447Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000008, - "output": 0.000024 - } - } - ] - }, - { - "modelName": "claude-1.2", - "matchPattern": "(?i)^(anthropic/)?(claude-1.2)$", - "startDate": "2024-01-30T15:44:13.447Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000008, - "output": 0.000024 - } - } - ] - }, - { - "modelName": "claude-1.1", - "matchPattern": "(?i)^(anthropic/)?(claude-1.1)$", - "startDate": "2024-01-30T15:44:13.447Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000008, - "output": 0.000024 - } - } - ] - }, - { - "modelName": "claude-instant-1", - "matchPattern": "(?i)^(anthropic/)?(claude-instant-1)$", - "startDate": "2024-01-30T15:44:13.447Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00000163, - "output": 0.00000551 - } - } - ] - }, - { - "modelName": "babbage-002", - "matchPattern": "(?i)^(babbage-002)$", - "startDate": "2024-01-26T17:35:21.129Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 4e-7, - "output": 0.0000016 - } - } - ] - }, - { - "modelName": "davinci-002", - "matchPattern": "(?i)^(davinci-002)$", - "startDate": "2024-01-26T17:35:21.129Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000006, - "output": 0.000012 - } - } - ] - }, - { - "modelName": "text-embedding-3-small", - "matchPattern": "(?i)^(text-embedding-3-small)$", - "startDate": "2024-01-26T17:35:21.129Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "total": 2e-8 - } - } - ] - }, - { - "modelName": "text-embedding-3-large", - "matchPattern": "(?i)^(text-embedding-3-large)$", - "startDate": "2024-01-26T17:35:21.129Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "total": 1.3e-7 - } - } - ] - }, - { - "modelName": "gpt-3.5-turbo-0125", - "matchPattern": "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo-0125)$", - "startDate": "2024-01-26T17:35:21.129Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 5e-7, - "output": 0.0000015 - } - } - ] - }, - { - "modelName": "gpt-3.5-turbo", - "matchPattern": "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo)$", - "startDate": "2024-02-13T12:00:37.424Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 5e-7, - "output": 0.0000015 - } - } - ] - }, - { - "modelName": "gpt-4-0125-preview", - "matchPattern": "(?i)^(openai/)?(gpt-4-0125-preview)$", - "startDate": "2024-01-26T17:35:21.129Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00001, - "output": 0.00003 - } - } - ] - }, - { - "modelName": "ft:gpt-3.5-turbo-1106", - "matchPattern": "(?i)^(ft:)(gpt-3.5-turbo-1106:)(.+)(:)(.*)(:)(.+)$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000003, - "output": 0.000006 - } - } - ] - }, - { - "modelName": "ft:gpt-3.5-turbo-0613", - "matchPattern": "(?i)^(ft:)(gpt-3.5-turbo-0613:)(.+)(:)(.*)(:)(.+)$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000012, - "output": 0.000016 - } - } - ] - }, - { - "modelName": "ft:davinci-002", - "matchPattern": "(?i)^(ft:)(davinci-002:)(.+)(:)(.*)(:)(.+)$$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000012, - "output": 0.000012 - } - } - ] - }, - { - "modelName": "ft:babbage-002", - "matchPattern": "(?i)^(ft:)(babbage-002:)(.+)(:)(.*)(:)(.+)$$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000016, - "output": 0.0000016 - } - } - ] - }, - { - "modelName": "chat-bison", - "matchPattern": "(?i)^(chat-bison)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2.5e-7, - "output": 5e-7 - } - } - ] - }, - { - "modelName": "codechat-bison-32k", - "matchPattern": "(?i)^(codechat-bison-32k)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2.5e-7, - "output": 5e-7 - } - } - ] - }, - { - "modelName": "codechat-bison", - "matchPattern": "(?i)^(codechat-bison)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2.5e-7, - "output": 5e-7 - } - } - ] - }, - { - "modelName": "text-bison-32k", - "matchPattern": "(?i)^(text-bison-32k)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2.5e-7, - "output": 5e-7 - } - } - ] - }, - { - "modelName": "chat-bison-32k", - "matchPattern": "(?i)^(chat-bison-32k)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2.5e-7, - "output": 5e-7 - } - } - ] - }, - { - "modelName": "text-unicorn", - "matchPattern": "(?i)^(text-unicorn)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000025, - "output": 0.0000075 - } - } - ] - }, - { - "modelName": "text-bison", - "matchPattern": "(?i)^(text-bison)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2.5e-7, - "output": 5e-7 - } - } - ] - }, - { - "modelName": "textembedding-gecko", - "matchPattern": "(?i)^(textembedding-gecko)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "total": 1e-7 - } - } - ] - }, - { - "modelName": "textembedding-gecko-multilingual", - "matchPattern": "(?i)^(textembedding-gecko-multilingual)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "total": 1e-7 - } - } - ] - }, - { - "modelName": "code-gecko", - "matchPattern": "(?i)^(code-gecko)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2.5e-7, - "output": 5e-7 - } - } - ] - }, - { - "modelName": "code-bison", - "matchPattern": "(?i)^(code-bison)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2.5e-7, - "output": 5e-7 - } - } - ] - }, - { - "modelName": "code-bison-32k", - "matchPattern": "(?i)^(code-bison-32k)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-01-31T13:25:02.141Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2.5e-7, - "output": 5e-7 - } - } - ] - }, - { - "modelName": "gpt-3.5-turbo-16k", - "matchPattern": "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo-16k)$", - "startDate": "2024-02-13T12:00:37.424Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 5e-7, - "output": 0.0000015 - } - } - ] - }, - { - "modelName": "gpt-4-turbo-preview", - "matchPattern": "(?i)^(openai/)?(gpt-4-turbo-preview)$", - "startDate": "2024-02-15T21:21:50.947Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00001, - "output": 0.00003 - } - } - ] - }, - { - "modelName": "claude-3-opus-20240229", - "matchPattern": "(?i)^(anthropic/)?(claude-3-opus-20240229|anthropic\\.claude-3-opus-20240229-v1:0|claude-3-opus@20240229)$", - "startDate": "2024-03-07T17:55:38.139Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000015, - "output": 0.000075 - } - } - ] - }, - { - "modelName": "claude-3-sonnet-20240229", - "matchPattern": "(?i)^(anthropic/)?(claude-3-sonnet-20240229|anthropic\\.claude-3-sonnet-20240229-v1:0|claude-3-sonnet@20240229)$", - "startDate": "2024-03-07T17:55:38.139Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000003, - "input_tokens": 0.000003, - "output": 0.000015, - "output_tokens": 0.000015, - "cache_creation_input_tokens": 0.00000375, - "input_cache_creation": 0.00000375, - "input_cache_creation_5m": 0.00000375, - "input_cache_creation_1h": 0.000006, - "cache_read_input_tokens": 3e-7, - "input_cache_read": 3e-7 - } - } - ] - }, - { - "modelName": "claude-3-haiku-20240307", - "matchPattern": "(?i)^(anthropic/)?(claude-3-haiku-20240307|anthropic\\.claude-3-haiku-20240307-v1:0|claude-3-haiku@20240307)$", - "startDate": "2024-03-14T09:41:18.736Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2.5e-7, - "output": 0.00000125 - } - } - ] - }, - { - "modelName": "gemini-1.0-pro-latest", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-1.0-pro-latest)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-04-11T10:27:46.517Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2.5e-7, - "output": 5e-7 - } - } - ] - }, - { - "modelName": "gemini-1.0-pro", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-1.0-pro)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-04-11T10:27:46.517Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 1.25e-7, - "output": 3.75e-7 - } - } - ] - }, - { - "modelName": "gemini-1.0-pro-001", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-1.0-pro-001)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-04-11T10:27:46.517Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 1.25e-7, - "output": 3.75e-7 - } - } - ] - }, - { - "modelName": "gemini-pro", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-pro)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-04-11T10:27:46.517Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 1.25e-7, - "output": 3.75e-7 - } - } - ] - }, - { - "modelName": "gemini-1.5-pro-latest", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-1.5-pro-latest)(@[a-zA-Z0-9]+)?$", - "startDate": "2024-04-11T10:27:46.517Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000025, - "output": 0.0000075 - } - } - ] - }, - { - "modelName": "gpt-4-turbo-2024-04-09", - "matchPattern": "(?i)^(openai/)?(gpt-4-turbo-2024-04-09)$", - "startDate": "2024-04-23T10:37:17.092Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00001, - "output": 0.00003 - } - } - ] - }, - { - "modelName": "gpt-4-turbo", - "matchPattern": "(?i)^(openai/)?(gpt-4-turbo)$", - "startDate": "2024-04-11T21:13:44.989Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00001, - "output": 0.00003 - } - } - ] - }, - { - "modelName": "gpt-4-preview", - "matchPattern": "(?i)^(openai/)?(gpt-4-preview)$", - "startDate": "2024-04-23T10:37:17.092Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00001, - "output": 0.00003 - } - } - ] - }, - { - "modelName": "claude-3-5-sonnet-20240620", - "matchPattern": "(?i)^(anthropic/)?(claude-3-5-sonnet-20240620|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-3-5-sonnet-20240620-v1:0|claude-3-5-sonnet@20240620)$", - "startDate": "2024-06-25T11:47:24.475Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000003, - "input_tokens": 0.000003, - "output": 0.000015, - "output_tokens": 0.000015, - "cache_creation_input_tokens": 0.00000375, - "input_cache_creation": 0.00000375, - "input_cache_creation_5m": 0.00000375, - "input_cache_creation_1h": 0.000006, - "cache_read_input_tokens": 3e-7, - "input_cache_read": 3e-7 - } - } - ] - }, - { - "modelName": "gpt-4o-mini", - "matchPattern": "(?i)^(openai/)?(gpt-4o-mini)$", - "startDate": "2024-07-18T17:56:09.591Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 1.5e-7, - "output": 6e-7, - "input_cached_tokens": 7.5e-8, - "input_cache_read": 7.5e-8 - } - } - ] - }, - { - "modelName": "gpt-4o-mini-2024-07-18", - "matchPattern": "(?i)^(openai/)?(gpt-4o-mini-2024-07-18)$", - "startDate": "2024-07-18T17:56:09.591Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 1.5e-7, - "input_cached_tokens": 7.5e-8, - "input_cache_read": 7.5e-8, - "output": 6e-7 - } - } - ] - }, - { - "modelName": "gpt-4o-2024-08-06", - "matchPattern": "(?i)^(openai/)?(gpt-4o-2024-08-06)$", - "startDate": "2024-08-07T11:54:31.298Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000025, - "input_cached_tokens": 0.00000125, - "input_cache_read": 0.00000125, - "output": 0.00001 - } - } - ] - }, - { - "modelName": "o1-preview", - "matchPattern": "(?i)^(openai/)?(o1-preview)$", - "startDate": "2024-09-13T10:01:35.373Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000015, - "input_cached_tokens": 0.0000075, - "input_cache_read": 0.0000075, - "output": 0.00006, - "output_reasoning_tokens": 0.00006, - "output_reasoning": 0.00006 - } - } - ] - }, - { - "modelName": "o1-preview-2024-09-12", - "matchPattern": "(?i)^(openai/)?(o1-preview-2024-09-12)$", - "startDate": "2024-09-13T10:01:35.373Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000015, - "input_cached_tokens": 0.0000075, - "input_cache_read": 0.0000075, - "output": 0.00006, - "output_reasoning_tokens": 0.00006, - "output_reasoning": 0.00006 - } - } - ] - }, - { - "modelName": "o1-mini", - "matchPattern": "(?i)^(openai/)?(o1-mini)$", - "startDate": "2024-09-13T10:01:35.373Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000011, - "input_cached_tokens": 5.5e-7, - "input_cache_read": 5.5e-7, - "output": 0.0000044, - "output_reasoning_tokens": 0.0000044, - "output_reasoning": 0.0000044 - } - } - ] - }, - { - "modelName": "o1-mini-2024-09-12", - "matchPattern": "(?i)^(openai/)?(o1-mini-2024-09-12)$", - "startDate": "2024-09-13T10:01:35.373Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000011, - "input_cached_tokens": 5.5e-7, - "input_cache_read": 5.5e-7, - "output": 0.0000044, - "output_reasoning_tokens": 0.0000044, - "output_reasoning": 0.0000044 - } - } - ] - }, - { - "modelName": "claude-3.5-sonnet-20241022", - "matchPattern": "(?i)^(anthropic/)?(claude-3-5-sonnet-20241022|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-3-5-sonnet-20241022-v2:0|claude-3-5-sonnet-V2@20241022)$", - "startDate": "2024-10-22T18:48:01.676Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000003, - "input_tokens": 0.000003, - "output": 0.000015, - "output_tokens": 0.000015, - "cache_creation_input_tokens": 0.00000375, - "input_cache_creation": 0.00000375, - "input_cache_creation_5m": 0.00000375, - "input_cache_creation_1h": 0.000006, - "cache_read_input_tokens": 3e-7, - "input_cache_read": 3e-7 - } - } - ] - }, - { - "modelName": "claude-3.5-sonnet-latest", - "matchPattern": "(?i)^(anthropic/)?(claude-3-5-sonnet-latest)$", - "startDate": "2024-10-22T18:48:01.676Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000003, - "input_tokens": 0.000003, - "output": 0.000015, - "output_tokens": 0.000015, - "cache_creation_input_tokens": 0.00000375, - "input_cache_creation": 0.00000375, - "input_cache_creation_5m": 0.00000375, - "input_cache_creation_1h": 0.000006, - "cache_read_input_tokens": 3e-7, - "input_cache_read": 3e-7 - } - } - ] - }, - { - "modelName": "claude-3-5-haiku-20241022", - "matchPattern": "(?i)^(anthropic/)?(claude-3-5-haiku-20241022|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-3-5-haiku-20241022-v1:0|claude-3-5-haiku-V1@20241022)$", - "startDate": "2024-11-05T10:30:50.566Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 8e-7, - "input_tokens": 8e-7, - "output": 0.000004, - "output_tokens": 0.000004, - "cache_creation_input_tokens": 0.000001, - "input_cache_creation": 0.000001, - "input_cache_creation_5m": 0.000001, - "input_cache_creation_1h": 0.0000016, - "cache_read_input_tokens": 8e-8, - "input_cache_read": 8e-8 - } - } - ] - }, - { - "modelName": "claude-3.5-haiku-latest", - "matchPattern": "(?i)^(anthropic/)?(claude-3-5-haiku-latest)$", - "startDate": "2024-11-05T10:30:50.566Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 8e-7, - "input_tokens": 8e-7, - "output": 0.000004, - "output_tokens": 0.000004, - "cache_creation_input_tokens": 0.000001, - "input_cache_creation": 0.000001, - "input_cache_creation_5m": 0.000001, - "input_cache_creation_1h": 0.0000016, - "cache_read_input_tokens": 8e-8, - "input_cache_read": 8e-8 - } - } - ] - }, - { - "modelName": "chatgpt-4o-latest", - "matchPattern": "(?i)^(chatgpt-4o-latest)$", - "startDate": "2024-11-25T12:47:17.504Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000005, - "output": 0.000015 - } - } - ] - }, - { - "modelName": "gpt-4o-2024-11-20", - "matchPattern": "(?i)^(openai/)?(gpt-4o-2024-11-20)$", - "startDate": "2024-12-03T10:06:12.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000025, - "input_cached_tokens": 0.00000125, - "input_cache_read": 0.00000125, - "output": 0.00001 - } - } - ] - }, - { - "modelName": "gpt-4o-audio-preview", - "matchPattern": "(?i)^(openai/)?(gpt-4o-audio-preview)$", - "startDate": "2024-12-03T10:19:56.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input_text_tokens": 0.0000025, - "output_text_tokens": 0.00001, - "input_audio_tokens": 0.0001, - "input_audio": 0.0001, - "output_audio_tokens": 0.0002, - "output_audio": 0.0002 - } - } - ] - }, - { - "modelName": "gpt-4o-audio-preview-2024-10-01", - "matchPattern": "(?i)^(openai/)?(gpt-4o-audio-preview-2024-10-01)$", - "startDate": "2024-12-03T10:19:56.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input_text_tokens": 0.0000025, - "output_text_tokens": 0.00001, - "input_audio_tokens": 0.0001, - "input_audio": 0.0001, - "output_audio_tokens": 0.0002, - "output_audio": 0.0002 - } - } - ] - }, - { - "modelName": "gpt-4o-realtime-preview", - "matchPattern": "(?i)^(openai/)?(gpt-4o-realtime-preview)$", - "startDate": "2024-12-03T10:19:56.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input_text_tokens": 0.000005, - "input_cached_text_tokens": 0.0000025, - "output_text_tokens": 0.00002, - "input_audio_tokens": 0.0001, - "input_audio": 0.0001, - "input_cached_audio_tokens": 0.00002, - "output_audio_tokens": 0.0002, - "output_audio": 0.0002 - } - } - ] - }, - { - "modelName": "gpt-4o-realtime-preview-2024-10-01", - "matchPattern": "(?i)^(openai/)?(gpt-4o-realtime-preview-2024-10-01)$", - "startDate": "2024-12-03T10:19:56.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input_text_tokens": 0.000005, - "input_cached_text_tokens": 0.0000025, - "output_text_tokens": 0.00002, - "input_audio_tokens": 0.0001, - "input_audio": 0.0001, - "input_cached_audio_tokens": 0.00002, - "output_audio_tokens": 0.0002, - "output_audio": 0.0002 - } - } - ] - }, - { - "modelName": "o1", - "matchPattern": "(?i)^(openai/)?(o1)$", - "startDate": "2025-01-17T00:01:35.373Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000015, - "input_cached_tokens": 0.0000075, - "input_cache_read": 0.0000075, - "output": 0.00006, - "output_reasoning_tokens": 0.00006, - "output_reasoning": 0.00006 - } - } - ] - }, - { - "modelName": "o1-2024-12-17", - "matchPattern": "(?i)^(openai/)?(o1-2024-12-17)$", - "startDate": "2025-01-17T00:01:35.373Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000015, - "input_cached_tokens": 0.0000075, - "input_cache_read": 0.0000075, - "output": 0.00006, - "output_reasoning_tokens": 0.00006, - "output_reasoning": 0.00006 - } - } - ] - }, - { - "modelName": "o3-mini", - "matchPattern": "(?i)^(openai/)?(o3-mini)$", - "startDate": "2025-01-31T20:41:35.373Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000011, - "input_cached_tokens": 5.5e-7, - "input_cache_read": 5.5e-7, - "output": 0.0000044, - "output_reasoning_tokens": 0.0000044, - "output_reasoning": 0.0000044 - } - } - ] - }, - { - "modelName": "o3-mini-2025-01-31", - "matchPattern": "(?i)^(openai/)?(o3-mini-2025-01-31)$", - "startDate": "2025-01-31T20:41:35.373Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000011, - "input_cached_tokens": 5.5e-7, - "input_cache_read": 5.5e-7, - "output": 0.0000044, - "output_reasoning_tokens": 0.0000044, - "output_reasoning": 0.0000044 - } - } - ] - }, - { - "modelName": "gemini-2.0-flash-001", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-2.0-flash-001)(@[a-zA-Z0-9]+)?$", - "startDate": "2025-02-06T11:11:35.241Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 1e-7, - "output": 4e-7 - } - } - ] - }, - { - "modelName": "gemini-2.0-flash-lite-preview-02-05", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-2.0-flash-lite-preview-02-05)(@[a-zA-Z0-9]+)?$", - "startDate": "2025-02-06T11:11:35.241Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 7.5e-8, - "output": 3e-7 - } - } - ] - }, - { - "modelName": "claude-3.7-sonnet-20250219", - "matchPattern": "(?i)^(anthropic/)?(claude-3.7-sonnet-20250219|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-3.7-sonnet-20250219-v1:0|claude-3-7-sonnet-V1@20250219)$", - "startDate": "2025-02-25T09:35:39.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000003, - "input_tokens": 0.000003, - "output": 0.000015, - "output_tokens": 0.000015, - "cache_creation_input_tokens": 0.00000375, - "input_cache_creation": 0.00000375, - "input_cache_creation_5m": 0.00000375, - "input_cache_creation_1h": 0.000006, - "cache_read_input_tokens": 3e-7, - "input_cache_read": 3e-7 - } - } - ] - }, - { - "modelName": "claude-3.7-sonnet-latest", - "matchPattern": "(?i)^(anthropic/)?(claude-3-7-sonnet-latest)$", - "startDate": "2025-02-25T09:35:39.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000003, - "input_tokens": 0.000003, - "output": 0.000015, - "output_tokens": 0.000015, - "cache_creation_input_tokens": 0.00000375, - "input_cache_creation": 0.00000375, - "input_cache_creation_5m": 0.00000375, - "input_cache_creation_1h": 0.000006, - "cache_read_input_tokens": 3e-7, - "input_cache_read": 3e-7 - } - } - ] - }, - { - "modelName": "gpt-4.5-preview", - "matchPattern": "(?i)^(openai/)?(gpt-4.5-preview)$", - "startDate": "2025-02-27T21:26:54.132Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000075, - "input_cached_tokens": 0.0000375, - "input_cached_text_tokens": 0.0000375, - "input_cache_read": 0.0000375, - "output": 0.00015 - } - } - ] - }, - { - "modelName": "gpt-4.5-preview-2025-02-27", - "matchPattern": "(?i)^(openai/)?(gpt-4.5-preview-2025-02-27)$", - "startDate": "2025-02-27T21:26:54.132Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000075, - "input_cached_tokens": 0.0000375, - "input_cached_text_tokens": 0.0000375, - "input_cache_read": 0.0000375, - "output": 0.00015 - } - } - ] - }, - { - "modelName": "gpt-4.1", - "matchPattern": "(?i)^(openai/)?(gpt-4.1)$", - "startDate": "2025-04-15T10:26:54.132Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000002, - "input_cached_tokens": 5e-7, - "input_cached_text_tokens": 5e-7, - "input_cache_read": 5e-7, - "output": 0.000008 - } - } - ] - }, - { - "modelName": "gpt-4.1-2025-04-14", - "matchPattern": "(?i)^(openai/)?(gpt-4.1-2025-04-14)$", - "startDate": "2025-04-15T10:26:54.132Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000002, - "input_cached_tokens": 5e-7, - "input_cached_text_tokens": 5e-7, - "input_cache_read": 5e-7, - "output": 0.000008 - } - } - ] - }, - { - "modelName": "gpt-4.1-mini-2025-04-14", - "matchPattern": "(?i)^(openai/)?(gpt-4.1-mini-2025-04-14)$", - "startDate": "2025-04-15T10:26:54.132Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 4e-7, - "input_cached_tokens": 1e-7, - "input_cached_text_tokens": 1e-7, - "input_cache_read": 1e-7, - "output": 0.0000016 - } - } - ] - }, - { - "modelName": "gpt-4.1-nano-2025-04-14", - "matchPattern": "(?i)^(openai/)?(gpt-4.1-nano-2025-04-14)$", - "startDate": "2025-04-15T10:26:54.132Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 1e-7, - "input_cached_tokens": 2.5e-8, - "input_cached_text_tokens": 2.5e-8, - "input_cache_read": 2.5e-8, - "output": 4e-7 - } - } - ] - }, - { - "modelName": "o3", - "matchPattern": "(?i)^(openai/)?(o3)$", - "startDate": "2025-04-16T23:26:54.132Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000002, - "input_cached_tokens": 5e-7, - "input_cache_read": 5e-7, - "output": 0.000008, - "output_reasoning_tokens": 0.000008, - "output_reasoning": 0.000008 - } - } - ] - }, - { - "modelName": "o3-2025-04-16", - "matchPattern": "(?i)^(openai/)?(o3-2025-04-16)$", - "startDate": "2025-04-16T23:26:54.132Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000002, - "input_cached_tokens": 5e-7, - "input_cache_read": 5e-7, - "output": 0.000008, - "output_reasoning_tokens": 0.000008, - "output_reasoning": 0.000008 - } - } - ] - }, - { - "modelName": "o4-mini", - "matchPattern": "(?i)^(o4-mini)$", - "startDate": "2025-04-16T23:26:54.132Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000011, - "input_cached_tokens": 2.75e-7, - "input_cache_read": 2.75e-7, - "output": 0.0000044, - "output_reasoning_tokens": 0.0000044, - "output_reasoning": 0.0000044 - } - } - ] - }, - { - "modelName": "o4-mini-2025-04-16", - "matchPattern": "(?i)^(o4-mini-2025-04-16)$", - "startDate": "2025-04-16T23:26:54.132Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000011, - "input_cached_tokens": 2.75e-7, - "input_cache_read": 2.75e-7, - "output": 0.0000044, - "output_reasoning_tokens": 0.0000044, - "output_reasoning": 0.0000044 - } - } - ] - }, - { - "modelName": "gemini-2.0-flash", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-2.0-flash)(@[a-zA-Z0-9]+)?$", - "startDate": "2025-04-22T10:11:35.241Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 1e-7, - "output": 4e-7 - } - } - ] - }, - { - "modelName": "gemini-2.0-flash-lite-preview", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-2.0-flash-lite-preview)(@[a-zA-Z0-9]+)?$", - "startDate": "2025-04-22T10:11:35.241Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 7.5e-8, - "output": 3e-7 - } - } - ] - }, - { - "modelName": "gpt-4.1-nano", - "matchPattern": "(?i)^(openai/)?(gpt-4.1-nano)$", - "startDate": "2025-04-22T10:11:35.241Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 1e-7, - "input_cached_tokens": 2.5e-8, - "input_cached_text_tokens": 2.5e-8, - "input_cache_read": 2.5e-8, - "output": 4e-7 - } - } - ] - }, - { - "modelName": "gpt-4.1-mini", - "matchPattern": "(?i)^(openai/)?(gpt-4.1-mini)$", - "startDate": "2025-04-22T10:11:35.241Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 4e-7, - "input_cached_tokens": 1e-7, - "input_cached_text_tokens": 1e-7, - "input_cache_read": 1e-7, - "output": 0.0000016 - } - } - ] - }, - { - "modelName": "claude-sonnet-4-5-20250929", - "matchPattern": "(?i)^(anthropic/)?(claude-sonnet-4-5(-20250929)?|(eu\\.|us\\.|apac\\.|global\\.)?anthropic\\.claude-sonnet-4-5(-20250929)?-v1(:0)?|claude-sonnet-4-5-V1(@20250929)?|claude-sonnet-4-5(@20250929)?)$", - "startDate": "2025-09-29T00:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000003, - "input_tokens": 0.000003, - "output": 0.000015, - "output_tokens": 0.000015, - "cache_creation_input_tokens": 0.00000375, - "input_cache_creation": 0.00000375, - "input_cache_creation_5m": 0.00000375, - "input_cache_creation_1h": 0.000006, - "cache_read_input_tokens": 3e-7, - "input_cache_read": 3e-7 - } - }, - { - "name": "Large Context", - "isDefault": false, - "priority": 1, - "conditions": [ + modelName: "gpt-4o", + matchPattern: "(?i)^(openai/)?(gpt-4o)$", + startDate: "2024-05-13T23:15:07.670Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000025, + input_cached_tokens: 0.00000125, + input_cache_read: 0.00000125, + output: 0.00001, + }, + }, + ], + }, + { + modelName: "gpt-4o-2024-05-13", + matchPattern: "(?i)^(openai/)?(gpt-4o-2024-05-13)$", + startDate: "2024-05-13T23:15:07.670Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000005, + output: 0.000015, + }, + }, + ], + }, + { + modelName: "gpt-4-1106-preview", + matchPattern: "(?i)^(openai/)?(gpt-4-1106-preview)$", + startDate: "2024-04-23T10:37:17.092Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00001, + output: 0.00003, + }, + }, + ], + }, + { + modelName: "gpt-4-turbo-vision", + matchPattern: "(?i)^(openai/)?(gpt-4(-\\d{4})?-vision-preview)$", + startDate: "2024-01-24T10:19:21.693Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00001, + output: 0.00003, + }, + }, + ], + }, + { + modelName: "gpt-4-32k", + matchPattern: "(?i)^(openai/)?(gpt-4-32k)$", + startDate: "2024-01-24T10:19:21.693Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00006, + output: 0.00012, + }, + }, + ], + }, + { + modelName: "gpt-4-32k-0613", + matchPattern: "(?i)^(openai/)?(gpt-4-32k-0613)$", + startDate: "2024-01-24T10:19:21.693Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00006, + output: 0.00012, + }, + }, + ], + }, + { + modelName: "gpt-3.5-turbo-1106", + matchPattern: "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo-1106)$", + startDate: "2024-01-24T10:19:21.693Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000001, + output: 0.000002, + }, + }, + ], + }, + { + modelName: "gpt-3.5-turbo-0613", + matchPattern: "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo-0613)$", + startDate: "2024-01-24T10:19:21.693Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000015, + output: 0.000002, + }, + }, + ], + }, + { + modelName: "gpt-4-0613", + matchPattern: "(?i)^(openai/)?(gpt-4-0613)$", + startDate: "2024-01-24T10:19:21.693Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00003, + output: 0.00006, + }, + }, + ], + }, + { + modelName: "gpt-3.5-turbo-instruct", + matchPattern: "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo-instruct)$", + startDate: "2024-01-24T10:19:21.693Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000015, + output: 0.000002, + }, + }, + ], + }, + { + modelName: "text-ada-001", + matchPattern: "(?i)^(text-ada-001)$", + startDate: "2024-01-24T18:18:50.861Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + total: 0.000004, + }, + }, + ], + }, + { + modelName: "text-babbage-001", + matchPattern: "(?i)^(text-babbage-001)$", + startDate: "2024-01-24T18:18:50.861Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + total: 5e-7, + }, + }, + ], + }, + { + modelName: "text-curie-001", + matchPattern: "(?i)^(text-curie-001)$", + startDate: "2024-01-24T18:18:50.861Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + total: 0.00002, + }, + }, + ], + }, + { + modelName: "text-davinci-001", + matchPattern: "(?i)^(text-davinci-001)$", + startDate: "2024-01-24T18:18:50.861Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + total: 0.00002, + }, + }, + ], + }, + { + modelName: "text-davinci-002", + matchPattern: "(?i)^(text-davinci-002)$", + startDate: "2024-01-24T18:18:50.861Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + total: 0.00002, + }, + }, + ], + }, + { + modelName: "text-davinci-003", + matchPattern: "(?i)^(text-davinci-003)$", + startDate: "2024-01-24T18:18:50.861Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + total: 0.00002, + }, + }, + ], + }, + { + modelName: "text-embedding-ada-002-v2", + matchPattern: "(?i)^(text-embedding-ada-002-v2)$", + startDate: "2024-01-24T18:18:50.861Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + total: 1e-7, + }, + }, + ], + }, + { + modelName: "text-embedding-ada-002", + matchPattern: "(?i)^(text-embedding-ada-002)$", + startDate: "2024-01-24T18:18:50.861Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + total: 1e-7, + }, + }, + ], + }, + { + modelName: "gpt-3.5-turbo-16k-0613", + matchPattern: "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo-16k-0613)$", + startDate: "2024-02-03T17:29:57.350Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000003, + output: 0.000004, + }, + }, + ], + }, + { + modelName: "gpt-3.5-turbo-0301", + matchPattern: "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo-0301)$", + startDate: "2024-01-24T10:19:21.693Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000002, + output: 0.000002, + }, + }, + ], + }, + { + modelName: "gpt-4-32k-0314", + matchPattern: "(?i)^(openai/)?(gpt-4-32k-0314)$", + startDate: "2024-01-24T10:19:21.693Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00006, + output: 0.00012, + }, + }, + ], + }, + { + modelName: "gpt-4-0314", + matchPattern: "(?i)^(openai/)?(gpt-4-0314)$", + startDate: "2024-01-24T10:19:21.693Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00003, + output: 0.00006, + }, + }, + ], + }, + { + modelName: "gpt-4", + matchPattern: "(?i)^(openai/)?(gpt-4)$", + startDate: "2024-01-24T10:19:21.693Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00003, + output: 0.00006, + }, + }, + ], + }, + { + modelName: "claude-instant-1.2", + matchPattern: "(?i)^(anthropic/)?(claude-instant-1.2)$", + startDate: "2024-01-30T15:44:13.447Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00000163, + output: 0.00000551, + }, + }, + ], + }, + { + modelName: "claude-2.0", + matchPattern: "(?i)^(anthropic/)?(claude-2.0)$", + startDate: "2024-01-30T15:44:13.447Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000008, + output: 0.000024, + }, + }, + ], + }, + { + modelName: "claude-2.1", + matchPattern: "(?i)^(anthropic/)?(claude-2.1)$", + startDate: "2024-01-30T15:44:13.447Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000008, + output: 0.000024, + }, + }, + ], + }, + { + modelName: "claude-1.3", + matchPattern: "(?i)^(anthropic/)?(claude-1.3)$", + startDate: "2024-01-30T15:44:13.447Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000008, + output: 0.000024, + }, + }, + ], + }, + { + modelName: "claude-1.2", + matchPattern: "(?i)^(anthropic/)?(claude-1.2)$", + startDate: "2024-01-30T15:44:13.447Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000008, + output: 0.000024, + }, + }, + ], + }, + { + modelName: "claude-1.1", + matchPattern: "(?i)^(anthropic/)?(claude-1.1)$", + startDate: "2024-01-30T15:44:13.447Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000008, + output: 0.000024, + }, + }, + ], + }, + { + modelName: "claude-instant-1", + matchPattern: "(?i)^(anthropic/)?(claude-instant-1)$", + startDate: "2024-01-30T15:44:13.447Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00000163, + output: 0.00000551, + }, + }, + ], + }, + { + modelName: "babbage-002", + matchPattern: "(?i)^(babbage-002)$", + startDate: "2024-01-26T17:35:21.129Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 4e-7, + output: 0.0000016, + }, + }, + ], + }, + { + modelName: "davinci-002", + matchPattern: "(?i)^(davinci-002)$", + startDate: "2024-01-26T17:35:21.129Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000006, + output: 0.000012, + }, + }, + ], + }, + { + modelName: "text-embedding-3-small", + matchPattern: "(?i)^(text-embedding-3-small)$", + startDate: "2024-01-26T17:35:21.129Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + total: 2e-8, + }, + }, + ], + }, + { + modelName: "text-embedding-3-large", + matchPattern: "(?i)^(text-embedding-3-large)$", + startDate: "2024-01-26T17:35:21.129Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + total: 1.3e-7, + }, + }, + ], + }, + { + modelName: "gpt-3.5-turbo-0125", + matchPattern: "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo-0125)$", + startDate: "2024-01-26T17:35:21.129Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 5e-7, + output: 0.0000015, + }, + }, + ], + }, + { + modelName: "gpt-3.5-turbo", + matchPattern: "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo)$", + startDate: "2024-02-13T12:00:37.424Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 5e-7, + output: 0.0000015, + }, + }, + ], + }, + { + modelName: "gpt-4-0125-preview", + matchPattern: "(?i)^(openai/)?(gpt-4-0125-preview)$", + startDate: "2024-01-26T17:35:21.129Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00001, + output: 0.00003, + }, + }, + ], + }, + { + modelName: "ft:gpt-3.5-turbo-1106", + matchPattern: "(?i)^(ft:)(gpt-3.5-turbo-1106:)(.+)(:)(.*)(:)(.+)$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000003, + output: 0.000006, + }, + }, + ], + }, + { + modelName: "ft:gpt-3.5-turbo-0613", + matchPattern: "(?i)^(ft:)(gpt-3.5-turbo-0613:)(.+)(:)(.*)(:)(.+)$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000012, + output: 0.000016, + }, + }, + ], + }, + { + modelName: "ft:davinci-002", + matchPattern: "(?i)^(ft:)(davinci-002:)(.+)(:)(.*)(:)(.+)$$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000012, + output: 0.000012, + }, + }, + ], + }, + { + modelName: "ft:babbage-002", + matchPattern: "(?i)^(ft:)(babbage-002:)(.+)(:)(.*)(:)(.+)$$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000016, + output: 0.0000016, + }, + }, + ], + }, + { + modelName: "chat-bison", + matchPattern: "(?i)^(chat-bison)(@[a-zA-Z0-9]+)?$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2.5e-7, + output: 5e-7, + }, + }, + ], + }, + { + modelName: "codechat-bison-32k", + matchPattern: "(?i)^(codechat-bison-32k)(@[a-zA-Z0-9]+)?$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2.5e-7, + output: 5e-7, + }, + }, + ], + }, + { + modelName: "codechat-bison", + matchPattern: "(?i)^(codechat-bison)(@[a-zA-Z0-9]+)?$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2.5e-7, + output: 5e-7, + }, + }, + ], + }, + { + modelName: "text-bison-32k", + matchPattern: "(?i)^(text-bison-32k)(@[a-zA-Z0-9]+)?$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2.5e-7, + output: 5e-7, + }, + }, + ], + }, + { + modelName: "chat-bison-32k", + matchPattern: "(?i)^(chat-bison-32k)(@[a-zA-Z0-9]+)?$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2.5e-7, + output: 5e-7, + }, + }, + ], + }, + { + modelName: "text-unicorn", + matchPattern: "(?i)^(text-unicorn)(@[a-zA-Z0-9]+)?$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000025, + output: 0.0000075, + }, + }, + ], + }, + { + modelName: "text-bison", + matchPattern: "(?i)^(text-bison)(@[a-zA-Z0-9]+)?$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2.5e-7, + output: 5e-7, + }, + }, + ], + }, + { + modelName: "textembedding-gecko", + matchPattern: "(?i)^(textembedding-gecko)(@[a-zA-Z0-9]+)?$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + total: 1e-7, + }, + }, + ], + }, + { + modelName: "textembedding-gecko-multilingual", + matchPattern: "(?i)^(textembedding-gecko-multilingual)(@[a-zA-Z0-9]+)?$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + total: 1e-7, + }, + }, + ], + }, + { + modelName: "code-gecko", + matchPattern: "(?i)^(code-gecko)(@[a-zA-Z0-9]+)?$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2.5e-7, + output: 5e-7, + }, + }, + ], + }, + { + modelName: "code-bison", + matchPattern: "(?i)^(code-bison)(@[a-zA-Z0-9]+)?$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2.5e-7, + output: 5e-7, + }, + }, + ], + }, + { + modelName: "code-bison-32k", + matchPattern: "(?i)^(code-bison-32k)(@[a-zA-Z0-9]+)?$", + startDate: "2024-01-31T13:25:02.141Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2.5e-7, + output: 5e-7, + }, + }, + ], + }, + { + modelName: "gpt-3.5-turbo-16k", + matchPattern: "(?i)^(openai/)?(gpt-)(35|3.5)(-turbo-16k)$", + startDate: "2024-02-13T12:00:37.424Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 5e-7, + output: 0.0000015, + }, + }, + ], + }, + { + modelName: "gpt-4-turbo-preview", + matchPattern: "(?i)^(openai/)?(gpt-4-turbo-preview)$", + startDate: "2024-02-15T21:21:50.947Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00001, + output: 0.00003, + }, + }, + ], + }, + { + modelName: "claude-3-opus-20240229", + matchPattern: + "(?i)^(anthropic/)?(claude-3-opus-20240229|anthropic\\.claude-3-opus-20240229-v1:0|claude-3-opus@20240229)$", + startDate: "2024-03-07T17:55:38.139Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000015, + output: 0.000075, + }, + }, + ], + }, + { + modelName: "claude-3-sonnet-20240229", + matchPattern: + "(?i)^(anthropic/)?(claude-3-sonnet-20240229|anthropic\\.claude-3-sonnet-20240229-v1:0|claude-3-sonnet@20240229)$", + startDate: "2024-03-07T17:55:38.139Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000003, + input_tokens: 0.000003, + output: 0.000015, + output_tokens: 0.000015, + cache_creation_input_tokens: 0.00000375, + input_cache_creation: 0.00000375, + input_cache_creation_5m: 0.00000375, + input_cache_creation_1h: 0.000006, + cache_read_input_tokens: 3e-7, + input_cache_read: 3e-7, + }, + }, + ], + }, + { + modelName: "claude-3-haiku-20240307", + matchPattern: + "(?i)^(anthropic/)?(claude-3-haiku-20240307|anthropic\\.claude-3-haiku-20240307-v1:0|claude-3-haiku@20240307)$", + startDate: "2024-03-14T09:41:18.736Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2.5e-7, + output: 0.00000125, + }, + }, + ], + }, + { + modelName: "gemini-1.0-pro-latest", + matchPattern: "(?i)^(google(ai)?/)?(gemini-1.0-pro-latest)(@[a-zA-Z0-9]+)?$", + startDate: "2024-04-11T10:27:46.517Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2.5e-7, + output: 5e-7, + }, + }, + ], + }, + { + modelName: "gemini-1.0-pro", + matchPattern: "(?i)^(google(ai)?/)?(gemini-1.0-pro)(@[a-zA-Z0-9]+)?$", + startDate: "2024-04-11T10:27:46.517Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 1.25e-7, + output: 3.75e-7, + }, + }, + ], + }, + { + modelName: "gemini-1.0-pro-001", + matchPattern: "(?i)^(google(ai)?/)?(gemini-1.0-pro-001)(@[a-zA-Z0-9]+)?$", + startDate: "2024-04-11T10:27:46.517Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 1.25e-7, + output: 3.75e-7, + }, + }, + ], + }, + { + modelName: "gemini-pro", + matchPattern: "(?i)^(google(ai)?/)?(gemini-pro)(@[a-zA-Z0-9]+)?$", + startDate: "2024-04-11T10:27:46.517Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 1.25e-7, + output: 3.75e-7, + }, + }, + ], + }, + { + modelName: "gemini-1.5-pro-latest", + matchPattern: "(?i)^(google(ai)?/)?(gemini-1.5-pro-latest)(@[a-zA-Z0-9]+)?$", + startDate: "2024-04-11T10:27:46.517Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000025, + output: 0.0000075, + }, + }, + ], + }, + { + modelName: "gpt-4-turbo-2024-04-09", + matchPattern: "(?i)^(openai/)?(gpt-4-turbo-2024-04-09)$", + startDate: "2024-04-23T10:37:17.092Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00001, + output: 0.00003, + }, + }, + ], + }, + { + modelName: "gpt-4-turbo", + matchPattern: "(?i)^(openai/)?(gpt-4-turbo)$", + startDate: "2024-04-11T21:13:44.989Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00001, + output: 0.00003, + }, + }, + ], + }, + { + modelName: "gpt-4-preview", + matchPattern: "(?i)^(openai/)?(gpt-4-preview)$", + startDate: "2024-04-23T10:37:17.092Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00001, + output: 0.00003, + }, + }, + ], + }, + { + modelName: "claude-3-5-sonnet-20240620", + matchPattern: + "(?i)^(anthropic/)?(claude-3-5-sonnet-20240620|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-3-5-sonnet-20240620-v1:0|claude-3-5-sonnet@20240620)$", + startDate: "2024-06-25T11:47:24.475Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000003, + input_tokens: 0.000003, + output: 0.000015, + output_tokens: 0.000015, + cache_creation_input_tokens: 0.00000375, + input_cache_creation: 0.00000375, + input_cache_creation_5m: 0.00000375, + input_cache_creation_1h: 0.000006, + cache_read_input_tokens: 3e-7, + input_cache_read: 3e-7, + }, + }, + ], + }, + { + modelName: "gpt-4o-mini", + matchPattern: "(?i)^(openai/)?(gpt-4o-mini)$", + startDate: "2024-07-18T17:56:09.591Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 1.5e-7, + output: 6e-7, + input_cached_tokens: 7.5e-8, + input_cache_read: 7.5e-8, + }, + }, + ], + }, + { + modelName: "gpt-4o-mini-2024-07-18", + matchPattern: "(?i)^(openai/)?(gpt-4o-mini-2024-07-18)$", + startDate: "2024-07-18T17:56:09.591Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 1.5e-7, + input_cached_tokens: 7.5e-8, + input_cache_read: 7.5e-8, + output: 6e-7, + }, + }, + ], + }, + { + modelName: "gpt-4o-2024-08-06", + matchPattern: "(?i)^(openai/)?(gpt-4o-2024-08-06)$", + startDate: "2024-08-07T11:54:31.298Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000025, + input_cached_tokens: 0.00000125, + input_cache_read: 0.00000125, + output: 0.00001, + }, + }, + ], + }, + { + modelName: "o1-preview", + matchPattern: "(?i)^(openai/)?(o1-preview)$", + startDate: "2024-09-13T10:01:35.373Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000015, + input_cached_tokens: 0.0000075, + input_cache_read: 0.0000075, + output: 0.00006, + output_reasoning_tokens: 0.00006, + output_reasoning: 0.00006, + }, + }, + ], + }, + { + modelName: "o1-preview-2024-09-12", + matchPattern: "(?i)^(openai/)?(o1-preview-2024-09-12)$", + startDate: "2024-09-13T10:01:35.373Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000015, + input_cached_tokens: 0.0000075, + input_cache_read: 0.0000075, + output: 0.00006, + output_reasoning_tokens: 0.00006, + output_reasoning: 0.00006, + }, + }, + ], + }, + { + modelName: "o1-mini", + matchPattern: "(?i)^(openai/)?(o1-mini)$", + startDate: "2024-09-13T10:01:35.373Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000011, + input_cached_tokens: 5.5e-7, + input_cache_read: 5.5e-7, + output: 0.0000044, + output_reasoning_tokens: 0.0000044, + output_reasoning: 0.0000044, + }, + }, + ], + }, + { + modelName: "o1-mini-2024-09-12", + matchPattern: "(?i)^(openai/)?(o1-mini-2024-09-12)$", + startDate: "2024-09-13T10:01:35.373Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000011, + input_cached_tokens: 5.5e-7, + input_cache_read: 5.5e-7, + output: 0.0000044, + output_reasoning_tokens: 0.0000044, + output_reasoning: 0.0000044, + }, + }, + ], + }, + { + modelName: "claude-3.5-sonnet-20241022", + matchPattern: + "(?i)^(anthropic/)?(claude-3-5-sonnet-20241022|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-3-5-sonnet-20241022-v2:0|claude-3-5-sonnet-V2@20241022)$", + startDate: "2024-10-22T18:48:01.676Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000003, + input_tokens: 0.000003, + output: 0.000015, + output_tokens: 0.000015, + cache_creation_input_tokens: 0.00000375, + input_cache_creation: 0.00000375, + input_cache_creation_5m: 0.00000375, + input_cache_creation_1h: 0.000006, + cache_read_input_tokens: 3e-7, + input_cache_read: 3e-7, + }, + }, + ], + }, + { + modelName: "claude-3.5-sonnet-latest", + matchPattern: "(?i)^(anthropic/)?(claude-3-5-sonnet-latest)$", + startDate: "2024-10-22T18:48:01.676Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000003, + input_tokens: 0.000003, + output: 0.000015, + output_tokens: 0.000015, + cache_creation_input_tokens: 0.00000375, + input_cache_creation: 0.00000375, + input_cache_creation_5m: 0.00000375, + input_cache_creation_1h: 0.000006, + cache_read_input_tokens: 3e-7, + input_cache_read: 3e-7, + }, + }, + ], + }, + { + modelName: "claude-3-5-haiku-20241022", + matchPattern: + "(?i)^(anthropic/)?(claude-3-5-haiku-20241022|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-3-5-haiku-20241022-v1:0|claude-3-5-haiku-V1@20241022)$", + startDate: "2024-11-05T10:30:50.566Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 8e-7, + input_tokens: 8e-7, + output: 0.000004, + output_tokens: 0.000004, + cache_creation_input_tokens: 0.000001, + input_cache_creation: 0.000001, + input_cache_creation_5m: 0.000001, + input_cache_creation_1h: 0.0000016, + cache_read_input_tokens: 8e-8, + input_cache_read: 8e-8, + }, + }, + ], + }, + { + modelName: "claude-3.5-haiku-latest", + matchPattern: "(?i)^(anthropic/)?(claude-3-5-haiku-latest)$", + startDate: "2024-11-05T10:30:50.566Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 8e-7, + input_tokens: 8e-7, + output: 0.000004, + output_tokens: 0.000004, + cache_creation_input_tokens: 0.000001, + input_cache_creation: 0.000001, + input_cache_creation_5m: 0.000001, + input_cache_creation_1h: 0.0000016, + cache_read_input_tokens: 8e-8, + input_cache_read: 8e-8, + }, + }, + ], + }, + { + modelName: "chatgpt-4o-latest", + matchPattern: "(?i)^(chatgpt-4o-latest)$", + startDate: "2024-11-25T12:47:17.504Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000005, + output: 0.000015, + }, + }, + ], + }, + { + modelName: "gpt-4o-2024-11-20", + matchPattern: "(?i)^(openai/)?(gpt-4o-2024-11-20)$", + startDate: "2024-12-03T10:06:12.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000025, + input_cached_tokens: 0.00000125, + input_cache_read: 0.00000125, + output: 0.00001, + }, + }, + ], + }, + { + modelName: "gpt-4o-audio-preview", + matchPattern: "(?i)^(openai/)?(gpt-4o-audio-preview)$", + startDate: "2024-12-03T10:19:56.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input_text_tokens: 0.0000025, + output_text_tokens: 0.00001, + input_audio_tokens: 0.0001, + input_audio: 0.0001, + output_audio_tokens: 0.0002, + output_audio: 0.0002, + }, + }, + ], + }, + { + modelName: "gpt-4o-audio-preview-2024-10-01", + matchPattern: "(?i)^(openai/)?(gpt-4o-audio-preview-2024-10-01)$", + startDate: "2024-12-03T10:19:56.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input_text_tokens: 0.0000025, + output_text_tokens: 0.00001, + input_audio_tokens: 0.0001, + input_audio: 0.0001, + output_audio_tokens: 0.0002, + output_audio: 0.0002, + }, + }, + ], + }, + { + modelName: "gpt-4o-realtime-preview", + matchPattern: "(?i)^(openai/)?(gpt-4o-realtime-preview)$", + startDate: "2024-12-03T10:19:56.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input_text_tokens: 0.000005, + input_cached_text_tokens: 0.0000025, + output_text_tokens: 0.00002, + input_audio_tokens: 0.0001, + input_audio: 0.0001, + input_cached_audio_tokens: 0.00002, + output_audio_tokens: 0.0002, + output_audio: 0.0002, + }, + }, + ], + }, + { + modelName: "gpt-4o-realtime-preview-2024-10-01", + matchPattern: "(?i)^(openai/)?(gpt-4o-realtime-preview-2024-10-01)$", + startDate: "2024-12-03T10:19:56.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input_text_tokens: 0.000005, + input_cached_text_tokens: 0.0000025, + output_text_tokens: 0.00002, + input_audio_tokens: 0.0001, + input_audio: 0.0001, + input_cached_audio_tokens: 0.00002, + output_audio_tokens: 0.0002, + output_audio: 0.0002, + }, + }, + ], + }, + { + modelName: "o1", + matchPattern: "(?i)^(openai/)?(o1)$", + startDate: "2025-01-17T00:01:35.373Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000015, + input_cached_tokens: 0.0000075, + input_cache_read: 0.0000075, + output: 0.00006, + output_reasoning_tokens: 0.00006, + output_reasoning: 0.00006, + }, + }, + ], + }, + { + modelName: "o1-2024-12-17", + matchPattern: "(?i)^(openai/)?(o1-2024-12-17)$", + startDate: "2025-01-17T00:01:35.373Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000015, + input_cached_tokens: 0.0000075, + input_cache_read: 0.0000075, + output: 0.00006, + output_reasoning_tokens: 0.00006, + output_reasoning: 0.00006, + }, + }, + ], + }, + { + modelName: "o3-mini", + matchPattern: "(?i)^(openai/)?(o3-mini)$", + startDate: "2025-01-31T20:41:35.373Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000011, + input_cached_tokens: 5.5e-7, + input_cache_read: 5.5e-7, + output: 0.0000044, + output_reasoning_tokens: 0.0000044, + output_reasoning: 0.0000044, + }, + }, + ], + }, + { + modelName: "o3-mini-2025-01-31", + matchPattern: "(?i)^(openai/)?(o3-mini-2025-01-31)$", + startDate: "2025-01-31T20:41:35.373Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000011, + input_cached_tokens: 5.5e-7, + input_cache_read: 5.5e-7, + output: 0.0000044, + output_reasoning_tokens: 0.0000044, + output_reasoning: 0.0000044, + }, + }, + ], + }, + { + modelName: "gemini-2.0-flash-001", + matchPattern: "(?i)^(google(ai)?/)?(gemini-2.0-flash-001)(@[a-zA-Z0-9]+)?$", + startDate: "2025-02-06T11:11:35.241Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 1e-7, + output: 4e-7, + }, + }, + ], + }, + { + modelName: "gemini-2.0-flash-lite-preview-02-05", + matchPattern: "(?i)^(google(ai)?/)?(gemini-2.0-flash-lite-preview-02-05)(@[a-zA-Z0-9]+)?$", + startDate: "2025-02-06T11:11:35.241Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 7.5e-8, + output: 3e-7, + }, + }, + ], + }, + { + modelName: "claude-3.7-sonnet-20250219", + matchPattern: + "(?i)^(anthropic/)?(claude-3.7-sonnet-20250219|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-3.7-sonnet-20250219-v1:0|claude-3-7-sonnet-V1@20250219)$", + startDate: "2025-02-25T09:35:39.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000003, + input_tokens: 0.000003, + output: 0.000015, + output_tokens: 0.000015, + cache_creation_input_tokens: 0.00000375, + input_cache_creation: 0.00000375, + input_cache_creation_5m: 0.00000375, + input_cache_creation_1h: 0.000006, + cache_read_input_tokens: 3e-7, + input_cache_read: 3e-7, + }, + }, + ], + }, + { + modelName: "claude-3.7-sonnet-latest", + matchPattern: "(?i)^(anthropic/)?(claude-3-7-sonnet-latest)$", + startDate: "2025-02-25T09:35:39.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000003, + input_tokens: 0.000003, + output: 0.000015, + output_tokens: 0.000015, + cache_creation_input_tokens: 0.00000375, + input_cache_creation: 0.00000375, + input_cache_creation_5m: 0.00000375, + input_cache_creation_1h: 0.000006, + cache_read_input_tokens: 3e-7, + input_cache_read: 3e-7, + }, + }, + ], + }, + { + modelName: "gpt-4.5-preview", + matchPattern: "(?i)^(openai/)?(gpt-4.5-preview)$", + startDate: "2025-02-27T21:26:54.132Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000075, + input_cached_tokens: 0.0000375, + input_cached_text_tokens: 0.0000375, + input_cache_read: 0.0000375, + output: 0.00015, + }, + }, + ], + }, + { + modelName: "gpt-4.5-preview-2025-02-27", + matchPattern: "(?i)^(openai/)?(gpt-4.5-preview-2025-02-27)$", + startDate: "2025-02-27T21:26:54.132Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000075, + input_cached_tokens: 0.0000375, + input_cached_text_tokens: 0.0000375, + input_cache_read: 0.0000375, + output: 0.00015, + }, + }, + ], + }, + { + modelName: "gpt-4.1", + matchPattern: "(?i)^(openai/)?(gpt-4.1)$", + startDate: "2025-04-15T10:26:54.132Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000002, + input_cached_tokens: 5e-7, + input_cached_text_tokens: 5e-7, + input_cache_read: 5e-7, + output: 0.000008, + }, + }, + ], + }, + { + modelName: "gpt-4.1-2025-04-14", + matchPattern: "(?i)^(openai/)?(gpt-4.1-2025-04-14)$", + startDate: "2025-04-15T10:26:54.132Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000002, + input_cached_tokens: 5e-7, + input_cached_text_tokens: 5e-7, + input_cache_read: 5e-7, + output: 0.000008, + }, + }, + ], + }, + { + modelName: "gpt-4.1-mini-2025-04-14", + matchPattern: "(?i)^(openai/)?(gpt-4.1-mini-2025-04-14)$", + startDate: "2025-04-15T10:26:54.132Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 4e-7, + input_cached_tokens: 1e-7, + input_cached_text_tokens: 1e-7, + input_cache_read: 1e-7, + output: 0.0000016, + }, + }, + ], + }, + { + modelName: "gpt-4.1-nano-2025-04-14", + matchPattern: "(?i)^(openai/)?(gpt-4.1-nano-2025-04-14)$", + startDate: "2025-04-15T10:26:54.132Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 1e-7, + input_cached_tokens: 2.5e-8, + input_cached_text_tokens: 2.5e-8, + input_cache_read: 2.5e-8, + output: 4e-7, + }, + }, + ], + }, + { + modelName: "o3", + matchPattern: "(?i)^(openai/)?(o3)$", + startDate: "2025-04-16T23:26:54.132Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000002, + input_cached_tokens: 5e-7, + input_cache_read: 5e-7, + output: 0.000008, + output_reasoning_tokens: 0.000008, + output_reasoning: 0.000008, + }, + }, + ], + }, + { + modelName: "o3-2025-04-16", + matchPattern: "(?i)^(openai/)?(o3-2025-04-16)$", + startDate: "2025-04-16T23:26:54.132Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000002, + input_cached_tokens: 5e-7, + input_cache_read: 5e-7, + output: 0.000008, + output_reasoning_tokens: 0.000008, + output_reasoning: 0.000008, + }, + }, + ], + }, + { + modelName: "o4-mini", + matchPattern: "(?i)^(o4-mini)$", + startDate: "2025-04-16T23:26:54.132Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000011, + input_cached_tokens: 2.75e-7, + input_cache_read: 2.75e-7, + output: 0.0000044, + output_reasoning_tokens: 0.0000044, + output_reasoning: 0.0000044, + }, + }, + ], + }, + { + modelName: "o4-mini-2025-04-16", + matchPattern: "(?i)^(o4-mini-2025-04-16)$", + startDate: "2025-04-16T23:26:54.132Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000011, + input_cached_tokens: 2.75e-7, + input_cache_read: 2.75e-7, + output: 0.0000044, + output_reasoning_tokens: 0.0000044, + output_reasoning: 0.0000044, + }, + }, + ], + }, + { + modelName: "gemini-2.0-flash", + matchPattern: "(?i)^(google(ai)?/)?(gemini-2.0-flash)(@[a-zA-Z0-9]+)?$", + startDate: "2025-04-22T10:11:35.241Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 1e-7, + output: 4e-7, + }, + }, + ], + }, + { + modelName: "gemini-2.0-flash-lite-preview", + matchPattern: "(?i)^(google(ai)?/)?(gemini-2.0-flash-lite-preview)(@[a-zA-Z0-9]+)?$", + startDate: "2025-04-22T10:11:35.241Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 7.5e-8, + output: 3e-7, + }, + }, + ], + }, + { + modelName: "gpt-4.1-nano", + matchPattern: "(?i)^(openai/)?(gpt-4.1-nano)$", + startDate: "2025-04-22T10:11:35.241Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 1e-7, + input_cached_tokens: 2.5e-8, + input_cached_text_tokens: 2.5e-8, + input_cache_read: 2.5e-8, + output: 4e-7, + }, + }, + ], + }, + { + modelName: "gpt-4.1-mini", + matchPattern: "(?i)^(openai/)?(gpt-4.1-mini)$", + startDate: "2025-04-22T10:11:35.241Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 4e-7, + input_cached_tokens: 1e-7, + input_cached_text_tokens: 1e-7, + input_cache_read: 1e-7, + output: 0.0000016, + }, + }, + ], + }, + { + modelName: "claude-sonnet-4-5-20250929", + matchPattern: + "(?i)^(anthropic/)?(claude-sonnet-4-5(-20250929)?|(eu\\.|us\\.|apac\\.|global\\.)?anthropic\\.claude-sonnet-4-5(-20250929)?-v1(:0)?|claude-sonnet-4-5-V1(@20250929)?|claude-sonnet-4-5(@20250929)?)$", + startDate: "2025-09-29T00:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000003, + input_tokens: 0.000003, + output: 0.000015, + output_tokens: 0.000015, + cache_creation_input_tokens: 0.00000375, + input_cache_creation: 0.00000375, + input_cache_creation_5m: 0.00000375, + input_cache_creation_1h: 0.000006, + cache_read_input_tokens: 3e-7, + input_cache_read: 3e-7, + }, + }, + { + name: "Large Context", + isDefault: false, + priority: 1, + conditions: [ { - "usageDetailPattern": "input", - "operator": "gt", - "value": 200000 - } + usageDetailPattern: "input", + operator: "gt", + value: 200000, + }, ], - "prices": { - "input": 0.000006, - "input_tokens": 0.000006, - "output": 0.0000225, - "output_tokens": 0.0000225, - "cache_creation_input_tokens": 0.0000075, - "input_cache_creation": 0.0000075, - "input_cache_creation_5m": 0.0000075, - "input_cache_creation_1h": 0.000012, - "cache_read_input_tokens": 6e-7, - "input_cache_read": 6e-7 - } - } - ] - }, - { - "modelName": "claude-sonnet-4-20250514", - "matchPattern": "(?i)^(anthropic/)?(claude-sonnet-4(-20250514)?|(eu\\.|us\\.|apac\\.|global\\.)?anthropic\\.claude-sonnet-4(-20250514)?-v1(:0)?|claude-sonnet-4-V1(@20250514)?|claude-sonnet-4(@20250514)?)$", - "startDate": "2025-05-22T17:09:02.131Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000003, - "input_tokens": 0.000003, - "output": 0.000015, - "output_tokens": 0.000015, - "cache_creation_input_tokens": 0.00000375, - "input_cache_creation": 0.00000375, - "input_cache_creation_5m": 0.00000375, - "input_cache_creation_1h": 0.000006, - "cache_read_input_tokens": 3e-7, - "input_cache_read": 3e-7 - } - } - ] - }, - { - "modelName": "claude-sonnet-4-latest", - "matchPattern": "(?i)^(anthropic/)?(claude-sonnet-4-latest)$", - "startDate": "2025-05-22T17:09:02.131Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000003, - "input_tokens": 0.000003, - "output": 0.000015, - "output_tokens": 0.000015, - "cache_creation_input_tokens": 0.00000375, - "input_cache_creation": 0.00000375, - "input_cache_creation_5m": 0.00000375, - "input_cache_creation_1h": 0.000006, - "cache_read_input_tokens": 3e-7, - "input_cache_read": 3e-7 - } - } - ] - }, - { - "modelName": "claude-opus-4-20250514", - "matchPattern": "(?i)^(anthropic/)?(claude-opus-4(-20250514)?|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4(-20250514)?-v1(:0)?|claude-opus-4(@20250514)?)$", - "startDate": "2025-05-22T17:09:02.131Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000015, - "input_tokens": 0.000015, - "output": 0.000075, - "output_tokens": 0.000075, - "cache_creation_input_tokens": 0.00001875, - "input_cache_creation": 0.00001875, - "input_cache_creation_5m": 0.00001875, - "input_cache_creation_1h": 0.00003, - "cache_read_input_tokens": 0.0000015, - "input_cache_read": 0.0000015 - } - } - ] - }, - { - "modelName": "o3-pro", - "matchPattern": "(?i)^(openai/)?(o3-pro)$", - "startDate": "2025-06-10T22:26:54.132Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00002, - "output": 0.00008, - "output_reasoning_tokens": 0.00008, - "output_reasoning": 0.00008 - } - } - ] - }, - { - "modelName": "o3-pro-2025-06-10", - "matchPattern": "(?i)^(openai/)?(o3-pro-2025-06-10)$", - "startDate": "2025-06-10T22:26:54.132Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00002, - "output": 0.00008, - "output_reasoning_tokens": 0.00008, - "output_reasoning": 0.00008 - } - } - ] - }, - { - "modelName": "o1-pro", - "matchPattern": "(?i)^(openai/)?(o1-pro)$", - "startDate": "2025-06-10T22:26:54.132Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00015, - "output": 0.0006, - "output_reasoning_tokens": 0.0006, - "output_reasoning": 0.0006 - } - } - ] - }, - { - "modelName": "o1-pro-2025-03-19", - "matchPattern": "(?i)^(openai/)?(o1-pro-2025-03-19)$", - "startDate": "2025-06-10T22:26:54.132Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00015, - "output": 0.0006, - "output_reasoning_tokens": 0.0006, - "output_reasoning": 0.0006 - } - } - ] - }, - { - "modelName": "gemini-2.5-flash", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-2.5-flash)$", - "startDate": "2025-07-03T13:44:06.964Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 3e-7, - "input_text": 3e-7, - "input_modality_1": 3e-7, - "prompt_token_count": 3e-7, - "promptTokenCount": 3e-7, - "input_cached_tokens": 3e-8, - "cached_content_token_count": 3e-8, - "output": 0.0000025, - "output_text": 0.0000025, - "output_modality_1": 0.0000025, - "candidates_token_count": 0.0000025, - "candidatesTokenCount": 0.0000025, - "thoughtsTokenCount": 0.0000025, - "thoughts_token_count": 0.0000025, - "output_reasoning": 0.0000025, - "input_audio_tokens": 0.000001 - } - } - ] - }, - { - "modelName": "gemini-2.5-flash-lite", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-2.5-flash-lite)$", - "startDate": "2025-07-03T13:44:06.964Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 1e-7, - "input_text": 1e-7, - "input_modality_1": 1e-7, - "prompt_token_count": 1e-7, - "promptTokenCount": 1e-7, - "input_cached_tokens": 2.5e-8, - "cached_content_token_count": 2.5e-8, - "output": 4e-7, - "output_text": 4e-7, - "output_modality_1": 4e-7, - "candidates_token_count": 4e-7, - "candidatesTokenCount": 4e-7, - "thoughtsTokenCount": 4e-7, - "thoughts_token_count": 4e-7, - "output_reasoning": 4e-7, - "input_audio_tokens": 5e-7 - } - } - ] - }, - { - "modelName": "claude-opus-4-1-20250805", - "matchPattern": "(?i)^(anthropic/)?(claude-opus-4-1(-20250805)?|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-1(-20250805)?-v1(:0)?|claude-opus-4-1(@20250805)?)$", - "startDate": "2025-08-05T15:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000015, - "input_tokens": 0.000015, - "output": 0.000075, - "output_tokens": 0.000075, - "cache_creation_input_tokens": 0.00001875, - "input_cache_creation": 0.00001875, - "input_cache_creation_5m": 0.00001875, - "input_cache_creation_1h": 0.00003, - "cache_read_input_tokens": 0.0000015, - "input_cache_read": 0.0000015 - } - } - ] - }, - { - "modelName": "gpt-5", - "matchPattern": "(?i)^(openai/)?(gpt-5)$", - "startDate": "2025-08-07T16:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00000125, - "input_cached_tokens": 1.25e-7, - "output": 0.00001, - "input_cache_read": 1.25e-7, - "output_reasoning_tokens": 0.00001, - "output_reasoning": 0.00001 - } - } - ] - }, - { - "modelName": "gpt-5-2025-08-07", - "matchPattern": "(?i)^(openai/)?(gpt-5-2025-08-07)$", - "startDate": "2025-08-11T08:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00000125, - "input_cached_tokens": 1.25e-7, - "output": 0.00001, - "input_cache_read": 1.25e-7, - "output_reasoning_tokens": 0.00001, - "output_reasoning": 0.00001 - } - } - ] - }, - { - "modelName": "gpt-5-mini", - "matchPattern": "(?i)^(openai/)?(gpt-5-mini)$", - "startDate": "2025-08-07T16:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2.5e-7, - "input_cached_tokens": 2.5e-8, - "output": 0.000002, - "input_cache_read": 2.5e-8, - "output_reasoning_tokens": 0.000002, - "output_reasoning": 0.000002 - } - } - ] - }, - { - "modelName": "gpt-5-mini-2025-08-07", - "matchPattern": "(?i)^(openai/)?(gpt-5-mini-2025-08-07)$", - "startDate": "2025-08-11T08:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2.5e-7, - "input_cached_tokens": 2.5e-8, - "output": 0.000002, - "input_cache_read": 2.5e-8, - "output_reasoning_tokens": 0.000002, - "output_reasoning": 0.000002 - } - } - ] - }, - { - "modelName": "gpt-5-nano", - "matchPattern": "(?i)^(openai/)?(gpt-5-nano)$", - "startDate": "2025-08-07T16:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 5e-8, - "input_cached_tokens": 5e-9, - "output": 4e-7, - "input_cache_read": 5e-9, - "output_reasoning_tokens": 4e-7, - "output_reasoning": 4e-7 - } - } - ] - }, - { - "modelName": "gpt-5-nano-2025-08-07", - "matchPattern": "(?i)^(openai/)?(gpt-5-nano-2025-08-07)$", - "startDate": "2025-08-11T08:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 5e-8, - "input_cached_tokens": 5e-9, - "output": 4e-7, - "input_cache_read": 5e-9, - "output_reasoning_tokens": 4e-7, - "output_reasoning": 4e-7 - } - } - ] - }, - { - "modelName": "gpt-5-chat-latest", - "matchPattern": "(?i)^(openai/)?(gpt-5-chat-latest)$", - "startDate": "2025-08-07T16:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00000125, - "input_cached_tokens": 1.25e-7, - "output": 0.00001, - "input_cache_read": 1.25e-7, - "output_reasoning_tokens": 0.00001, - "output_reasoning": 0.00001 - } - } - ] - }, - { - "modelName": "gpt-5-pro", - "matchPattern": "(?i)^(openai/)?(gpt-5-pro)$", - "startDate": "2025-10-07T08:03:54.727Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000015, - "output": 0.00012, - "output_reasoning_tokens": 0.00012, - "output_reasoning": 0.00012 - } - } - ] - }, - { - "modelName": "gpt-5-pro-2025-10-06", - "matchPattern": "(?i)^(openai/)?(gpt-5-pro-2025-10-06)$", - "startDate": "2025-10-07T08:03:54.727Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000015, - "output": 0.00012, - "output_reasoning_tokens": 0.00012, - "output_reasoning": 0.00012 - } - } - ] - }, - { - "modelName": "claude-haiku-4-5-20251001", - "matchPattern": "(?i)^(anthropic/)?(claude-haiku-4-5-20251001|(eu\\.|us\\.|apac\\.|global\\.)?anthropic\\.claude-haiku-4-5-20251001-v1:0|claude-4-5-haiku@20251001)$", - "startDate": "2025-10-16T08:20:44.558Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000001, - "input_tokens": 0.000001, - "output": 0.000005, - "output_tokens": 0.000005, - "cache_creation_input_tokens": 0.00000125, - "input_cache_creation": 0.00000125, - "input_cache_creation_5m": 0.00000125, - "input_cache_creation_1h": 0.000002, - "cache_read_input_tokens": 1e-7, - "input_cache_read": 1e-7 - } - } - ] - }, - { - "modelName": "gpt-5.1", - "matchPattern": "(?i)^(openai/)?(gpt-5.1)$", - "startDate": "2025-11-14T08:57:23.481Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00000125, - "input_cached_tokens": 1.25e-7, - "output": 0.00001, - "input_cache_read": 1.25e-7, - "output_reasoning_tokens": 0.00001, - "output_reasoning": 0.00001 - } - } - ] - }, - { - "modelName": "gpt-5.1-2025-11-13", - "matchPattern": "(?i)^(openai/)?(gpt-5.1-2025-11-13)$", - "startDate": "2025-11-14T08:57:23.481Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00000125, - "input_cached_tokens": 1.25e-7, - "output": 0.00001, - "input_cache_read": 1.25e-7, - "output_reasoning_tokens": 0.00001, - "output_reasoning": 0.00001 - } - } - ] - }, - { - "modelName": "claude-opus-4-5-20251101", - "matchPattern": "(?i)^(anthropic/)?(claude-opus-4-5(-20251101)?|(eu\\.|us\\.|apac\\.|global\\.)?anthropic\\.claude-opus-4-5(-20251101)?-v1(:0)?|claude-opus-4-5(@20251101)?)$", - "startDate": "2025-11-24T20:53:27.571Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000005, - "input_tokens": 0.000005, - "output": 0.000025, - "output_tokens": 0.000025, - "cache_creation_input_tokens": 0.00000625, - "input_cache_creation": 0.00000625, - "input_cache_creation_5m": 0.00000625, - "input_cache_creation_1h": 0.00001, - "cache_read_input_tokens": 5e-7, - "input_cache_read": 5e-7 - } - } - ] - }, - { - "modelName": "claude-sonnet-4-6", - "matchPattern": "(?i)^(anthropic\\/)?(claude-sonnet-4-6|(eu\\.|us\\.|apac\\.|global\\.)?anthropic\\.claude-sonnet-4-6(-v1(:0)?)?|claude-sonnet-4-6)$", - "startDate": "2026-02-18T00:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000003, - "input_tokens": 0.000003, - "output": 0.000015, - "output_tokens": 0.000015, - "cache_creation_input_tokens": 0.00000375, - "input_cache_creation": 0.00000375, - "input_cache_creation_5m": 0.00000375, - "input_cache_creation_1h": 0.000006, - "cache_read_input_tokens": 3e-7, - "input_cache_read": 3e-7 - } - }, - { - "name": "Large Context", - "isDefault": false, - "priority": 1, - "conditions": [ + prices: { + input: 0.000006, + input_tokens: 0.000006, + output: 0.0000225, + output_tokens: 0.0000225, + cache_creation_input_tokens: 0.0000075, + input_cache_creation: 0.0000075, + input_cache_creation_5m: 0.0000075, + input_cache_creation_1h: 0.000012, + cache_read_input_tokens: 6e-7, + input_cache_read: 6e-7, + }, + }, + ], + }, + { + modelName: "claude-sonnet-4-20250514", + matchPattern: + "(?i)^(anthropic/)?(claude-sonnet-4(-20250514)?|(eu\\.|us\\.|apac\\.|global\\.)?anthropic\\.claude-sonnet-4(-20250514)?-v1(:0)?|claude-sonnet-4-V1(@20250514)?|claude-sonnet-4(@20250514)?)$", + startDate: "2025-05-22T17:09:02.131Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000003, + input_tokens: 0.000003, + output: 0.000015, + output_tokens: 0.000015, + cache_creation_input_tokens: 0.00000375, + input_cache_creation: 0.00000375, + input_cache_creation_5m: 0.00000375, + input_cache_creation_1h: 0.000006, + cache_read_input_tokens: 3e-7, + input_cache_read: 3e-7, + }, + }, + ], + }, + { + modelName: "claude-sonnet-4-latest", + matchPattern: "(?i)^(anthropic/)?(claude-sonnet-4-latest)$", + startDate: "2025-05-22T17:09:02.131Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000003, + input_tokens: 0.000003, + output: 0.000015, + output_tokens: 0.000015, + cache_creation_input_tokens: 0.00000375, + input_cache_creation: 0.00000375, + input_cache_creation_5m: 0.00000375, + input_cache_creation_1h: 0.000006, + cache_read_input_tokens: 3e-7, + input_cache_read: 3e-7, + }, + }, + ], + }, + { + modelName: "claude-opus-4-20250514", + matchPattern: + "(?i)^(anthropic/)?(claude-opus-4(-20250514)?|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4(-20250514)?-v1(:0)?|claude-opus-4(@20250514)?)$", + startDate: "2025-05-22T17:09:02.131Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000015, + input_tokens: 0.000015, + output: 0.000075, + output_tokens: 0.000075, + cache_creation_input_tokens: 0.00001875, + input_cache_creation: 0.00001875, + input_cache_creation_5m: 0.00001875, + input_cache_creation_1h: 0.00003, + cache_read_input_tokens: 0.0000015, + input_cache_read: 0.0000015, + }, + }, + ], + }, + { + modelName: "o3-pro", + matchPattern: "(?i)^(openai/)?(o3-pro)$", + startDate: "2025-06-10T22:26:54.132Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00002, + output: 0.00008, + output_reasoning_tokens: 0.00008, + output_reasoning: 0.00008, + }, + }, + ], + }, + { + modelName: "o3-pro-2025-06-10", + matchPattern: "(?i)^(openai/)?(o3-pro-2025-06-10)$", + startDate: "2025-06-10T22:26:54.132Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00002, + output: 0.00008, + output_reasoning_tokens: 0.00008, + output_reasoning: 0.00008, + }, + }, + ], + }, + { + modelName: "o1-pro", + matchPattern: "(?i)^(openai/)?(o1-pro)$", + startDate: "2025-06-10T22:26:54.132Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00015, + output: 0.0006, + output_reasoning_tokens: 0.0006, + output_reasoning: 0.0006, + }, + }, + ], + }, + { + modelName: "o1-pro-2025-03-19", + matchPattern: "(?i)^(openai/)?(o1-pro-2025-03-19)$", + startDate: "2025-06-10T22:26:54.132Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00015, + output: 0.0006, + output_reasoning_tokens: 0.0006, + output_reasoning: 0.0006, + }, + }, + ], + }, + { + modelName: "gemini-2.5-flash", + matchPattern: "(?i)^(google(ai)?/)?(gemini-2.5-flash)$", + startDate: "2025-07-03T13:44:06.964Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 3e-7, + input_text: 3e-7, + input_modality_1: 3e-7, + prompt_token_count: 3e-7, + promptTokenCount: 3e-7, + input_cached_tokens: 3e-8, + cached_content_token_count: 3e-8, + output: 0.0000025, + output_text: 0.0000025, + output_modality_1: 0.0000025, + candidates_token_count: 0.0000025, + candidatesTokenCount: 0.0000025, + thoughtsTokenCount: 0.0000025, + thoughts_token_count: 0.0000025, + output_reasoning: 0.0000025, + input_audio_tokens: 0.000001, + }, + }, + ], + }, + { + modelName: "gemini-2.5-flash-lite", + matchPattern: "(?i)^(google(ai)?/)?(gemini-2.5-flash-lite)$", + startDate: "2025-07-03T13:44:06.964Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 1e-7, + input_text: 1e-7, + input_modality_1: 1e-7, + prompt_token_count: 1e-7, + promptTokenCount: 1e-7, + input_cached_tokens: 2.5e-8, + cached_content_token_count: 2.5e-8, + output: 4e-7, + output_text: 4e-7, + output_modality_1: 4e-7, + candidates_token_count: 4e-7, + candidatesTokenCount: 4e-7, + thoughtsTokenCount: 4e-7, + thoughts_token_count: 4e-7, + output_reasoning: 4e-7, + input_audio_tokens: 5e-7, + }, + }, + ], + }, + { + modelName: "claude-opus-4-1-20250805", + matchPattern: + "(?i)^(anthropic/)?(claude-opus-4-1(-20250805)?|(eu\\.|us\\.|apac\\.)?anthropic\\.claude-opus-4-1(-20250805)?-v1(:0)?|claude-opus-4-1(@20250805)?)$", + startDate: "2025-08-05T15:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000015, + input_tokens: 0.000015, + output: 0.000075, + output_tokens: 0.000075, + cache_creation_input_tokens: 0.00001875, + input_cache_creation: 0.00001875, + input_cache_creation_5m: 0.00001875, + input_cache_creation_1h: 0.00003, + cache_read_input_tokens: 0.0000015, + input_cache_read: 0.0000015, + }, + }, + ], + }, + { + modelName: "gpt-5", + matchPattern: "(?i)^(openai/)?(gpt-5)$", + startDate: "2025-08-07T16:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00000125, + input_cached_tokens: 1.25e-7, + output: 0.00001, + input_cache_read: 1.25e-7, + output_reasoning_tokens: 0.00001, + output_reasoning: 0.00001, + }, + }, + ], + }, + { + modelName: "gpt-5-2025-08-07", + matchPattern: "(?i)^(openai/)?(gpt-5-2025-08-07)$", + startDate: "2025-08-11T08:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00000125, + input_cached_tokens: 1.25e-7, + output: 0.00001, + input_cache_read: 1.25e-7, + output_reasoning_tokens: 0.00001, + output_reasoning: 0.00001, + }, + }, + ], + }, + { + modelName: "gpt-5-mini", + matchPattern: "(?i)^(openai/)?(gpt-5-mini)$", + startDate: "2025-08-07T16:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2.5e-7, + input_cached_tokens: 2.5e-8, + output: 0.000002, + input_cache_read: 2.5e-8, + output_reasoning_tokens: 0.000002, + output_reasoning: 0.000002, + }, + }, + ], + }, + { + modelName: "gpt-5-mini-2025-08-07", + matchPattern: "(?i)^(openai/)?(gpt-5-mini-2025-08-07)$", + startDate: "2025-08-11T08:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2.5e-7, + input_cached_tokens: 2.5e-8, + output: 0.000002, + input_cache_read: 2.5e-8, + output_reasoning_tokens: 0.000002, + output_reasoning: 0.000002, + }, + }, + ], + }, + { + modelName: "gpt-5-nano", + matchPattern: "(?i)^(openai/)?(gpt-5-nano)$", + startDate: "2025-08-07T16:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 5e-8, + input_cached_tokens: 5e-9, + output: 4e-7, + input_cache_read: 5e-9, + output_reasoning_tokens: 4e-7, + output_reasoning: 4e-7, + }, + }, + ], + }, + { + modelName: "gpt-5-nano-2025-08-07", + matchPattern: "(?i)^(openai/)?(gpt-5-nano-2025-08-07)$", + startDate: "2025-08-11T08:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 5e-8, + input_cached_tokens: 5e-9, + output: 4e-7, + input_cache_read: 5e-9, + output_reasoning_tokens: 4e-7, + output_reasoning: 4e-7, + }, + }, + ], + }, + { + modelName: "gpt-5-chat-latest", + matchPattern: "(?i)^(openai/)?(gpt-5-chat-latest)$", + startDate: "2025-08-07T16:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00000125, + input_cached_tokens: 1.25e-7, + output: 0.00001, + input_cache_read: 1.25e-7, + output_reasoning_tokens: 0.00001, + output_reasoning: 0.00001, + }, + }, + ], + }, + { + modelName: "gpt-5-pro", + matchPattern: "(?i)^(openai/)?(gpt-5-pro)$", + startDate: "2025-10-07T08:03:54.727Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000015, + output: 0.00012, + output_reasoning_tokens: 0.00012, + output_reasoning: 0.00012, + }, + }, + ], + }, + { + modelName: "gpt-5-pro-2025-10-06", + matchPattern: "(?i)^(openai/)?(gpt-5-pro-2025-10-06)$", + startDate: "2025-10-07T08:03:54.727Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000015, + output: 0.00012, + output_reasoning_tokens: 0.00012, + output_reasoning: 0.00012, + }, + }, + ], + }, + { + modelName: "claude-haiku-4-5-20251001", + matchPattern: + "(?i)^(anthropic/)?(claude-haiku-4-5-20251001|(eu\\.|us\\.|apac\\.|global\\.)?anthropic\\.claude-haiku-4-5-20251001-v1:0|claude-4-5-haiku@20251001)$", + startDate: "2025-10-16T08:20:44.558Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000001, + input_tokens: 0.000001, + output: 0.000005, + output_tokens: 0.000005, + cache_creation_input_tokens: 0.00000125, + input_cache_creation: 0.00000125, + input_cache_creation_5m: 0.00000125, + input_cache_creation_1h: 0.000002, + cache_read_input_tokens: 1e-7, + input_cache_read: 1e-7, + }, + }, + ], + }, + { + modelName: "gpt-5.1", + matchPattern: "(?i)^(openai/)?(gpt-5.1)$", + startDate: "2025-11-14T08:57:23.481Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00000125, + input_cached_tokens: 1.25e-7, + output: 0.00001, + input_cache_read: 1.25e-7, + output_reasoning_tokens: 0.00001, + output_reasoning: 0.00001, + }, + }, + ], + }, + { + modelName: "gpt-5.1-2025-11-13", + matchPattern: "(?i)^(openai/)?(gpt-5.1-2025-11-13)$", + startDate: "2025-11-14T08:57:23.481Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00000125, + input_cached_tokens: 1.25e-7, + output: 0.00001, + input_cache_read: 1.25e-7, + output_reasoning_tokens: 0.00001, + output_reasoning: 0.00001, + }, + }, + ], + }, + { + modelName: "claude-opus-4-5-20251101", + matchPattern: + "(?i)^(anthropic/)?(claude-opus-4-5(-20251101)?|(eu\\.|us\\.|apac\\.|global\\.)?anthropic\\.claude-opus-4-5(-20251101)?-v1(:0)?|claude-opus-4-5(@20251101)?)$", + startDate: "2025-11-24T20:53:27.571Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000005, + input_tokens: 0.000005, + output: 0.000025, + output_tokens: 0.000025, + cache_creation_input_tokens: 0.00000625, + input_cache_creation: 0.00000625, + input_cache_creation_5m: 0.00000625, + input_cache_creation_1h: 0.00001, + cache_read_input_tokens: 5e-7, + input_cache_read: 5e-7, + }, + }, + ], + }, + { + modelName: "claude-sonnet-4-6", + matchPattern: + "(?i)^(anthropic\\/)?(claude-sonnet-4-6|(eu\\.|us\\.|apac\\.|global\\.)?anthropic\\.claude-sonnet-4-6(-v1(:0)?)?|claude-sonnet-4-6)$", + startDate: "2026-02-18T00:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000003, + input_tokens: 0.000003, + output: 0.000015, + output_tokens: 0.000015, + cache_creation_input_tokens: 0.00000375, + input_cache_creation: 0.00000375, + input_cache_creation_5m: 0.00000375, + input_cache_creation_1h: 0.000006, + cache_read_input_tokens: 3e-7, + input_cache_read: 3e-7, + }, + }, + { + name: "Large Context", + isDefault: false, + priority: 1, + conditions: [ { - "usageDetailPattern": "input", - "operator": "gt", - "value": 200000 - } + usageDetailPattern: "input", + operator: "gt", + value: 200000, + }, ], - "prices": { - "input": 0.000006, - "input_tokens": 0.000006, - "output": 0.0000225, - "output_tokens": 0.0000225, - "cache_creation_input_tokens": 0.0000075, - "input_cache_creation": 0.0000075, - "input_cache_creation_5m": 0.0000075, - "input_cache_creation_1h": 0.000012, - "cache_read_input_tokens": 6e-7, - "input_cache_read": 6e-7 - } - } - ] - }, - { - "modelName": "claude-opus-4-6", - "matchPattern": "(?i)^(anthropic/)?(claude-opus-4-6|(eu\\.|us\\.|apac\\.|global\\.)?anthropic\\.claude-opus-4-6-v1(:0)?|claude-opus-4-6)$", - "startDate": "2026-02-09T00:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000005, - "input_tokens": 0.000005, - "output": 0.000025, - "output_tokens": 0.000025, - "cache_creation_input_tokens": 0.00000625, - "input_cache_creation": 0.00000625, - "input_cache_creation_5m": 0.00000625, - "input_cache_creation_1h": 0.00001, - "cache_read_input_tokens": 5e-7, - "input_cache_read": 5e-7 - } - } - ] - }, - { - "modelName": "gemini-2.5-pro", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-2.5-pro)$", - "startDate": "2025-11-26T13:27:53.545Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00000125, - "input_text": 0.00000125, - "input_modality_1": 0.00000125, - "prompt_token_count": 0.00000125, - "promptTokenCount": 0.00000125, - "input_cached_tokens": 1.25e-7, - "cached_content_token_count": 1.25e-7, - "output": 0.00001, - "output_text": 0.00001, - "output_modality_1": 0.00001, - "candidates_token_count": 0.00001, - "candidatesTokenCount": 0.00001, - "thoughtsTokenCount": 0.00001, - "thoughts_token_count": 0.00001, - "output_reasoning": 0.00001 - } - }, - { - "name": "Large Context", - "isDefault": false, - "priority": 1, - "conditions": [ + prices: { + input: 0.000006, + input_tokens: 0.000006, + output: 0.0000225, + output_tokens: 0.0000225, + cache_creation_input_tokens: 0.0000075, + input_cache_creation: 0.0000075, + input_cache_creation_5m: 0.0000075, + input_cache_creation_1h: 0.000012, + cache_read_input_tokens: 6e-7, + input_cache_read: 6e-7, + }, + }, + ], + }, + { + modelName: "claude-opus-4-6", + matchPattern: + "(?i)^(anthropic/)?(claude-opus-4-6|(eu\\.|us\\.|apac\\.|global\\.)?anthropic\\.claude-opus-4-6-v1(:0)?|claude-opus-4-6)$", + startDate: "2026-02-09T00:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000005, + input_tokens: 0.000005, + output: 0.000025, + output_tokens: 0.000025, + cache_creation_input_tokens: 0.00000625, + input_cache_creation: 0.00000625, + input_cache_creation_5m: 0.00000625, + input_cache_creation_1h: 0.00001, + cache_read_input_tokens: 5e-7, + input_cache_read: 5e-7, + }, + }, + ], + }, + { + modelName: "gemini-2.5-pro", + matchPattern: "(?i)^(google(ai)?/)?(gemini-2.5-pro)$", + startDate: "2025-11-26T13:27:53.545Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00000125, + input_text: 0.00000125, + input_modality_1: 0.00000125, + prompt_token_count: 0.00000125, + promptTokenCount: 0.00000125, + input_cached_tokens: 1.25e-7, + cached_content_token_count: 1.25e-7, + output: 0.00001, + output_text: 0.00001, + output_modality_1: 0.00001, + candidates_token_count: 0.00001, + candidatesTokenCount: 0.00001, + thoughtsTokenCount: 0.00001, + thoughts_token_count: 0.00001, + output_reasoning: 0.00001, + }, + }, + { + name: "Large Context", + isDefault: false, + priority: 1, + conditions: [ { - "usageDetailPattern": "(input|prompt|cached)", - "operator": "gt", - "value": 200000 - } + usageDetailPattern: "(input|prompt|cached)", + operator: "gt", + value: 200000, + }, ], - "prices": { - "input": 0.0000025, - "input_text": 0.0000025, - "input_modality_1": 0.0000025, - "prompt_token_count": 0.0000025, - "promptTokenCount": 0.0000025, - "input_cached_tokens": 2.5e-7, - "cached_content_token_count": 2.5e-7, - "output": 0.000015, - "output_text": 0.000015, - "output_modality_1": 0.000015, - "candidates_token_count": 0.000015, - "candidatesTokenCount": 0.000015, - "thoughtsTokenCount": 0.000015, - "thoughts_token_count": 0.000015, - "output_reasoning": 0.000015 - } - } - ] - }, - { - "modelName": "gemini-3-pro-preview", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-3-pro-preview)$", - "startDate": "2025-11-26T13:27:53.545Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000002, - "input_text": 0.000002, - "input_modality_1": 0.000002, - "prompt_token_count": 0.000002, - "promptTokenCount": 0.000002, - "input_cached_tokens": 2e-7, - "cached_content_token_count": 2e-7, - "output": 0.000012, - "output_text": 0.000012, - "output_modality_1": 0.000012, - "candidates_token_count": 0.000012, - "candidatesTokenCount": 0.000012, - "thoughtsTokenCount": 0.000012, - "thoughts_token_count": 0.000012, - "output_reasoning": 0.000012 - } - }, - { - "name": "Large Context", - "isDefault": false, - "priority": 1, - "conditions": [ + prices: { + input: 0.0000025, + input_text: 0.0000025, + input_modality_1: 0.0000025, + prompt_token_count: 0.0000025, + promptTokenCount: 0.0000025, + input_cached_tokens: 2.5e-7, + cached_content_token_count: 2.5e-7, + output: 0.000015, + output_text: 0.000015, + output_modality_1: 0.000015, + candidates_token_count: 0.000015, + candidatesTokenCount: 0.000015, + thoughtsTokenCount: 0.000015, + thoughts_token_count: 0.000015, + output_reasoning: 0.000015, + }, + }, + ], + }, + { + modelName: "gemini-3-pro-preview", + matchPattern: "(?i)^(google(ai)?/)?(gemini-3-pro-preview)$", + startDate: "2025-11-26T13:27:53.545Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000002, + input_text: 0.000002, + input_modality_1: 0.000002, + prompt_token_count: 0.000002, + promptTokenCount: 0.000002, + input_cached_tokens: 2e-7, + cached_content_token_count: 2e-7, + output: 0.000012, + output_text: 0.000012, + output_modality_1: 0.000012, + candidates_token_count: 0.000012, + candidatesTokenCount: 0.000012, + thoughtsTokenCount: 0.000012, + thoughts_token_count: 0.000012, + output_reasoning: 0.000012, + }, + }, + { + name: "Large Context", + isDefault: false, + priority: 1, + conditions: [ { - "usageDetailPattern": "(input|prompt|cached)", - "operator": "gt", - "value": 200000 - } + usageDetailPattern: "(input|prompt|cached)", + operator: "gt", + value: 200000, + }, ], - "prices": { - "input": 0.000004, - "input_text": 0.000004, - "input_modality_1": 0.000004, - "prompt_token_count": 0.000004, - "promptTokenCount": 0.000004, - "input_cached_tokens": 4e-7, - "cached_content_token_count": 4e-7, - "output": 0.000018, - "output_text": 0.000018, - "output_modality_1": 0.000018, - "candidates_token_count": 0.000018, - "candidatesTokenCount": 0.000018, - "thoughtsTokenCount": 0.000018, - "thoughts_token_count": 0.000018, - "output_reasoning": 0.000018 - } - } - ] - }, - { - "modelName": "gemini-3.1-pro-preview", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-3.1-pro-preview(-customtools)?)$", - "startDate": "2026-02-19T00:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000002, - "input_modality_1": 0.000002, - "input_text": 0.000002, - "prompt_token_count": 0.000002, - "promptTokenCount": 0.000002, - "input_cached_tokens": 2e-7, - "cached_content_token_count": 2e-7, - "output": 0.000012, - "output_text": 0.000012, - "output_modality_1": 0.000012, - "candidates_token_count": 0.000012, - "candidatesTokenCount": 0.000012, - "thoughtsTokenCount": 0.000012, - "thoughts_token_count": 0.000012, - "output_reasoning": 0.000012 - } - }, - { - "name": "Large Context", - "isDefault": false, - "priority": 1, - "conditions": [ + prices: { + input: 0.000004, + input_text: 0.000004, + input_modality_1: 0.000004, + prompt_token_count: 0.000004, + promptTokenCount: 0.000004, + input_cached_tokens: 4e-7, + cached_content_token_count: 4e-7, + output: 0.000018, + output_text: 0.000018, + output_modality_1: 0.000018, + candidates_token_count: 0.000018, + candidatesTokenCount: 0.000018, + thoughtsTokenCount: 0.000018, + thoughts_token_count: 0.000018, + output_reasoning: 0.000018, + }, + }, + ], + }, + { + modelName: "gemini-3.1-pro-preview", + matchPattern: "(?i)^(google(ai)?/)?(gemini-3.1-pro-preview(-customtools)?)$", + startDate: "2026-02-19T00:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000002, + input_modality_1: 0.000002, + input_text: 0.000002, + prompt_token_count: 0.000002, + promptTokenCount: 0.000002, + input_cached_tokens: 2e-7, + cached_content_token_count: 2e-7, + output: 0.000012, + output_text: 0.000012, + output_modality_1: 0.000012, + candidates_token_count: 0.000012, + candidatesTokenCount: 0.000012, + thoughtsTokenCount: 0.000012, + thoughts_token_count: 0.000012, + output_reasoning: 0.000012, + }, + }, + { + name: "Large Context", + isDefault: false, + priority: 1, + conditions: [ { - "usageDetailPattern": "(input|prompt|cached)", - "operator": "gt", - "value": 200000 - } + usageDetailPattern: "(input|prompt|cached)", + operator: "gt", + value: 200000, + }, ], - "prices": { - "input": 0.000004, - "input_modality_1": 0.000004, - "input_text": 0.000004, - "prompt_token_count": 0.000004, - "promptTokenCount": 0.000004, - "input_cached_tokens": 4e-7, - "cached_content_token_count": 4e-7, - "output": 0.000018, - "output_text": 0.000018, - "output_modality_1": 0.000018, - "candidates_token_count": 0.000018, - "candidatesTokenCount": 0.000018, - "thoughtsTokenCount": 0.000018, - "thoughts_token_count": 0.000018, - "output_reasoning": 0.000018 - } - } - ] - }, - { - "modelName": "gpt-5.2", - "matchPattern": "(?i)^(openai/)?(gpt-5.2)$", - "startDate": "2025-12-12T09:00:06.513Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00000175, - "input_cached_tokens": 1.75e-7, - "input_cache_read": 1.75e-7, - "output": 0.000014, - "output_reasoning_tokens": 0.000014, - "output_reasoning": 0.000014 - } - } - ] - }, - { - "modelName": "gpt-5.2-2025-12-11", - "matchPattern": "(?i)^(openai/)?(gpt-5.2-2025-12-11)$", - "startDate": "2025-12-12T09:00:06.513Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00000175, - "input_cached_tokens": 1.75e-7, - "input_cache_read": 1.75e-7, - "output": 0.000014, - "output_reasoning_tokens": 0.000014, - "output_reasoning": 0.000014 - } - } - ] - }, - { - "modelName": "gpt-5.2-pro", - "matchPattern": "(?i)^(openai/)?(gpt-5.2-pro)$", - "startDate": "2025-12-12T09:00:06.513Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000021, - "output": 0.000168, - "output_reasoning_tokens": 0.000168, - "output_reasoning": 0.000168 - } - } - ] - }, - { - "modelName": "gpt-5.2-pro-2025-12-11", - "matchPattern": "(?i)^(openai/)?(gpt-5.2-pro-2025-12-11)$", - "startDate": "2025-12-12T09:00:06.513Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.000021, - "output": 0.000168, - "output_reasoning_tokens": 0.000168, - "output_reasoning": 0.000168 - } - } - ] - }, - { - "modelName": "gpt-5.4", - "matchPattern": "(?i)^(openai/)?(gpt-5.4)$", - "startDate": "2026-03-05T00:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000025, - "input_cached_tokens": 2.5e-7, - "input_cache_read": 2.5e-7, - "output": 0.000015, - "output_reasoning_tokens": 0.000015, - "output_reasoning": 0.000015 - } - } - ] - }, - { - "modelName": "gpt-5.4-pro", - "matchPattern": "(?i)^(openai/)?(gpt-5.4-pro)$", - "startDate": "2026-03-05T00:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00003, - "output": 0.00018, - "output_reasoning_tokens": 0.00018, - "output_reasoning": 0.00018 - } - } - ] - }, - { - "modelName": "gpt-5.4-2026-03-05", - "matchPattern": "(?i)^(openai/)?(gpt-5.4-2026-03-05)$", - "startDate": "2026-03-05T00:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.0000025, - "input_cached_tokens": 2.5e-7, - "input_cache_read": 2.5e-7, - "output": 0.000015, - "output_reasoning_tokens": 0.000015, - "output_reasoning": 0.000015 - } - } - ] - }, - { - "modelName": "gpt-5.4-pro-2026-03-05", - "matchPattern": "(?i)^(openai/)?(gpt-5.4-pro-2026-03-05)$", - "startDate": "2026-03-05T00:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 0.00003, - "output": 0.00018, - "output_reasoning_tokens": 0.00018, - "output_reasoning": 0.00018 - } - } - ] - }, - { - "modelName": "gpt-5.4-mini", - "matchPattern": "(?i)^(openai\\/)?(gpt-5.4-mini)$", - "startDate": "2026-03-18T00:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 7.5e-7, - "input_cached_tokens": 7.5e-8, - "input_cache_read": 7.5e-8, - "output": 0.0000045 - } - } - ] - }, - { - "modelName": "gpt-5.4-mini-2026-03-17", - "matchPattern": "(?i)^(openai\\/)?(gpt-5.4-mini-2026-03-17)$", - "startDate": "2026-03-18T00:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 7.5e-7, - "input_cached_tokens": 7.5e-8, - "input_cache_read": 7.5e-8, - "output": 0.0000045 - } - } - ] - }, - { - "modelName": "gpt-5.4-nano", - "matchPattern": "(?i)^(openai\\/)?(gpt-5.4-nano)$", - "startDate": "2026-03-18T00:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2e-7, - "input_cached_tokens": 2e-8, - "input_cache_read": 2e-8, - "output": 0.00000125 - } - } - ] - }, - { - "modelName": "gpt-5.4-nano-2026-03-17", - "matchPattern": "(?i)^(openai\\/)?(gpt-5.4-nano-2026-03-17)$", - "startDate": "2026-03-18T00:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2e-7, - "input_cached_tokens": 2e-8, - "input_cache_read": 2e-8, - "output": 0.00000125 - } - } - ] - }, - { - "modelName": "gemini-3-flash-preview", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-3-flash-preview)$", - "startDate": "2025-12-21T12:01:42.282Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 5e-7, - "input_text": 5e-7, - "input_modality_1": 5e-7, - "prompt_token_count": 5e-7, - "promptTokenCount": 5e-7, - "input_cached_tokens": 5e-8, - "cached_content_token_count": 5e-8, - "output": 0.000003, - "output_text": 0.000003, - "output_modality_1": 0.000003, - "candidates_token_count": 0.000003, - "candidatesTokenCount": 0.000003, - "thoughtsTokenCount": 0.000003, - "thoughts_token_count": 0.000003, - "output_reasoning": 0.000003 - } - } - ] - }, - { - "modelName": "gemini-3.1-flash-lite-preview", - "matchPattern": "(?i)^(google(ai)?/)?(gemini-3.1-flash-lite-preview)$", - "startDate": "2026-03-03T00:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input": 2.5e-7, - "input_modality_1": 2.5e-7, - "input_text": 2.5e-7, - "prompt_token_count": 2.5e-7, - "promptTokenCount": 2.5e-7, - "input_cached_tokens": 2.5e-8, - "cached_content_token_count": 2.5e-8, - "output": 0.0000015, - "output_text": 0.0000015, - "output_modality_1": 0.0000015, - "candidates_token_count": 0.0000015, - "candidatesTokenCount": 0.0000015, - "thoughtsTokenCount": 0.0000015, - "thoughts_token_count": 0.0000015, - "output_reasoning": 0.0000015, - "input_audio_tokens": 5e-7 - } - } - ] - }, - { - "modelName": "gemini-live-2.5-flash-native-audio", - "matchPattern": "(?i)^(google/)?(gemini-live-2.5-flash-native-audio)$", - "startDate": "2026-03-16T00:00:00.000Z", - "pricingTiers": [ - { - "name": "Standard", - "isDefault": true, - "priority": 0, - "conditions": [], - "prices": { - "input_text": 5e-7, - "input_audio": 0.000003, - "input_image": 0.000003, - "output_text": 0.000002, - "output_audio": 0.000012 - } - } - ] - } + prices: { + input: 0.000004, + input_modality_1: 0.000004, + input_text: 0.000004, + prompt_token_count: 0.000004, + promptTokenCount: 0.000004, + input_cached_tokens: 4e-7, + cached_content_token_count: 4e-7, + output: 0.000018, + output_text: 0.000018, + output_modality_1: 0.000018, + candidates_token_count: 0.000018, + candidatesTokenCount: 0.000018, + thoughtsTokenCount: 0.000018, + thoughts_token_count: 0.000018, + output_reasoning: 0.000018, + }, + }, + ], + }, + { + modelName: "gpt-5.2", + matchPattern: "(?i)^(openai/)?(gpt-5.2)$", + startDate: "2025-12-12T09:00:06.513Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00000175, + input_cached_tokens: 1.75e-7, + input_cache_read: 1.75e-7, + output: 0.000014, + output_reasoning_tokens: 0.000014, + output_reasoning: 0.000014, + }, + }, + ], + }, + { + modelName: "gpt-5.2-2025-12-11", + matchPattern: "(?i)^(openai/)?(gpt-5.2-2025-12-11)$", + startDate: "2025-12-12T09:00:06.513Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00000175, + input_cached_tokens: 1.75e-7, + input_cache_read: 1.75e-7, + output: 0.000014, + output_reasoning_tokens: 0.000014, + output_reasoning: 0.000014, + }, + }, + ], + }, + { + modelName: "gpt-5.2-pro", + matchPattern: "(?i)^(openai/)?(gpt-5.2-pro)$", + startDate: "2025-12-12T09:00:06.513Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000021, + output: 0.000168, + output_reasoning_tokens: 0.000168, + output_reasoning: 0.000168, + }, + }, + ], + }, + { + modelName: "gpt-5.2-pro-2025-12-11", + matchPattern: "(?i)^(openai/)?(gpt-5.2-pro-2025-12-11)$", + startDate: "2025-12-12T09:00:06.513Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.000021, + output: 0.000168, + output_reasoning_tokens: 0.000168, + output_reasoning: 0.000168, + }, + }, + ], + }, + { + modelName: "gpt-5.4", + matchPattern: "(?i)^(openai/)?(gpt-5.4)$", + startDate: "2026-03-05T00:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000025, + input_cached_tokens: 2.5e-7, + input_cache_read: 2.5e-7, + output: 0.000015, + output_reasoning_tokens: 0.000015, + output_reasoning: 0.000015, + }, + }, + ], + }, + { + modelName: "gpt-5.4-pro", + matchPattern: "(?i)^(openai/)?(gpt-5.4-pro)$", + startDate: "2026-03-05T00:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00003, + output: 0.00018, + output_reasoning_tokens: 0.00018, + output_reasoning: 0.00018, + }, + }, + ], + }, + { + modelName: "gpt-5.4-2026-03-05", + matchPattern: "(?i)^(openai/)?(gpt-5.4-2026-03-05)$", + startDate: "2026-03-05T00:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.0000025, + input_cached_tokens: 2.5e-7, + input_cache_read: 2.5e-7, + output: 0.000015, + output_reasoning_tokens: 0.000015, + output_reasoning: 0.000015, + }, + }, + ], + }, + { + modelName: "gpt-5.4-pro-2026-03-05", + matchPattern: "(?i)^(openai/)?(gpt-5.4-pro-2026-03-05)$", + startDate: "2026-03-05T00:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 0.00003, + output: 0.00018, + output_reasoning_tokens: 0.00018, + output_reasoning: 0.00018, + }, + }, + ], + }, + { + modelName: "gpt-5.4-mini", + matchPattern: "(?i)^(openai\\/)?(gpt-5.4-mini)$", + startDate: "2026-03-18T00:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 7.5e-7, + input_cached_tokens: 7.5e-8, + input_cache_read: 7.5e-8, + output: 0.0000045, + }, + }, + ], + }, + { + modelName: "gpt-5.4-mini-2026-03-17", + matchPattern: "(?i)^(openai\\/)?(gpt-5.4-mini-2026-03-17)$", + startDate: "2026-03-18T00:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 7.5e-7, + input_cached_tokens: 7.5e-8, + input_cache_read: 7.5e-8, + output: 0.0000045, + }, + }, + ], + }, + { + modelName: "gpt-5.4-nano", + matchPattern: "(?i)^(openai\\/)?(gpt-5.4-nano)$", + startDate: "2026-03-18T00:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2e-7, + input_cached_tokens: 2e-8, + input_cache_read: 2e-8, + output: 0.00000125, + }, + }, + ], + }, + { + modelName: "gpt-5.4-nano-2026-03-17", + matchPattern: "(?i)^(openai\\/)?(gpt-5.4-nano-2026-03-17)$", + startDate: "2026-03-18T00:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2e-7, + input_cached_tokens: 2e-8, + input_cache_read: 2e-8, + output: 0.00000125, + }, + }, + ], + }, + { + modelName: "gemini-3-flash-preview", + matchPattern: "(?i)^(google(ai)?/)?(gemini-3-flash-preview)$", + startDate: "2025-12-21T12:01:42.282Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 5e-7, + input_text: 5e-7, + input_modality_1: 5e-7, + prompt_token_count: 5e-7, + promptTokenCount: 5e-7, + input_cached_tokens: 5e-8, + cached_content_token_count: 5e-8, + output: 0.000003, + output_text: 0.000003, + output_modality_1: 0.000003, + candidates_token_count: 0.000003, + candidatesTokenCount: 0.000003, + thoughtsTokenCount: 0.000003, + thoughts_token_count: 0.000003, + output_reasoning: 0.000003, + }, + }, + ], + }, + { + modelName: "gemini-3.1-flash-lite-preview", + matchPattern: "(?i)^(google(ai)?/)?(gemini-3.1-flash-lite-preview)$", + startDate: "2026-03-03T00:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input: 2.5e-7, + input_modality_1: 2.5e-7, + input_text: 2.5e-7, + prompt_token_count: 2.5e-7, + promptTokenCount: 2.5e-7, + input_cached_tokens: 2.5e-8, + cached_content_token_count: 2.5e-8, + output: 0.0000015, + output_text: 0.0000015, + output_modality_1: 0.0000015, + candidates_token_count: 0.0000015, + candidatesTokenCount: 0.0000015, + thoughtsTokenCount: 0.0000015, + thoughts_token_count: 0.0000015, + output_reasoning: 0.0000015, + input_audio_tokens: 5e-7, + }, + }, + ], + }, + { + modelName: "gemini-live-2.5-flash-native-audio", + matchPattern: "(?i)^(google/)?(gemini-live-2.5-flash-native-audio)$", + startDate: "2026-03-16T00:00:00.000Z", + pricingTiers: [ + { + name: "Standard", + isDefault: true, + priority: 0, + conditions: [], + prices: { + input_text: 5e-7, + input_audio: 0.000003, + input_image: 0.000003, + output_text: 0.000002, + output_audio: 0.000012, + }, + }, + ], + }, ]; diff --git a/internal-packages/llm-model-catalog/src/modelCatalog.ts b/internal-packages/llm-model-catalog/src/modelCatalog.ts index 71ae921c3e7..d90eb1a992b 100644 --- a/internal-packages/llm-model-catalog/src/modelCatalog.ts +++ b/internal-packages/llm-model-catalog/src/modelCatalog.ts @@ -5,677 +5,564 @@ import type { ModelCatalogEntry } from "./types.js"; export const modelCatalog: Record = { "chatgpt-4o-latest": { - "provider": "openai", - "description": "OpenAI's flagship multimodal model optimized for speed and cost, capable of processing text, images, and audio with strong performance across reasoning, coding, and creative tasks.", - "contextWindow": 128000, - "maxOutputTokens": 16384, - "capabilities": [ + provider: "openai", + description: + "OpenAI's flagship multimodal model optimized for speed and cost, capable of processing text, images, and audio with strong performance across reasoning, coding, and creative tasks.", + contextWindow: 128000, + maxOutputTokens: 16384, + capabilities: [ "vision", "tool_use", "streaming", "json_mode", "audio_input", "audio_output", - "fine_tunable" + "fine_tunable", ], - "releaseDate": "2024-05-13", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T10:55:46.469Z", - "baseModelName": "chatgpt-4o" + releaseDate: "2024-05-13", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T10:55:46.469Z", + baseModelName: "chatgpt-4o", }, "claude-1.1": { - "provider": "anthropic", - "description": "An early-generation Claude model from Anthropic, offering basic conversational and text completion capabilities. It was quickly superseded by Claude 1.2, 1.3, and the Claude 2 family.", - "contextWindow": 9000, - "maxOutputTokens": 8191, - "capabilities": [ - "streaming" - ], - "releaseDate": "2023-03-14", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": null, - "knowledgeCutoff": null, - "resolvedAt": "2026-03-24T10:55:47.906Z", - "baseModelName": null + provider: "anthropic", + description: + "An early-generation Claude model from Anthropic, offering basic conversational and text completion capabilities. It was quickly superseded by Claude 1.2, 1.3, and the Claude 2 family.", + contextWindow: 9000, + maxOutputTokens: 8191, + capabilities: ["streaming"], + releaseDate: "2023-03-14", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: null, + knowledgeCutoff: null, + resolvedAt: "2026-03-24T10:55:47.906Z", + baseModelName: null, }, "claude-1.2": { - "provider": "anthropic", - "description": "An early-generation Anthropic model, part of the original Claude 1.x family. It offered improved performance over Claude 1.0 but was quickly superseded by Claude 1.3 and later model families.", - "contextWindow": 9000, - "maxOutputTokens": 8191, - "capabilities": [ - "streaming" - ], - "releaseDate": null, - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": null, - "knowledgeCutoff": null, - "resolvedAt": "2026-03-24T10:55:46.760Z", - "baseModelName": null + provider: "anthropic", + description: + "An early-generation Anthropic model, part of the original Claude 1.x family. It offered improved performance over Claude 1.0 but was quickly superseded by Claude 1.3 and later model families.", + contextWindow: 9000, + maxOutputTokens: 8191, + capabilities: ["streaming"], + releaseDate: null, + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: null, + knowledgeCutoff: null, + resolvedAt: "2026-03-24T10:55:46.760Z", + baseModelName: null, }, "claude-1.3": { - "provider": "anthropic", - "description": "Early-generation Claude model from Anthropic, offering improved performance over Claude 1.0-1.2 in reasoning and instruction-following tasks.", - "contextWindow": 100000, - "maxOutputTokens": null, - "capabilities": [ - "streaming" - ], - "releaseDate": "2023-03-14", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": null, - "knowledgeCutoff": null, - "resolvedAt": "2026-03-24T10:55:46.227Z", - "baseModelName": null + provider: "anthropic", + description: + "Early-generation Claude model from Anthropic, offering improved performance over Claude 1.0-1.2 in reasoning and instruction-following tasks.", + contextWindow: 100000, + maxOutputTokens: null, + capabilities: ["streaming"], + releaseDate: "2023-03-14", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: null, + knowledgeCutoff: null, + resolvedAt: "2026-03-24T10:55:46.227Z", + baseModelName: null, }, "claude-2.0": { - "provider": "anthropic", - "description": "Anthropic's second-generation large language model, offering improved performance over Claude 1.x with longer context support. Succeeded by Claude 2.1 and later the Claude 3 family.", - "contextWindow": 100000, - "maxOutputTokens": 4096, - "capabilities": [ - "streaming" - ], - "releaseDate": "2023-07-11", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": null, - "knowledgeCutoff": "2023-02-01", - "resolvedAt": "2026-03-24T10:55:45.922Z", - "baseModelName": null + provider: "anthropic", + description: + "Anthropic's second-generation large language model, offering improved performance over Claude 1.x with longer context support. Succeeded by Claude 2.1 and later the Claude 3 family.", + contextWindow: 100000, + maxOutputTokens: 4096, + capabilities: ["streaming"], + releaseDate: "2023-07-11", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: null, + knowledgeCutoff: "2023-02-01", + resolvedAt: "2026-03-24T10:55:45.922Z", + baseModelName: null, }, "claude-2.1": { - "provider": "anthropic", - "description": "Anthropic's Claude 2.1 model featuring a 200K context window, reduced hallucination rates compared to Claude 2.0, and improved accuracy on long document comprehension.", - "contextWindow": 200000, - "maxOutputTokens": 4096, - "capabilities": [ - "streaming", - "tool_use" - ], - "releaseDate": "2023-11-21", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": null, - "knowledgeCutoff": "2023-01-01", - "resolvedAt": "2026-03-24T10:56:22.743Z", - "baseModelName": null + provider: "anthropic", + description: + "Anthropic's Claude 2.1 model featuring a 200K context window, reduced hallucination rates compared to Claude 2.0, and improved accuracy on long document comprehension.", + contextWindow: 200000, + maxOutputTokens: 4096, + capabilities: ["streaming", "tool_use"], + releaseDate: "2023-11-21", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: null, + knowledgeCutoff: "2023-01-01", + resolvedAt: "2026-03-24T10:56:22.743Z", + baseModelName: null, }, "claude-3-5-haiku-20241022": { - "provider": "anthropic", - "description": "Anthropic's fastest and most cost-effective model in the Claude 3.5 family, optimized for speed and efficiency while maintaining strong performance across common tasks.", - "contextWindow": 200000, - "maxOutputTokens": 8192, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-10-22", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-07-01", - "resolvedAt": "2026-03-24T10:56:25.724Z", - "baseModelName": "claude-3-5-haiku" + provider: "anthropic", + description: + "Anthropic's fastest and most cost-effective model in the Claude 3.5 family, optimized for speed and efficiency while maintaining strong performance across common tasks.", + contextWindow: 200000, + maxOutputTokens: 8192, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2024-10-22", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-07-01", + resolvedAt: "2026-03-24T10:56:25.724Z", + baseModelName: "claude-3-5-haiku", }, "claude-3-5-sonnet-20240620": { - "provider": "anthropic", - "description": "Anthropic's Claude 3.5 Sonnet is a mid-tier model balancing intelligence and speed, excelling at coding, analysis, and vision tasks while being faster and cheaper than Opus.", - "contextWindow": 200000, - "maxOutputTokens": 8192, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-06-20", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-04-01", - "resolvedAt": "2026-03-24T10:56:35.401Z", - "baseModelName": "claude-3-5-sonnet" + provider: "anthropic", + description: + "Anthropic's Claude 3.5 Sonnet is a mid-tier model balancing intelligence and speed, excelling at coding, analysis, and vision tasks while being faster and cheaper than Opus.", + contextWindow: 200000, + maxOutputTokens: 8192, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2024-06-20", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-04-01", + resolvedAt: "2026-03-24T10:56:35.401Z", + baseModelName: "claude-3-5-sonnet", }, "claude-3-haiku-20240307": { - "provider": "anthropic", - "description": "Anthropic's fastest and most compact Claude 3 model, optimized for speed and cost-efficiency while maintaining strong performance on everyday tasks.", - "contextWindow": 200000, - "maxOutputTokens": 4096, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-03-13", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-08-01", - "resolvedAt": "2026-03-24T10:56:25.288Z", - "baseModelName": "claude-3-haiku" + provider: "anthropic", + description: + "Anthropic's fastest and most compact Claude 3 model, optimized for speed and cost-efficiency while maintaining strong performance on everyday tasks.", + contextWindow: 200000, + maxOutputTokens: 4096, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2024-03-13", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-08-01", + resolvedAt: "2026-03-24T10:56:25.288Z", + baseModelName: "claude-3-haiku", }, "claude-3-opus-20240229": { - "provider": "anthropic", - "description": "Anthropic's most capable model in the Claude 3 family, excelling at complex analysis, nuanced content generation, and advanced reasoning tasks.", - "contextWindow": 200000, - "maxOutputTokens": 4096, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-03-04", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-08-01", - "resolvedAt": "2026-03-24T10:56:26.008Z", - "baseModelName": "claude-3-opus" + provider: "anthropic", + description: + "Anthropic's most capable model in the Claude 3 family, excelling at complex analysis, nuanced content generation, and advanced reasoning tasks.", + contextWindow: 200000, + maxOutputTokens: 4096, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2024-03-04", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-08-01", + resolvedAt: "2026-03-24T10:56:26.008Z", + baseModelName: "claude-3-opus", }, "claude-3-sonnet-20240229": { - "provider": "anthropic", - "description": "Mid-tier model in Anthropic's Claude 3 family, balancing performance and speed for a wide range of tasks including analysis, coding, and content generation.", - "contextWindow": 200000, - "maxOutputTokens": 4096, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-03-04", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-02-01", - "resolvedAt": "2026-03-24T10:56:59.532Z", - "baseModelName": "claude-3-sonnet" + provider: "anthropic", + description: + "Mid-tier model in Anthropic's Claude 3 family, balancing performance and speed for a wide range of tasks including analysis, coding, and content generation.", + contextWindow: 200000, + maxOutputTokens: 4096, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2024-03-04", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-02-01", + resolvedAt: "2026-03-24T10:56:59.532Z", + baseModelName: "claude-3-sonnet", }, "claude-3.5-haiku-latest": { - "provider": "anthropic", - "description": "Anthropic's fastest and most cost-effective model in the Claude 3.5 family, optimized for speed and efficiency while maintaining strong performance across a wide range of tasks.", - "contextWindow": 200000, - "maxOutputTokens": 8192, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-10-29", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-07-01", - "resolvedAt": "2026-03-24T10:57:04.392Z", - "baseModelName": "claude-3.5-haiku" + provider: "anthropic", + description: + "Anthropic's fastest and most cost-effective model in the Claude 3.5 family, optimized for speed and efficiency while maintaining strong performance across a wide range of tasks.", + contextWindow: 200000, + maxOutputTokens: 8192, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2024-10-29", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-07-01", + resolvedAt: "2026-03-24T10:57:04.392Z", + baseModelName: "claude-3.5-haiku", }, "claude-3.5-sonnet-20241022": { - "provider": "anthropic", - "description": "Anthropic's mid-tier model offering strong reasoning, coding, and analysis capabilities at a balance of speed and intelligence, positioned between Haiku and Opus in the Claude 3.5 family.", - "contextWindow": 200000, - "maxOutputTokens": 8192, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-06-20", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-04-01", - "resolvedAt": "2026-03-24T10:57:13.346Z", - "baseModelName": "claude-3.5-sonnet" + provider: "anthropic", + description: + "Anthropic's mid-tier model offering strong reasoning, coding, and analysis capabilities at a balance of speed and intelligence, positioned between Haiku and Opus in the Claude 3.5 family.", + contextWindow: 200000, + maxOutputTokens: 8192, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2024-06-20", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-04-01", + resolvedAt: "2026-03-24T10:57:13.346Z", + baseModelName: "claude-3.5-sonnet", }, "claude-3.5-sonnet-latest": { - "provider": "anthropic", - "description": "Anthropic's mid-tier model offering strong reasoning, coding, and analysis capabilities at a balance of speed and intelligence, positioned between Haiku and Opus in the Claude 3.5 family.", - "contextWindow": 200000, - "maxOutputTokens": 8192, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-06-20", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-04-01", - "resolvedAt": "2026-03-24T10:57:13.346Z", - "baseModelName": "claude-3.5-sonnet" + provider: "anthropic", + description: + "Anthropic's mid-tier model offering strong reasoning, coding, and analysis capabilities at a balance of speed and intelligence, positioned between Haiku and Opus in the Claude 3.5 family.", + contextWindow: 200000, + maxOutputTokens: 8192, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2024-06-20", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-04-01", + resolvedAt: "2026-03-24T10:57:13.346Z", + baseModelName: "claude-3.5-sonnet", }, "claude-3.7-sonnet-20250219": { - "provider": "anthropic", - "description": "Anthropic's Claude 3.7 Sonnet is a hybrid reasoning model that introduced extended thinking capabilities, offering strong performance on coding, math, and complex reasoning tasks.", - "contextWindow": 200000, - "maxOutputTokens": 16384, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-02-24", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-04-01", - "resolvedAt": "2026-03-24T10:57:12.967Z", - "baseModelName": "claude-3.7-sonnet" + provider: "anthropic", + description: + "Anthropic's Claude 3.7 Sonnet is a hybrid reasoning model that introduced extended thinking capabilities, offering strong performance on coding, math, and complex reasoning tasks.", + contextWindow: 200000, + maxOutputTokens: 16384, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-02-24", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-04-01", + resolvedAt: "2026-03-24T10:57:12.967Z", + baseModelName: "claude-3.7-sonnet", }, "claude-3.7-sonnet-latest": { - "provider": "anthropic", - "description": "Anthropic's Claude 3.7 Sonnet is a hybrid reasoning model that introduced extended thinking capabilities, offering strong performance on coding, math, and complex reasoning tasks.", - "contextWindow": 200000, - "maxOutputTokens": 16384, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-02-24", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-04-01", - "resolvedAt": "2026-03-24T10:57:12.967Z", - "baseModelName": "claude-3.7-sonnet" + provider: "anthropic", + description: + "Anthropic's Claude 3.7 Sonnet is a hybrid reasoning model that introduced extended thinking capabilities, offering strong performance on coding, math, and complex reasoning tasks.", + contextWindow: 200000, + maxOutputTokens: 16384, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-02-24", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-04-01", + resolvedAt: "2026-03-24T10:57:12.967Z", + baseModelName: "claude-3.7-sonnet", }, "claude-haiku-4-5-20251001": { - "provider": "anthropic", - "description": "Anthropic's fastest model with near-frontier intelligence, optimized for speed and cost efficiency while supporting extended thinking and vision.", - "contextWindow": 200000, - "maxOutputTokens": 64000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-10-01", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-07-01", - "resolvedAt": "2026-03-24T10:57:29.685Z", - "baseModelName": "claude-haiku-4-5" + provider: "anthropic", + description: + "Anthropic's fastest model with near-frontier intelligence, optimized for speed and cost efficiency while supporting extended thinking and vision.", + contextWindow: 200000, + maxOutputTokens: 64000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-10-01", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-07-01", + resolvedAt: "2026-03-24T10:57:29.685Z", + baseModelName: "claude-haiku-4-5", }, "claude-instant-1": { - "provider": "anthropic", - "description": "Anthropic's fast and cost-effective model optimized for speed and efficiency, positioned as a lighter alternative to Claude 1.x for tasks requiring lower latency.", - "contextWindow": 100000, - "maxOutputTokens": 8191, - "capabilities": [ - "streaming" - ], - "releaseDate": "2023-03-14", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2025-01-06", - "knowledgeCutoff": "2023-01-01", - "resolvedAt": "2026-03-24T10:57:36.888Z", - "baseModelName": null + provider: "anthropic", + description: + "Anthropic's fast and cost-effective model optimized for speed and efficiency, positioned as a lighter alternative to Claude 1.x for tasks requiring lower latency.", + contextWindow: 100000, + maxOutputTokens: 8191, + capabilities: ["streaming"], + releaseDate: "2023-03-14", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2025-01-06", + knowledgeCutoff: "2023-01-01", + resolvedAt: "2026-03-24T10:57:36.888Z", + baseModelName: null, }, "claude-instant-1.2": { - "provider": "anthropic", - "description": "Anthropic's fast and cost-effective model, optimized for speed and efficiency while maintaining strong performance on conversational and text generation tasks.", - "contextWindow": 100000, - "maxOutputTokens": 8191, - "capabilities": [ - "streaming" - ], - "releaseDate": "2023-08-09", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": null, - "knowledgeCutoff": "2023-01-01", - "resolvedAt": "2026-03-24T10:57:41.865Z", - "baseModelName": null + provider: "anthropic", + description: + "Anthropic's fast and cost-effective model, optimized for speed and efficiency while maintaining strong performance on conversational and text generation tasks.", + contextWindow: 100000, + maxOutputTokens: 8191, + capabilities: ["streaming"], + releaseDate: "2023-08-09", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: null, + knowledgeCutoff: "2023-01-01", + resolvedAt: "2026-03-24T10:57:41.865Z", + baseModelName: null, }, "claude-opus-4-1-20250805": { - "provider": "anthropic", - "description": "Anthropic's hybrid reasoning model with strong software engineering and agentic capabilities, scoring 74.5% on SWE-bench Verified. Supports both rapid responses and step-by-step extended thinking.", - "contextWindow": 200000, - "maxOutputTokens": 32000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-08-05", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-03-01", - "resolvedAt": "2026-03-24T10:58:36.876Z", - "baseModelName": "claude-opus-4-1" + provider: "anthropic", + description: + "Anthropic's hybrid reasoning model with strong software engineering and agentic capabilities, scoring 74.5% on SWE-bench Verified. Supports both rapid responses and step-by-step extended thinking.", + contextWindow: 200000, + maxOutputTokens: 32000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-08-05", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-03-01", + resolvedAt: "2026-03-24T10:58:36.876Z", + baseModelName: "claude-opus-4-1", }, "claude-opus-4-20250514": { - "provider": "anthropic", - "description": "Anthropic's flagship model from the Claude 4 family, excelling at complex coding tasks, long-running agent workflows, and deep reasoning with extended thinking support.", - "contextWindow": 200000, - "maxOutputTokens": 32000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-05-14", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-03-01", - "resolvedAt": "2026-03-24T10:58:47.518Z", - "baseModelName": "claude-opus-4" + provider: "anthropic", + description: + "Anthropic's flagship model from the Claude 4 family, excelling at complex coding tasks, long-running agent workflows, and deep reasoning with extended thinking support.", + contextWindow: 200000, + maxOutputTokens: 32000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-05-14", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-03-01", + resolvedAt: "2026-03-24T10:58:47.518Z", + baseModelName: "claude-opus-4", }, "claude-opus-4-5-20251101": { - "provider": "anthropic", - "description": "Anthropic's flagship intelligence model released in November 2025, excelling at complex reasoning, vision, and extended thinking with the best performance in Anthropic's lineup before Opus 4.6.", - "contextWindow": 200000, - "maxOutputTokens": 64000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-11-01", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-03-01", - "resolvedAt": "2026-03-24T10:58:48.961Z", - "baseModelName": "claude-opus-4-5" + provider: "anthropic", + description: + "Anthropic's flagship intelligence model released in November 2025, excelling at complex reasoning, vision, and extended thinking with the best performance in Anthropic's lineup before Opus 4.6.", + contextWindow: 200000, + maxOutputTokens: 64000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-11-01", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-03-01", + resolvedAt: "2026-03-24T10:58:48.961Z", + baseModelName: "claude-opus-4-5", }, "claude-opus-4-6": { - "provider": "anthropic", - "description": "Anthropic's most intelligent model, optimized for building agents and coding with exceptional reasoning capabilities and extended agentic task horizons.", - "contextWindow": 1000000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2026-02-05", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-05-01", - "resolvedAt": "2026-03-24T10:58:42.061Z", - "baseModelName": null + provider: "anthropic", + description: + "Anthropic's most intelligent model, optimized for building agents and coding with exceptional reasoning capabilities and extended agentic task horizons.", + contextWindow: 1000000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2026-02-05", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-05-01", + resolvedAt: "2026-03-24T10:58:42.061Z", + baseModelName: null, }, "claude-sonnet-4-20250514": { - "provider": "anthropic", - "description": "Anthropic's balanced Claude 4 model offering strong coding, reasoning, and multilingual performance at moderate cost. Now a legacy model superseded by Claude Sonnet 4.5 and 4.6.", - "contextWindow": 200000, - "maxOutputTokens": 64000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-05-14", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-03-01", - "resolvedAt": "2026-03-24T10:58:39.601Z", - "baseModelName": "claude-sonnet-4" + provider: "anthropic", + description: + "Anthropic's balanced Claude 4 model offering strong coding, reasoning, and multilingual performance at moderate cost. Now a legacy model superseded by Claude Sonnet 4.5 and 4.6.", + contextWindow: 200000, + maxOutputTokens: 64000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-05-14", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-03-01", + resolvedAt: "2026-03-24T10:58:39.601Z", + baseModelName: "claude-sonnet-4", }, "claude-sonnet-4-5-20250929": { - "provider": "anthropic", - "description": "Anthropic's high-performance mid-tier model with strong coding, reasoning, and multi-step problem solving capabilities. Successor to Claude Sonnet 4, offering improved benchmarks at the same price point.", - "contextWindow": 200000, - "maxOutputTokens": 64000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-09-29", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-01-01", - "resolvedAt": "2026-03-24T10:59:54.426Z", - "baseModelName": "claude-sonnet-4-5" + provider: "anthropic", + description: + "Anthropic's high-performance mid-tier model with strong coding, reasoning, and multi-step problem solving capabilities. Successor to Claude Sonnet 4, offering improved benchmarks at the same price point.", + contextWindow: 200000, + maxOutputTokens: 64000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-09-29", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-01-01", + resolvedAt: "2026-03-24T10:59:54.426Z", + baseModelName: "claude-sonnet-4-5", }, "claude-sonnet-4-6": { - "provider": "anthropic", - "description": "Anthropic's best combination of speed and intelligence, excelling at coding, agentic tasks, and computer use, with a 1M token context window and performance rivaling prior Opus-class models.", - "contextWindow": 1000000, - "maxOutputTokens": 64000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2026-02-17", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2026-01-01", - "resolvedAt": "2026-03-24T10:59:59.014Z", - "baseModelName": null + provider: "anthropic", + description: + "Anthropic's best combination of speed and intelligence, excelling at coding, agentic tasks, and computer use, with a 1M token context window and performance rivaling prior Opus-class models.", + contextWindow: 1000000, + maxOutputTokens: 64000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2026-02-17", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2026-01-01", + resolvedAt: "2026-03-24T10:59:59.014Z", + baseModelName: null, }, "claude-sonnet-4-latest": { - "provider": "anthropic", - "description": "Anthropic's balanced Claude 4 model offering strong coding, reasoning, and multilingual performance at moderate cost. Now a legacy model superseded by Claude Sonnet 4.5 and 4.6.", - "contextWindow": 200000, - "maxOutputTokens": 64000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-05-14", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-03-01", - "resolvedAt": "2026-03-24T10:58:39.601Z", - "baseModelName": "claude-sonnet-4" + provider: "anthropic", + description: + "Anthropic's balanced Claude 4 model offering strong coding, reasoning, and multilingual performance at moderate cost. Now a legacy model superseded by Claude Sonnet 4.5 and 4.6.", + contextWindow: 200000, + maxOutputTokens: 64000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-05-14", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-03-01", + resolvedAt: "2026-03-24T10:58:39.601Z", + baseModelName: "claude-sonnet-4", }, "gemini-1.0-pro": { - "provider": "google", - "description": "Google's first-generation Gemini Pro model, a mid-size multimodal model designed for text generation, reasoning, and chat applications. Succeeded by Gemini 1.5 Pro.", - "contextWindow": 32760, - "maxOutputTokens": 8192, - "capabilities": [ - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2023-12-13", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2025-02-15", - "knowledgeCutoff": "2023-04-01", - "resolvedAt": "2026-03-24T10:59:26.767Z", - "baseModelName": null + provider: "google", + description: + "Google's first-generation Gemini Pro model, a mid-size multimodal model designed for text generation, reasoning, and chat applications. Succeeded by Gemini 1.5 Pro.", + contextWindow: 32760, + maxOutputTokens: 8192, + capabilities: ["tool_use", "streaming", "json_mode"], + releaseDate: "2023-12-13", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2025-02-15", + knowledgeCutoff: "2023-04-01", + resolvedAt: "2026-03-24T10:59:26.767Z", + baseModelName: null, }, "gemini-1.0-pro-001": { - "provider": "google", - "description": "Google's first-generation Pro model optimized for text generation, reasoning, and multi-turn conversation tasks, part of the original Gemini 1.0 lineup.", - "contextWindow": 30720, - "maxOutputTokens": 2048, - "capabilities": [ - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-02-15", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2025-02-15", - "knowledgeCutoff": "2023-04-01", - "resolvedAt": "2026-03-24T10:59:27.391Z", - "baseModelName": null + provider: "google", + description: + "Google's first-generation Pro model optimized for text generation, reasoning, and multi-turn conversation tasks, part of the original Gemini 1.0 lineup.", + contextWindow: 30720, + maxOutputTokens: 2048, + capabilities: ["tool_use", "streaming", "json_mode"], + releaseDate: "2024-02-15", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2025-02-15", + knowledgeCutoff: "2023-04-01", + resolvedAt: "2026-03-24T10:59:27.391Z", + baseModelName: null, }, "gemini-1.0-pro-latest": { - "provider": "google", - "description": "Google's first-generation Gemini Pro model, a mid-size multimodal model designed for text generation, reasoning, and chat applications. Succeeded by Gemini 1.5 Pro.", - "contextWindow": 32760, - "maxOutputTokens": 8192, - "capabilities": [ - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2023-12-13", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2025-02-15", - "knowledgeCutoff": "2023-04-01", - "resolvedAt": "2026-03-24T10:59:26.767Z", - "baseModelName": "gemini-1.0-pro" + provider: "google", + description: + "Google's first-generation Gemini Pro model, a mid-size multimodal model designed for text generation, reasoning, and chat applications. Succeeded by Gemini 1.5 Pro.", + contextWindow: 32760, + maxOutputTokens: 8192, + capabilities: ["tool_use", "streaming", "json_mode"], + releaseDate: "2023-12-13", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2025-02-15", + knowledgeCutoff: "2023-04-01", + resolvedAt: "2026-03-24T10:59:26.767Z", + baseModelName: "gemini-1.0-pro", }, "gemini-1.5-pro-latest": { - "provider": "google", - "description": "Google's mid-size multimodal model with a massive context window, strong at long-document understanding, code generation, and multi-turn conversation.", - "contextWindow": 2097152, - "maxOutputTokens": 8192, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "audio_input" - ], - "releaseDate": "2024-02-15", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2025-09-24", - "knowledgeCutoff": "2024-04-01", - "resolvedAt": "2026-03-24T10:59:25.463Z", - "baseModelName": "gemini-1.5-pro" + provider: "google", + description: + "Google's mid-size multimodal model with a massive context window, strong at long-document understanding, code generation, and multi-turn conversation.", + contextWindow: 2097152, + maxOutputTokens: 8192, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "audio_input"], + releaseDate: "2024-02-15", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2025-09-24", + knowledgeCutoff: "2024-04-01", + resolvedAt: "2026-03-24T10:59:25.463Z", + baseModelName: "gemini-1.5-pro", }, "gemini-2.0-flash": { - "provider": "google", - "description": "Google's second-generation workhorse model optimized for speed, with native tool use, multimodal input (text, images, audio, video), and a 1M token context window.", - "contextWindow": 1048576, - "maxOutputTokens": 8192, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "code_execution", - "audio_input" - ], - "releaseDate": "2025-02-05", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2026-06-01", - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:01:15.429Z", - "baseModelName": null + provider: "google", + description: + "Google's second-generation workhorse model optimized for speed, with native tool use, multimodal input (text, images, audio, video), and a 1M token context window.", + contextWindow: 1048576, + maxOutputTokens: 8192, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "code_execution", "audio_input"], + releaseDate: "2025-02-05", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2026-06-01", + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:01:15.429Z", + baseModelName: null, }, "gemini-2.0-flash-001": { - "provider": "google", - "description": "Google's fast and efficient multimodal model that outperforms Gemini 1.5 Pro on key benchmarks at twice the speed, supporting text, image, audio, and video inputs with native tool use.", - "contextWindow": 1048576, - "maxOutputTokens": 8192, - "capabilities": [ + provider: "google", + description: + "Google's fast and efficient multimodal model that outperforms Gemini 1.5 Pro on key benchmarks at twice the speed, supporting text, image, audio, and video inputs with native tool use.", + contextWindow: 1048576, + maxOutputTokens: 8192, + capabilities: [ "vision", "tool_use", "streaming", @@ -683,1890 +570,1614 @@ export const modelCatalog: Record = { "audio_input", "image_generation", "audio_output", - "code_execution" + "code_execution", ], - "releaseDate": "2025-02-05", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2026-06-01", - "knowledgeCutoff": "2024-08-01", - "resolvedAt": "2026-03-24T11:01:04.084Z", - "baseModelName": null + releaseDate: "2025-02-05", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2026-06-01", + knowledgeCutoff: "2024-08-01", + resolvedAt: "2026-03-24T11:01:04.084Z", + baseModelName: null, }, "gemini-2.0-flash-lite-preview": { - "provider": "google", - "description": "A lightweight, cost-efficient variant of Gemini 2.0 Flash optimized for low latency and high throughput, supporting multimodal input with text output.", - "contextWindow": 1048576, - "maxOutputTokens": 8192, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-02-05", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2026-06-01", - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:00:56.775Z", - "baseModelName": null + provider: "google", + description: + "A lightweight, cost-efficient variant of Gemini 2.0 Flash optimized for low latency and high throughput, supporting multimodal input with text output.", + contextWindow: 1048576, + maxOutputTokens: 8192, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-02-05", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2026-06-01", + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:00:56.775Z", + baseModelName: null, }, "gemini-2.0-flash-lite-preview-02-05": { - "provider": "google", - "description": "Google's cost-optimized, low-latency model in the Gemini 2.0 family, designed for high-volume tasks like summarization, multimodal processing, and categorization.", - "contextWindow": 1048576, - "maxOutputTokens": 8192, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-02-05", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2025-12-09", - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:01:34.165Z", - "baseModelName": null + provider: "google", + description: + "Google's cost-optimized, low-latency model in the Gemini 2.0 family, designed for high-volume tasks like summarization, multimodal processing, and categorization.", + contextWindow: 1048576, + maxOutputTokens: 8192, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-02-05", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2025-12-09", + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:01:34.165Z", + baseModelName: null, }, "gemini-2.5-flash": { - "provider": "google", - "description": "Google's best price-performance model optimized for low-latency, high-volume tasks requiring reasoning, with built-in thinking capabilities and multimodal input support.", - "contextWindow": 1048576, - "maxOutputTokens": 65536, - "capabilities": [ + provider: "google", + description: + "Google's best price-performance model optimized for low-latency, high-volume tasks requiring reasoning, with built-in thinking capabilities and multimodal input support.", + contextWindow: 1048576, + maxOutputTokens: 65536, + capabilities: [ "vision", "tool_use", "streaming", "json_mode", "extended_thinking", "code_execution", - "audio_input" + "audio_input", ], - "releaseDate": "2025-06-01", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": false, - "deprecationDate": null, - "knowledgeCutoff": "2025-01-01", - "resolvedAt": "2026-03-24T11:01:25.200Z", - "baseModelName": null + releaseDate: "2025-06-01", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: false, + deprecationDate: null, + knowledgeCutoff: "2025-01-01", + resolvedAt: "2026-03-24T11:01:25.200Z", + baseModelName: null, }, "gemini-2.5-flash-lite": { - "provider": "google", - "description": "Google's most cost-efficient Gemini model, optimized for low-latency use cases with strong reasoning, multilingual, and long-context capabilities at minimal cost.", - "contextWindow": 1048576, - "maxOutputTokens": 65535, - "capabilities": [ + provider: "google", + description: + "Google's most cost-efficient Gemini model, optimized for low-latency use cases with strong reasoning, multilingual, and long-context capabilities at minimal cost.", + contextWindow: 1048576, + maxOutputTokens: 65535, + capabilities: [ "vision", "tool_use", "streaming", "json_mode", "extended_thinking", "code_execution", - "audio_input" + "audio_input", ], - "releaseDate": "2025-07-22", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2026-07-22", - "knowledgeCutoff": "2025-01-01", - "resolvedAt": "2026-03-24T11:02:30.060Z", - "baseModelName": null + releaseDate: "2025-07-22", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2026-07-22", + knowledgeCutoff: "2025-01-01", + resolvedAt: "2026-03-24T11:02:30.060Z", + baseModelName: null, }, "gemini-2.5-pro": { - "provider": "google", - "description": "Google's most advanced reasoning model with deep thinking capabilities, excelling at complex tasks like coding, math, and multimodal understanding across text, images, audio, and video.", - "contextWindow": 1048576, - "maxOutputTokens": 65535, - "capabilities": [ + provider: "google", + description: + "Google's most advanced reasoning model with deep thinking capabilities, excelling at complex tasks like coding, math, and multimodal understanding across text, images, audio, and video.", + contextWindow: 1048576, + maxOutputTokens: 65535, + capabilities: [ "vision", "tool_use", "streaming", "json_mode", "extended_thinking", "code_execution", - "audio_input" + "audio_input", ], - "releaseDate": "2025-03-25", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2026-06-17", - "knowledgeCutoff": "2025-01-31", - "resolvedAt": "2026-03-24T11:02:25.573Z", - "baseModelName": null + releaseDate: "2025-03-25", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2026-06-17", + knowledgeCutoff: "2025-01-31", + resolvedAt: "2026-03-24T11:02:25.573Z", + baseModelName: null, }, "gemini-3-flash-preview": { - "provider": "google", - "description": "Google's high-speed thinking model that matches Gemini 2.5 Pro performance at ~3x faster speed and lower cost, designed for agentic workflows, multi-turn chat, and coding assistance with configurable reasoning levels.", - "contextWindow": 1048576, - "maxOutputTokens": 65536, - "capabilities": [ + provider: "google", + description: + "Google's high-speed thinking model that matches Gemini 2.5 Pro performance at ~3x faster speed and lower cost, designed for agentic workflows, multi-turn chat, and coding assistance with configurable reasoning levels.", + contextWindow: 1048576, + maxOutputTokens: 65536, + capabilities: [ "vision", "tool_use", "streaming", "json_mode", "extended_thinking", "code_execution", - "audio_input" + "audio_input", ], - "releaseDate": "2025-12-17", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-01-01", - "resolvedAt": "2026-03-24T11:02:13.388Z", - "baseModelName": null + releaseDate: "2025-12-17", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-01-01", + resolvedAt: "2026-03-24T11:02:13.388Z", + baseModelName: null, }, "gemini-3-pro-preview": { - "provider": "google", - "description": "Google's flagship reasoning and multimodal model with strong coding and agentic capabilities, now deprecated in favor of Gemini 3.1 Pro.", - "contextWindow": 1048576, - "maxOutputTokens": 65536, - "capabilities": [ + provider: "google", + description: + "Google's flagship reasoning and multimodal model with strong coding and agentic capabilities, now deprecated in favor of Gemini 3.1 Pro.", + contextWindow: 1048576, + maxOutputTokens: 65536, + capabilities: [ "vision", "tool_use", "streaming", "json_mode", "extended_thinking", "code_execution", - "audio_input" + "audio_input", ], - "releaseDate": "2025-11-01", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2026-03-09", - "knowledgeCutoff": "2025-01-01", - "resolvedAt": "2026-03-24T11:02:29.313Z", - "baseModelName": null + releaseDate: "2025-11-01", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2026-03-09", + knowledgeCutoff: "2025-01-01", + resolvedAt: "2026-03-24T11:02:29.313Z", + baseModelName: null, }, "gemini-3.1-flash-lite-preview": { - "provider": "google", - "description": "Google's most cost-efficient multimodal model in the Gemini 3 series, optimized for high-volume, low-latency tasks like translation, classification, and simple data extraction. Offers 2.5x faster time-to-first-token than Gemini 2.5 Flash.", - "contextWindow": 1048576, - "maxOutputTokens": 65536, - "capabilities": [ + provider: "google", + description: + "Google's most cost-efficient multimodal model in the Gemini 3 series, optimized for high-volume, low-latency tasks like translation, classification, and simple data extraction. Offers 2.5x faster time-to-first-token than Gemini 2.5 Flash.", + contextWindow: 1048576, + maxOutputTokens: 65536, + capabilities: [ "vision", "tool_use", "streaming", "json_mode", "extended_thinking", "code_execution", - "audio_input" + "audio_input", ], - "releaseDate": "2026-03-03", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-01-01", - "resolvedAt": "2026-03-24T11:02:29.253Z", - "baseModelName": null + releaseDate: "2026-03-03", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-01-01", + resolvedAt: "2026-03-24T11:02:29.253Z", + baseModelName: null, }, "gemini-3.1-pro-preview": { - "provider": "google", - "description": "Google's most advanced reasoning model in the Gemini 3.1 family, excelling at complex problem-solving across text, audio, images, video, and code with a 1M token context window and extended thinking capabilities.", - "contextWindow": 1048576, - "maxOutputTokens": 65536, - "capabilities": [ + provider: "google", + description: + "Google's most advanced reasoning model in the Gemini 3.1 family, excelling at complex problem-solving across text, audio, images, video, and code with a 1M token context window and extended thinking capabilities.", + contextWindow: 1048576, + maxOutputTokens: 65536, + capabilities: [ "vision", "tool_use", "streaming", "json_mode", "extended_thinking", "code_execution", - "audio_input" + "audio_input", ], - "releaseDate": "2026-02-19", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-01-01", - "resolvedAt": "2026-03-24T11:03:33.071Z", - "baseModelName": null + releaseDate: "2026-02-19", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-01-01", + resolvedAt: "2026-03-24T11:03:33.071Z", + baseModelName: null, }, "gemini-pro": { - "provider": "google", - "description": "Google's first-generation Gemini model for text generation, reasoning, and multi-turn conversation. Superseded by Gemini 1.5 Pro and later models.", - "contextWindow": 32768, - "maxOutputTokens": 8192, - "capabilities": [ - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2023-12-13", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": false, - "deprecationDate": "2025-04-09", - "knowledgeCutoff": "2023-04-01", - "resolvedAt": "2026-03-24T11:03:45.401Z", - "baseModelName": null + provider: "google", + description: + "Google's first-generation Gemini model for text generation, reasoning, and multi-turn conversation. Superseded by Gemini 1.5 Pro and later models.", + contextWindow: 32768, + maxOutputTokens: 8192, + capabilities: ["tool_use", "streaming", "json_mode"], + releaseDate: "2023-12-13", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: false, + deprecationDate: "2025-04-09", + knowledgeCutoff: "2023-04-01", + resolvedAt: "2026-03-24T11:03:45.401Z", + baseModelName: null, }, "gpt-3.5-turbo": { - "provider": "openai", - "description": "OpenAI's fast and cost-effective model optimized for chat and instruction-following tasks, now superseded by GPT-4o mini.", - "contextWindow": 16385, - "maxOutputTokens": 4096, - "capabilities": [ - "tool_use", - "streaming", - "json_mode", - "fine_tunable" - ], - "releaseDate": "2023-03-01", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2025-09-13", - "knowledgeCutoff": "2021-09-01", - "resolvedAt": "2026-03-24T11:03:11.412Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's fast and cost-effective model optimized for chat and instruction-following tasks, now superseded by GPT-4o mini.", + contextWindow: 16385, + maxOutputTokens: 4096, + capabilities: ["tool_use", "streaming", "json_mode", "fine_tunable"], + releaseDate: "2023-03-01", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2025-09-13", + knowledgeCutoff: "2021-09-01", + resolvedAt: "2026-03-24T11:03:11.412Z", + baseModelName: null, }, "gpt-3.5-turbo-0125": { - "provider": "openai", - "description": "A fast and cost-effective GPT-3.5 Turbo snapshot optimized for chat completions, offering improved accuracy for function calling and reduced instances of incomplete responses.", - "contextWindow": 16385, - "maxOutputTokens": 4096, - "capabilities": [ - "tool_use", - "streaming", - "json_mode", - "fine_tunable" - ], - "releaseDate": "2024-01-25", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2025-09-13", - "knowledgeCutoff": "2021-09-01", - "resolvedAt": "2026-03-24T11:03:11.310Z", - "baseModelName": null + provider: "openai", + description: + "A fast and cost-effective GPT-3.5 Turbo snapshot optimized for chat completions, offering improved accuracy for function calling and reduced instances of incomplete responses.", + contextWindow: 16385, + maxOutputTokens: 4096, + capabilities: ["tool_use", "streaming", "json_mode", "fine_tunable"], + releaseDate: "2024-01-25", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2025-09-13", + knowledgeCutoff: "2021-09-01", + resolvedAt: "2026-03-24T11:03:11.310Z", + baseModelName: null, }, "gpt-3.5-turbo-0301": { - "provider": "openai", - "description": "Early snapshot of GPT-3.5 Turbo, OpenAI's first ChatGPT-optimized model for chat completions. Fast and cost-effective for simple tasks but superseded by later revisions.", - "contextWindow": 4096, - "maxOutputTokens": 4096, - "capabilities": [ - "streaming", - "fine_tunable" - ], - "releaseDate": "2023-03-01", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2024-06-13", - "knowledgeCutoff": "2021-09-01", - "resolvedAt": "2026-03-24T11:03:12.060Z", - "baseModelName": null + provider: "openai", + description: + "Early snapshot of GPT-3.5 Turbo, OpenAI's first ChatGPT-optimized model for chat completions. Fast and cost-effective for simple tasks but superseded by later revisions.", + contextWindow: 4096, + maxOutputTokens: 4096, + capabilities: ["streaming", "fine_tunable"], + releaseDate: "2023-03-01", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2024-06-13", + knowledgeCutoff: "2021-09-01", + resolvedAt: "2026-03-24T11:03:12.060Z", + baseModelName: null, }, "gpt-3.5-turbo-0613": { - "provider": "openai", - "description": "A snapshot of GPT-3.5 Turbo from June 2023, optimized for chat and instruction-following tasks with function calling support.", - "contextWindow": 4096, - "maxOutputTokens": 4096, - "capabilities": [ - "tool_use", - "streaming", - "json_mode", - "fine_tunable" - ], - "releaseDate": "2023-06-13", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2024-09-13", - "knowledgeCutoff": "2021-09-01", - "resolvedAt": "2026-03-24T11:04:04.463Z", - "baseModelName": null + provider: "openai", + description: + "A snapshot of GPT-3.5 Turbo from June 2023, optimized for chat and instruction-following tasks with function calling support.", + contextWindow: 4096, + maxOutputTokens: 4096, + capabilities: ["tool_use", "streaming", "json_mode", "fine_tunable"], + releaseDate: "2023-06-13", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2024-09-13", + knowledgeCutoff: "2021-09-01", + resolvedAt: "2026-03-24T11:04:04.463Z", + baseModelName: null, }, "gpt-3.5-turbo-1106": { - "provider": "openai", - "description": "A dated snapshot of GPT-3.5 Turbo released in November 2023, offering improved instruction following, JSON mode, and parallel function calling over previous GPT-3.5 variants.", - "contextWindow": 16385, - "maxOutputTokens": 4096, - "capabilities": [ - "tool_use", - "streaming", - "json_mode", - "fine_tunable" - ], - "releaseDate": "2023-11-06", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2025-09-13", - "knowledgeCutoff": "2021-09-01", - "resolvedAt": "2026-03-24T11:04:23.054Z", - "baseModelName": null + provider: "openai", + description: + "A dated snapshot of GPT-3.5 Turbo released in November 2023, offering improved instruction following, JSON mode, and parallel function calling over previous GPT-3.5 variants.", + contextWindow: 16385, + maxOutputTokens: 4096, + capabilities: ["tool_use", "streaming", "json_mode", "fine_tunable"], + releaseDate: "2023-11-06", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2025-09-13", + knowledgeCutoff: "2021-09-01", + resolvedAt: "2026-03-24T11:04:23.054Z", + baseModelName: null, }, "gpt-3.5-turbo-16k": { - "provider": "openai", - "description": "Extended context version of GPT-3.5 Turbo with 16K token context window, offering the same capabilities as the base model but able to process longer inputs.", - "contextWindow": 16384, - "maxOutputTokens": 4096, - "capabilities": [ - "streaming", - "json_mode", - "fine_tunable", - "tool_use" - ], - "releaseDate": "2023-06-13", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2025-09-13", - "knowledgeCutoff": "2021-09-01", - "resolvedAt": "2026-03-24T11:04:36.307Z", - "baseModelName": null + provider: "openai", + description: + "Extended context version of GPT-3.5 Turbo with 16K token context window, offering the same capabilities as the base model but able to process longer inputs.", + contextWindow: 16384, + maxOutputTokens: 4096, + capabilities: ["streaming", "json_mode", "fine_tunable", "tool_use"], + releaseDate: "2023-06-13", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2025-09-13", + knowledgeCutoff: "2021-09-01", + resolvedAt: "2026-03-24T11:04:36.307Z", + baseModelName: null, }, "gpt-3.5-turbo-16k-0613": { - "provider": "openai", - "description": "Extended context window variant of GPT-3.5 Turbo with 16K token context, snapshot from June 2023. Optimized for chat completions with longer document processing.", - "contextWindow": 16384, - "maxOutputTokens": 4096, - "capabilities": [ - "streaming", - "json_mode", - "fine_tunable" - ], - "releaseDate": "2023-06-13", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2024-09-13", - "knowledgeCutoff": "2021-09-01", - "resolvedAt": "2026-03-24T11:04:22.894Z", - "baseModelName": null + provider: "openai", + description: + "Extended context window variant of GPT-3.5 Turbo with 16K token context, snapshot from June 2023. Optimized for chat completions with longer document processing.", + contextWindow: 16384, + maxOutputTokens: 4096, + capabilities: ["streaming", "json_mode", "fine_tunable"], + releaseDate: "2023-06-13", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2024-09-13", + knowledgeCutoff: "2021-09-01", + resolvedAt: "2026-03-24T11:04:22.894Z", + baseModelName: null, }, "gpt-3.5-turbo-instruct": { - "provider": "openai", - "description": "OpenAI's GPT-3.5 Turbo Instruct is a completions-only model (not chat) optimized for following explicit instructions, replacing the legacy text-davinci-003 model.", - "contextWindow": 4096, - "maxOutputTokens": 4096, - "capabilities": [ - "fine_tunable" - ], - "releaseDate": "2023-09-19", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2025-01-27", - "knowledgeCutoff": "2021-09-01", - "resolvedAt": "2026-03-24T11:04:22.309Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's GPT-3.5 Turbo Instruct is a completions-only model (not chat) optimized for following explicit instructions, replacing the legacy text-davinci-003 model.", + contextWindow: 4096, + maxOutputTokens: 4096, + capabilities: ["fine_tunable"], + releaseDate: "2023-09-19", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2025-01-27", + knowledgeCutoff: "2021-09-01", + resolvedAt: "2026-03-24T11:04:22.309Z", + baseModelName: null, }, "gpt-4": { - "provider": "openai", - "description": "OpenAI's flagship large language model that preceded GPT-4o, known for strong reasoning and instruction-following capabilities across a wide range of tasks.", - "contextWindow": 8192, - "maxOutputTokens": 8192, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2023-03-14", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2025-06-06", - "knowledgeCutoff": "2023-12-01", - "resolvedAt": "2026-03-24T11:04:36.773Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's flagship large language model that preceded GPT-4o, known for strong reasoning and instruction-following capabilities across a wide range of tasks.", + contextWindow: 8192, + maxOutputTokens: 8192, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2023-03-14", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2025-06-06", + knowledgeCutoff: "2023-12-01", + resolvedAt: "2026-03-24T11:04:36.773Z", + baseModelName: null, }, "gpt-4-0125-preview": { - "provider": "openai", - "description": "An improved GPT-4 Turbo preview model with better task completion, reduced laziness in code generation, and enhanced instruction following.", - "contextWindow": 128000, - "maxOutputTokens": 4096, - "capabilities": [ - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-01-25", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-12-01", - "resolvedAt": "2026-03-24T11:04:54.196Z", - "baseModelName": null + provider: "openai", + description: + "An improved GPT-4 Turbo preview model with better task completion, reduced laziness in code generation, and enhanced instruction following.", + contextWindow: 128000, + maxOutputTokens: 4096, + capabilities: ["tool_use", "streaming", "json_mode"], + releaseDate: "2024-01-25", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-12-01", + resolvedAt: "2026-03-24T11:04:54.196Z", + baseModelName: null, }, "gpt-4-0314": { - "provider": "openai", - "description": "Original GPT-4 snapshot from March 2023, a large multimodal model (text-only at launch) that was one of OpenAI's first GPT-4 releases. Now deprecated and replaced by newer GPT-4 variants.", - "contextWindow": 8192, - "maxOutputTokens": 4096, - "capabilities": [ - "streaming" - ], - "releaseDate": "2023-03-14", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2024-06-13", - "knowledgeCutoff": "2021-09-01", - "resolvedAt": "2026-03-24T11:05:14.112Z", - "baseModelName": null + provider: "openai", + description: + "Original GPT-4 snapshot from March 2023, a large multimodal model (text-only at launch) that was one of OpenAI's first GPT-4 releases. Now deprecated and replaced by newer GPT-4 variants.", + contextWindow: 8192, + maxOutputTokens: 4096, + capabilities: ["streaming"], + releaseDate: "2023-03-14", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2024-06-13", + knowledgeCutoff: "2021-09-01", + resolvedAt: "2026-03-24T11:05:14.112Z", + baseModelName: null, }, "gpt-4-0613": { - "provider": "openai", - "description": "A snapshot of GPT-4 from June 2023, offering strong reasoning and instruction-following capabilities. It was one of the first widely available GPT-4 variants with function calling support.", - "contextWindow": 8192, - "maxOutputTokens": 8192, - "capabilities": [ - "tool_use", - "streaming", - "json_mode", - "fine_tunable" - ], - "releaseDate": "2023-06-13", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2025-06-06", - "knowledgeCutoff": "2021-09-01", - "resolvedAt": "2026-03-24T11:05:13.885Z", - "baseModelName": null + provider: "openai", + description: + "A snapshot of GPT-4 from June 2023, offering strong reasoning and instruction-following capabilities. It was one of the first widely available GPT-4 variants with function calling support.", + contextWindow: 8192, + maxOutputTokens: 8192, + capabilities: ["tool_use", "streaming", "json_mode", "fine_tunable"], + releaseDate: "2023-06-13", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2025-06-06", + knowledgeCutoff: "2021-09-01", + resolvedAt: "2026-03-24T11:05:13.885Z", + baseModelName: null, }, "gpt-4-1106-preview": { - "provider": "openai", - "description": "GPT-4 Turbo preview model with 128K context window, offering improved instruction following and JSON mode support at reduced cost compared to GPT-4.", - "contextWindow": 128000, - "maxOutputTokens": 4096, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2023-11-06", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-04-01", - "resolvedAt": "2026-03-24T11:05:12.960Z", - "baseModelName": null + provider: "openai", + description: + "GPT-4 Turbo preview model with 128K context window, offering improved instruction following and JSON mode support at reduced cost compared to GPT-4.", + contextWindow: 128000, + maxOutputTokens: 4096, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2023-11-06", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-04-01", + resolvedAt: "2026-03-24T11:05:12.960Z", + baseModelName: null, }, "gpt-4-32k": { - "provider": "openai", - "description": "Extended context window variant of GPT-4 with 32,768 token capacity, offering the same capabilities as GPT-4 but able to process longer documents and conversations.", - "contextWindow": 32768, - "maxOutputTokens": 4096, - "capabilities": [ - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2023-03-14", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2025-06-06", - "knowledgeCutoff": "2021-09-01", - "resolvedAt": "2026-03-24T11:05:14.584Z", - "baseModelName": null + provider: "openai", + description: + "Extended context window variant of GPT-4 with 32,768 token capacity, offering the same capabilities as GPT-4 but able to process longer documents and conversations.", + contextWindow: 32768, + maxOutputTokens: 4096, + capabilities: ["tool_use", "streaming", "json_mode"], + releaseDate: "2023-03-14", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2025-06-06", + knowledgeCutoff: "2021-09-01", + resolvedAt: "2026-03-24T11:05:14.584Z", + baseModelName: null, }, "gpt-4-32k-0314": { - "provider": "openai", - "description": "Extended context (32k token) variant of the original GPT-4 launch snapshot from March 2024, offering the same capabilities as gpt-4-0314 but with 4x the context window.", - "contextWindow": 32768, - "maxOutputTokens": 4096, - "capabilities": [ - "streaming" - ], - "releaseDate": "2023-03-14", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2024-06-13", - "knowledgeCutoff": "2021-09-01", - "resolvedAt": "2026-03-24T11:05:32.044Z", - "baseModelName": null + provider: "openai", + description: + "Extended context (32k token) variant of the original GPT-4 launch snapshot from March 2024, offering the same capabilities as gpt-4-0314 but with 4x the context window.", + contextWindow: 32768, + maxOutputTokens: 4096, + capabilities: ["streaming"], + releaseDate: "2023-03-14", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2024-06-13", + knowledgeCutoff: "2021-09-01", + resolvedAt: "2026-03-24T11:05:32.044Z", + baseModelName: null, }, "gpt-4-32k-0613": { - "provider": "openai", - "description": "Extended context window variant of GPT-4 with 32,768 token context, based on the June 2023 snapshot. Offers the same capabilities as GPT-4 but with 4x the context length.", - "contextWindow": 32768, - "maxOutputTokens": 4096, - "capabilities": [ - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2023-06-13", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2025-06-06", - "knowledgeCutoff": "2021-09-01", - "resolvedAt": "2026-03-24T11:05:53.070Z", - "baseModelName": null + provider: "openai", + description: + "Extended context window variant of GPT-4 with 32,768 token context, based on the June 2023 snapshot. Offers the same capabilities as GPT-4 but with 4x the context length.", + contextWindow: 32768, + maxOutputTokens: 4096, + capabilities: ["tool_use", "streaming", "json_mode"], + releaseDate: "2023-06-13", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2025-06-06", + knowledgeCutoff: "2021-09-01", + resolvedAt: "2026-03-24T11:05:53.070Z", + baseModelName: null, }, "gpt-4-preview": { - "provider": "openai", - "description": "GPT-4 Turbo preview model with 128K context window, JSON mode, and parallel function calling. A preview release in the GPT-4 Turbo series, now deprecated in favor of newer models.", - "contextWindow": 128000, - "maxOutputTokens": 4096, - "capabilities": [ - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2023-11-06", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2025-06-06", - "knowledgeCutoff": "2023-04-01", - "resolvedAt": "2026-03-24T11:06:54.248Z", - "baseModelName": null + provider: "openai", + description: + "GPT-4 Turbo preview model with 128K context window, JSON mode, and parallel function calling. A preview release in the GPT-4 Turbo series, now deprecated in favor of newer models.", + contextWindow: 128000, + maxOutputTokens: 4096, + capabilities: ["tool_use", "streaming", "json_mode"], + releaseDate: "2023-11-06", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2025-06-06", + knowledgeCutoff: "2023-04-01", + resolvedAt: "2026-03-24T11:06:54.248Z", + baseModelName: null, }, "gpt-4-turbo": { - "provider": "openai", - "description": "OpenAI's optimized GPT-4 variant offering faster inference and lower cost than the original GPT-4, with vision capabilities and a 128K context window.", - "contextWindow": 128000, - "maxOutputTokens": 4096, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-04-09", - "isHidden": false, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-12-01", - "resolvedAt": "2026-03-24T11:05:51.415Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's optimized GPT-4 variant offering faster inference and lower cost than the original GPT-4, with vision capabilities and a 128K context window.", + contextWindow: 128000, + maxOutputTokens: 4096, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2024-04-09", + isHidden: false, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-12-01", + resolvedAt: "2026-03-24T11:05:51.415Z", + baseModelName: null, }, "gpt-4-turbo-2024-04-09": { - "provider": "openai", - "description": "OpenAI's optimized GPT-4 variant offering faster inference and lower cost than the original GPT-4, with vision capabilities and a 128K context window.", - "contextWindow": 128000, - "maxOutputTokens": 4096, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-04-09", - "isHidden": false, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-12-01", - "resolvedAt": "2026-03-24T11:05:51.415Z", - "baseModelName": "gpt-4-turbo" + provider: "openai", + description: + "OpenAI's optimized GPT-4 variant offering faster inference and lower cost than the original GPT-4, with vision capabilities and a 128K context window.", + contextWindow: 128000, + maxOutputTokens: 4096, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2024-04-09", + isHidden: false, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-12-01", + resolvedAt: "2026-03-24T11:05:51.415Z", + baseModelName: "gpt-4-turbo", }, "gpt-4-turbo-preview": { - "provider": "openai", - "description": "An early preview of GPT-4 Turbo with a 128K context window, offering improved instruction following and JSON mode support at reduced cost compared to GPT-4.", - "contextWindow": 128000, - "maxOutputTokens": 4096, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-01-25", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-12-01", - "resolvedAt": "2026-03-24T11:05:52.346Z", - "baseModelName": null + provider: "openai", + description: + "An early preview of GPT-4 Turbo with a 128K context window, offering improved instruction following and JSON mode support at reduced cost compared to GPT-4.", + contextWindow: 128000, + maxOutputTokens: 4096, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2024-01-25", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-12-01", + resolvedAt: "2026-03-24T11:05:52.346Z", + baseModelName: null, }, "gpt-4-turbo-vision": { - "provider": "openai", - "description": "OpenAI's GPT-4 Turbo model with vision capabilities, able to analyze and understand images alongside text. It was a preview model later superseded by GPT-4 Turbo (gpt-4-turbo-2024-04-09) and then GPT-4o.", - "contextWindow": 128000, - "maxOutputTokens": 4096, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2023-11-06", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2024-12-06", - "knowledgeCutoff": "2023-04-01", - "resolvedAt": "2026-03-24T11:06:38.455Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's GPT-4 Turbo model with vision capabilities, able to analyze and understand images alongside text. It was a preview model later superseded by GPT-4 Turbo (gpt-4-turbo-2024-04-09) and then GPT-4o.", + contextWindow: 128000, + maxOutputTokens: 4096, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2023-11-06", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2024-12-06", + knowledgeCutoff: "2023-04-01", + resolvedAt: "2026-03-24T11:06:38.455Z", + baseModelName: null, }, "gpt-4.1": { - "provider": "openai", - "description": "OpenAI's flagship model optimized for coding, instruction following, and tool calling with a 1M token context window. Excels at structured outputs and long-context tasks.", - "contextWindow": 1047576, - "maxOutputTokens": 32768, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-04-14", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:07:00.439Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's flagship model optimized for coding, instruction following, and tool calling with a 1M token context window. Excels at structured outputs and long-context tasks.", + contextWindow: 1047576, + maxOutputTokens: 32768, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-04-14", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:07:00.439Z", + baseModelName: null, }, "gpt-4.1-2025-04-14": { - "provider": "openai", - "description": "OpenAI's flagship model optimized for coding, instruction following, and tool calling with a 1M token context window. Excels at structured outputs and long-context tasks.", - "contextWindow": 1047576, - "maxOutputTokens": 32768, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-04-14", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:07:00.439Z", - "baseModelName": "gpt-4.1" + provider: "openai", + description: + "OpenAI's flagship model optimized for coding, instruction following, and tool calling with a 1M token context window. Excels at structured outputs and long-context tasks.", + contextWindow: 1047576, + maxOutputTokens: 32768, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-04-14", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:07:00.439Z", + baseModelName: "gpt-4.1", }, "gpt-4.1-mini": { - "provider": "openai", - "description": "A compact, cost-efficient model in OpenAI's GPT-4.1 family that matches or exceeds GPT-4o on many benchmarks while offering nearly half the latency and significantly lower cost.", - "contextWindow": 1000000, - "maxOutputTokens": 32768, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "fine_tunable" - ], - "releaseDate": "2025-04-14", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:08:14.524Z", - "baseModelName": null + provider: "openai", + description: + "A compact, cost-efficient model in OpenAI's GPT-4.1 family that matches or exceeds GPT-4o on many benchmarks while offering nearly half the latency and significantly lower cost.", + contextWindow: 1000000, + maxOutputTokens: 32768, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "fine_tunable"], + releaseDate: "2025-04-14", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:08:14.524Z", + baseModelName: null, }, "gpt-4.1-mini-2025-04-14": { - "provider": "openai", - "description": "A compact, cost-efficient model in OpenAI's GPT-4.1 family that matches or exceeds GPT-4o on many benchmarks while offering nearly half the latency and significantly lower cost.", - "contextWindow": 1000000, - "maxOutputTokens": 32768, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "fine_tunable" - ], - "releaseDate": "2025-04-14", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:08:14.524Z", - "baseModelName": "gpt-4.1-mini" + provider: "openai", + description: + "A compact, cost-efficient model in OpenAI's GPT-4.1 family that matches or exceeds GPT-4o on many benchmarks while offering nearly half the latency and significantly lower cost.", + contextWindow: 1000000, + maxOutputTokens: 32768, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "fine_tunable"], + releaseDate: "2025-04-14", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:08:14.524Z", + baseModelName: "gpt-4.1-mini", }, "gpt-4.1-nano": { - "provider": "openai", - "description": "OpenAI's fastest and most cost-effective model in the GPT-4.1 family, optimized for low-latency tasks like classification, autocompletion, and lightweight agentic workflows with strong instruction-following and tool-calling capabilities.", - "contextWindow": 1047576, - "maxOutputTokens": 32768, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-04-14", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:08:04.533Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's fastest and most cost-effective model in the GPT-4.1 family, optimized for low-latency tasks like classification, autocompletion, and lightweight agentic workflows with strong instruction-following and tool-calling capabilities.", + contextWindow: 1047576, + maxOutputTokens: 32768, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-04-14", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:08:04.533Z", + baseModelName: null, }, "gpt-4.1-nano-2025-04-14": { - "provider": "openai", - "description": "OpenAI's fastest and most cost-effective model in the GPT-4.1 family, optimized for low-latency tasks like classification, autocompletion, and lightweight agentic workflows with strong instruction-following and tool-calling capabilities.", - "contextWindow": 1047576, - "maxOutputTokens": 32768, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-04-14", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:08:04.533Z", - "baseModelName": "gpt-4.1-nano" + provider: "openai", + description: + "OpenAI's fastest and most cost-effective model in the GPT-4.1 family, optimized for low-latency tasks like classification, autocompletion, and lightweight agentic workflows with strong instruction-following and tool-calling capabilities.", + contextWindow: 1047576, + maxOutputTokens: 32768, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-04-14", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:08:04.533Z", + baseModelName: "gpt-4.1-nano", }, "gpt-4.5-preview": { - "provider": "openai", - "description": "OpenAI's largest pretrained model before the GPT-5 series, emphasizing broad knowledge, creative writing, and improved emotional intelligence over reasoning-focused models.", - "contextWindow": 128000, - "maxOutputTokens": 16384, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-02-27", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2025-07-14", - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:07:57.880Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's largest pretrained model before the GPT-5 series, emphasizing broad knowledge, creative writing, and improved emotional intelligence over reasoning-focused models.", + contextWindow: 128000, + maxOutputTokens: 16384, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-02-27", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2025-07-14", + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:07:57.880Z", + baseModelName: null, }, "gpt-4.5-preview-2025-02-27": { - "provider": "openai", - "description": "OpenAI's largest pretrained model before the GPT-5 series, emphasizing broad knowledge, creative writing, and improved emotional intelligence over reasoning-focused models.", - "contextWindow": 128000, - "maxOutputTokens": 16384, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-02-27", - "isHidden": true, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2025-07-14", - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:07:57.880Z", - "baseModelName": "gpt-4.5-preview" + provider: "openai", + description: + "OpenAI's largest pretrained model before the GPT-5 series, emphasizing broad knowledge, creative writing, and improved emotional intelligence over reasoning-focused models.", + contextWindow: 128000, + maxOutputTokens: 16384, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-02-27", + isHidden: true, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2025-07-14", + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:07:57.880Z", + baseModelName: "gpt-4.5-preview", }, "gpt-4o": { - "provider": "openai", - "description": "OpenAI's flagship multimodal model combining strong reasoning with vision, audio, and tool use capabilities at faster speeds and lower cost than GPT-4 Turbo.", - "contextWindow": 128000, - "maxOutputTokens": 16384, - "capabilities": [ + provider: "openai", + description: + "OpenAI's flagship multimodal model combining strong reasoning with vision, audio, and tool use capabilities at faster speeds and lower cost than GPT-4 Turbo.", + contextWindow: 128000, + maxOutputTokens: 16384, + capabilities: [ "vision", "tool_use", "streaming", "json_mode", "audio_input", "audio_output", - "fine_tunable" + "fine_tunable", ], - "releaseDate": "2024-05-13", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:07:31.638Z", - "baseModelName": null + releaseDate: "2024-05-13", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:07:31.638Z", + baseModelName: null, }, "gpt-4o-2024-05-13": { - "provider": "openai", - "description": "OpenAI's flagship multimodal model combining strong reasoning with vision, audio, and tool use capabilities at faster speeds and lower cost than GPT-4 Turbo.", - "contextWindow": 128000, - "maxOutputTokens": 16384, - "capabilities": [ + provider: "openai", + description: + "OpenAI's flagship multimodal model combining strong reasoning with vision, audio, and tool use capabilities at faster speeds and lower cost than GPT-4 Turbo.", + contextWindow: 128000, + maxOutputTokens: 16384, + capabilities: [ "vision", "tool_use", "streaming", "json_mode", "audio_input", "audio_output", - "fine_tunable" + "fine_tunable", ], - "releaseDate": "2024-05-13", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:07:31.638Z", - "baseModelName": "gpt-4o" + releaseDate: "2024-05-13", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:07:31.638Z", + baseModelName: "gpt-4o", }, "gpt-4o-2024-08-06": { - "provider": "openai", - "description": "OpenAI's flagship multimodal model combining strong reasoning with vision, audio, and tool use capabilities at faster speeds and lower cost than GPT-4 Turbo.", - "contextWindow": 128000, - "maxOutputTokens": 16384, - "capabilities": [ + provider: "openai", + description: + "OpenAI's flagship multimodal model combining strong reasoning with vision, audio, and tool use capabilities at faster speeds and lower cost than GPT-4 Turbo.", + contextWindow: 128000, + maxOutputTokens: 16384, + capabilities: [ "vision", "tool_use", "streaming", "json_mode", "audio_input", "audio_output", - "fine_tunable" + "fine_tunable", ], - "releaseDate": "2024-05-13", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:07:31.638Z", - "baseModelName": "gpt-4o" + releaseDate: "2024-05-13", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:07:31.638Z", + baseModelName: "gpt-4o", }, "gpt-4o-2024-11-20": { - "provider": "openai", - "description": "OpenAI's flagship multimodal model combining strong reasoning with vision, audio, and tool use capabilities at faster speeds and lower cost than GPT-4 Turbo.", - "contextWindow": 128000, - "maxOutputTokens": 16384, - "capabilities": [ + provider: "openai", + description: + "OpenAI's flagship multimodal model combining strong reasoning with vision, audio, and tool use capabilities at faster speeds and lower cost than GPT-4 Turbo.", + contextWindow: 128000, + maxOutputTokens: 16384, + capabilities: [ "vision", "tool_use", "streaming", "json_mode", "audio_input", "audio_output", - "fine_tunable" + "fine_tunable", ], - "releaseDate": "2024-05-13", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:07:31.638Z", - "baseModelName": "gpt-4o" + releaseDate: "2024-05-13", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:07:31.638Z", + baseModelName: "gpt-4o", }, "gpt-4o-audio-preview": { - "provider": "openai", - "description": "GPT-4o variant with native audio input and output capabilities via the Chat Completions API, supporting both text and audio modalities for conversational and voice-based applications.", - "contextWindow": 128000, - "maxOutputTokens": 16384, - "capabilities": [ - "audio_input", - "audio_output", - "tool_use", - "streaming" - ], - "releaseDate": "2024-10-01", - "isHidden": false, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2026-05-07", - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:08:09.590Z", - "baseModelName": null + provider: "openai", + description: + "GPT-4o variant with native audio input and output capabilities via the Chat Completions API, supporting both text and audio modalities for conversational and voice-based applications.", + contextWindow: 128000, + maxOutputTokens: 16384, + capabilities: ["audio_input", "audio_output", "tool_use", "streaming"], + releaseDate: "2024-10-01", + isHidden: false, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2026-05-07", + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:08:09.590Z", + baseModelName: null, }, "gpt-4o-audio-preview-2024-10-01": { - "provider": "openai", - "description": "GPT-4o variant with native audio input and output capabilities via the Chat Completions API, supporting both text and audio modalities for conversational and voice-based applications.", - "contextWindow": 128000, - "maxOutputTokens": 16384, - "capabilities": [ - "audio_input", - "audio_output", - "tool_use", - "streaming" - ], - "releaseDate": "2024-10-01", - "isHidden": false, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": "2026-05-07", - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:08:09.590Z", - "baseModelName": "gpt-4o-audio-preview" + provider: "openai", + description: + "GPT-4o variant with native audio input and output capabilities via the Chat Completions API, supporting both text and audio modalities for conversational and voice-based applications.", + contextWindow: 128000, + maxOutputTokens: 16384, + capabilities: ["audio_input", "audio_output", "tool_use", "streaming"], + releaseDate: "2024-10-01", + isHidden: false, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: "2026-05-07", + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:08:09.590Z", + baseModelName: "gpt-4o-audio-preview", }, "gpt-4o-mini": { - "provider": "openai", - "description": "Fast, affordable small model optimized for focused tasks. Positioned as OpenAI's cost-efficient option with strong performance on benchmarks relative to its size.", - "contextWindow": 128000, - "maxOutputTokens": 16384, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "fine_tunable" - ], - "releaseDate": "2024-07-18", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:09:50.130Z", - "baseModelName": null + provider: "openai", + description: + "Fast, affordable small model optimized for focused tasks. Positioned as OpenAI's cost-efficient option with strong performance on benchmarks relative to its size.", + contextWindow: 128000, + maxOutputTokens: 16384, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "fine_tunable"], + releaseDate: "2024-07-18", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:09:50.130Z", + baseModelName: null, }, "gpt-4o-mini-2024-07-18": { - "provider": "openai", - "description": "Fast, affordable small model optimized for focused tasks. Positioned as OpenAI's cost-efficient option with strong performance on benchmarks relative to its size.", - "contextWindow": 128000, - "maxOutputTokens": 16384, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "fine_tunable" - ], - "releaseDate": "2024-07-18", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:09:50.130Z", - "baseModelName": "gpt-4o-mini" + provider: "openai", + description: + "Fast, affordable small model optimized for focused tasks. Positioned as OpenAI's cost-efficient option with strong performance on benchmarks relative to its size.", + contextWindow: 128000, + maxOutputTokens: 16384, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "fine_tunable"], + releaseDate: "2024-07-18", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:09:50.130Z", + baseModelName: "gpt-4o-mini", }, "gpt-4o-realtime-preview": { - "provider": "openai", - "description": "OpenAI's real-time multimodal model capable of processing and generating both text and audio over WebRTC or WebSocket, enabling low-latency voice conversations and audio interactions.", - "contextWindow": 128000, - "maxOutputTokens": 4096, - "capabilities": [ - "audio_input", - "audio_output", - "tool_use", - "streaming" - ], - "releaseDate": "2024-10-01", - "isHidden": false, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": false, - "deprecationDate": "2026-05-07", - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:09:35.495Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's real-time multimodal model capable of processing and generating both text and audio over WebRTC or WebSocket, enabling low-latency voice conversations and audio interactions.", + contextWindow: 128000, + maxOutputTokens: 4096, + capabilities: ["audio_input", "audio_output", "tool_use", "streaming"], + releaseDate: "2024-10-01", + isHidden: false, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: false, + deprecationDate: "2026-05-07", + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:09:35.495Z", + baseModelName: null, }, "gpt-4o-realtime-preview-2024-10-01": { - "provider": "openai", - "description": "OpenAI's real-time multimodal model capable of processing and generating both text and audio over WebRTC or WebSocket, enabling low-latency voice conversations and audio interactions.", - "contextWindow": 128000, - "maxOutputTokens": 4096, - "capabilities": [ - "audio_input", - "audio_output", - "tool_use", - "streaming" - ], - "releaseDate": "2024-10-01", - "isHidden": false, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": false, - "deprecationDate": "2026-05-07", - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:09:35.495Z", - "baseModelName": "gpt-4o-realtime-preview" + provider: "openai", + description: + "OpenAI's real-time multimodal model capable of processing and generating both text and audio over WebRTC or WebSocket, enabling low-latency voice conversations and audio interactions.", + contextWindow: 128000, + maxOutputTokens: 4096, + capabilities: ["audio_input", "audio_output", "tool_use", "streaming"], + releaseDate: "2024-10-01", + isHidden: false, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: false, + deprecationDate: "2026-05-07", + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:09:35.495Z", + baseModelName: "gpt-4o-realtime-preview", }, "gpt-5": { - "provider": "openai", - "description": "OpenAI's flagship reasoning model released August 2025, featuring a 400K token context window with strong coding, reasoning, and agentic capabilities.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ + provider: "openai", + description: + "OpenAI's flagship reasoning model released August 2025, featuring a 400K token context window with strong coding, reasoning, and agentic capabilities.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: [ "vision", "tool_use", "streaming", "json_mode", "image_generation", - "code_execution" + "code_execution", ], - "releaseDate": "2025-08-07", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-09-30", - "resolvedAt": "2026-03-24T11:09:28.216Z", - "baseModelName": null + releaseDate: "2025-08-07", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-09-30", + resolvedAt: "2026-03-24T11:09:28.216Z", + baseModelName: null, }, "gpt-5-2025-08-07": { - "provider": "openai", - "description": "OpenAI's flagship reasoning model released August 2025, featuring a 400K token context window with strong coding, reasoning, and agentic capabilities.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ + provider: "openai", + description: + "OpenAI's flagship reasoning model released August 2025, featuring a 400K token context window with strong coding, reasoning, and agentic capabilities.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: [ "vision", "tool_use", "streaming", "json_mode", "image_generation", - "code_execution" + "code_execution", ], - "releaseDate": "2025-08-07", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-09-30", - "resolvedAt": "2026-03-24T11:09:28.216Z", - "baseModelName": "gpt-5" + releaseDate: "2025-08-07", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-09-30", + resolvedAt: "2026-03-24T11:09:28.216Z", + baseModelName: "gpt-5", }, "gpt-5-chat-latest": { - "provider": "openai", - "description": "Non-reasoning GPT-5 model used in ChatGPT, optimized for conversational tasks. Supports text and image inputs with function calling and structured outputs.", - "contextWindow": 128000, - "maxOutputTokens": 16384, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-08-07", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-09-30", - "resolvedAt": "2026-03-24T11:09:24.834Z", - "baseModelName": "gpt-5-chat" + provider: "openai", + description: + "Non-reasoning GPT-5 model used in ChatGPT, optimized for conversational tasks. Supports text and image inputs with function calling and structured outputs.", + contextWindow: 128000, + maxOutputTokens: 16384, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-08-07", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-09-30", + resolvedAt: "2026-03-24T11:09:24.834Z", + baseModelName: "gpt-5-chat", }, "gpt-5-mini": { - "provider": "openai", - "description": "A faster, more cost-efficient version of GPT-5 designed for well-defined tasks and precise prompts. Supports reasoning with configurable effort levels and offers reduced latency compared to the full GPT-5 model.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-08-07", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-05-31", - "resolvedAt": "2026-03-24T11:09:42.822Z", - "baseModelName": null + provider: "openai", + description: + "A faster, more cost-efficient version of GPT-5 designed for well-defined tasks and precise prompts. Supports reasoning with configurable effort levels and offers reduced latency compared to the full GPT-5 model.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-08-07", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-05-31", + resolvedAt: "2026-03-24T11:09:42.822Z", + baseModelName: null, }, "gpt-5-mini-2025-08-07": { - "provider": "openai", - "description": "A faster, more cost-efficient version of GPT-5 designed for well-defined tasks and precise prompts. Supports reasoning with configurable effort levels and offers reduced latency compared to the full GPT-5 model.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-08-07", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-05-31", - "resolvedAt": "2026-03-24T11:09:42.822Z", - "baseModelName": "gpt-5-mini" + provider: "openai", + description: + "A faster, more cost-efficient version of GPT-5 designed for well-defined tasks and precise prompts. Supports reasoning with configurable effort levels and offers reduced latency compared to the full GPT-5 model.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-08-07", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-05-31", + resolvedAt: "2026-03-24T11:09:42.822Z", + baseModelName: "gpt-5-mini", }, "gpt-5-nano": { - "provider": "openai", - "description": "The smallest and fastest variant in the GPT-5 family, optimized for developer tools, rapid interactions, and ultra-low latency environments. Best suited for classification, data extraction, ranking, and sub-agent tasks.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-08-07", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-05-31", - "resolvedAt": "2026-03-24T11:11:24.884Z", - "baseModelName": null + provider: "openai", + description: + "The smallest and fastest variant in the GPT-5 family, optimized for developer tools, rapid interactions, and ultra-low latency environments. Best suited for classification, data extraction, ranking, and sub-agent tasks.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-08-07", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-05-31", + resolvedAt: "2026-03-24T11:11:24.884Z", + baseModelName: null, }, "gpt-5-nano-2025-08-07": { - "provider": "openai", - "description": "The smallest and fastest variant in the GPT-5 family, optimized for developer tools, rapid interactions, and ultra-low latency environments. Best suited for classification, data extraction, ranking, and sub-agent tasks.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-08-07", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-05-31", - "resolvedAt": "2026-03-24T11:11:24.884Z", - "baseModelName": "gpt-5-nano" + provider: "openai", + description: + "The smallest and fastest variant in the GPT-5 family, optimized for developer tools, rapid interactions, and ultra-low latency environments. Best suited for classification, data extraction, ranking, and sub-agent tasks.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-08-07", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-05-31", + resolvedAt: "2026-03-24T11:11:24.884Z", + baseModelName: "gpt-5-nano", }, "gpt-5-pro": { - "provider": "openai", - "description": "OpenAI's enhanced GPT-5 variant optimized for complex tasks requiring step-by-step reasoning, with reduced hallucination and improved code quality compared to the base GPT-5.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-10-06", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-10-01", - "resolvedAt": "2026-03-24T11:11:37.048Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's enhanced GPT-5 variant optimized for complex tasks requiring step-by-step reasoning, with reduced hallucination and improved code quality compared to the base GPT-5.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-10-06", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-10-01", + resolvedAt: "2026-03-24T11:11:37.048Z", + baseModelName: null, }, "gpt-5-pro-2025-10-06": { - "provider": "openai", - "description": "OpenAI's enhanced GPT-5 variant optimized for complex tasks requiring step-by-step reasoning, with reduced hallucination and improved code quality compared to the base GPT-5.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-10-06", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-10-01", - "resolvedAt": "2026-03-24T11:11:37.048Z", - "baseModelName": "gpt-5-pro" + provider: "openai", + description: + "OpenAI's enhanced GPT-5 variant optimized for complex tasks requiring step-by-step reasoning, with reduced hallucination and improved code quality compared to the base GPT-5.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-10-06", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-10-01", + resolvedAt: "2026-03-24T11:11:37.048Z", + baseModelName: "gpt-5-pro", }, "gpt-5.1": { - "provider": "openai", - "description": "GPT-5.1 is OpenAI's frontier-grade model in the GPT-5 series, offering adaptive reasoning with configurable effort levels, improved coding and math performance, and a more natural conversational style compared to GPT-5.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-11-13", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-09-30", - "resolvedAt": "2026-03-24T11:11:47.327Z", - "baseModelName": null + provider: "openai", + description: + "GPT-5.1 is OpenAI's frontier-grade model in the GPT-5 series, offering adaptive reasoning with configurable effort levels, improved coding and math performance, and a more natural conversational style compared to GPT-5.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-11-13", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-09-30", + resolvedAt: "2026-03-24T11:11:47.327Z", + baseModelName: null, }, "gpt-5.1-2025-11-13": { - "provider": "openai", - "description": "GPT-5.1 is OpenAI's frontier-grade model in the GPT-5 series, offering adaptive reasoning with configurable effort levels, improved coding and math performance, and a more natural conversational style compared to GPT-5.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2025-11-13", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-09-30", - "resolvedAt": "2026-03-24T11:11:47.327Z", - "baseModelName": "gpt-5.1" + provider: "openai", + description: + "GPT-5.1 is OpenAI's frontier-grade model in the GPT-5 series, offering adaptive reasoning with configurable effort levels, improved coding and math performance, and a more natural conversational style compared to GPT-5.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2025-11-13", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-09-30", + resolvedAt: "2026-03-24T11:11:47.327Z", + baseModelName: "gpt-5.1", }, "gpt-5.2": { - "provider": "openai", - "description": "OpenAI's flagship multimodal model released December 2025, excelling at long-context reasoning, agentic tool use, software engineering, and professional knowledge work. Available in Instant, Thinking, and Pro variants.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-12-11", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-08-31", - "resolvedAt": "2026-03-24T11:11:13.129Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's flagship multimodal model released December 2025, excelling at long-context reasoning, agentic tool use, software engineering, and professional knowledge work. Available in Instant, Thinking, and Pro variants.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-12-11", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-08-31", + resolvedAt: "2026-03-24T11:11:13.129Z", + baseModelName: null, }, "gpt-5.2-2025-12-11": { - "provider": "openai", - "description": "OpenAI's flagship multimodal model released December 2025, excelling at long-context reasoning, agentic tool use, software engineering, and professional knowledge work. Available in Instant, Thinking, and Pro variants.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-12-11", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-08-31", - "resolvedAt": "2026-03-24T11:11:13.129Z", - "baseModelName": "gpt-5.2" + provider: "openai", + description: + "OpenAI's flagship multimodal model released December 2025, excelling at long-context reasoning, agentic tool use, software engineering, and professional knowledge work. Available in Instant, Thinking, and Pro variants.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-12-11", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-08-31", + resolvedAt: "2026-03-24T11:11:13.129Z", + baseModelName: "gpt-5.2", }, "gpt-5.2-pro": { - "provider": "openai", - "description": "OpenAI's previous pro-tier reasoning model optimized for complex professional work requiring step-by-step reasoning, instruction following, and accuracy in high-stakes use cases. Superseded by GPT-5.4 pro.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "extended_thinking" - ], - "releaseDate": "2025-12-11", - "isHidden": false, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-08-31", - "resolvedAt": "2026-03-24T11:11:12.711Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's previous pro-tier reasoning model optimized for complex professional work requiring step-by-step reasoning, instruction following, and accuracy in high-stakes use cases. Superseded by GPT-5.4 pro.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "extended_thinking"], + releaseDate: "2025-12-11", + isHidden: false, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-08-31", + resolvedAt: "2026-03-24T11:11:12.711Z", + baseModelName: null, }, "gpt-5.2-pro-2025-12-11": { - "provider": "openai", - "description": "OpenAI's previous pro-tier reasoning model optimized for complex professional work requiring step-by-step reasoning, instruction following, and accuracy in high-stakes use cases. Superseded by GPT-5.4 pro.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "extended_thinking" - ], - "releaseDate": "2025-12-11", - "isHidden": false, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-08-31", - "resolvedAt": "2026-03-24T11:11:12.711Z", - "baseModelName": "gpt-5.2-pro" + provider: "openai", + description: + "OpenAI's previous pro-tier reasoning model optimized for complex professional work requiring step-by-step reasoning, instruction following, and accuracy in high-stakes use cases. Superseded by GPT-5.4 pro.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "extended_thinking"], + releaseDate: "2025-12-11", + isHidden: false, + supportsStructuredOutput: false, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-08-31", + resolvedAt: "2026-03-24T11:11:12.711Z", + baseModelName: "gpt-5.2-pro", }, "gpt-5.4": { - "provider": "openai", - "description": "OpenAI's most capable frontier model as of March 2026, featuring state-of-the-art coding, native computer-use capabilities, and a 1M-token context window for professional and agentic workflows.", - "contextWindow": 1050000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "code_execution" - ], - "releaseDate": "2026-03-05", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-08-31", - "resolvedAt": "2026-03-24T11:12:09.220Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's most capable frontier model as of March 2026, featuring state-of-the-art coding, native computer-use capabilities, and a 1M-token context window for professional and agentic workflows.", + contextWindow: 1050000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "code_execution"], + releaseDate: "2026-03-05", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-08-31", + resolvedAt: "2026-03-24T11:12:09.220Z", + baseModelName: null, }, "gpt-5.4-2026-03-05": { - "provider": "openai", - "description": "OpenAI's most capable frontier model as of March 2026, featuring state-of-the-art coding, native computer-use capabilities, and a 1M-token context window for professional and agentic workflows.", - "contextWindow": 1050000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "code_execution" - ], - "releaseDate": "2026-03-05", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-08-31", - "resolvedAt": "2026-03-24T11:12:09.220Z", - "baseModelName": "gpt-5.4" + provider: "openai", + description: + "OpenAI's most capable frontier model as of March 2026, featuring state-of-the-art coding, native computer-use capabilities, and a 1M-token context window for professional and agentic workflows.", + contextWindow: 1050000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "code_execution"], + releaseDate: "2026-03-05", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-08-31", + resolvedAt: "2026-03-24T11:12:09.220Z", + baseModelName: "gpt-5.4", }, "gpt-5.4-mini": { - "provider": "openai", - "description": "OpenAI's fast and efficient small model from the GPT-5.4 family, designed for high-volume workloads. Approaches GPT-5.4 performance on coding and reasoning while running over 2x faster than GPT-5 mini.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2026-03-17", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-08-31", - "resolvedAt": "2026-03-24T11:12:35.473Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's fast and efficient small model from the GPT-5.4 family, designed for high-volume workloads. Approaches GPT-5.4 performance on coding and reasoning while running over 2x faster than GPT-5 mini.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2026-03-17", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-08-31", + resolvedAt: "2026-03-24T11:12:35.473Z", + baseModelName: null, }, "gpt-5.4-mini-2026-03-17": { - "provider": "openai", - "description": "OpenAI's fast and efficient small model from the GPT-5.4 family, designed for high-volume workloads. Approaches GPT-5.4 performance on coding and reasoning while running over 2x faster than GPT-5 mini.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2026-03-17", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-08-31", - "resolvedAt": "2026-03-24T11:12:35.473Z", - "baseModelName": "gpt-5.4-mini" + provider: "openai", + description: + "OpenAI's fast and efficient small model from the GPT-5.4 family, designed for high-volume workloads. Approaches GPT-5.4 performance on coding and reasoning while running over 2x faster than GPT-5 mini.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2026-03-17", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-08-31", + resolvedAt: "2026-03-24T11:12:35.473Z", + baseModelName: "gpt-5.4-mini", }, "gpt-5.4-nano": { - "provider": "openai", - "description": "OpenAI's cheapest GPT-5.4-class model optimized for simple high-volume tasks like classification, data extraction, ranking, and sub-agent delegation in agentic workflows.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2026-03-17", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-08-31", - "resolvedAt": "2026-03-24T11:12:52.285Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's cheapest GPT-5.4-class model optimized for simple high-volume tasks like classification, data extraction, ranking, and sub-agent delegation in agentic workflows.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2026-03-17", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-08-31", + resolvedAt: "2026-03-24T11:12:52.285Z", + baseModelName: null, }, "gpt-5.4-nano-2026-03-17": { - "provider": "openai", - "description": "OpenAI's cheapest GPT-5.4-class model optimized for simple high-volume tasks like classification, data extraction, ranking, and sub-agent delegation in agentic workflows.", - "contextWindow": 400000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2026-03-17", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-08-31", - "resolvedAt": "2026-03-24T11:12:52.285Z", - "baseModelName": "gpt-5.4-nano" + provider: "openai", + description: + "OpenAI's cheapest GPT-5.4-class model optimized for simple high-volume tasks like classification, data extraction, ranking, and sub-agent delegation in agentic workflows.", + contextWindow: 400000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2026-03-17", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-08-31", + resolvedAt: "2026-03-24T11:12:52.285Z", + baseModelName: "gpt-5.4-nano", }, "gpt-5.4-pro": { - "provider": "openai", - "description": "OpenAI's highest-capability GPT-5.4 variant, using additional compute for harder problems. Available via Responses API only, designed for complex reasoning, coding, and agentic workflows.", - "contextWindow": 1050000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "extended_thinking" - ], - "releaseDate": "2026-03-05", - "isHidden": false, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": null, - "knowledgeCutoff": "2025-08-31", - "resolvedAt": "2026-03-24T11:12:56.903Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's highest-capability GPT-5.4 variant, using additional compute for harder problems. Available via Responses API only, designed for complex reasoning, coding, and agentic workflows.", + contextWindow: 1050000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "extended_thinking"], + releaseDate: "2026-03-05", + isHidden: false, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: null, + knowledgeCutoff: "2025-08-31", + resolvedAt: "2026-03-24T11:12:56.903Z", + baseModelName: null, }, "gpt-5.4-pro-2026-03-05": { - "provider": "openai", - "description": "OpenAI's highest-capability GPT-5.4 variant, using additional compute for harder problems. Available via Responses API only, designed for complex reasoning, coding, and agentic workflows.", - "contextWindow": 1050000, - "maxOutputTokens": 128000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "extended_thinking" - ], - "releaseDate": "2026-03-05", - "isHidden": false, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": null, - "knowledgeCutoff": "2025-08-31", - "resolvedAt": "2026-03-24T11:12:56.903Z", - "baseModelName": "gpt-5.4-pro" - }, - "o1": { - "provider": "openai", - "description": "OpenAI's reasoning model designed for complex tasks requiring multi-step logical thinking, excelling at math, science, and coding problems.", - "contextWindow": 200000, - "maxOutputTokens": 100000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-12-17", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:12:23.948Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's highest-capability GPT-5.4 variant, using additional compute for harder problems. Available via Responses API only, designed for complex reasoning, coding, and agentic workflows.", + contextWindow: 1050000, + maxOutputTokens: 128000, + capabilities: ["vision", "tool_use", "streaming", "extended_thinking"], + releaseDate: "2026-03-05", + isHidden: false, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: null, + knowledgeCutoff: "2025-08-31", + resolvedAt: "2026-03-24T11:12:56.903Z", + baseModelName: "gpt-5.4-pro", + }, + o1: { + provider: "openai", + description: + "OpenAI's reasoning model designed for complex tasks requiring multi-step logical thinking, excelling at math, science, and coding problems.", + contextWindow: 200000, + maxOutputTokens: 100000, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2024-12-17", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:12:23.948Z", + baseModelName: null, }, "o1-2024-12-17": { - "provider": "openai", - "description": "OpenAI's reasoning model designed for complex tasks requiring multi-step logical thinking, excelling at math, science, and coding problems.", - "contextWindow": 200000, - "maxOutputTokens": 100000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode" - ], - "releaseDate": "2024-12-17", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:12:23.948Z", - "baseModelName": "o1" + provider: "openai", + description: + "OpenAI's reasoning model designed for complex tasks requiring multi-step logical thinking, excelling at math, science, and coding problems.", + contextWindow: 200000, + maxOutputTokens: 100000, + capabilities: ["vision", "tool_use", "streaming", "json_mode"], + releaseDate: "2024-12-17", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:12:23.948Z", + baseModelName: "o1", }, "o1-mini": { - "provider": "openai", - "description": "A smaller, faster, and cheaper reasoning model in OpenAI's o1 series, optimized for coding, math, and science tasks requiring multi-step reasoning.", - "contextWindow": 128000, - "maxOutputTokens": 65536, - "capabilities": [ - "streaming", - "json_mode" - ], - "releaseDate": "2024-09-12", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2025-06-30", - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:12:37.030Z", - "baseModelName": null + provider: "openai", + description: + "A smaller, faster, and cheaper reasoning model in OpenAI's o1 series, optimized for coding, math, and science tasks requiring multi-step reasoning.", + contextWindow: 128000, + maxOutputTokens: 65536, + capabilities: ["streaming", "json_mode"], + releaseDate: "2024-09-12", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2025-06-30", + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:12:37.030Z", + baseModelName: null, }, "o1-mini-2024-09-12": { - "provider": "openai", - "description": "A smaller, faster, and cheaper reasoning model in OpenAI's o1 series, optimized for coding, math, and science tasks requiring multi-step reasoning.", - "contextWindow": 128000, - "maxOutputTokens": 65536, - "capabilities": [ - "streaming", - "json_mode" - ], - "releaseDate": "2024-09-12", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2025-06-30", - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:12:37.030Z", - "baseModelName": "o1-mini" + provider: "openai", + description: + "A smaller, faster, and cheaper reasoning model in OpenAI's o1 series, optimized for coding, math, and science tasks requiring multi-step reasoning.", + contextWindow: 128000, + maxOutputTokens: 65536, + capabilities: ["streaming", "json_mode"], + releaseDate: "2024-09-12", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2025-06-30", + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:12:37.030Z", + baseModelName: "o1-mini", }, "o1-preview": { - "provider": "openai", - "description": "OpenAI's first reasoning model using chain-of-thought to solve complex problems in science, coding, and math. Predecessor to o1 and o3 series.", - "contextWindow": 128000, - "maxOutputTokens": 32768, - "capabilities": [ - "streaming" - ], - "releaseDate": "2024-09-12", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2025-10-31", - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:12:59.198Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's first reasoning model using chain-of-thought to solve complex problems in science, coding, and math. Predecessor to o1 and o3 series.", + contextWindow: 128000, + maxOutputTokens: 32768, + capabilities: ["streaming"], + releaseDate: "2024-09-12", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2025-10-31", + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:12:59.198Z", + baseModelName: null, }, "o1-preview-2024-09-12": { - "provider": "openai", - "description": "OpenAI's first reasoning model using chain-of-thought to solve complex problems in science, coding, and math. Predecessor to o1 and o3 series.", - "contextWindow": 128000, - "maxOutputTokens": 32768, - "capabilities": [ - "streaming" - ], - "releaseDate": "2024-09-12", - "isHidden": true, - "supportsStructuredOutput": false, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": "2025-10-31", - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:12:59.198Z", - "baseModelName": "o1-preview" + provider: "openai", + description: + "OpenAI's first reasoning model using chain-of-thought to solve complex problems in science, coding, and math. Predecessor to o1 and o3 series.", + contextWindow: 128000, + maxOutputTokens: 32768, + capabilities: ["streaming"], + releaseDate: "2024-09-12", + isHidden: true, + supportsStructuredOutput: false, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: "2025-10-31", + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:12:59.198Z", + baseModelName: "o1-preview", }, "o1-pro": { - "provider": "openai", - "description": "A version of OpenAI's o1 reasoning model that uses significantly more compute to deliver better, more consistent answers on complex reasoning tasks in science, coding, and math.", - "contextWindow": 200000, - "maxOutputTokens": 100000, - "capabilities": [ - "vision", - "tool_use", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-03-19", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": null, - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:13:57.532Z", - "baseModelName": null + provider: "openai", + description: + "A version of OpenAI's o1 reasoning model that uses significantly more compute to deliver better, more consistent answers on complex reasoning tasks in science, coding, and math.", + contextWindow: 200000, + maxOutputTokens: 100000, + capabilities: ["vision", "tool_use", "json_mode", "extended_thinking"], + releaseDate: "2025-03-19", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: null, + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:13:57.532Z", + baseModelName: null, }, "o1-pro-2025-03-19": { - "provider": "openai", - "description": "A version of OpenAI's o1 reasoning model that uses significantly more compute to deliver better, more consistent answers on complex reasoning tasks in science, coding, and math.", - "contextWindow": 200000, - "maxOutputTokens": 100000, - "capabilities": [ - "vision", - "tool_use", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-03-19", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": null, - "knowledgeCutoff": "2023-10-01", - "resolvedAt": "2026-03-24T11:13:57.532Z", - "baseModelName": "o1-pro" - }, - "o3": { - "provider": "openai", - "description": "OpenAI's advanced reasoning model designed for complex tasks requiring deep reasoning, excelling at software engineering, mathematics, scientific reasoning, and visual reasoning tasks.", - "contextWindow": 200000, - "maxOutputTokens": 100000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-04-16", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:14:04.906Z", - "baseModelName": null + provider: "openai", + description: + "A version of OpenAI's o1 reasoning model that uses significantly more compute to deliver better, more consistent answers on complex reasoning tasks in science, coding, and math.", + contextWindow: 200000, + maxOutputTokens: 100000, + capabilities: ["vision", "tool_use", "json_mode", "extended_thinking"], + releaseDate: "2025-03-19", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: null, + knowledgeCutoff: "2023-10-01", + resolvedAt: "2026-03-24T11:13:57.532Z", + baseModelName: "o1-pro", + }, + o3: { + provider: "openai", + description: + "OpenAI's advanced reasoning model designed for complex tasks requiring deep reasoning, excelling at software engineering, mathematics, scientific reasoning, and visual reasoning tasks.", + contextWindow: 200000, + maxOutputTokens: 100000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-04-16", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:14:04.906Z", + baseModelName: null, }, "o3-2025-04-16": { - "provider": "openai", - "description": "OpenAI's advanced reasoning model designed for complex tasks requiring deep reasoning, excelling at software engineering, mathematics, scientific reasoning, and visual reasoning tasks.", - "contextWindow": 200000, - "maxOutputTokens": 100000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-04-16", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:14:04.906Z", - "baseModelName": "o3" + provider: "openai", + description: + "OpenAI's advanced reasoning model designed for complex tasks requiring deep reasoning, excelling at software engineering, mathematics, scientific reasoning, and visual reasoning tasks.", + contextWindow: 200000, + maxOutputTokens: 100000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-04-16", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:14:04.906Z", + baseModelName: "o3", }, "o3-mini": { - "provider": "openai", - "description": "OpenAI's compact reasoning model optimized for STEM tasks, offering strong performance in math, science, and coding at lower cost than o3.", - "contextWindow": 200000, - "maxOutputTokens": 100000, - "capabilities": [ - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-01-31", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-01-01", - "resolvedAt": "2026-03-24T11:13:33.788Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's compact reasoning model optimized for STEM tasks, offering strong performance in math, science, and coding at lower cost than o3.", + contextWindow: 200000, + maxOutputTokens: 100000, + capabilities: ["tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-01-31", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-01-01", + resolvedAt: "2026-03-24T11:13:33.788Z", + baseModelName: null, }, "o3-mini-2025-01-31": { - "provider": "openai", - "description": "OpenAI's compact reasoning model optimized for STEM tasks, offering strong performance in math, science, and coding at lower cost than o3.", - "contextWindow": 200000, - "maxOutputTokens": 100000, - "capabilities": [ - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-01-31", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2025-01-01", - "resolvedAt": "2026-03-24T11:13:33.788Z", - "baseModelName": "o3-mini" + provider: "openai", + description: + "OpenAI's compact reasoning model optimized for STEM tasks, offering strong performance in math, science, and coding at lower cost than o3.", + contextWindow: 200000, + maxOutputTokens: 100000, + capabilities: ["tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-01-31", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2025-01-01", + resolvedAt: "2026-03-24T11:13:33.788Z", + baseModelName: "o3-mini", }, "o3-pro": { - "provider": "openai", - "description": "OpenAI's most reliable reasoning model, a version of o3 designed to think longer and provide more consistently accurate answers for challenging math, science, and coding problems.", - "contextWindow": 200000, - "maxOutputTokens": 100000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-06-10", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": null, - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:14:10.900Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's most reliable reasoning model, a version of o3 designed to think longer and provide more consistently accurate answers for challenging math, science, and coding problems.", + contextWindow: 200000, + maxOutputTokens: 100000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-06-10", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: null, + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:14:10.900Z", + baseModelName: null, }, "o3-pro-2025-06-10": { - "provider": "openai", - "description": "OpenAI's most reliable reasoning model, a version of o3 designed to think longer and provide more consistently accurate answers for challenging math, science, and coding problems.", - "contextWindow": 200000, - "maxOutputTokens": 100000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-06-10", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": false, - "supportsStreamingToolCalls": false, - "deprecationDate": null, - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:14:10.900Z", - "baseModelName": "o3-pro" + provider: "openai", + description: + "OpenAI's most reliable reasoning model, a version of o3 designed to think longer and provide more consistently accurate answers for challenging math, science, and coding problems.", + contextWindow: 200000, + maxOutputTokens: 100000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-06-10", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: false, + supportsStreamingToolCalls: false, + deprecationDate: null, + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:14:10.900Z", + baseModelName: "o3-pro", }, "o4-mini": { - "provider": "openai", - "description": "OpenAI's small reasoning model optimized for fast, cost-efficient reasoning with strong performance in math, coding, and visual tasks.", - "contextWindow": 200000, - "maxOutputTokens": 100000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-04-16", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:14:16.050Z", - "baseModelName": null + provider: "openai", + description: + "OpenAI's small reasoning model optimized for fast, cost-efficient reasoning with strong performance in math, coding, and visual tasks.", + contextWindow: 200000, + maxOutputTokens: 100000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-04-16", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:14:16.050Z", + baseModelName: null, }, "o4-mini-2025-04-16": { - "provider": "openai", - "description": "OpenAI's small reasoning model optimized for fast, cost-efficient reasoning with strong performance in math, coding, and visual tasks.", - "contextWindow": 200000, - "maxOutputTokens": 100000, - "capabilities": [ - "vision", - "tool_use", - "streaming", - "json_mode", - "extended_thinking" - ], - "releaseDate": "2025-04-16", - "isHidden": false, - "supportsStructuredOutput": true, - "supportsParallelToolCalls": true, - "supportsStreamingToolCalls": true, - "deprecationDate": null, - "knowledgeCutoff": "2024-06-01", - "resolvedAt": "2026-03-24T11:14:16.050Z", - "baseModelName": "o4-mini" - } + provider: "openai", + description: + "OpenAI's small reasoning model optimized for fast, cost-efficient reasoning with strong performance in math, coding, and visual tasks.", + contextWindow: 200000, + maxOutputTokens: 100000, + capabilities: ["vision", "tool_use", "streaming", "json_mode", "extended_thinking"], + releaseDate: "2025-04-16", + isHidden: false, + supportsStructuredOutput: true, + supportsParallelToolCalls: true, + supportsStreamingToolCalls: true, + deprecationDate: null, + knowledgeCutoff: "2024-06-01", + resolvedAt: "2026-03-24T11:14:16.050Z", + baseModelName: "o4-mini", + }, }; From 256a45a2588b57c6d9b8ab62d09f2ab7dfca5ec8 Mon Sep 17 00:00:00 2001 From: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:08:46 +0100 Subject: [PATCH 3/7] fix(webapp): redact secrets & PII from logs (worker secret, API key/JWT, Slack) (#41) --- .server-changes/redact-secrets-from-logs.md | 6 +++ .../app/models/orgIntegration.server.ts | 16 ++++--- apps/webapp/app/models/safeIntegrationLog.ts | 20 +++++++++ apps/webapp/app/models/slackOAuthResultLog.ts | 18 ++++++++ apps/webapp/app/services/apiAuth.server.ts | 11 +++-- .../webapp/app/services/safeEnvironmentLog.ts | 19 ++++++++ .../app/services/safeRequestLogContext.ts | 15 +++++++ .../services/worker/sanitizeWorkerHeaders.ts | 27 ++++++++++++ .../worker/workerGroupTokenService.server.ts | 17 +++---- apps/webapp/test/safeEnvironmentLog.test.ts | 31 +++++++++++++ apps/webapp/test/safeIntegrationLog.test.ts | 35 +++++++++++++++ .../webapp/test/safeRequestLogContext.test.ts | 29 ++++++++++++ .../webapp/test/sanitizeWorkerHeaders.test.ts | 44 +++++++++++++++++++ apps/webapp/test/slackOAuthResultLog.test.ts | 34 ++++++++++++++ 14 files changed, 300 insertions(+), 22 deletions(-) create mode 100644 .server-changes/redact-secrets-from-logs.md create mode 100644 apps/webapp/app/models/safeIntegrationLog.ts create mode 100644 apps/webapp/app/models/slackOAuthResultLog.ts create mode 100644 apps/webapp/app/services/safeEnvironmentLog.ts create mode 100644 apps/webapp/app/services/safeRequestLogContext.ts create mode 100644 apps/webapp/app/v3/services/worker/sanitizeWorkerHeaders.ts create mode 100644 apps/webapp/test/safeEnvironmentLog.test.ts create mode 100644 apps/webapp/test/safeIntegrationLog.test.ts create mode 100644 apps/webapp/test/safeRequestLogContext.test.ts create mode 100644 apps/webapp/test/sanitizeWorkerHeaders.test.ts create mode 100644 apps/webapp/test/slackOAuthResultLog.test.ts diff --git a/.server-changes/redact-secrets-from-logs.md b/.server-changes/redact-secrets-from-logs.md new file mode 100644 index 00000000000..b2115c9a7f2 --- /dev/null +++ b/.server-changes/redact-secrets-from-logs.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Improve redaction of secrets from debug logs diff --git a/apps/webapp/app/models/orgIntegration.server.ts b/apps/webapp/app/models/orgIntegration.server.ts index a362e61acef..f2bd0feebce 100644 --- a/apps/webapp/app/models/orgIntegration.server.ts +++ b/apps/webapp/app/models/orgIntegration.server.ts @@ -9,6 +9,8 @@ import { z } from "zod"; import { $transaction, prisma } from "~/db.server"; import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; +import { slackSecretLogFields } from "./safeIntegrationLog"; +import { slackAccessResultLogFields } from "./slackOAuthResultLog"; import { getSecretStore } from "~/services/secrets/secretStore.server"; import { commitSession, getUserSession } from "~/services/sessionStorage.server"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; @@ -205,9 +207,8 @@ export class OrgIntegrationRepository { }); if (result.ok) { - logger.debug("Received slack access token", { - result, - }); + // `result` carries Slack tokens; log only non-secret diagnostics. + logger.debug("Received slack access token", slackAccessResultLogFields(result)); if (!result.access_token) { throw new Error("Failed to get access token"); @@ -230,9 +231,12 @@ export class OrgIntegrationRepository { raw: result, }; - logger.debug("Setting secret", { - secretValue, - }); + // `secretValue` carries the tokens encrypted below; log only + // non-secret fields. + logger.debug( + "Setting secret", + slackSecretLogFields(integrationFriendlyId, secretValue) + ); await secretStore.setSecret(integrationFriendlyId, secretValue); diff --git a/apps/webapp/app/models/safeIntegrationLog.ts b/apps/webapp/app/models/safeIntegrationLog.ts new file mode 100644 index 00000000000..be08706030c --- /dev/null +++ b/apps/webapp/app/models/safeIntegrationLog.ts @@ -0,0 +1,20 @@ +// Non-secret fields for logging a Slack integration secret: presence booleans +// and scope arrays only, never the token values. Dependency-free so it's +// unit-tested directly. +export type SlackSecretLike = { + botAccessToken?: string; + userAccessToken?: string; + refreshToken?: string; + botScopes?: string[]; + userScopes?: string[]; +}; + +export function slackSecretLogFields(friendlyId: string, secret: SlackSecretLike) { + return { + friendlyId, + hasUserToken: !!secret.userAccessToken, + hasRefreshToken: !!secret.refreshToken, + botScopes: secret.botScopes, + userScopes: secret.userScopes, + }; +} diff --git a/apps/webapp/app/models/slackOAuthResultLog.ts b/apps/webapp/app/models/slackOAuthResultLog.ts new file mode 100644 index 00000000000..3127d26a467 --- /dev/null +++ b/apps/webapp/app/models/slackOAuthResultLog.ts @@ -0,0 +1,18 @@ +// Non-secret fields for logging a Slack `oauth.v2.access` response, which +// otherwise carries bot/user/refresh tokens. Dependency-free so it's +// unit-tested directly. +export type SlackAccessResultLike = { + team?: { id?: string } | null; + scope?: string; + authed_user?: { access_token?: string } | null; + refresh_token?: string; +}; + +export function slackAccessResultLogFields(result: SlackAccessResultLike) { + return { + teamId: result.team?.id, + scope: result.scope, + hasUserToken: !!result.authed_user?.access_token, + hasRefreshToken: !!result.refresh_token, + }; +} diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 8e9a37f5521..899b693a98e 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -14,6 +14,8 @@ import { } from "~/models/runtimeEnvironment.server"; import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { logger } from "./logger.server"; +import { safeEnvironmentLogFields } from "./safeEnvironmentLog"; +import { missingJwtLogContext } from "./safeRequestLogContext"; import { type PersonalAccessTokenAuthenticationResult, authenticateApiRequestWithPersonalAccessToken, @@ -673,9 +675,9 @@ export async function validateJWTTokenAndRenew( const jwt = request.headers.get("x-trigger-jwt"); if (!jwt) { - logger.debug("Missing JWT token in request", { - headers: Object.fromEntries(request.headers), - }); + // Log a safe breadcrumb, not the raw headers (which carry the + // caller's Authorization credential). + logger.debug("Missing JWT token in request", missingJwtLogContext(request)); return; } @@ -735,8 +737,9 @@ export async function validateJWTTokenAndRenew( ...payload.data, }); + // The environment carries secret material; log only non-secret fields. logger.debug("Renewed JWT token from Authorization header API Key", { - environment: authenticatedEnv.environment, + environment: safeEnvironmentLogFields(authenticatedEnv.environment), payload: payload.data, }); diff --git a/apps/webapp/app/services/safeEnvironmentLog.ts b/apps/webapp/app/services/safeEnvironmentLog.ts new file mode 100644 index 00000000000..a6703a32671 --- /dev/null +++ b/apps/webapp/app/services/safeEnvironmentLog.ts @@ -0,0 +1,19 @@ +// Non-secret subset of an AuthenticatedEnvironment for logging (the full shape +// carries the env's apiKey). Dependency-free so it's unit-tested directly. +export type EnvironmentForLog = { + id: string; + slug: string; + type: string; + projectId: string; + organizationId: string; +}; + +export function safeEnvironmentLogFields(environment: EnvironmentForLog) { + return { + id: environment.id, + slug: environment.slug, + type: environment.type, + projectId: environment.projectId, + organizationId: environment.organizationId, + }; +} diff --git a/apps/webapp/app/services/safeRequestLogContext.ts b/apps/webapp/app/services/safeRequestLogContext.ts new file mode 100644 index 00000000000..90b7a220a1d --- /dev/null +++ b/apps/webapp/app/services/safeRequestLogContext.ts @@ -0,0 +1,15 @@ +// A safe breadcrumb for logging an inbound API request. Must never include +// header *values*, only presence — the Authorization header carries the +// caller's credential. Dependency-free so it's unit-tested directly. +export function missingJwtLogContext(request: Request): { + method: string; + path: string; + hasAuthorization: boolean; +} { + const url = new URL(request.url); + return { + method: request.method, + path: url.pathname, + hasAuthorization: request.headers.has("authorization"), + }; +} diff --git a/apps/webapp/app/v3/services/worker/sanitizeWorkerHeaders.ts b/apps/webapp/app/v3/services/worker/sanitizeWorkerHeaders.ts new file mode 100644 index 00000000000..47be4a728fb --- /dev/null +++ b/apps/webapp/app/v3/services/worker/sanitizeWorkerHeaders.ts @@ -0,0 +1,27 @@ +import { WORKER_HEADERS } from "@trigger.dev/core/v3/workers"; + +// Secret-bearing headers to drop before logging request headers. +// Dependency-free so the redaction is unit-tested directly. +export const SENSITIVE_WORKER_HEADERS = new Set([ + "authorization", + "cookie", + WORKER_HEADERS.MANAGED_SECRET.toLowerCase(), +]); + +/** + * Copy `headers` into a plain object, dropping any header whose (lower-cased) + * name is in `denylist`. Used before logging request headers. + */ +export function sanitizeWorkerHeaders( + headers: Headers, + denylist: ReadonlySet = SENSITIVE_WORKER_HEADERS +): Partial> { + const skip = new Set(Array.from(denylist, (h) => h.toLowerCase())); + const sanitized: Partial> = {}; + for (const [key, value] of headers.entries()) { + if (!skip.has(key.toLowerCase())) { + sanitized[key] = value; + } + } + return sanitized; +} diff --git a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts index 7749ed266e0..b1660065646 100644 --- a/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts +++ b/apps/webapp/app/v3/services/worker/workerGroupTokenService.server.ts @@ -18,6 +18,7 @@ import { fromFriendlyId } from "@trigger.dev/core/v3/isomorphic"; import { WORKER_HEADERS, type WorkerQueueClass } from "@trigger.dev/core/v3/workers"; import type { RuntimeEnvironment, WorkerInstanceGroup } from "@trigger.dev/database"; import { Prisma, WorkerInstanceGroupType } from "@trigger.dev/database"; +import { SENSITIVE_WORKER_HEADERS, sanitizeWorkerHeaders } from "./sanitizeWorkerHeaders"; import { createHash, timingSafeEqual } from "crypto"; import { customAlphabet } from "nanoid"; import { z } from "zod"; @@ -187,7 +188,6 @@ export class WorkerGroupTokenService extends WithRunEngine { if (a.byteLength !== b.byteLength) { logger.error("[WorkerGroupTokenService] Managed secret length mismatch", { - managedWorkerSecret, headers: this.sanitizeHeaders(request), }); return; @@ -195,7 +195,6 @@ export class WorkerGroupTokenService extends WithRunEngine { if (!timingSafeEqual(a, b)) { logger.error("[WorkerGroupTokenService] Managed secret mismatch", { - managedWorkerSecret, headers: this.sanitizeHeaders(request), }); return; @@ -317,16 +316,10 @@ export class WorkerGroupTokenService extends WithRunEngine { } } - private sanitizeHeaders(request: Request, skipHeaders = ["authorization"]) { - const sanitizedHeaders: Partial> = {}; - - for (const [key, value] of request.headers.entries()) { - if (!skipHeaders.includes(key.toLowerCase())) { - sanitizedHeaders[key] = value; - } - } - - return sanitizedHeaders; + // Strip sensitive headers before logging request headers — see + // `sanitizeWorkerHeaders`. + private sanitizeHeaders(request: Request, denylist = SENSITIVE_WORKER_HEADERS) { + return sanitizeWorkerHeaders(request.headers, denylist); } } diff --git a/apps/webapp/test/safeEnvironmentLog.test.ts b/apps/webapp/test/safeEnvironmentLog.test.ts new file mode 100644 index 00000000000..6d8a598d629 --- /dev/null +++ b/apps/webapp/test/safeEnvironmentLog.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { safeEnvironmentLogFields } from "../app/services/safeEnvironmentLog.js"; + +// AuthenticatedEnvironment carries `apiKey` (and pkApiKey) — these must never +// reach the logger when an environment is logged. +const environment = { + id: "env_1", + slug: "prod", + type: "PRODUCTION", + projectId: "proj_1", + organizationId: "org_1", + apiKey: "tr_prod_SUPERSECRET", + pkApiKey: "pk_prod_SECRET", +} as any; + +describe("safeEnvironmentLogFields", () => { + it("emits only non-secret identity fields, never the api key", () => { + const fields = safeEnvironmentLogFields(environment); + const serialized = JSON.stringify(fields); + expect(serialized).not.toContain("tr_prod_SUPERSECRET"); + expect(serialized).not.toContain("pk_prod_SECRET"); + expect(serialized).not.toContain("apiKey"); + expect(fields).toEqual({ + id: "env_1", + slug: "prod", + type: "PRODUCTION", + projectId: "proj_1", + organizationId: "org_1", + }); + }); +}); diff --git a/apps/webapp/test/safeIntegrationLog.test.ts b/apps/webapp/test/safeIntegrationLog.test.ts new file mode 100644 index 00000000000..18c466c0e03 --- /dev/null +++ b/apps/webapp/test/safeIntegrationLog.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { slackSecretLogFields } from "../app/models/safeIntegrationLog.js"; + +const secret = { + botAccessToken: "xoxb-BOT-SECRET", + userAccessToken: "xoxp-USER-SECRET", + refreshToken: "xoxe-REFRESH-SECRET", + botScopes: ["chat:write", "channels:read"], + userScopes: ["identity.basic"], +}; + +// Built right before secretStore encrypts the same object — the log fields must +// never carry the actual token strings. +describe("slackSecretLogFields", () => { + it("emits presence booleans + scopes, never the token values", () => { + const fields = slackSecretLogFields("int_123", secret); + const serialized = JSON.stringify(fields); + for (const token of ["xoxb-BOT-SECRET", "xoxp-USER-SECRET", "xoxe-REFRESH-SECRET"]) { + expect(serialized).not.toContain(token); + } + expect(fields).toEqual({ + friendlyId: "int_123", + hasUserToken: true, + hasRefreshToken: true, + botScopes: ["chat:write", "channels:read"], + userScopes: ["identity.basic"], + }); + }); + + it("reports false when optional tokens are absent", () => { + const fields = slackSecretLogFields("int_456", { botAccessToken: "xoxb-x", botScopes: [] }); + expect(fields.hasUserToken).toBe(false); + expect(fields.hasRefreshToken).toBe(false); + }); +}); diff --git a/apps/webapp/test/safeRequestLogContext.test.ts b/apps/webapp/test/safeRequestLogContext.test.ts new file mode 100644 index 00000000000..b90c54a4830 --- /dev/null +++ b/apps/webapp/test/safeRequestLogContext.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { missingJwtLogContext } from "../app/services/safeRequestLogContext.js"; + +const reqWith = (headers: Record) => + new Request("https://api.trigger.dev/api/v1/runs?x=1", { method: "POST", headers }); + +// The breadcrumb must never carry header values, only presence. +describe("missingJwtLogContext", () => { + it("returns only method, path, and a hasAuthorization boolean", () => { + const ctx = missingJwtLogContext(reqWith({ authorization: "Bearer tr_secret_key" })); + expect(ctx).toEqual({ method: "POST", path: "/api/v1/runs", hasAuthorization: true }); + }); + + it("never includes the Authorization value or a raw headers map (the leak)", () => { + const ctx = missingJwtLogContext( + reqWith({ authorization: "Bearer tr_secret_key", cookie: "__session=abc" }) + ); + const serialized = JSON.stringify(ctx); + expect(serialized).not.toContain("tr_secret_key"); + expect(serialized).not.toContain("Bearer"); + expect(serialized).not.toContain("__session"); + // hasAuthorization signals presence without leaking the value. + expect(ctx.hasAuthorization).toBe(true); + }); + + it("reports hasAuthorization=false when the header is absent", () => { + expect(missingJwtLogContext(reqWith({})).hasAuthorization).toBe(false); + }); +}); diff --git a/apps/webapp/test/sanitizeWorkerHeaders.test.ts b/apps/webapp/test/sanitizeWorkerHeaders.test.ts new file mode 100644 index 00000000000..30a219ff527 --- /dev/null +++ b/apps/webapp/test/sanitizeWorkerHeaders.test.ts @@ -0,0 +1,44 @@ +import { WORKER_HEADERS } from "@trigger.dev/core/v3/workers"; +import { describe, expect, it } from "vitest"; +import { sanitizeWorkerHeaders } from "../app/v3/services/worker/sanitizeWorkerHeaders.js"; + +// Runs before request headers are logged. The security property: secret-bearing +// managed-worker headers must never make it into the sanitized (loggable) +// object. +describe("sanitizeWorkerHeaders", () => { + const build = () => + new Headers({ + authorization: "Bearer tr_secret", + cookie: "__session=abc", + [WORKER_HEADERS.MANAGED_SECRET]: "cluster-shared-secret", + [WORKER_HEADERS.INSTANCE_NAME]: "supervisor-1", + "content-type": "application/json", + }); + + it("strips the managed worker secret (the leak this fixes)", () => { + const out = sanitizeWorkerHeaders(build()); + expect(out[WORKER_HEADERS.MANAGED_SECRET]).toBeUndefined(); + }); + + it("strips authorization and cookie", () => { + const out = sanitizeWorkerHeaders(build()); + expect(out["authorization"]).toBeUndefined(); + expect(out["cookie"]).toBeUndefined(); + }); + + it("preserves non-sensitive headers", () => { + const out = sanitizeWorkerHeaders(build()); + expect(out["content-type"]).toBe("application/json"); + expect(out[WORKER_HEADERS.INSTANCE_NAME]).toBe("supervisor-1"); + }); + + it("matches header names case-insensitively", () => { + const h = new Headers({ + Authorization: "Bearer x", + "X-Trigger-Worker-Managed-Secret": "s", + }); + const out = sanitizeWorkerHeaders(h); + // Headers lower-cases keys; both must be gone regardless of input casing. + expect(Object.keys(out)).toHaveLength(0); + }); +}); diff --git a/apps/webapp/test/slackOAuthResultLog.test.ts b/apps/webapp/test/slackOAuthResultLog.test.ts new file mode 100644 index 00000000000..63845d0db47 --- /dev/null +++ b/apps/webapp/test/slackOAuthResultLog.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { slackAccessResultLogFields } from "../app/models/slackOAuthResultLog.js"; + +const result = { + ok: true, + access_token: "xoxb-BOT-SECRET", + refresh_token: "xoxe-REFRESH-SECRET", + scope: "chat:write,channels:read", + team: { id: "T123" }, + authed_user: { id: "U1", access_token: "xoxp-USER-SECRET" }, +}; + +// Logged right after the Slack OAuth exchange — must never carry the tokens. +describe("slackAccessResultLogFields", () => { + it("emits only non-secret diagnostics, never the tokens", () => { + const fields = slackAccessResultLogFields(result); + const serialized = JSON.stringify(fields); + for (const token of ["xoxb-BOT-SECRET", "xoxp-USER-SECRET", "xoxe-REFRESH-SECRET"]) { + expect(serialized).not.toContain(token); + } + expect(fields).toEqual({ + teamId: "T123", + scope: "chat:write,channels:read", + hasUserToken: true, + hasRefreshToken: true, + }); + }); + + it("reports false when user/refresh tokens are absent", () => { + const fields = slackAccessResultLogFields({ team: { id: "T9" }, scope: "chat:write" }); + expect(fields.hasUserToken).toBe(false); + expect(fields.hasRefreshToken).toBe(false); + }); +}); From 106729328e727f43b29c2d3eddd083b09359ee36 Mon Sep 17 00:00:00 2001 From: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:09:26 +0100 Subject: [PATCH 4/7] fix(cli): thread --dev-only into McpContext so the env guard fires (#32) --- .changeset/mcp-thread-dev-only-flag.md | 5 +++ packages/cli-v3/src/commands/mcp.ts | 10 ++---- .../cli-v3/src/mcp/contextOptions.test.ts | 31 +++++++++++++++++++ packages/cli-v3/src/mcp/contextOptions.ts | 26 ++++++++++++++++ 4 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 .changeset/mcp-thread-dev-only-flag.md create mode 100644 packages/cli-v3/src/mcp/contextOptions.test.ts create mode 100644 packages/cli-v3/src/mcp/contextOptions.ts diff --git a/.changeset/mcp-thread-dev-only-flag.md b/.changeset/mcp-thread-dev-only-flag.md new file mode 100644 index 00000000000..f5513b02a98 --- /dev/null +++ b/.changeset/mcp-thread-dev-only-flag.md @@ -0,0 +1,5 @@ +--- +"trigger.dev": patch +--- + +fix(cli): honor the MCP server's `--dev-only` flag diff --git a/packages/cli-v3/src/commands/mcp.ts b/packages/cli-v3/src/commands/mcp.ts index d391762a3d0..7f94e40a3af 100644 --- a/packages/cli-v3/src/commands/mcp.ts +++ b/packages/cli-v3/src/commands/mcp.ts @@ -6,9 +6,9 @@ import { tryCatch } from "@trigger.dev/core/utils"; import type { Command } from "commander"; import { z } from "zod"; import { CommonCommandOptions, commonOptions, wrapCommandAction } from "../cli/common.js"; -import { CLOUD_API_URL } from "../consts.js"; import { serverMetadata } from "../mcp/config.js"; import { McpContext } from "../mcp/context.js"; +import { toMcpContextOptions } from "../mcp/contextOptions.js"; import { FileLogger } from "../mcp/logger.js"; import { registerTools } from "../mcp/tools.js"; import { printStandloneInitialBanner } from "../utilities/initialBanner.js"; @@ -95,13 +95,7 @@ export async function mcpCommand(options: McpCommandOptions) { ? new FileLogger(options.logFile, server) : undefined; - const context = new McpContext(server, { - projectRef: options.projectRef, - fileLogger, - apiUrl: options.apiUrl ?? CLOUD_API_URL, - profile: options.profile, - readonly: options.readonly, - }); + const context = new McpContext(server, toMcpContextOptions(options, fileLogger)); registerTools(context); diff --git a/packages/cli-v3/src/mcp/contextOptions.test.ts b/packages/cli-v3/src/mcp/contextOptions.test.ts new file mode 100644 index 00000000000..2825cbfd811 --- /dev/null +++ b/packages/cli-v3/src/mcp/contextOptions.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { toMcpContextOptions } from "./contextOptions.js"; +import type { McpCommandOptions } from "../commands/mcp.js"; + +// The dev-only guards only work if `--dev-only` is threaded from the parsed +// CLI options into the McpContext. These tests pin that wiring. +const baseOptions = { + projectRef: "proj_123", + apiUrl: "https://api.example.com", + profile: "default", + readonly: false, + devOnly: false, +} as unknown as McpCommandOptions; + +describe("toMcpContextOptions", () => { + it("threads devOnly=true through to the context options", () => { + expect(toMcpContextOptions({ ...baseOptions, devOnly: true }, undefined).devOnly).toBe(true); + }); + + it("threads devOnly=false through to the context options", () => { + expect(toMcpContextOptions({ ...baseOptions, devOnly: false }, undefined).devOnly).toBe(false); + }); + + it("carries the other relevant options across", () => { + const result = toMcpContextOptions(baseOptions, undefined); + expect(result.projectRef).toBe("proj_123"); + expect(result.apiUrl).toBe("https://api.example.com"); + expect(result.profile).toBe("default"); + expect(result.readonly).toBe(false); + }); +}); diff --git a/packages/cli-v3/src/mcp/contextOptions.ts b/packages/cli-v3/src/mcp/contextOptions.ts new file mode 100644 index 00000000000..6eef141f63c --- /dev/null +++ b/packages/cli-v3/src/mcp/contextOptions.ts @@ -0,0 +1,26 @@ +import { CLOUD_API_URL } from "../consts.js"; +import type { McpCommandOptions } from "../commands/mcp.js"; +import type { McpContextOptions } from "./context.js"; +import type { FileLogger } from "./logger.js"; + +/** + * Map parsed CLI options onto the `McpContext` options. `devOnly` must be + * forwarded here or the context sees `undefined` and its dev-only guards + * never fire. + * + * Kept in its own module (type-only imports) so the wiring can be + * unit-tested without loading the full command/tool/build chain. + */ +export function toMcpContextOptions( + options: McpCommandOptions, + fileLogger: FileLogger | undefined +): McpContextOptions { + return { + projectRef: options.projectRef, + fileLogger, + apiUrl: options.apiUrl ?? CLOUD_API_URL, + profile: options.profile, + readonly: options.readonly, + devOnly: options.devOnly, + }; +} From 9dc737fb7b3c2dfe590cdf230374d6737d6e6087 Mon Sep 17 00:00:00 2001 From: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:14:04 +0100 Subject: [PATCH 5/7] fix(webapp): query & API endpoint hardening (query authz, prompt-override, ai-title rate limit) (#44) --- .../query-and-api-endpoint-hardening.md | 6 + .../routes/api.v1.prompts.$slug.override.ts | 6 +- apps/webapp/app/routes/api.v1.query.ts | 25 ++-- ...jectParam.env.$envParam.query.ai-title.tsx | 19 ++- apps/webapp/app/v3/detectQueryTables.ts | 113 ++++++++++++++++++ .../v3/services/aiTitleRateLimiter.server.ts | 25 ++++ .../app/v3/services/promptOverrideSource.ts | 7 ++ .../app/v3/services/promptService.server.ts | 7 +- apps/webapp/test/aiTitleRateLimiter.test.ts | 54 +++++++++ apps/webapp/test/detectQueryTables.test.ts | 59 +++++++++ apps/webapp/test/promptOverrideSource.test.ts | 23 ++++ 11 files changed, 329 insertions(+), 15 deletions(-) create mode 100644 .server-changes/query-and-api-endpoint-hardening.md create mode 100644 apps/webapp/app/v3/detectQueryTables.ts create mode 100644 apps/webapp/app/v3/services/aiTitleRateLimiter.server.ts create mode 100644 apps/webapp/app/v3/services/promptOverrideSource.ts create mode 100644 apps/webapp/test/aiTitleRateLimiter.test.ts create mode 100644 apps/webapp/test/detectQueryTables.test.ts create mode 100644 apps/webapp/test/promptOverrideSource.test.ts diff --git a/.server-changes/query-and-api-endpoint-hardening.md b/.server-changes/query-and-api-endpoint-hardening.md new file mode 100644 index 00000000000..9b2aa79483b --- /dev/null +++ b/.server-changes/query-and-api-endpoint-hardening.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Harden the query and prompt-override APIs and rate-limit the query ai-title endpoint diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts index f31ade052ea..380f7f731df 100644 --- a/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts +++ b/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts @@ -13,7 +13,11 @@ const CreateBody = z.object({ textContent: z.string(), model: z.string().optional(), commitMessage: z.string().optional(), - source: z.string().optional(), + // `code` is reserved for deploy-created versions. + source: z + .string() + .refine((source) => source !== "code") + .optional(), }); const UpdateBody = z.object({ diff --git a/apps/webapp/app/routes/api.v1.query.ts b/apps/webapp/app/routes/api.v1.query.ts index e62d9b9366a..b3459fa51c8 100644 --- a/apps/webapp/app/routes/api.v1.query.ts +++ b/apps/webapp/app/routes/api.v1.query.ts @@ -5,6 +5,7 @@ import { createActionApiRoute, everyResource } from "~/services/routeBuilders/ap import { executeQuery, type QueryScope } from "~/services/queryService.server"; import { logger } from "~/services/logger.server"; import { rowsToCSV } from "~/utils/dataExport"; +import { detectQueryTables } from "~/v3/detectQueryTables"; import { querySchemas } from "~/v3/querySchemas"; const BodySchema = z.object({ @@ -16,14 +17,12 @@ const BodySchema = z.object({ format: z.enum(["json", "csv"]).default("json"), }); -/** Extract table names from a TRQL query for authorization */ -function detectTables(query: string): string[] { - return querySchemas - .filter((s) => { - const escaped = s.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return new RegExp(`\\bFROM\\s+${escaped}\\b`, "i").test(query); - }) - .map((s) => s.name); +const allowedQueryTables = new Set(querySchemas.map((s) => s.name)); + +/** Every table the query reads, for per-table JWT-scope authorization. + * `null` means unparseable — callers deny by default. */ +function detectTables(query: string): string[] | null { + return detectQueryTables(query, allowedQueryTables); } const { action, loader } = createActionApiRoute( @@ -34,12 +33,14 @@ const { action, loader } = createActionApiRoute( findResource: async () => 1, authorization: { action: "read", - // A multi-table query reads from every detected table. Wrap with - // everyResource so a JWT scoped to one table can't pass auth for - // a query that also reads tables it isn't scoped to (would be the - // same OR-loophole the batch trigger route had pre-fix). + // A multi-table query reads from every detected table, so wrap with + // everyResource: a JWT scoped to one table must not pass auth for a + // query that also reads tables it isn't scoped to. resource: (_, __, ___, body) => { const tables = detectTables(body.query); + // Unparseable query → deny. It must not fall through to the + // permissive {type:"query",id:"all"} branch. + if (tables === null) return { type: "query", id: "__unparseable__" }; return tables.length > 0 ? everyResource(tables.map((id) => ({ type: "query", id }))) : { type: "query", id: "all" }; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title.tsx index 9fa57d7fbe8..badfae28218 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title.tsx @@ -8,10 +8,15 @@ import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { requireUserId } from "~/services/session.server"; import { EnvironmentParamSchema } from "~/utils/pathBuilder"; +import { aiTitleRateLimiter } from "~/v3/services/aiTitleRateLimiter.server"; import { AIQueryTitleService } from "~/v3/services/aiQueryTitleService.server"; +// `/resources/*` isn't covered by the global apiRateLimiter (`/api/*` only), +// so this endpoint needs its own per-route limiter and length cap. +const MAX_QUERY_LENGTH = 10_000; + const RequestSchema = z.object({ - query: z.string().min(1, "Query is required"), + query: z.string().min(1, "Query is required").max(MAX_QUERY_LENGTH), queryId: z.string().optional(), }); @@ -19,6 +24,18 @@ export async function action({ request, params }: ActionFunctionArgs) { const userId = await requireUserId(request); const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + const limit = await aiTitleRateLimiter.limit(`user:${userId}`); + if (!limit.success) { + return json( + { + success: false as const, + error: "Too many requests — please wait a moment and try again.", + title: null, + }, + { status: 429 } + ); + } + // Parse the request body const [error, data] = await tryCatch(request.json()); if (error) { diff --git a/apps/webapp/app/v3/detectQueryTables.ts b/apps/webapp/app/v3/detectQueryTables.ts new file mode 100644 index 00000000000..28b8d7caa87 --- /dev/null +++ b/apps/webapp/app/v3/detectQueryTables.ts @@ -0,0 +1,113 @@ +import { + parseTSQLSelect, + SyntaxError as TSQLSyntaxError, + type Field, + type JoinExpr, + type SelectQuery, + type SelectSetQuery, +} from "@internal/tsql"; + +/** + * Extract every known table a TRQL query reads — the FROM table, every JOIN in + * the chain, and any subqueries — for per-table JWT-scope authorization. + * + * `allowedTableNames` is the set of recognised table names (matched + * case-insensitively); anything not in it is ignored. Injected so this stays + * dependency-free (the caller derives it from the query schemas). + * + * Returns `null` when the query can't be parsed; callers MUST treat `null` as + * deny-by-default. + */ +export function detectQueryTables(query: string, allowedTableNames: Set): string[] | null { + let ast: SelectQuery | SelectSetQuery; + try { + ast = parseTSQLSelect(query); + } catch (err) { + if (err instanceof TSQLSyntaxError) return null; + throw err; + } + + const allowed = new Map(Array.from(allowedTableNames, (n) => [n.toLowerCase(), n])); + const seen = new Set(); + const scanned = new WeakSet(); + + function visitSelect(q: SelectQuery): void { + // CTE bodies: `WITH r AS (SELECT ... FROM ) ...` — the table is + // read by the CTE even when the outer query only references the CTE alias. + if (q.ctes) { + for (const cte of Object.values(q.ctes)) { + scanForSubqueries(cte.expr); + } + } + // FROM / JOIN chain (tables + FROM-position subqueries). + if (q.select_from) visitJoin(q.select_from); + // Subqueries anywhere else (WHERE, SELECT list, GROUP BY, ORDER BY, etc.) + // can each embed a SELECT that reads a real table, e.g. + // `WHERE id IN (SELECT … FROM runs)`. + scanForSubqueries(q.select); + scanForSubqueries(q.where); + scanForSubqueries(q.prewhere); + scanForSubqueries(q.having); + scanForSubqueries(q.group_by); + scanForSubqueries(q.array_join_list); + scanForSubqueries(q.order_by); + scanForSubqueries(q.limit); + scanForSubqueries(q.offset); + scanForSubqueries(q.limit_by); + scanForSubqueries(q.window_exprs); + } + // Shape-agnostic walk of an expression subtree: descends every nested + // object/array and hands any embedded SELECT to the query visitors, so a new + // node shape can't silently reintroduce a detection gap. The WeakSet guards + // against back-reference cycles the AST might carry. + function scanForSubqueries(node: unknown): void { + if (node === null || typeof node !== "object") return; + if (scanned.has(node)) return; + scanned.add(node); + if (Array.isArray(node)) { + for (const item of node) scanForSubqueries(item); + return; + } + const expressionType = (node as { expression_type?: string }).expression_type; + if (expressionType === "select_query") { + visitSelect(node as SelectQuery); + return; + } + if (expressionType === "select_set_query") { + visitSelectSet(node as SelectSetQuery); + return; + } + for (const value of Object.values(node)) scanForSubqueries(value); + } + function visitSelectSet(q: SelectSetQuery): void { + visitAny(q.initial_select_query); + for (const node of q.subsequent_select_queries ?? []) { + visitAny(node.select_query); + } + } + function visitAny(q: SelectQuery | SelectSetQuery): void { + if (q.expression_type === "select_query") visitSelect(q); + else visitSelectSet(q); + } + function visitJoin(node: JoinExpr): void { + const tableExpr = node.table; + if (tableExpr) { + if ((tableExpr as Field).expression_type === "field") { + const name = (tableExpr as Field).chain[0]; + const canonicalName = + typeof name === "string" ? allowed.get(name.toLowerCase()) : undefined; + if (canonicalName) seen.add(canonicalName); + } else if ((tableExpr as SelectQuery).expression_type === "select_query") { + visitSelect(tableExpr as SelectQuery); + } else if ((tableExpr as SelectSetQuery).expression_type === "select_set_query") { + visitSelectSet(tableExpr as SelectSetQuery); + } + } + if (node.next_join) visitJoin(node.next_join); + } + + if (ast.expression_type === "select_set_query") visitSelectSet(ast); + else visitSelect(ast); + + return Array.from(seen); +} diff --git a/apps/webapp/app/v3/services/aiTitleRateLimiter.server.ts b/apps/webapp/app/v3/services/aiTitleRateLimiter.server.ts new file mode 100644 index 00000000000..e2e8723453d --- /dev/null +++ b/apps/webapp/app/v3/services/aiTitleRateLimiter.server.ts @@ -0,0 +1,25 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import { type RedisWithClusterOptions } from "~/redis.server"; +import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server"; +import { singleton } from "~/utils/singleton"; + +// The query ai-title endpoint lives under `/resources/*`, which the global +// apiRateLimiter (only `/api/*`) does not cover, so it needs its own per-user +// cap. Exported so the policy is asserted in tests rather than re-encoded. +export const AI_TITLE_RATE_LIMIT_ATTEMPTS = 30; +export const AI_TITLE_RATE_LIMIT_WINDOW = "10 m" as const; + +/** + * Build the ai-title per-user rate limiter. Production uses the env-derived + * rate-limit Redis; tests inject a container Redis. + */ +export function createAITitleRateLimiter(redisOptions?: RedisWithClusterOptions): RateLimiter { + return new RateLimiter({ + ...(redisOptions ? { redisClient: createRedisRateLimitClient(redisOptions) } : {}), + keyPrefix: "query.ai-title", + limiter: Ratelimit.slidingWindow(AI_TITLE_RATE_LIMIT_ATTEMPTS, AI_TITLE_RATE_LIMIT_WINDOW), + logFailure: true, + }); +} + +export const aiTitleRateLimiter = singleton("aiTitleRateLimiter", () => createAITitleRateLimiter()); diff --git a/apps/webapp/app/v3/services/promptOverrideSource.ts b/apps/webapp/app/v3/services/promptOverrideSource.ts new file mode 100644 index 00000000000..44d8213af1b --- /dev/null +++ b/apps/webapp/app/v3/services/promptOverrideSource.ts @@ -0,0 +1,7 @@ +// `source: "code"` is reserved for the deploy path; no other caller may write +// it. Normalize anything that isn't a legitimate caller-supplied value to +// "dashboard". Dependency-free so the rule can be unit-tested directly; it +// backs the service-layer check (the route layer also constrains `source`). +export function normalizePromptOverrideSource(source: string | undefined | null): string { + return source && source !== "code" ? source : "dashboard"; +} diff --git a/apps/webapp/app/v3/services/promptService.server.ts b/apps/webapp/app/v3/services/promptService.server.ts index 56e6b8fa662..6d65f6dd957 100644 --- a/apps/webapp/app/v3/services/promptService.server.ts +++ b/apps/webapp/app/v3/services/promptService.server.ts @@ -1,6 +1,7 @@ import { createHash } from "crypto"; import { prisma } from "~/db.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; +import { normalizePromptOverrideSource } from "./promptOverrideSource"; export class PromptService extends BaseService { async promoteVersion(promptId: string, versionId: string, options?: { sourceGuard?: boolean }) { @@ -62,13 +63,17 @@ export class PromptService extends BaseService { WHERE "promptId" = ${promptId} AND 'override' = ANY("labels") `; + // Defence in depth: reject the reserved `code` source regardless of + // caller, in case a future caller skips the route-layer check. + const safeSource = normalizePromptOverrideSource(data.source); + await tx.promptVersion.create({ data: { promptId, version: nextVersion, textContent: data.textContent, model: data.model || null, - source: data.source || "dashboard", + source: safeSource, commitMessage: data.commitMessage || null, contentHash, labels: ["override"], diff --git a/apps/webapp/test/aiTitleRateLimiter.test.ts b/apps/webapp/test/aiTitleRateLimiter.test.ts new file mode 100644 index 00000000000..402512d859f --- /dev/null +++ b/apps/webapp/test/aiTitleRateLimiter.test.ts @@ -0,0 +1,54 @@ +import { redisTest } from "@internal/testcontainers"; +import { type RedisOptions } from "ioredis"; +import { describe, expect, vi } from "vitest"; +import { type RedisWithClusterOptions } from "../app/redis.server.js"; +import { + AI_TITLE_RATE_LIMIT_ATTEMPTS, + createAITitleRateLimiter, +} from "../app/v3/services/aiTitleRateLimiter.server.js"; + +vi.setConfig({ testTimeout: 60_000 }); + +// Plaintext container: without tlsDisabled the client attempts TLS, the +// connection fails, and @upstash/ratelimit fails open (allowing everything). +const toRedisOptions = (o: RedisOptions): RedisWithClusterOptions => ({ + host: o.host, + port: o.port, + username: o.username, + password: o.password, + tlsDisabled: true, +}); + +let seq = 0; +const userKey = (label: string) => `user:${label}-${seq++}`; + +// The query ai-title endpoint isn't covered by the global apiRateLimiter, so +// this per-user limiter is the only thing bounding it. +describe("aiTitleRateLimiter", () => { + redisTest("allows up to the limit then blocks further attempts", async ({ redisOptions }) => { + const limiter = createAITitleRateLimiter(toRedisOptions(redisOptions)); + const key = userKey("loop"); + + for (let i = 0; i < AI_TITLE_RATE_LIMIT_ATTEMPTS; i++) { + const r = await limiter.limit(key); + expect(r.success).toBe(true); + } + + const blocked = await limiter.limit(key); + expect(blocked.success).toBe(false); + }); + + redisTest("scopes the limit per user", async ({ redisOptions }) => { + const limiter = createAITitleRateLimiter(toRedisOptions(redisOptions)); + const victim = userKey("victim"); + const bystander = userKey("bystander"); + + for (let i = 0; i < AI_TITLE_RATE_LIMIT_ATTEMPTS; i++) { + await limiter.limit(victim); + } + expect((await limiter.limit(victim)).success).toBe(false); + + // A different user is unaffected. + expect((await limiter.limit(bystander)).success).toBe(true); + }); +}); diff --git a/apps/webapp/test/detectQueryTables.test.ts b/apps/webapp/test/detectQueryTables.test.ts new file mode 100644 index 00000000000..5372a398c33 --- /dev/null +++ b/apps/webapp/test/detectQueryTables.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vitest"; +import { detectQueryTables } from "../app/v3/detectQueryTables.js"; + +const allowed = new Set(["runs", "tasks"]); +const sorted = (xs: string[] | null) => (xs === null ? null : [...xs].sort()); + +// detectQueryTables backs per-table JWT-scope authorization for /api/v1/query. +// Key behaviours over the old FROM-only regex: it sees JOINed and subquery +// tables, and returns null for unparseable queries so the caller denies by +// default. +describe("detectQueryTables", () => { + it("detects the FROM table", () => { + expect(sorted(detectQueryTables("SELECT * FROM runs", allowed))).toEqual(["runs"]); + }); + + it("returns the canonical table name when query casing differs", () => { + expect(sorted(detectQueryTables("SELECT * FROM RUNS", allowed))).toEqual(["runs"]); + }); + + it("detects every JOINed table, not just FROM", () => { + expect( + sorted(detectQueryTables("SELECT * FROM runs JOIN tasks ON runs.id = tasks.run_id", allowed)) + ).toEqual(["runs", "tasks"]); + }); + + it("detects tables inside a FROM subquery", () => { + expect(sorted(detectQueryTables("SELECT * FROM (SELECT * FROM runs) AS r", allowed))).toEqual([ + "runs", + ]); + }); + + it("detects a table read only inside a CTE body", () => { + expect( + sorted(detectQueryTables("WITH r AS (SELECT * FROM runs) SELECT * FROM r", allowed)) + ).toEqual(["runs"]); + }); + + it("detects a table read only inside a WHERE subquery", () => { + expect( + sorted( + detectQueryTables("SELECT * FROM tasks WHERE id IN (SELECT run_id FROM runs)", allowed) + ) + ).toEqual(["runs", "tasks"]); + }); + + it("detects a table read only inside a SELECT-list subquery", () => { + expect( + sorted(detectQueryTables("SELECT (SELECT count() FROM runs) AS c FROM tasks", allowed)) + ).toEqual(["runs", "tasks"]); + }); + + it("ignores tables that aren't in the allowed schema set", () => { + expect(detectQueryTables("SELECT * FROM runs", new Set(["tasks"]))).toEqual([]); + }); + + it("returns null for an unparseable query (caller denies by default)", () => { + expect(detectQueryTables("definitely not a valid query !!!", allowed)).toBeNull(); + }); +}); diff --git a/apps/webapp/test/promptOverrideSource.test.ts b/apps/webapp/test/promptOverrideSource.test.ts new file mode 100644 index 00000000000..edbc165de9f --- /dev/null +++ b/apps/webapp/test/promptOverrideSource.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { normalizePromptOverrideSource } from "../app/v3/services/promptOverrideSource.js"; + +// Invariant: an override-creation path must never write the reserved +// `source: "code"`. Anything that isn't a legitimate caller value +// collapses to "dashboard". +describe("normalizePromptOverrideSource", () => { + it("never lets the privileged 'code' value through", () => { + expect(normalizePromptOverrideSource("code")).toBe("dashboard"); + }); + + it("passes through caller-supplied non-code sources", () => { + expect(normalizePromptOverrideSource("api")).toBe("api"); + expect(normalizePromptOverrideSource("dashboard")).toBe("dashboard"); + expect(normalizePromptOverrideSource("sdk")).toBe("sdk"); + }); + + it("defaults missing/empty source to 'dashboard'", () => { + expect(normalizePromptOverrideSource(undefined)).toBe("dashboard"); + expect(normalizePromptOverrideSource(null)).toBe("dashboard"); + expect(normalizePromptOverrideSource("")).toBe("dashboard"); + }); +}); From 7fa8f9518132ac6803368a597d2f18ba2dea9e5d Mon Sep 17 00:00:00 2001 From: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:18:00 +0100 Subject: [PATCH 6/7] fix(webapp): account & access-control hardening (auth, RBAC, org membership, impersonation) (#42) --- .../account-access-control-hardening.md | 6 + apps/webapp/app/models/member.server.ts | 37 ---- .../app/models/removeTeamMember.server.ts | 41 +++++ apps/webapp/app/models/user.server.ts | 4 +- .../routes/_app.@.orgs.$organizationSlug.$.ts | 14 ++ .../route.tsx | 38 +---- .../route.tsx | 26 +-- apps/webapp/app/routes/login.mfa/route.tsx | 2 +- apps/webapp/app/services/googleAuth.server.ts | 9 + .../app/services/googleEmailVerification.ts | 15 ++ .../app/services/mfa/mfaRateLimiter.server.ts | 85 +++++++--- .../mfa/mfaRateLimiterGlobal.server.ts | 37 ++++ .../webapp/app/services/rateLimiter.server.ts | 118 +++---------- .../app/services/rateLimiterCore.server.ts | 100 +++++++++++ apps/webapp/app/utils/email.ts | 5 +- apps/webapp/app/utils/emailPattern.ts | 72 ++++++++ apps/webapp/app/utils/inviteRoleLadder.ts | 33 ++++ apps/webapp/app/utils/sameOriginNavigation.ts | 21 +++ apps/webapp/test/emailPattern.test.ts | 58 +++++++ .../test/googleEmailVerification.test.ts | 33 ++++ apps/webapp/test/inviteRoleLadder.test.ts | 33 ++++ apps/webapp/test/mfaRateLimiter.test.ts | 124 ++++++++++++++ apps/webapp/test/removeTeamMember.test.ts | 158 ++++++++++++++++++ apps/webapp/test/sameOriginNavigation.test.ts | 50 ++++++ 24 files changed, 907 insertions(+), 212 deletions(-) create mode 100644 .server-changes/account-access-control-hardening.md create mode 100644 apps/webapp/app/models/removeTeamMember.server.ts create mode 100644 apps/webapp/app/services/googleEmailVerification.ts create mode 100644 apps/webapp/app/services/mfa/mfaRateLimiterGlobal.server.ts create mode 100644 apps/webapp/app/services/rateLimiterCore.server.ts create mode 100644 apps/webapp/app/utils/emailPattern.ts create mode 100644 apps/webapp/app/utils/inviteRoleLadder.ts create mode 100644 apps/webapp/app/utils/sameOriginNavigation.ts create mode 100644 apps/webapp/test/emailPattern.test.ts create mode 100644 apps/webapp/test/googleEmailVerification.test.ts create mode 100644 apps/webapp/test/inviteRoleLadder.test.ts create mode 100644 apps/webapp/test/mfaRateLimiter.test.ts create mode 100644 apps/webapp/test/removeTeamMember.test.ts create mode 100644 apps/webapp/test/sameOriginNavigation.test.ts diff --git a/.server-changes/account-access-control-hardening.md b/.server-changes/account-access-control-hardening.md new file mode 100644 index 00000000000..e98c04db671 --- /dev/null +++ b/.server-changes/account-access-control-hardening.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Harden account and access-control handling across auth, RBAC, org membership, and impersonation diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index d9906f6eed0..7cd515014b4 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -74,43 +74,6 @@ export async function getTeamMembersAndInvites({ return { members: org.members, invites: org.invites }; } -export async function removeTeamMember({ - userId, - slug, - memberId, -}: { - userId: string; - slug: string; - memberId: string; -}) { - const org = await prisma.organization.findFirst({ - where: { slug, members: { some: { userId } } }, - }); - - if (!org) { - throw new Error("User does not have access to this organization"); - } - - // Scope the target to this org. A member id is a globally unique key, so - // deleting by id alone would remove members of other orgs; bind it to the - // resolved org and reject a foreign id. - const member = await prisma.orgMember.findFirst({ - where: { id: memberId, organizationId: org.id }, - include: { - organization: true, - user: true, - }, - }); - - if (!member) { - throw new Error("Member not found in this organization"); - } - - await prisma.orgMember.delete({ where: { id: member.id } }); - - return member; -} - export async function inviteMembers({ slug, emails, diff --git a/apps/webapp/app/models/removeTeamMember.server.ts b/apps/webapp/app/models/removeTeamMember.server.ts new file mode 100644 index 00000000000..d6ae3cb85aa --- /dev/null +++ b/apps/webapp/app/models/removeTeamMember.server.ts @@ -0,0 +1,41 @@ +import type { PrismaClient } from "@trigger.dev/database"; + +// Leaf module with a type-only Prisma import (caller passes the client) so it +// can be unit-tested without importing `~/db.server`, which eagerly connects +// the global prisma singleton. +export async function removeTeamMember( + { + userId, + slug, + memberId, + }: { + userId: string; + slug: string; + memberId: string; + }, + prismaClient: PrismaClient +) { + const org = await prismaClient.organization.findFirst({ + where: { slug, members: { some: { userId } } }, + }); + + if (!org) { + throw new Error("User does not have access to this organization"); + } + + // Scope both the lookup and the delete to org.id, in a transaction, so the + // member id is only ever resolved within the actor's organization. + return prismaClient.$transaction(async (tx) => { + const target = await tx.orgMember.findFirst({ + where: { id: memberId, organizationId: org.id }, + include: { organization: true, user: true }, + }); + + if (!target) { + throw new Error("Member not found in this organization"); + } + + await tx.orgMember.delete({ where: { id: target.id } }); + return target; + }); +} diff --git a/apps/webapp/app/models/user.server.ts b/apps/webapp/app/models/user.server.ts index 443340d9646..b499d753b45 100644 --- a/apps/webapp/app/models/user.server.ts +++ b/apps/webapp/app/models/user.server.ts @@ -7,6 +7,7 @@ import type { DashboardPreferences } from "~/services/dashboardPreferences.serve import { getDashboardPreferences } from "~/services/dashboardPreferences.server"; export type { User } from "@trigger.dev/database"; import { assertEmailAllowed } from "~/utils/email"; +import { emailMatchesPattern } from "~/utils/emailPattern"; import { logger } from "~/services/logger.server"; type FindOrCreateMagicLink = { @@ -74,8 +75,7 @@ export async function findOrCreateMagicLinkUser({ }, }); - const adminEmailRegex = env.ADMIN_EMAILS ? new RegExp(env.ADMIN_EMAILS) : undefined; - const makeAdmin = adminEmailRegex ? adminEmailRegex.test(email) : false; + const makeAdmin = env.ADMIN_EMAILS ? emailMatchesPattern(env.ADMIN_EMAILS, email) : false; const user = await prisma.user.upsert({ where: { diff --git a/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts b/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts index b430b4a2195..a47deeac6aa 100644 --- a/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts +++ b/apps/webapp/app/routes/_app.@.orgs.$organizationSlug.$.ts @@ -2,8 +2,10 @@ import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "remix-typedjson"; import { $replica } from "~/db.server"; import { clearImpersonation, redirectWithImpersonation } from "~/models/admin.server"; +import { env } from "~/env.server"; import { logger } from "~/services/logger.server"; import { requireUser } from "~/services/session.server"; +import { isSameOriginNavigation } from "~/utils/sameOriginNavigation"; export async function loader({ request, params }: LoaderFunctionArgs) { const user = await requireUser(request); @@ -29,6 +31,18 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return clearImpersonation(request, "/admin"); } + // CSRF gate for the SET-impersonation path. Clearing impersonation + // above is benign and stays reachable without the check. + if (!isSameOriginNavigation(request, env.LOGIN_ORIGIN)) { + logger.warn("Refusing cross-site impersonation entry", { + userId: user.id, + organizationSlug, + referer: request.headers.get("referer"), + secFetchSite: request.headers.get("sec-fetch-site"), + }); + return redirect("/admin"); + } + const org = await $replica.organization.findFirst({ where: { slug: organizationSlug, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx index d895b0b0c87..6a5a0e7a010 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -36,6 +36,7 @@ import { rbac } from "~/services/rbac.server"; import { ssoController } from "~/services/sso.server"; import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder"; +import { isAtOrBelow } from "~/utils/inviteRoleLadder"; import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route"; const Params = z.object({ @@ -109,43 +110,6 @@ export const loader = dashboardLoader( // dropdown is hidden) or as a defensive default. const NO_RBAC_ROLE = "__no_rbac_role__"; -// An inviter can only assign a role at or below their own. The -// plugin's systemRoles array is in canonical order (highest authority -// first), so array index drives the ladder — earlier index = higher -// rank. Plan-tier filtering happens separately via assignableRoleIds; -// the ladder is the absolute hierarchy. Custom roles aren't in the -// ladder yet, so they're refused for now. -type LadderRole = { id: string }; - -function buildRoleLevel(roles: ReadonlyArray): Record { - const level: Record = {}; - roles.forEach((r, i) => { - // Top of the array = highest level. Subtract from length so larger - // numbers always mean "more authority" — no off-by-one when a role - // is added or removed. - level[r.id] = roles.length - i; - }); - return level; -} - -function isAtOrBelow( - roles: ReadonlyArray, - inviterRoleId: string | null, - invitedRoleId: string -): boolean { - // No resolvable role for the inviter → fail closed: we can't confirm a - // target role is at or below an unknown level, so refuse it. The invite - // itself still proceeds (it's gated by manage:members); only assigning an - // explicit role is refused, and the picker offers nothing in this case. - if (!inviterRoleId) return false; - const level = buildRoleLevel(roles); - const inviter = level[inviterRoleId]; - const invited = level[invitedRoleId]; - // Custom roles aren't in the level table — refuse. - if (inviter === undefined || invited === undefined) return false; - return invited <= inviter; -} - const schema = z.object({ emails: z.preprocess((i) => { if (typeof i === "string") return [i]; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index 18664b4f998..c7935544082 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -46,11 +46,11 @@ import * as Property from "~/components/primitives/PropertyTable"; import { Select, SelectItem, SelectLinkItem } from "~/components/primitives/Select"; import { SpinnerWhite } from "~/components/primitives/Spinner"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; -import { $replica } from "~/db.server"; +import { $replica, prisma } from "~/db.server"; import { useShowSelfServe } from "~/hooks/useShowSelfServe"; import { useOrganization } from "~/hooks/useOrganizations"; import { useUser } from "~/hooks/useUser"; -import { removeTeamMember } from "~/models/member.server"; +import { removeTeamMember } from "~/models/removeTeamMember.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { resolveOrgIdFromSlug } from "~/models/organization.server"; import { TeamPresenter } from "~/presenters/TeamPresenter.server"; @@ -262,12 +262,9 @@ export const action = dashboardAction( return json(submission.reply()); } - // Default intent: remove a member or leave the org. Scope the target to - // the actor's organization: an orgMember id is a globally unique key, so an - // unscoped lookup (plus an unscoped delete in the model) would let a - // manager in one org remove members of another by submitting a foreign id. - // Self-leave is always allowed; removing someone else requires - // manage:members. + // Default intent: remove a member or leave the org, with the target scoped + // to the actor's organization. Self-leave is always allowed; removing + // someone else requires manage:members. const orgId = context.organizationId; if (!orgId) { return json({ ok: false, error: "Organization not found" } as const, { status: 404 }); @@ -295,11 +292,14 @@ export const action = dashboardAction( } try { - const deletedMember = await removeTeamMember({ - userId, - memberId: submission.value.memberId, - slug: organizationSlug, - }); + const deletedMember = await removeTeamMember( + { + userId, + memberId: submission.value.memberId, + slug: organizationSlug, + }, + prisma + ); // Sticky removal: record a tombstone so passive SSO-JIT won't re-add // them on next login (best-effort; no-op without the SSO plugin). diff --git a/apps/webapp/app/routes/login.mfa/route.tsx b/apps/webapp/app/routes/login.mfa/route.tsx index fb1612823d1..b31025d54ff 100644 --- a/apps/webapp/app/routes/login.mfa/route.tsx +++ b/apps/webapp/app/routes/login.mfa/route.tsx @@ -25,7 +25,7 @@ import { redirectWithErrorMessage, } from "~/models/message.server"; import { authenticator } from "~/services/auth.server"; -import { checkMfaRateLimit, MfaRateLimitError } from "~/services/mfa/mfaRateLimiter.server"; +import { checkMfaRateLimit, MfaRateLimitError } from "~/services/mfa/mfaRateLimiterGlobal.server"; import { MultiFactorAuthenticationService } from "~/services/mfa/multiFactorAuthentication.server"; import { trackAndClearReferralSource } from "~/services/referralSource.server"; import { commitAuthenticatedSession } from "~/services/sessionDuration.server"; diff --git a/apps/webapp/app/services/googleAuth.server.ts b/apps/webapp/app/services/googleAuth.server.ts index d79c4983418..fe48b2e94d4 100644 --- a/apps/webapp/app/services/googleAuth.server.ts +++ b/apps/webapp/app/services/googleAuth.server.ts @@ -3,6 +3,7 @@ import { GoogleStrategy } from "remix-auth-google"; import { env } from "~/env.server"; import { findOrCreateUser } from "~/models/user.server"; import type { AuthUser } from "./authUser"; +import { isGoogleEmailVerified } from "./googleEmailVerification"; import { logger } from "./logger.server"; import { postAuthentication } from "./postAuth.server"; import { SsoRequiredError, ssoRedirectForEmail } from "./ssoAutoDiscovery.server"; @@ -27,6 +28,14 @@ export function addGoogleStrategy( const email = emails[0].value; + // Only trust the email if Google asserts it's verified, since account + // linking keys off it. See isGoogleEmailVerified. + if (!isGoogleEmailVerified(profile)) { + throw new Error( + "Google login refused: the Google account's email is not verified. Sign in with an account whose email Google has verified, or use magic-link / GitHub." + ); + } + // SSO auto-discovery gate — BEFORE findOrCreateUser, so an // SSO-enforced domain never gets this Google identity linked onto // an existing account. diff --git a/apps/webapp/app/services/googleEmailVerification.ts b/apps/webapp/app/services/googleEmailVerification.ts new file mode 100644 index 00000000000..e1d530a17b7 --- /dev/null +++ b/apps/webapp/app/services/googleEmailVerification.ts @@ -0,0 +1,15 @@ +import type { GoogleProfile } from "remix-auth-google"; + +/** + * Whether Google has asserted that the profile's email is verified. A + * successful OAuth flow proves control of the Google account, not ownership of + * the email it carries, and account linking keys off the email. + * + * Strict by design: only a real boolean `true` counts. A missing claim, missing + * `_json`, the string `"true"`, or a truthy `1` are all treated as unverified. + */ +export function isGoogleEmailVerified(profile: GoogleProfile): boolean { + const emailVerified = (profile as { _json?: { email_verified?: unknown } })?._json + ?.email_verified; + return emailVerified === true; +} diff --git a/apps/webapp/app/services/mfa/mfaRateLimiter.server.ts b/apps/webapp/app/services/mfa/mfaRateLimiter.server.ts index cbf15c157e9..78f5b5daf07 100644 --- a/apps/webapp/app/services/mfa/mfaRateLimiter.server.ts +++ b/apps/webapp/app/services/mfa/mfaRateLimiter.server.ts @@ -1,27 +1,51 @@ import { Ratelimit } from "@upstash/ratelimit"; -import { env } from "~/env.server"; -import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server"; -import { singleton } from "~/utils/singleton"; - -export const mfaRateLimiter = singleton("mfaRateLimiter", initializeMfaRateLimiter); - -function initializeMfaRateLimiter() { - const redisClient = createRedisRateLimitClient({ - port: env.RATE_LIMIT_REDIS_PORT, - host: env.RATE_LIMIT_REDIS_HOST, - username: env.RATE_LIMIT_REDIS_USERNAME, - password: env.RATE_LIMIT_REDIS_PASSWORD, - tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true", - clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1", - }); - - return new RateLimiter({ - redisClient, - keyPrefix: "mfa:validation", - limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 attempts per minute - logSuccess: false, // Don't log successful attempts for privacy - logFailure: true, // Log rate limit violations for security monitoring - }); +import { type RedisWithClusterOptions } from "~/redis.server"; +import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiterCore.server"; + +// MFA rate limiting: two sliding windows in series, both of which must pass. +// A per-minute window covers interactive retries; a cumulative daily window +// caps total attempts per pending-MFA session. +// +// Free of `env.server` so it can be tested directly against a container Redis; +// the env-derived production singletons live in `mfaRateLimiterGlobal.server.ts`. + +// Production policy. Exported so tests assert against the real numbers. +export const MFA_PER_MINUTE_ATTEMPTS = 5; +export const MFA_DAILY_ATTEMPTS = 30; + +export type MfaRateLimiters = { + perMinute: Pick; + daily: Pick; +}; + +/** + * Build the pair of MFA rate limiters. Production passes the env-derived + * Redis connection and the default policy; tests inject a container + * Redis (and may override the attempt caps to isolate one window). + */ +export function createMfaRateLimiters(options: { + redisOptions: RedisWithClusterOptions; + perMinuteAttempts?: number; + dailyAttempts?: number; +}): { perMinute: RateLimiter; daily: RateLimiter } { + const redisClient = createRedisRateLimitClient(options.redisOptions); + + return { + perMinute: new RateLimiter({ + redisClient, + keyPrefix: "mfa:validation", + limiter: Ratelimit.slidingWindow(options.perMinuteAttempts ?? MFA_PER_MINUTE_ATTEMPTS, "1 m"), + logSuccess: false, + logFailure: true, + }), + daily: new RateLimiter({ + redisClient, + keyPrefix: "mfa:validation:daily", + limiter: Ratelimit.slidingWindow(options.dailyAttempts ?? MFA_DAILY_ATTEMPTS, "24 h"), + logSuccess: false, + logFailure: true, + }), + }; } export class MfaRateLimitError extends Error { @@ -34,13 +58,20 @@ export class MfaRateLimitError extends Error { } /** - * Check if the user can attempt MFA validation + * Check whether the user can attempt MFA validation, enforcing both the + * per-minute and the daily cap. The daily cap is checked first. * @param userId - The user ID to rate limit - * @throws {MfaRateLimitError} If rate limit is exceeded + * @param limiters - The limiter pair (production singletons or test-injected) + * @throws {MfaRateLimitError} If either rate limit is exceeded */ -export async function checkMfaRateLimit(userId: string): Promise { - const result = await mfaRateLimiter.limit(userId); +export async function checkMfaRateLimit(userId: string, limiters: MfaRateLimiters): Promise { + const dailyResult = await limiters.daily.limit(userId); + if (!dailyResult.success) { + const retryAfter = new Date(dailyResult.reset).getTime() - Date.now(); + throw new MfaRateLimitError(retryAfter); + } + const result = await limiters.perMinute.limit(userId); if (!result.success) { const retryAfter = new Date(result.reset).getTime() - Date.now(); throw new MfaRateLimitError(retryAfter); diff --git a/apps/webapp/app/services/mfa/mfaRateLimiterGlobal.server.ts b/apps/webapp/app/services/mfa/mfaRateLimiterGlobal.server.ts new file mode 100644 index 00000000000..ff5e47c7864 --- /dev/null +++ b/apps/webapp/app/services/mfa/mfaRateLimiterGlobal.server.ts @@ -0,0 +1,37 @@ +import { env } from "~/env.server"; +import { singleton } from "~/utils/singleton"; +import { + checkMfaRateLimit as checkMfaRateLimitWith, + createMfaRateLimiters, + type MfaRateLimiters, +} from "./mfaRateLimiter.server"; + +// Production singletons, wired to the env-derived rate-limit Redis. +// Kept out of `mfaRateLimiter.server.ts` so that module stays free of +// `env.server` and remains testable in isolation (see that file). +const mfaRateLimiters = singleton("mfaRateLimiters", () => + createMfaRateLimiters({ + redisOptions: { + port: env.RATE_LIMIT_REDIS_PORT, + host: env.RATE_LIMIT_REDIS_HOST, + username: env.RATE_LIMIT_REDIS_USERNAME, + password: env.RATE_LIMIT_REDIS_PASSWORD, + tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true", + clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1", + }, + }) +); + +export const mfaRateLimiter = mfaRateLimiters.perMinute; +export const mfaDailyRateLimiter = mfaRateLimiters.daily; + +/** + * Production entrypoint: rate-limit an MFA validation attempt for `userId` + * against the env-configured limiter pair. Throws `MfaRateLimitError` when + * either the per-minute or the cumulative daily cap is exceeded. + */ +export function checkMfaRateLimit(userId: string, limiters: MfaRateLimiters = mfaRateLimiters) { + return checkMfaRateLimitWith(userId, limiters); +} + +export { MfaRateLimitError } from "./mfaRateLimiter.server"; diff --git a/apps/webapp/app/services/rateLimiter.server.ts b/apps/webapp/app/services/rateLimiter.server.ts index 1c5a8b28feb..499892e4003 100644 --- a/apps/webapp/app/services/rateLimiter.server.ts +++ b/apps/webapp/app/services/rateLimiter.server.ts @@ -1,12 +1,21 @@ -import { Ratelimit } from "@upstash/ratelimit"; -import type { RedisOptions } from "ioredis"; import { env } from "~/env.server"; import type { RedisWithClusterOptions } from "~/redis.server"; -import { createRedisClient } from "~/redis.server"; -import { logger } from "./logger.server"; +import { + RateLimiter as CoreRateLimiter, + type Limiter, + type RateLimiterRedisClient, +} from "./rateLimiterCore.server"; + +export { + createRedisRateLimitClient, + type Duration, + type Limiter, + type RateLimitResponse, + type RateLimiterRedisClient, +} from "./rateLimiterCore.server"; type Options = { - redis?: RedisOptions; + redis?: RedisWithClusterOptions; redisClient?: RateLimiterRedisClient; keyPrefix: string; limiter: Limiter; @@ -14,93 +23,18 @@ type Options = { logFailure?: boolean; }; -export type Limiter = ConstructorParameters[0]["limiter"]; -export type Duration = Parameters[1]; -export type RateLimitResponse = Awaited>; -export type RateLimiterRedisClient = ConstructorParameters[0]["redis"]; - -export class RateLimiter { - #ratelimit: Ratelimit; - - constructor(private readonly options: Options) { - const { redis, redisClient, keyPrefix, limiter } = options; - const prefix = `ratelimit:${keyPrefix}`; - this.#ratelimit = new Ratelimit({ - redis: - redisClient ?? - createRedisRateLimitClient( - redis ?? { - port: env.RATE_LIMIT_REDIS_PORT, - host: env.RATE_LIMIT_REDIS_HOST, - username: env.RATE_LIMIT_REDIS_USERNAME, - password: env.RATE_LIMIT_REDIS_PASSWORD, - tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true", - clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1", - } - ), - limiter, - ephemeralCache: new Map(), - analytics: false, - prefix, +export class RateLimiter extends CoreRateLimiter { + constructor(options: Options) { + super({ + ...options, + redis: options.redis ?? { + port: env.RATE_LIMIT_REDIS_PORT, + host: env.RATE_LIMIT_REDIS_HOST, + username: env.RATE_LIMIT_REDIS_USERNAME, + password: env.RATE_LIMIT_REDIS_PASSWORD, + tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true", + clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1", + }, }); } - - async limit(identifier: string, rate = 1): Promise { - const result = this.#ratelimit.limit(identifier, { rate }); - const { success, limit, reset, remaining } = await result; - - if (success && this.options.logSuccess) { - logger.info(`RateLimiter (${this.options.keyPrefix}): under rate limit`, { - limit, - reset, - remaining, - identifier, - }); - } - - //log these by default - if (!success && this.options.logFailure !== false) { - logger.info(`RateLimiter (${this.options.keyPrefix}): rate limit exceeded`, { - limit, - reset, - remaining, - identifier, - }); - } - - return result; - } -} - -export function createRedisRateLimitClient( - redisOptions: RedisWithClusterOptions -): RateLimiterRedisClient { - const redis = createRedisClient("trigger:rateLimiter", redisOptions); - - return { - sadd: async (key: string, ...members: TData[]): Promise => { - return redis.sadd(key, members as (string | number | Buffer)[]); - }, - hset: ( - key: string, - obj: { - [key: string]: TValue; - } - ): Promise => { - return redis.hset(key, obj); - }, - eval: ( - ...args: [script: string, keys: string[], args: TArgs] - ): Promise => { - const script = args[0]; - const keys = args[1]; - const argsArray = args[2]; - return redis.eval( - script, - keys.length, - ...keys, - ...(argsArray as (string | Buffer | number)[]) - ) as Promise; - }, - }; } diff --git a/apps/webapp/app/services/rateLimiterCore.server.ts b/apps/webapp/app/services/rateLimiterCore.server.ts new file mode 100644 index 00000000000..8e675dee5c7 --- /dev/null +++ b/apps/webapp/app/services/rateLimiterCore.server.ts @@ -0,0 +1,100 @@ +import { Ratelimit } from "@upstash/ratelimit"; +import type { RedisWithClusterOptions } from "~/redis.server"; +import { createRedisClient } from "~/redis.server"; +import { logger } from "./logger.server"; + +type Options = { + redis?: RedisWithClusterOptions; + redisClient?: RateLimiterRedisClient; + keyPrefix: string; + limiter: Limiter; + logSuccess?: boolean; + logFailure?: boolean; +}; + +export type Limiter = ConstructorParameters[0]["limiter"]; +export type Duration = Parameters[1]; +export type RateLimitResponse = Awaited>; +export type RateLimiterRedisClient = ConstructorParameters[0]["redis"]; + +export class RateLimiter { + #ratelimit: Ratelimit; + + constructor(private readonly options: Options) { + const { redis, redisClient, keyPrefix, limiter } = options; + const prefix = `ratelimit:${keyPrefix}`; + const resolvedRedisClient = + redisClient ?? (redis ? createRedisRateLimitClient(redis) : undefined); + + if (!resolvedRedisClient) { + throw new Error("RateLimiter requires either redis or redisClient options"); + } + + this.#ratelimit = new Ratelimit({ + redis: resolvedRedisClient, + limiter, + ephemeralCache: new Map(), + analytics: false, + prefix, + }); + } + + async limit(identifier: string, rate = 1): Promise { + const result = this.#ratelimit.limit(identifier, { rate }); + const { success, limit, reset, remaining } = await result; + + if (success && this.options.logSuccess) { + logger.info(`RateLimiter (${this.options.keyPrefix}): under rate limit`, { + limit, + reset, + remaining, + identifier, + }); + } + + //log these by default + if (!success && this.options.logFailure !== false) { + logger.info(`RateLimiter (${this.options.keyPrefix}): rate limit exceeded`, { + limit, + reset, + remaining, + identifier, + }); + } + + return result; + } +} + +export function createRedisRateLimitClient( + redisOptions: RedisWithClusterOptions +): RateLimiterRedisClient { + const redis = createRedisClient("trigger:rateLimiter", redisOptions); + + return { + sadd: async (key: string, ...members: TData[]): Promise => { + return redis.sadd(key, members as (string | number | Buffer)[]); + }, + hset: ( + key: string, + obj: { + [key: string]: TValue; + } + ): Promise => { + return redis.hset(key, obj); + }, + eval: ( + ...args: [script: string, keys: string[], args: TArgs] + ): Promise => { + const script = args[0]; + const keys = args[1]; + const argsArray = args[2]; + return redis.eval( + script, + keys.length, + ...keys, + ...(argsArray as (string | Buffer | number)[]) + ) as Promise; + }, + }; +} diff --git a/apps/webapp/app/utils/email.ts b/apps/webapp/app/utils/email.ts index de41ae592ae..ae2cf6d2cb0 100644 --- a/apps/webapp/app/utils/email.ts +++ b/apps/webapp/app/utils/email.ts @@ -1,13 +1,12 @@ import { env } from "~/env.server"; +import { emailMatchesPattern } from "./emailPattern"; export function assertEmailAllowed(email: string) { if (!env.WHITELISTED_EMAILS) { return; } - const regexp = new RegExp(env.WHITELISTED_EMAILS); - - if (!regexp.test(email)) { + if (!emailMatchesPattern(env.WHITELISTED_EMAILS, email)) { throw new Error("This email is unauthorized"); } } diff --git a/apps/webapp/app/utils/emailPattern.ts b/apps/webapp/app/utils/emailPattern.ts new file mode 100644 index 00000000000..1cbc47fb9f4 --- /dev/null +++ b/apps/webapp/app/utils/emailPattern.ts @@ -0,0 +1,72 @@ +/** + * Match an email against an operator-supplied pattern (ADMIN_EMAILS / + * WHITELISTED_EMAILS), anchored to the whole address with `^(?:...)$` so the + * pattern matches the entire email rather than a substring. + * + * The non-capturing group keeps top-level alternation working + * (`a@x.com|b@x.com` stays two whole-string alternatives). Patterns that + * already carry their own `^`/`$` anchors remain equivalent. A top-level + * alternative that is just `@domain.tld` is expanded to "any mailbox at exactly + * that domain". + * + * Dependency-free so it can be tested directly; callers pass the pattern from `env`. + */ +export function emailMatchesPattern(pattern: string, email: string): boolean { + return new RegExp(`^(?:${expandDomainShorthand(pattern)})$`).test(email); +} + +function expandDomainShorthand(pattern: string): string { + return splitTopLevelAlternatives(pattern) + .map((alternative) => { + const domain = alternative.match(/^@([A-Za-z0-9.-]+)$/)?.[1]; + return domain ? `[^@]+@${escapeRegExp(domain)}` : alternative; + }) + .join("|"); +} + +function splitTopLevelAlternatives(pattern: string): string[] { + const alternatives: string[] = []; + let current = ""; + let escaped = false; + let depth = 0; + let inCharacterClass = false; + + for (const char of pattern) { + if (escaped) { + current += char; + escaped = false; + continue; + } + + if (char === "\\") { + current += char; + escaped = true; + continue; + } + + if (char === "[" && !inCharacterClass) { + inCharacterClass = true; + } else if (char === "]" && inCharacterClass) { + inCharacterClass = false; + } else if (!inCharacterClass && char === "(") { + depth++; + } else if (!inCharacterClass && char === ")" && depth > 0) { + depth--; + } + + if (char === "|" && depth === 0 && !inCharacterClass) { + alternatives.push(current); + current = ""; + continue; + } + + current += char; + } + + alternatives.push(current); + return alternatives; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/apps/webapp/app/utils/inviteRoleLadder.ts b/apps/webapp/app/utils/inviteRoleLadder.ts new file mode 100644 index 00000000000..e0bd9f7471f --- /dev/null +++ b/apps/webapp/app/utils/inviteRoleLadder.ts @@ -0,0 +1,33 @@ +// An inviter can only assign a role at or below their own. The systemRoles +// array is in canonical order (highest authority first), so array index drives +// the ladder. Custom roles aren't in the table and are refused. Dependency-free +// so the rule can be unit-tested directly. + +export type LadderRole = { id: string }; + +export function buildRoleLevel(roles: ReadonlyArray): Record { + const level: Record = {}; + roles.forEach((r, i) => { + // Top of the array = highest level; larger number means more authority. + level[r.id] = roles.length - i; + }); + return level; +} + +/** + * Whether an inviter holding `inviterRoleId` may assign `invitedRoleId`. + * A roleless inviter (`inviterRoleId == null`) and custom/unknown roles absent + * from the ladder are all refused. + */ +export function isAtOrBelow( + roles: ReadonlyArray, + inviterRoleId: string | null, + invitedRoleId: string +): boolean { + if (!inviterRoleId) return false; + const level = buildRoleLevel(roles); + const inviter = level[inviterRoleId]; + const invited = level[invitedRoleId]; + if (inviter === undefined || invited === undefined) return false; + return invited <= inviter; +} diff --git a/apps/webapp/app/utils/sameOriginNavigation.ts b/apps/webapp/app/utils/sameOriginNavigation.ts new file mode 100644 index 00000000000..e6bf0733582 --- /dev/null +++ b/apps/webapp/app/utils/sameOriginNavigation.ts @@ -0,0 +1,21 @@ +/** + * Whether `request` is an unambiguously same-origin navigation, used to + * CSRF-gate state-changing GET routes. `allowedOrigin` is the dashboard origin + * (caller passes `env.LOGIN_ORIGIN`, kept out so the rule stays testable). + * + * Deny-by-default: prefer `Sec-Fetch-Site: same-origin` when present, otherwise + * require a `Referer` whose origin matches `allowedOrigin`. Anything + * missing/cross-site/unparseable returns `false`. + */ +export function isSameOriginNavigation(request: Request, allowedOrigin: string): boolean { + const fetchSite = request.headers.get("sec-fetch-site"); + if (fetchSite) return fetchSite === "same-origin"; + + const referer = request.headers.get("referer"); + if (!referer) return false; + try { + return new URL(referer).origin === new URL(allowedOrigin).origin; + } catch { + return false; + } +} diff --git a/apps/webapp/test/emailPattern.test.ts b/apps/webapp/test/emailPattern.test.ts new file mode 100644 index 00000000000..117006e39aa --- /dev/null +++ b/apps/webapp/test/emailPattern.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { emailMatchesPattern } from "../app/utils/emailPattern.js"; + +// emailMatchesPattern backs the ADMIN_EMAILS and WHITELISTED_EMAILS gates. +// Property under test: a pattern matches the whole address, never a substring. +describe("emailMatchesPattern", () => { + it("matches an address that equals the operator pattern exactly", () => { + expect(emailMatchesPattern("admin@company.com", "admin@company.com")).toBe(true); + }); + + it("matches a leading-@ domain shorthand against addresses at that domain", () => { + expect(emailMatchesPattern("@company.com", "alice@company.com")).toBe(true); + expect(emailMatchesPattern("@company.com", "bob@company.com")).toBe(true); + }); + + it("rejects a look-alike address that merely contains the pattern", () => { + // A look-alike address embeds the pattern as a substring; an unanchored + // match would wrongly accept it. + expect(emailMatchesPattern("admin@company.com", "evil@admin@company.com.attacker.com")).toBe( + false + ); + expect(emailMatchesPattern("@company.com", "evil@company.com.attacker.com")).toBe(false); + expect(emailMatchesPattern("@company.com", "alice@sub.company.com")).toBe(false); + }); + + it("rejects a trailing-garbage address (pattern is only a prefix)", () => { + expect(emailMatchesPattern("admin@company.com", "admin@company.computer-evil.com")).toBe(false); + }); + + it("rejects a leading-garbage address (pattern is only a suffix)", () => { + expect(emailMatchesPattern("admin@company.com", "not-admin@company.com")).toBe(false); + }); + + it("preserves top-level alternation as whole-string alternatives", () => { + // Guards against anchoring without the non-capturing group, which would + // turn `^a|b$` into anchored-a OR anchored-b and break multi-address configs. + const pattern = "alice@x.com|bob@x.com"; + expect(emailMatchesPattern(pattern, "alice@x.com")).toBe(true); + expect(emailMatchesPattern(pattern, "bob@x.com")).toBe(true); + expect(emailMatchesPattern(pattern, "eve@x.com")).toBe(false); + // ...and alternation must not become a substring match either. + expect(emailMatchesPattern(pattern, "eve+alice@x.com.evil.com")).toBe(false); + }); + + it("expands domain shorthand inside simple top-level alternation", () => { + const pattern = "alice@x.com|@company.com"; + expect(emailMatchesPattern(pattern, "alice@x.com")).toBe(true); + expect(emailMatchesPattern(pattern, "carol@company.com")).toBe(true); + expect(emailMatchesPattern(pattern, "carol@company.com.evil.com")).toBe(false); + }); + + it("accepts patterns that already carry their own anchors", () => { + // Operators who already wrote a fully-anchored pattern keep working: + // ^(?:^...$)$ accepts exactly the same strings. + expect(emailMatchesPattern("^ops@company\\.com$", "ops@company.com")).toBe(true); + expect(emailMatchesPattern("^ops@company\\.com$", "ops@company.com.evil.com")).toBe(false); + }); +}); diff --git a/apps/webapp/test/googleEmailVerification.test.ts b/apps/webapp/test/googleEmailVerification.test.ts new file mode 100644 index 00000000000..16316b1b80f --- /dev/null +++ b/apps/webapp/test/googleEmailVerification.test.ts @@ -0,0 +1,33 @@ +import type { GoogleProfile } from "remix-auth-google"; +import { describe, expect, it } from "vitest"; +import { isGoogleEmailVerified } from "../app/services/googleEmailVerification.js"; + +// Build a minimal Google profile carrying just the email_verified claim the +// guard inspects. The real profile is much larger; only _json.email_verified +// matters here. +const profileWith = (emailVerified: unknown): GoogleProfile => + ({ _json: { email_verified: emailVerified } }) as unknown as GoogleProfile; + +describe("isGoogleEmailVerified", () => { + it("accepts a profile Google marked email_verified === true", () => { + expect(isGoogleEmailVerified(profileWith(true))).toBe(true); + }); + + it("rejects a profile with email_verified === false (the account-linking takeover vector)", () => { + expect(isGoogleEmailVerified(profileWith(false))).toBe(false); + }); + + it("rejects when the email_verified claim is absent", () => { + expect(isGoogleEmailVerified(profileWith(undefined))).toBe(false); + // _json missing entirely + expect(isGoogleEmailVerified({} as unknown as GoogleProfile)).toBe(false); + }); + + it("is strict: truthy non-true values do not count as verified", () => { + // Google asserts a real boolean; a string "true"/"false" or a truthy number + // means the claim wasn't a genuine verification and must not be trusted. + expect(isGoogleEmailVerified(profileWith("true"))).toBe(false); + expect(isGoogleEmailVerified(profileWith("false"))).toBe(false); + expect(isGoogleEmailVerified(profileWith(1))).toBe(false); + }); +}); diff --git a/apps/webapp/test/inviteRoleLadder.test.ts b/apps/webapp/test/inviteRoleLadder.test.ts new file mode 100644 index 00000000000..4c8a01c019d --- /dev/null +++ b/apps/webapp/test/inviteRoleLadder.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { isAtOrBelow } from "../app/utils/inviteRoleLadder.js"; + +// systemRoles in canonical order: highest authority first. +const roles = [{ id: "owner" }, { id: "admin" }, { id: "member" }]; + +// Property under test: an inviter can only assign a role at or below their own, +// and a roleless inviter can assign nothing. +describe("isAtOrBelow", () => { + it("lets an inviter assign a role below their own", () => { + expect(isAtOrBelow(roles, "owner", "admin")).toBe(true); + expect(isAtOrBelow(roles, "admin", "member")).toBe(true); + }); + + it("lets an inviter assign their own level", () => { + expect(isAtOrBelow(roles, "admin", "admin")).toBe(true); + }); + + it("refuses assigning a role above the inviter's", () => { + expect(isAtOrBelow(roles, "admin", "owner")).toBe(false); + expect(isAtOrBelow(roles, "member", "admin")).toBe(false); + }); + + it("refuses a roleless inviter outright — the privilege-escalation vector", () => { + expect(isAtOrBelow(roles, null, "owner")).toBe(false); + expect(isAtOrBelow(roles, null, "member")).toBe(false); + }); + + it("refuses unknown / custom roles not on the ladder", () => { + expect(isAtOrBelow(roles, "owner", "custom-role-id")).toBe(false); + expect(isAtOrBelow(roles, "custom-role-id", "member")).toBe(false); + }); +}); diff --git a/apps/webapp/test/mfaRateLimiter.test.ts b/apps/webapp/test/mfaRateLimiter.test.ts new file mode 100644 index 00000000000..8b177a6ad03 --- /dev/null +++ b/apps/webapp/test/mfaRateLimiter.test.ts @@ -0,0 +1,124 @@ +import { redisTest } from "@internal/testcontainers"; +import { type RedisOptions } from "ioredis"; +import { describe, expect, it, vi } from "vitest"; +import { type RedisWithClusterOptions } from "../app/redis.server.js"; +import { + checkMfaRateLimit, + createMfaRateLimiters, + MFA_DAILY_ATTEMPTS, + MFA_PER_MINUTE_ATTEMPTS, + MfaRateLimitError, +} from "../app/services/mfa/mfaRateLimiter.server.js"; + +// redisTest spins up a container per test; give startup + the cumulative +// 30-attempt loop room. +vi.setConfig({ testTimeout: 60_000 }); + +// The container speaks plaintext; without tlsDisabled the client tries TLS, +// the connection fails, and @upstash/ratelimit fails open (allowing every +// attempt). Map the fixture options onto the shape createMfaRateLimiters +// expects, with TLS off. +const toRedisOptions = (redisOptions: RedisOptions): RedisWithClusterOptions => ({ + host: redisOptions.host, + port: redisOptions.port, + username: redisOptions.username, + password: redisOptions.password, + tlsDisabled: true, +}); + +// A unique user id per test so sliding-window state never bleeds between +// cases (Redis is shared within a container). +let seq = 0; +const userId = (label: string) => `mfa-${label}-${seq++}`; + +describe("checkMfaRateLimit", () => { + redisTest( + "allows up to the per-minute cap then blocks the next attempt", + async ({ redisOptions }) => { + const limiters = createMfaRateLimiters({ redisOptions: toRedisOptions(redisOptions) }); + const id = userId("per-min"); + + // The first MFA_PER_MINUTE_ATTEMPTS (5) succeed. + for (let i = 0; i < MFA_PER_MINUTE_ATTEMPTS; i++) { + await expect(checkMfaRateLimit(id, limiters)).resolves.toBeUndefined(); + } + + // The 6th within the same minute is rejected. + await expect(checkMfaRateLimit(id, limiters)).rejects.toBeInstanceOf(MfaRateLimitError); + } + ); + + redisTest( + "caps cumulative attempts at the daily limit even when the per-minute window would allow them", + async ({ redisOptions }) => { + // Raise the per-minute cap out of the way so this test isolates the + // 24h cumulative window. + const limiters = createMfaRateLimiters({ + redisOptions: toRedisOptions(redisOptions), + perMinuteAttempts: 100_000, + }); + const id = userId("daily"); + + for (let i = 0; i < MFA_DAILY_ATTEMPTS; i++) { + await expect(checkMfaRateLimit(id, limiters)).resolves.toBeUndefined(); + } + + await expect(checkMfaRateLimit(id, limiters)).rejects.toBeInstanceOf(MfaRateLimitError); + } + ); + + redisTest("rate limits are scoped per user id", async ({ redisOptions }) => { + const limiters = createMfaRateLimiters({ redisOptions: toRedisOptions(redisOptions) }); + const victim = userId("victim"); + const bystander = userId("bystander"); + + // Exhaust the per-minute window for the victim. + for (let i = 0; i < MFA_PER_MINUTE_ATTEMPTS; i++) { + await checkMfaRateLimit(victim, limiters); + } + await expect(checkMfaRateLimit(victim, limiters)).rejects.toBeInstanceOf(MfaRateLimitError); + + // A different user is unaffected. + await expect(checkMfaRateLimit(bystander, limiters)).resolves.toBeUndefined(); + }); + + redisTest("the thrown error carries a positive retry-after", async ({ redisOptions }) => { + const limiters = createMfaRateLimiters({ redisOptions: toRedisOptions(redisOptions) }); + const id = userId("retry-after"); + + for (let i = 0; i < MFA_PER_MINUTE_ATTEMPTS; i++) { + await checkMfaRateLimit(id, limiters); + } + + const error = await checkMfaRateLimit(id, limiters).catch((e) => e); + expect(error).toBeInstanceOf(MfaRateLimitError); + expect((error as MfaRateLimitError).retryAfter).toBeGreaterThan(0); + }); + + redisTest( + "the daily cap is checked before the per-minute cap, so an exhausted day blocks the very first attempt of a fresh minute", + async ({ redisOptions }) => { + // perMinute high enough that only the daily window can trip. + const limiters = createMfaRateLimiters({ + redisOptions: toRedisOptions(redisOptions), + perMinuteAttempts: 100_000, + dailyAttempts: 3, + }); + const id = userId("daily-first"); + + await checkMfaRateLimit(id, limiters); + await checkMfaRateLimit(id, limiters); + await checkMfaRateLimit(id, limiters); + + // Daily budget (3) is spent; the next attempt is rejected even though + // the per-minute window is nowhere near full. + await expect(checkMfaRateLimit(id, limiters)).rejects.toBeInstanceOf(MfaRateLimitError); + } + ); + + it("pins the production policy to the documented values", () => { + // Guards against a silent loosening of the caps in future edits. + expect(MFA_PER_MINUTE_ATTEMPTS).toBe(5); + expect(MFA_DAILY_ATTEMPTS).toBe(30); + }); +}); diff --git a/apps/webapp/test/removeTeamMember.test.ts b/apps/webapp/test/removeTeamMember.test.ts new file mode 100644 index 00000000000..4cbaadfcbcc --- /dev/null +++ b/apps/webapp/test/removeTeamMember.test.ts @@ -0,0 +1,158 @@ +import { containerTest } from "@internal/testcontainers"; +import type { PrismaClient } from "@trigger.dev/database"; +import { describe, expect, vi } from "vitest"; +import { removeTeamMember } from "~/models/removeTeamMember.server"; + +vi.setConfig({ testTimeout: 60_000 }); + +async function seedOrgWithMembers(prisma: PrismaClient, slugBase: string) { + const slug = `${slugBase}_${Math.random().toString(36).slice(2, 10)}`; + const admin = await prisma.user.create({ + data: { email: `admin_${slug}@example.com`, authenticationMethod: "MAGIC_LINK" }, + }); + const member = await prisma.user.create({ + data: { email: `member_${slug}@example.com`, authenticationMethod: "MAGIC_LINK" }, + }); + + const organization = await prisma.organization.create({ + data: { + title: slug, + slug, + members: { + createMany: { + data: [ + { userId: admin.id, role: "ADMIN" }, + { userId: member.id, role: "MEMBER" }, + ], + }, + }, + }, + include: { members: true }, + }); + + const adminMember = organization.members.find((m) => m.userId === admin.id)!; + const regularMember = organization.members.find((m) => m.userId === member.id)!; + return { organization, admin, member, adminMember, regularMember }; +} + +describe("removeTeamMember", () => { + containerTest( + "refuses to delete an OrgMember that belongs to a different org", + async ({ prisma }) => { + const a = await seedOrgWithMembers(prisma, "orga"); + const b = await seedOrgWithMembers(prisma, "orgb"); + + await expect( + removeTeamMember( + { + userId: a.admin.id, + slug: a.organization.slug, + memberId: b.regularMember.id, + }, + prisma + ) + ).rejects.toThrow(); + + const stillThere = await prisma.orgMember.findUnique({ + where: { id: b.regularMember.id }, + }); + expect(stillThere).not.toBeNull(); + } + ); + + containerTest("removes a member that belongs to the actor's org", async ({ prisma }) => { + const a = await seedOrgWithMembers(prisma, "orga"); + + const result = await removeTeamMember( + { + userId: a.admin.id, + slug: a.organization.slug, + memberId: a.regularMember.id, + }, + prisma + ); + expect(result.id).toBe(a.regularMember.id); + + const gone = await prisma.orgMember.findUnique({ + where: { id: a.regularMember.id }, + }); + expect(gone).toBeNull(); + }); + + containerTest("allows the actor to leave their own org (self-leave)", async ({ prisma }) => { + const a = await seedOrgWithMembers(prisma, "orga"); + + const result = await removeTeamMember( + { + userId: a.member.id, + slug: a.organization.slug, + memberId: a.regularMember.id, + }, + prisma + ); + expect(result.userId).toBe(a.member.id); + + const gone = await prisma.orgMember.findUnique({ + where: { id: a.regularMember.id }, + }); + expect(gone).toBeNull(); + }); + + containerTest( + "throws the in-org not-found error for an unknown memberId (locks the error message the route renders)", + async ({ prisma }) => { + const a = await seedOrgWithMembers(prisma, "orga"); + + await expect( + removeTeamMember( + { + userId: a.admin.id, + slug: a.organization.slug, + memberId: "doesnotexist", + }, + prisma + ) + ).rejects.toThrow("Member not found in this organization"); + } + ); + + containerTest("throws when the actor is not a member of the slug org", async ({ prisma }) => { + const a = await seedOrgWithMembers(prisma, "orga"); + const b = await seedOrgWithMembers(prisma, "orgb"); + + await expect( + removeTeamMember( + { + userId: a.admin.id, + slug: b.organization.slug, + memberId: b.regularMember.id, + }, + prisma + ) + ).rejects.toThrow("User does not have access to this organization"); + + const stillThere = await prisma.orgMember.findUnique({ + where: { id: b.regularMember.id }, + }); + expect(stillThere).not.toBeNull(); + }); + + containerTest( + "uses an exact-message error for cross-tenant attempts (locks contract)", + async ({ prisma }) => { + const a = await seedOrgWithMembers(prisma, "orga"); + const b = await seedOrgWithMembers(prisma, "orgb"); + + await expect( + removeTeamMember( + { + userId: a.admin.id, + slug: a.organization.slug, + memberId: b.regularMember.id, + }, + prisma + ) + ).rejects.toThrow("Member not found in this organization"); + } + ); +}); diff --git a/apps/webapp/test/sameOriginNavigation.test.ts b/apps/webapp/test/sameOriginNavigation.test.ts new file mode 100644 index 00000000000..0be50afe05b --- /dev/null +++ b/apps/webapp/test/sameOriginNavigation.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { isSameOriginNavigation } from "../app/utils/sameOriginNavigation.js"; + +const ORIGIN = "https://app.trigger.dev"; +const req = (headers: Record) => + new Request("https://app.trigger.dev/@/orgs/victim/anything", { headers }); + +// Property under test: only an unambiguously same-origin navigation is +// accepted; anything cross-site is refused. +describe("isSameOriginNavigation", () => { + it("accepts Sec-Fetch-Site: same-origin", () => { + expect(isSameOriginNavigation(req({ "sec-fetch-site": "same-origin" }), ORIGIN)).toBe(true); + }); + + it("rejects cross-site / same-site / none Sec-Fetch-Site (the phishing vector)", () => { + for (const v of ["cross-site", "same-site", "none"]) { + expect(isSameOriginNavigation(req({ "sec-fetch-site": v }), ORIGIN)).toBe(false); + } + }); + + it("falls back to a Referer matching the dashboard origin", () => { + expect(isSameOriginNavigation(req({ referer: "https://app.trigger.dev/runs" }), ORIGIN)).toBe( + true + ); + }); + + it("rejects a Referer from a different origin", () => { + expect(isSameOriginNavigation(req({ referer: "https://evil.example.com/x" }), ORIGIN)).toBe( + false + ); + }); + + it("denies by default when neither Sec-Fetch-Site nor Referer is present", () => { + expect(isSameOriginNavigation(req({}), ORIGIN)).toBe(false); + }); + + it("rejects an unparseable Referer", () => { + expect(isSameOriginNavigation(req({ referer: "not a url" }), ORIGIN)).toBe(false); + }); + + it("prefers Sec-Fetch-Site over Referer when both are present", () => { + // A same-origin Referer must not rescue a cross-site Sec-Fetch-Site. + expect( + isSameOriginNavigation( + req({ "sec-fetch-site": "cross-site", referer: "https://app.trigger.dev/x" }), + ORIGIN + ) + ).toBe(false); + }); +}); From 49c951e56606eb2756e8b02064b0b4e63a21645a Mon Sep 17 00:00:00 2001 From: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:41:39 +0100 Subject: [PATCH 7/7] fix(webapp): run/batch/trigger tenant scoping & batch-resume authorization (#43) --- .../scope-run-batch-trigger-to-tenant.md | 6 + ...urces.batches.$batchId.check-completion.ts | 28 +- .../concerns/idempotencyKeys.server.ts | 18 +- .../app/v3/services/batchRunAccess.server.ts | 50 +++ .../app/v3/services/batchTriggerV3.server.ts | 4 +- .../app/v3/services/dependentAttemptScope.ts | 17 + .../app/v3/services/triggerTaskV1.server.ts | 9 +- .../app/v3/services/triggerV1Scoping.ts | 20 ++ .../v3/services/worker/workerGroupAccess.ts | 19 + .../worker/workerGroupService.server.ts | 8 + apps/webapp/test/batchRunAccess.test.ts | 56 +++ .../webapp/test/dependentAttemptScope.test.ts | 22 ++ .../engine/idempotencyParentRunScope.test.ts | 329 ++++++++++++++++++ apps/webapp/test/triggerV1Scoping.test.ts | 22 ++ apps/webapp/test/workerGroupAccess.test.ts | 22 ++ 15 files changed, 620 insertions(+), 10 deletions(-) create mode 100644 .server-changes/scope-run-batch-trigger-to-tenant.md create mode 100644 apps/webapp/app/v3/services/batchRunAccess.server.ts create mode 100644 apps/webapp/app/v3/services/dependentAttemptScope.ts create mode 100644 apps/webapp/app/v3/services/triggerV1Scoping.ts create mode 100644 apps/webapp/app/v3/services/worker/workerGroupAccess.ts create mode 100644 apps/webapp/test/batchRunAccess.test.ts create mode 100644 apps/webapp/test/dependentAttemptScope.test.ts create mode 100644 apps/webapp/test/engine/idempotencyParentRunScope.test.ts create mode 100644 apps/webapp/test/triggerV1Scoping.test.ts create mode 100644 apps/webapp/test/workerGroupAccess.test.ts diff --git a/.server-changes/scope-run-batch-trigger-to-tenant.md b/.server-changes/scope-run-batch-trigger-to-tenant.md new file mode 100644 index 00000000000..713b0555e99 --- /dev/null +++ b/.server-changes/scope-run-batch-trigger-to-tenant.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Scope run, batch, and trigger lookups to the caller's tenant diff --git a/apps/webapp/app/routes/resources.batches.$batchId.check-completion.ts b/apps/webapp/app/routes/resources.batches.$batchId.check-completion.ts index 150af0237a3..74b274ef436 100644 --- a/apps/webapp/app/routes/resources.batches.$batchId.check-completion.ts +++ b/apps/webapp/app/routes/resources.batches.$batchId.check-completion.ts @@ -3,8 +3,13 @@ import type { ActionFunction } from "@remix-run/node"; import { json } from "@remix-run/node"; import { assertExhaustive } from "@trigger.dev/core/utils"; import { z } from "zod"; +import { prisma } from "~/db.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; +import { sanitizeRedirectPath } from "~/utils"; +import { runStore } from "~/v3/runStore.server"; +import { findBatchRunIdForUser } from "~/v3/services/batchRunAccess.server"; import { ResumeBatchRunService } from "~/v3/services/resumeBatchRun.server"; export const checkCompletionSchema = z.object({ @@ -16,6 +21,8 @@ const ParamSchema = z.object({ }); export const action: ActionFunction = async ({ request, params }) => { + // Require a logged-in user; org membership is checked below before resuming. + const userId = await requireUserId(request); const { batchId } = ParamSchema.parse(params); const formData = await request.formData(); @@ -25,9 +32,22 @@ export const action: ActionFunction = async ({ request, params }) => { return json(submission.reply()); } + // Keep the post-action redirect same-origin. + const safeRedirectUrl = sanitizeRedirectPath(submission.value.redirectUrl); + + // Only act on a batch in an org the caller belongs to. Accepts either the + // friendlyId or the internal id; both forms stay org-scoped. + const ownedBatchRunId = await findBatchRunIdForUser(prisma, runStore, batchId, userId); + + if (!ownedBatchRunId) { + return redirectWithErrorMessage(safeRedirectUrl, request, "Batch not found"); + } + try { const resumeBatchRunService = new ResumeBatchRunService(); - const resumeResult = await resumeBatchRunService.call(batchId); + // Resume by the resolved internal id: the service looks up strictly by + // `{ id }`, so passing a friendlyId param would resolve to nothing. + const resumeResult = await resumeBatchRunService.call(ownedBatchRunId); let message: string | undefined; @@ -52,7 +72,7 @@ export const action: ActionFunction = async ({ request, params }) => { } } - return redirectWithSuccessMessage(submission.value.redirectUrl, request, message); + return redirectWithSuccessMessage(safeRedirectUrl, request, message); } catch (error) { if (error instanceof Error) { logger.error("Failed to check batch completion", { @@ -62,10 +82,10 @@ export const action: ActionFunction = async ({ request, params }) => { stack: error.stack, }, }); - return redirectWithErrorMessage(submission.value.redirectUrl, request, error.message); + return redirectWithErrorMessage(safeRedirectUrl, request, error.message); } else { logger.error("Failed to check batch completion", { error }); - return redirectWithErrorMessage(submission.value.redirectUrl, request, "Unknown error"); + return redirectWithErrorMessage(safeRedirectUrl, request, "Unknown error"); } } }; diff --git a/apps/webapp/app/runEngine/concerns/idempotencyKeys.server.ts b/apps/webapp/app/runEngine/concerns/idempotencyKeys.server.ts index c856f67af08..ea50261a5a1 100644 --- a/apps/webapp/app/runEngine/concerns/idempotencyKeys.server.ts +++ b/apps/webapp/app/runEngine/concerns/idempotencyKeys.server.ts @@ -248,6 +248,22 @@ export class IdempotencyKeyConcern { //We're using `andWait` so we need to block the parent run with a waitpoint if (resumeParentOnCompletion && parentRunId) { + // `parentRunId` comes from the request body and isn't re-validated + // here, so confirm the parent run is in the caller's environment + // before wiring a waitpoint against it. + const parentRunInternalId = RunId.fromFriendlyId(parentRunId); + const parentRunInCallerEnv = await runStore.findRun( + { + id: parentRunInternalId, + runtimeEnvironmentId: request.environment.id, + }, + { select: { id: true } }, + this.prisma + ); + if (!parentRunInCallerEnv) { + throw new ServiceValidationError("Parent run not found in the calling environment", 404); + } + // Get or create waitpoint lazily (existing run may not have one if it was standalone) let associatedWaitpoint = existingRun.associatedWaitpoint; if (!associatedWaitpoint) { @@ -276,7 +292,7 @@ export class IdempotencyKeyConcern { : event.spanId; await this.engine.blockRunWithWaitpoint({ - runId: RunId.fromFriendlyId(parentRunId), + runId: parentRunInternalId, waitpoints: associatedWaitpoint!.id, spanIdToComplete: spanId, batch: request.options?.batchId diff --git a/apps/webapp/app/v3/services/batchRunAccess.server.ts b/apps/webapp/app/v3/services/batchRunAccess.server.ts new file mode 100644 index 00000000000..36e0b75ace8 --- /dev/null +++ b/apps/webapp/app/v3/services/batchRunAccess.server.ts @@ -0,0 +1,50 @@ +import type { RunStore } from "@internal/run-store"; +import { BatchId } from "@trigger.dev/core/v3/isomorphic"; +import type { PrismaClientOrTransaction } from "@trigger.dev/database"; + +/** + * Resolve the BatchTaskRun id for `batchId` (accepting either the friendlyId or + * the internal id) only if `userId` is a member of the batch's owning + * organization. Returns null otherwise. Batch lookup goes through runStore so + * batches resident in either run-store database are visible. + */ +export async function findBatchRunIdForUser( + prisma: PrismaClientOrTransaction, + store: RunStore, + batchId: string, + userId: string +): Promise { + const batchRunId = toBatchRunId(batchId); + if (!batchRunId) return null; + + const batchRun = await store.findBatchTaskRunById(batchRunId); + if (!batchRun) return null; + + return (await userCanAccessEnvironment(prisma, batchRun.runtimeEnvironmentId, userId)) + ? batchRun.id + : null; +} + +function toBatchRunId(batchId: string): string | null { + try { + return BatchId.toId(batchId); + } catch { + return null; + } +} + +async function userCanAccessEnvironment( + prisma: PrismaClientOrTransaction, + runtimeEnvironmentId: string, + userId: string +): Promise { + const environment = await prisma.runtimeEnvironment.findFirst({ + where: { + id: runtimeEnvironmentId, + organization: { members: { some: { userId } } }, + }, + select: { id: true }, + }); + + return !!environment; +} diff --git a/apps/webapp/app/v3/services/batchTriggerV3.server.ts b/apps/webapp/app/v3/services/batchTriggerV3.server.ts index 299508e32ce..66bcca84d30 100644 --- a/apps/webapp/app/v3/services/batchTriggerV3.server.ts +++ b/apps/webapp/app/v3/services/batchTriggerV3.server.ts @@ -20,6 +20,7 @@ import { env } from "~/env.server"; import { findEnvironmentById } from "~/models/runtimeEnvironment.server"; import { batchTaskRunItemStatusForRunStatus } from "~/models/taskRun.server"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { dependentAttemptWhere } from "./dependentAttemptScope"; import { logger } from "~/services/logger.server"; import { getEntitlement } from "~/services/platform.v3.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; @@ -193,7 +194,8 @@ export class BatchTriggerV3Service extends BaseService { const dependentAttempt = body?.dependentAttempt ? await this._prisma.taskRunAttempt.findFirst({ - where: { friendlyId: body.dependentAttempt }, + // Scope to the caller's environment (see dependentAttemptWhere). + where: dependentAttemptWhere(body.dependentAttempt, environment.id), include: { taskRun: { select: { diff --git a/apps/webapp/app/v3/services/dependentAttemptScope.ts b/apps/webapp/app/v3/services/dependentAttemptScope.ts new file mode 100644 index 00000000000..9b5db2489b5 --- /dev/null +++ b/apps/webapp/app/v3/services/dependentAttemptScope.ts @@ -0,0 +1,17 @@ +import type { Prisma } from "@trigger.dev/database"; + +/** + * Where-clause for resolving a dependent/parent TaskRunAttempt by friendlyId, + * scoped to the caller's environment via the related run. The env scope keeps a + * foreign friendlyId from resolving onto the new batch. Standalone builder so + * the scope can be asserted directly in tests. + */ +export function dependentAttemptWhere( + friendlyId: string, + environmentId: string +): Prisma.TaskRunAttemptWhereInput { + return { + friendlyId, + taskRun: { runtimeEnvironmentId: environmentId }, + }; +} diff --git a/apps/webapp/app/v3/services/triggerTaskV1.server.ts b/apps/webapp/app/v3/services/triggerTaskV1.server.ts index 927ddeee6f9..e41e247bd05 100644 --- a/apps/webapp/app/v3/services/triggerTaskV1.server.ts +++ b/apps/webapp/app/v3/services/triggerTaskV1.server.ts @@ -31,6 +31,7 @@ import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus"; import { startActiveSpan } from "../tracer.server"; import { clampMaxDuration } from "../utils/maxDuration"; import { BaseService, ServiceValidationError } from "./baseService.server"; +import { attemptInEnvironmentWhere, batchRunInEnvironmentWhere } from "./triggerV1Scoping"; import { EnqueueDelayedRunService } from "./enqueueDelayedRun.server"; import { enqueueRun } from "./enqueueRun.server"; import { ExpireEnqueuedRunService } from "./expireEnqueuedRun.server"; @@ -165,7 +166,7 @@ export class TriggerTaskServiceV1 extends BaseService { const dependentAttempt = body.options?.dependentAttempt ? await this._prisma.taskRunAttempt.findFirst({ - where: { friendlyId: body.options.dependentAttempt }, + where: attemptInEnvironmentWhere(body.options.dependentAttempt, environment.id), include: { taskRun: { select: { @@ -205,7 +206,7 @@ export class TriggerTaskServiceV1 extends BaseService { const parentAttempt = body.options?.parentAttempt ? await this._prisma.taskRunAttempt.findFirst({ - where: { friendlyId: body.options.parentAttempt }, + where: attemptInEnvironmentWhere(body.options.parentAttempt, environment.id), include: { taskRun: { select: { @@ -223,7 +224,7 @@ export class TriggerTaskServiceV1 extends BaseService { const dependentBatchRun = body.options?.dependentBatch ? await this._prisma.batchTaskRun.findFirst({ - where: { friendlyId: body.options.dependentBatch }, + where: batchRunInEnvironmentWhere(body.options.dependentBatch, environment.id), include: { dependentTaskAttempt: { include: { @@ -270,7 +271,7 @@ export class TriggerTaskServiceV1 extends BaseService { const parentBatchRun = body.options?.parentBatch ? await this._prisma.batchTaskRun.findFirst({ - where: { friendlyId: body.options.parentBatch }, + where: batchRunInEnvironmentWhere(body.options.parentBatch, environment.id), include: { dependentTaskAttempt: { include: { diff --git a/apps/webapp/app/v3/services/triggerV1Scoping.ts b/apps/webapp/app/v3/services/triggerV1Scoping.ts new file mode 100644 index 00000000000..bd9abc89bca --- /dev/null +++ b/apps/webapp/app/v3/services/triggerV1Scoping.ts @@ -0,0 +1,20 @@ +import type { Prisma } from "@trigger.dev/database"; + +// Where-clauses for resolving caller-supplied parent/dependent attempt & batch +// friendlyIds in the V1 trigger path, scoped to the caller's environment so a +// foreign friendlyId can't be wired onto the new run/batch. Standalone builders +// so the scope can be asserted directly in tests. + +export function attemptInEnvironmentWhere( + friendlyId: string, + environmentId: string +): Prisma.TaskRunAttemptWhereInput { + return { friendlyId, taskRun: { runtimeEnvironmentId: environmentId } }; +} + +export function batchRunInEnvironmentWhere( + friendlyId: string, + environmentId: string +): Prisma.BatchTaskRunWhereInput { + return { friendlyId, runtimeEnvironmentId: environmentId }; +} diff --git a/apps/webapp/app/v3/services/worker/workerGroupAccess.ts b/apps/webapp/app/v3/services/worker/workerGroupAccess.ts new file mode 100644 index 00000000000..ca59a4b9045 --- /dev/null +++ b/apps/webapp/app/v3/services/worker/workerGroupAccess.ts @@ -0,0 +1,19 @@ +import { WorkerInstanceGroupType } from "@trigger.dev/database"; + +/** + * Whether a worker group may be used by the calling project. + * + * MANAGED groups are shared across projects. UNMANAGED groups are per-project + * (masterQueue is `${projectId}-${name}`), so a project may only use an + * UNMANAGED group whose `projectId` matches it. Dependency-free so it can be + * unit-tested directly. + */ +export function isWorkerGroupAllowedForProject( + workerGroup: { type: WorkerInstanceGroupType; projectId: string | null }, + projectId: string +): boolean { + if (workerGroup.type === WorkerInstanceGroupType.UNMANAGED) { + return workerGroup.projectId === projectId; + } + return true; +} diff --git a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts index 32c3e3bb1b7..6b78013c88c 100644 --- a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts +++ b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts @@ -1,6 +1,7 @@ import type { WorkerInstanceGroup, WorkloadType } from "@trigger.dev/database"; import { WorkerInstanceGroupType } from "@trigger.dev/database"; import { WithRunEngine } from "../baseService.server"; +import { isWorkerGroupAllowedForProject } from "./workerGroupAccess"; import { WorkerGroupTokenService } from "./workerGroupTokenService.server"; import { logger } from "~/services/logger.server"; import { FEATURE_FLAG } from "~/v3/featureFlags"; @@ -252,6 +253,13 @@ export class WorkerGroupService extends WithRunEngine { throw new Error(`The region you specified doesn't exist ("${regionOverride}").`); } + // The masterQueue-only lookup above can resolve another project's + // UNMANAGED group, so reject groups not usable by this project + // (see isWorkerGroupAllowedForProject). + if (!isWorkerGroupAllowedForProject(workerGroup, project.id)) { + throw new Error(`The region you specified isn't available to you ("${regionOverride}").`); + } + // If they're restricted, check they have access if (project.allowedWorkerQueues.length > 0) { if (project.allowedWorkerQueues.includes(workerGroup.masterQueue)) { diff --git a/apps/webapp/test/batchRunAccess.test.ts b/apps/webapp/test/batchRunAccess.test.ts new file mode 100644 index 00000000000..5fc4c6cf889 --- /dev/null +++ b/apps/webapp/test/batchRunAccess.test.ts @@ -0,0 +1,56 @@ +import { setupAuthenticatedEnvironment } from "@internal/run-engine/tests"; +import { PostgresRunStore } from "@internal/run-store"; +import { containerTest } from "@internal/testcontainers"; +import { BatchId } from "@trigger.dev/core/v3/isomorphic"; +import { describe, expect, vi } from "vitest"; +import { findBatchRunIdForUser } from "~/v3/services/batchRunAccess.server"; + +vi.setConfig({ testTimeout: 60_000 }); + +const rand = () => Math.random().toString(36).slice(2, 10); + +// The batch-resume route was previously unauthenticated. This is the org-scoped +// ownership gate: a user may only resolve a batch in an org they belong to. +describe("findBatchRunIdForUser", () => { + containerTest( + "resolves a batch for an org member, by friendlyId and by internal id", + async ({ prisma }) => { + const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma }); + const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const member = await prisma.user.create({ + data: { email: `member_${rand()}@example.com`, authenticationMethod: "MAGIC_LINK" }, + }); + await prisma.orgMember.create({ + data: { organizationId: env.organizationId, userId: member.id }, + }); + const batchId = BatchId.generate(); + const batch = await prisma.batchTaskRun.create({ + data: { id: batchId.id, friendlyId: batchId.friendlyId, runtimeEnvironmentId: env.id }, + }); + + expect(await findBatchRunIdForUser(prisma, store, batch.friendlyId, member.id)).toBe( + batch.id + ); + expect(await findBatchRunIdForUser(prisma, store, batch.id, member.id)).toBe(batch.id); + } + ); + + containerTest( + "returns null for a user who is not a member of the batch's org", + async ({ prisma }) => { + const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma }); + const env = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const batchId = BatchId.generate(); + const batch = await prisma.batchTaskRun.create({ + data: { id: batchId.id, friendlyId: batchId.friendlyId, runtimeEnvironmentId: env.id }, + }); + // A user who exists but isn't a member of the org. + const stranger = await prisma.user.create({ + data: { email: `stranger_${rand()}@example.com`, authenticationMethod: "MAGIC_LINK" }, + }); + + expect(await findBatchRunIdForUser(prisma, store, batch.friendlyId, stranger.id)).toBeNull(); + expect(await findBatchRunIdForUser(prisma, store, batch.id, stranger.id)).toBeNull(); + } + ); +}); diff --git a/apps/webapp/test/dependentAttemptScope.test.ts b/apps/webapp/test/dependentAttemptScope.test.ts new file mode 100644 index 00000000000..a563df56200 --- /dev/null +++ b/apps/webapp/test/dependentAttemptScope.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { dependentAttemptWhere } from "../app/v3/services/dependentAttemptScope.js"; + +// The dependent-attempt lookup must be scoped to the caller's environment via +// the related run — a where clause missing that constraint lets a foreign +// attempt friendlyId resolve (the cross-tenant bug). This pins the scope on the +// query itself. +describe("dependentAttemptWhere", () => { + it("scopes the attempt lookup to the environment via the related run", () => { + const where = dependentAttemptWhere("attempt_abc", "env_caller"); + expect(where.friendlyId).toBe("attempt_abc"); + expect(where.taskRun).toEqual({ runtimeEnvironmentId: "env_caller" }); + }); + + it("threads the exact environment id through (no cross-env match)", () => { + const where = dependentAttemptWhere("attempt_abc", "env_A"); + // The env constraint must reference the caller's env, not be absent/empty. + expect((where.taskRun as { runtimeEnvironmentId?: string })?.runtimeEnvironmentId).toBe( + "env_A" + ); + }); +}); diff --git a/apps/webapp/test/engine/idempotencyParentRunScope.test.ts b/apps/webapp/test/engine/idempotencyParentRunScope.test.ts new file mode 100644 index 00000000000..08cc772840e --- /dev/null +++ b/apps/webapp/test/engine/idempotencyParentRunScope.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, vi } from "vitest"; + +// Mock the db prisma singleton so the real testcontainer prisma is used +// instead of the webapp's env-bound client (mirrors triggerTask.test.ts). +vi.mock("~/db.server", () => ({ + prisma: {}, + $replica: {}, + runOpsNewPrisma: {}, + runOpsLegacyPrisma: {}, +})); + +vi.mock("~/services/platform.v3.server", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + getEntitlement: vi.fn(), + }; +}); + +import { RunEngine } from "@internal/run-engine"; +import { + setupAuthenticatedEnvironment, + setupBackgroundWorker, + type AuthenticatedEnvironment, +} from "@internal/run-engine/tests"; +import { containerTest } from "@internal/testcontainers"; +import { trace } from "@opentelemetry/api"; +import type { IOPacket } from "@trigger.dev/core/v3"; +import { + Decimal, + type PrismaClient, + type RuntimeEnvironmentType, + type TaskRun, +} from "@trigger.dev/database"; +import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server"; +import { DefaultQueueManager } from "~/runEngine/concerns/queues.server"; +import type { + EntitlementValidationParams, + MaxAttemptsValidationParams, + ParentRunValidationParams, + PayloadProcessor, + TagValidationParams, + TraceEventConcern, + TracedEventSpan, + TriggerTaskRequest, + TriggerTaskValidator, + ValidationResult, +} from "~/runEngine/types"; +import { RunEngineTriggerTaskService } from "../../app/runEngine/services/triggerTask.server"; +import { setTimeout } from "node:timers/promises"; + +vi.setConfig({ testTimeout: 60_000 }); + +class MockPayloadProcessor implements PayloadProcessor { + async process(request: TriggerTaskRequest): Promise { + return { + data: JSON.stringify(request.body.payload), + dataType: "application/json", + }; + } +} + +// Permissive validator: the main (non-cached) trigger path is not the +// subject here — we want the cached idempotency branch's own scoping +// guard to be the only thing standing between a caller and an +// arbitrary parent run. +class MockTriggerTaskValidator implements TriggerTaskValidator { + validateTags(_params: TagValidationParams): ValidationResult { + return { ok: true }; + } + validateEntitlement(_params: EntitlementValidationParams): Promise { + return Promise.resolve({ ok: true }); + } + validateMaxAttempts(_params: MaxAttemptsValidationParams): ValidationResult { + return { ok: true }; + } + validateParentRun(_params: ParentRunValidationParams): ValidationResult { + return { ok: true }; + } +} + +const MOCK_TRACE_ID = "0123456789abcdef0123456789abcdef"; +const MOCK_SPAN_ID = "fedcba9876543210"; +const MOCK_TRACEPARENT = `00-${MOCK_TRACE_ID}-${MOCK_SPAN_ID}-01`; + +class MockTraceEventConcern implements TraceEventConcern { + private span(): TracedEventSpan { + return { + traceId: MOCK_TRACE_ID, + spanId: MOCK_SPAN_ID, + traceContext: { traceparent: MOCK_TRACEPARENT }, + traceparent: undefined, + setAttribute: () => {}, + failWithError: () => {}, + stop: () => {}, + }; + } + async traceRun( + _request: TriggerTaskRequest, + _parentStore: string | undefined, + callback: (span: TracedEventSpan, store: string) => Promise + ): Promise { + return callback(this.span(), "test"); + } + async traceIdempotentRun( + _request: TriggerTaskRequest, + _parentStore: string | undefined, + _options: { + existingRun: TaskRun; + idempotencyKey: string; + incomplete: boolean; + isError: boolean; + }, + callback: (span: TracedEventSpan, store: string) => Promise + ): Promise { + return callback(this.span(), "test"); + } + async traceDebouncedRun( + _request: TriggerTaskRequest, + _parentStore: string | undefined, + _options: { existingRun: TaskRun; debounceKey: string; incomplete: boolean; isError: boolean }, + callback: (span: TracedEventSpan, store: string) => Promise + ): Promise { + return callback(this.span(), "test"); + } +} + +// setupAuthenticatedEnvironment hardcodes every unique field (slug, +// apiKey, shortcode, ...), so it can only be called once per database. +// This builds a second, fully-distinct tenant for the cross-environment +// assertion. +async function createDistinctTenant( + prisma: PrismaClient, + type: RuntimeEnvironmentType, + suffix: string +): Promise { + const org = await prisma.organization.create({ + data: { title: `Test Org ${suffix}`, slug: `test-organization-${suffix}` }, + }); + const workerGroup = await prisma.workerInstanceGroup.create({ + data: { + name: `default-${suffix}`, + masterQueue: `default-${suffix}`, + type: "MANAGED", + token: { create: { tokenHash: `token_hash_${suffix}` } }, + }, + }); + const project = await prisma.project.create({ + data: { + name: `Test Project ${suffix}`, + slug: `test-project-${suffix}`, + externalRef: `proj_${suffix}`, + organizationId: org.id, + defaultWorkerGroupId: workerGroup.id, + }, + }); + const environment = await prisma.runtimeEnvironment.create({ + data: { + type, + slug: `slug-${suffix}`, + projectId: project.id, + organizationId: org.id, + apiKey: `api_key_${suffix}`, + pkApiKey: `pk_api_key_${suffix}`, + shortcode: `short_code_${suffix}`, + maximumConcurrencyLimit: 10, + concurrencyLimitBurstFactor: new Decimal(2.0), + }, + }); + return prisma.runtimeEnvironment.findUniqueOrThrow({ + where: { id: environment.id }, + include: { project: true, organization: true, orgMember: true }, + }); +} + +describe("IdempotencyKeyConcern cached-branch parent-run scoping", () => { + containerTest( + "rejects a parentRunId from another environment, permits one from the caller's environment", + async ({ prisma, redisOptions }) => { + const engine = new RunEngine({ + prisma, + worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 }, + queue: { redis: redisOptions }, + runLock: { redis: redisOptions }, + machines: { + defaultMachine: "small-1x", + machines: { + "small-1x": { name: "small-1x" as const, cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 }, + }, + baseCostInCents: 0.0005, + }, + tracer: trace.getTracer("test", "0.0.0"), + }); + + const parentTask = "parent-task"; + const childTask = "child-task"; + + // Two independent tenants. + const callerEnv = await setupAuthenticatedEnvironment(prisma, "PRODUCTION"); + const victimEnv = await createDistinctTenant(prisma, "PRODUCTION", "victim"); + + await setupBackgroundWorker(engine, callerEnv, [parentTask, childTask]); + await setupBackgroundWorker(engine, victimEnv, [parentTask, childTask]); + + // Helper: trigger a parent run and start its attempt so it is a + // valid waitpoint target for resumeParentOnCompletion. + const triggerAndStart = async ( + env: typeof callerEnv, + friendlyId: string, + idSuffix: string + ) => { + const run = await engine.trigger( + { + number: 1, + friendlyId, + environment: env, + taskIdentifier: parentTask, + payload: "{}", + payloadType: "application/json", + context: {}, + traceContext: {}, + traceId: `t${idSuffix}`, + spanId: `s${idSuffix}`, + queue: `task/${parentTask}`, + isTest: false, + tags: [], + workerQueue: "main", + }, + prisma + ); + await setTimeout(500); + const dequeued = await engine.dequeueFromWorkerQueue({ + consumerId: `consumer${idSuffix}`, + workerQueue: "main", + }); + await engine.startRunAttempt({ runId: run.id, snapshotId: dequeued[0].snapshot.id }); + return run; + }; + + // Victim parent lives in the OTHER environment. + const victimParent = await triggerAndStart(victimEnv, "run_victimp", "11111"); + // A legitimate parent in the caller's own environment. + const callerParent = await triggerAndStart(callerEnv, "run_callerp", "22222"); + + const queuesManager = new DefaultQueueManager(prisma, engine); + const idempotencyKeyConcern = new IdempotencyKeyConcern( + prisma, + engine, + new MockTraceEventConcern() + ); + + const service = new RunEngineTriggerTaskService({ + engine, + prisma, + payloadProcessor: new MockPayloadProcessor(), + queueConcern: queuesManager, + idempotencyKeyConcern, + validator: new MockTriggerTaskValidator(), + traceEventConcern: new MockTraceEventConcern(), + tracer: trace.getTracer("test", "0.0.0"), + metadataMaximumSize: 1024 * 1024 * 1, + }); + + // Seed two cached idempotent child runs in the caller's env. The + // first call for each key takes the non-cached path and creates the + // run; the second call hits the cached branch under test. + const crossEnvKey = "cross-env-key"; + const sameEnvKey = "same-env-key"; + + const seedCross = await service.call({ + taskId: childTask, + environment: callerEnv, + body: { payload: { n: 1 }, options: { idempotencyKey: crossEnvKey } }, + }); + expect(seedCross?.isCached).toBe(false); + + const seedSame = await service.call({ + taskId: childTask, + environment: callerEnv, + body: { payload: { n: 2 }, options: { idempotencyKey: sameEnvKey } }, + }); + expect(seedSame?.isCached).toBe(false); + + // ATTACK: cached call in the caller's env naming the victim's parent + // run (which belongs to victimEnv). Must be refused. + await expect( + service.call({ + taskId: childTask, + environment: callerEnv, + body: { + payload: { n: 1 }, + options: { + idempotencyKey: crossEnvKey, + parentRunId: victimParent.friendlyId, + resumeParentOnCompletion: true, + }, + }, + }) + ).rejects.toThrow(/Parent run not found in the calling environment/); + + // CONTROL: same cached path, but the parent is in the caller's own + // env — the guard must let this through (isCached hit). + const sameEnvResult = await service.call({ + taskId: childTask, + environment: callerEnv, + body: { + payload: { n: 2 }, + options: { + idempotencyKey: sameEnvKey, + parentRunId: callerParent.friendlyId, + resumeParentOnCompletion: true, + }, + }, + }); + expect(sameEnvResult?.isCached).toBe(true); + expect(sameEnvResult?.run.friendlyId).toBe(seedSame?.run.friendlyId); + + // And the cross-tenant victim run must NOT have been blocked by the + // attacker's waitpoint — its execution snapshot stays in its own env. + const victimAfter = await prisma.taskRun.findFirst({ + where: { id: victimParent.id }, + select: { runtimeEnvironmentId: true }, + }); + expect(victimAfter?.runtimeEnvironmentId).toBe(victimEnv.id); + + await engine.quit(); + } + ); +}); diff --git a/apps/webapp/test/triggerV1Scoping.test.ts b/apps/webapp/test/triggerV1Scoping.test.ts new file mode 100644 index 00000000000..c4450f3487a --- /dev/null +++ b/apps/webapp/test/triggerV1Scoping.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { + attemptInEnvironmentWhere, + batchRunInEnvironmentWhere, +} from "../app/v3/services/triggerV1Scoping.js"; + +// Caller-supplied parent/dependent attempt & batch friendlyIds must be resolved +// scoped to the caller's environment — a where clause missing that constraint +// lets a foreign id resolve (the cross-tenant bug). Pins the scope on each query. +describe("triggerV1 scoping where-clauses", () => { + it("scopes attempt lookups to the env via the related run", () => { + const where = attemptInEnvironmentWhere("attempt_x", "env_caller"); + expect(where.friendlyId).toBe("attempt_x"); + expect(where.taskRun).toEqual({ runtimeEnvironmentId: "env_caller" }); + }); + + it("scopes batch-run lookups to the env directly", () => { + const where = batchRunInEnvironmentWhere("batch_x", "env_caller"); + expect(where.friendlyId).toBe("batch_x"); + expect(where.runtimeEnvironmentId).toBe("env_caller"); + }); +}); diff --git a/apps/webapp/test/workerGroupAccess.test.ts b/apps/webapp/test/workerGroupAccess.test.ts new file mode 100644 index 00000000000..cea98c6240e --- /dev/null +++ b/apps/webapp/test/workerGroupAccess.test.ts @@ -0,0 +1,22 @@ +import { WorkerInstanceGroupType } from "@trigger.dev/database"; +import { describe, expect, it } from "vitest"; +import { isWorkerGroupAllowedForProject } from "../app/v3/services/worker/workerGroupAccess.js"; + +// UNMANAGED worker groups are per-project; MANAGED are shared. A project must +// not be able to route onto another project's UNMANAGED group. +describe("isWorkerGroupAllowedForProject", () => { + it("allows an UNMANAGED group owned by the calling project", () => { + const group = { type: WorkerInstanceGroupType.UNMANAGED, projectId: "proj_me" }; + expect(isWorkerGroupAllowedForProject(group, "proj_me")).toBe(true); + }); + + it("rejects an UNMANAGED group owned by a different project (the cross-tenant vector)", () => { + const group = { type: WorkerInstanceGroupType.UNMANAGED, projectId: "proj_other" }; + expect(isWorkerGroupAllowedForProject(group, "proj_me")).toBe(false); + }); + + it("allows MANAGED (shared) groups regardless of project", () => { + const group = { type: WorkerInstanceGroupType.MANAGED, projectId: null }; + expect(isWorkerGroupAllowedForProject(group, "proj_me")).toBe(true); + }); +});